From 327d2c5281d4f2b71901ba006c1766712fd33cce Mon Sep 17 00:00:00 2001 From: Damon Lu <59256766+WhatDamon@users.noreply.github.com> Date: Sat, 29 Nov 2025 02:58:53 +0800 Subject: [PATCH 01/10] update --- .../platform/macos/MacOSHardwareDetector.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java index 706294fad8..aeeea9af64 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java @@ -149,4 +149,38 @@ public List detectGraphicsCards() { return Collections.emptyList(); } + + public long getFreeMemorySize() { + try { + Process process = Runtime.getRuntime().exec("vm_stat"); + long pageSize = -1; + long freePages = -1; + long inactivePages = -1; + + try (var reader = process.inputReader()) { + String line; + while ((line = reader.readLine()) != null) { + if (pageSize == -1 && line.contains("page size of")) { + pageSize = Long.parseLong(line.replaceAll(".*page size of (\\d+) bytes.*", "$1")); + } else if (freePages == -1 && line.startsWith("Pages free:")) { + freePages = Long.parseLong(line.split(":")[1].trim().replace(".", "")); + } else if (inactivePages == -1 && line.startsWith("Pages inactive:")) { + inactivePages = Long.parseLong(line.split(":")[1].trim().replace(".", "")); + } + if (pageSize != -1 && freePages != -1 && inactivePages != -1) { + break; + } + } + } + + process.waitFor(); + + if (pageSize != -1 && freePages != -1 && inactivePages != -1) { + return (freePages + inactivePages) * pageSize; + } + } catch (Throwable e) { + LOG.warning("Failed to parse vm_stat output", e); + } + return super.getFreeMemorySize(); + } } From 4a4f93d6aedd08391db3782eb870e41f110984cd Mon Sep 17 00:00:00 2001 From: Damon Lu <59256766+WhatDamon@users.noreply.github.com> Date: Sat, 29 Nov 2025 03:25:38 +0800 Subject: [PATCH 02/10] update --- .../hmcl/util/platform/macos/MacOSHardwareDetector.java | 1 + 1 file changed, 1 insertion(+) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java index aeeea9af64..7e137bd8f8 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java @@ -150,6 +150,7 @@ public List detectGraphicsCards() { return Collections.emptyList(); } + @Override public long getFreeMemorySize() { try { Process process = Runtime.getRuntime().exec("vm_stat"); From b0a24b7d0624cea6cd0653ef183edab37d023a33 Mon Sep 17 00:00:00 2001 From: Damon Lu <59256766+WhatDamon@users.noreply.github.com> Date: Sat, 29 Nov 2025 11:08:01 +0800 Subject: [PATCH 03/10] update --- .../util/platform/macos/MacOSHardwareDetector.java | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java index 7e137bd8f8..af79e039a3 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java @@ -32,6 +32,7 @@ import org.jetbrains.annotations.Nullable; import java.io.BufferedReader; +import java.io.IOException; import java.io.InputStreamReader; import java.util.*; @@ -172,13 +173,17 @@ public long getFreeMemorySize() { break; } } + process.waitFor(); } - process.waitFor(); + if (pageSize == -1) throw new IOException("Missing 'page size' in vm_stat output"); + if (freePages == -1) throw new IOException("Missing 'Pages free' in vm_stat output"); + if (inactivePages == -1) throw new IOException("Missing 'Pages inactive' in vm_stat output"); - if (pageSize != -1 && freePages != -1 && inactivePages != -1) { - return (freePages + inactivePages) * pageSize; - } + long available = (freePages + inactivePages) * pageSize; + if (available < 0) throw new IOException("Invalid available memory size: " + available + " bytes"); + + return available; } catch (Throwable e) { LOG.warning("Failed to parse vm_stat output", e); } From 4079af7b64ae8723a13ed25e5f58a893620c6e87 Mon Sep 17 00:00:00 2001 From: Damon Lu <59256766+WhatDamon@users.noreply.github.com> Date: Fri, 5 Dec 2025 13:45:09 +0800 Subject: [PATCH 04/10] update --- .../platform/macos/MacOSHardwareDetector.java | 47 +++++++++++-------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java index af79e039a3..859389c815 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java @@ -155,30 +155,37 @@ public List detectGraphicsCards() { public long getFreeMemorySize() { try { Process process = Runtime.getRuntime().exec("vm_stat"); + Map stats; long pageSize = -1; - long freePages = -1; - long inactivePages = -1; - - try (var reader = process.inputReader()) { - String line; - while ((line = reader.readLine()) != null) { - if (pageSize == -1 && line.contains("page size of")) { - pageSize = Long.parseLong(line.replaceAll(".*page size of (\\d+) bytes.*", "$1")); - } else if (freePages == -1 && line.startsWith("Pages free:")) { - freePages = Long.parseLong(line.split(":")[1].trim().replace(".", "")); - } else if (inactivePages == -1 && line.startsWith("Pages inactive:")) { - inactivePages = Long.parseLong(line.split(":")[1].trim().replace(".", "")); - } - if (pageSize != -1 && freePages != -1 && inactivePages != -1) { - break; + + try (var reader = process.inputReader(OperatingSystem.NATIVE_CHARSET)) { + List lines = reader.lines().toList(); + process.waitFor(); + + if (!lines.isEmpty()) { + String first = lines.get(0); + if (first.contains("page size of")) { + pageSize = Long.parseLong(first.replaceAll(".*page size of (\\d+) bytes.*", "$1")); } } - process.waitFor(); + + stats = KeyValuePairUtils.loadPairs(lines.iterator()); + } + + if (pageSize == -1) + throw new IOException("Missing 'page size' in vm_stat output"); + + Long freePages = null, inactivePages = null; + try { + String freeStr = stats.get("Pages free"); + if (freeStr != null) freePages = Long.parseLong(freeStr.replace(".", "")); + String inactiveStr = stats.get("Pages inactive"); + if (inactiveStr != null) inactivePages = Long.parseLong(inactiveStr.replace(".", "")); + } catch (NumberFormatException ignored) { } - if (pageSize == -1) throw new IOException("Missing 'page size' in vm_stat output"); - if (freePages == -1) throw new IOException("Missing 'Pages free' in vm_stat output"); - if (inactivePages == -1) throw new IOException("Missing 'Pages inactive' in vm_stat output"); + if (freePages == null) throw new IOException("Missing 'Pages free' in vm_stat output"); + if (inactivePages == null) throw new IOException("Missing 'Pages inactive' in vm_stat output"); long available = (freePages + inactivePages) * pageSize; if (available < 0) throw new IOException("Invalid available memory size: " + available + " bytes"); @@ -186,7 +193,7 @@ public long getFreeMemorySize() { return available; } catch (Throwable e) { LOG.warning("Failed to parse vm_stat output", e); + return super.getFreeMemorySize(); } - return super.getFreeMemorySize(); } } From 8859551528fb98686fd3832c065f0b1318f5d9e2 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 9 Dec 2025 21:25:37 +0800 Subject: [PATCH 05/10] update --- .../platform/macos/MacOSHardwareDetector.java | 79 ++++++++++--------- 1 file changed, 40 insertions(+), 39 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java index 859389c815..11923506e1 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java @@ -32,9 +32,10 @@ import org.jetbrains.annotations.Nullable; import java.io.BufferedReader; -import java.io.IOException; import java.io.InputStreamReader; import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -151,49 +152,49 @@ public List detectGraphicsCards() { return Collections.emptyList(); } + private static final Pattern PATTERN = Pattern.compile("\\(page size of (?\\d+) bytes\\)"); + @Override public long getFreeMemorySize() { + vmStat: try { - Process process = Runtime.getRuntime().exec("vm_stat"); - Map stats; - long pageSize = -1; - - try (var reader = process.inputReader(OperatingSystem.NATIVE_CHARSET)) { - List lines = reader.lines().toList(); - process.waitFor(); - - if (!lines.isEmpty()) { - String first = lines.get(0); - if (first.contains("page size of")) { - pageSize = Long.parseLong(first.replaceAll(".*page size of (\\d+) bytes.*", "$1")); - } - } - - stats = KeyValuePairUtils.loadPairs(lines.iterator()); - } - - if (pageSize == -1) - throw new IOException("Missing 'page size' in vm_stat output"); - - Long freePages = null, inactivePages = null; - try { - String freeStr = stats.get("Pages free"); - if (freeStr != null) freePages = Long.parseLong(freeStr.replace(".", "")); - String inactiveStr = stats.get("Pages inactive"); - if (inactiveStr != null) inactivePages = Long.parseLong(inactiveStr.replace(".", "")); - } catch (NumberFormatException ignored) { - } - - if (freePages == null) throw new IOException("Missing 'Pages free' in vm_stat output"); - if (inactivePages == null) throw new IOException("Missing 'Pages inactive' in vm_stat output"); - - long available = (freePages + inactivePages) * pageSize; - if (available < 0) throw new IOException("Invalid available memory size: " + available + " bytes"); - - return available; + Map stats = SystemUtils.run(List.of("/usr/bin/vm_stat"), + inputStream -> KeyValuePairUtils.loadPairs( + new BufferedReader(new InputStreamReader(inputStream, OperatingSystem.NATIVE_CHARSET)))); + String statistics = stats.get("Mach Virtual Memory Statistics"); + + long pageSize; + long pagesFree; + long pagesInactive; + + if (statistics != null) { + Matcher matcher = PATTERN.matcher(statistics); + if (matcher.matches()) + pageSize = Long.parseLong(matcher.group("size")); + else + break vmStat; + } else + break vmStat; + + String pagesFreeStr = stats.get("Pages free"); + if (pagesFreeStr != null && pagesFreeStr.endsWith(".")) + pagesFree = Long.parseUnsignedLong(pagesFreeStr, 0, pagesFreeStr.length() - 1, 10); + else + break vmStat; + + String pagesInactiveStr = stats.get("Pages inactive"); + if (pagesInactiveStr != null && pagesInactiveStr.endsWith(".")) + pagesInactive = Long.parseUnsignedLong(pagesInactiveStr, 0, pagesInactiveStr.length() - 1, 10); + else + break vmStat; + + long available = (pagesFree + pagesInactive) * pageSize; + if (available > 0) + return available; } catch (Throwable e) { LOG.warning("Failed to parse vm_stat output", e); - return super.getFreeMemorySize(); } + + return super.getFreeMemorySize(); } } From 047020aaf263706193a8158e7eddef6cbbfcbddb Mon Sep 17 00:00:00 2001 From: Damon Lu <59256766+WhatDamon@users.noreply.github.com> Date: Sat, 13 Dec 2025 00:34:13 +0800 Subject: [PATCH 06/10] code format and formula change --- .../platform/macos/MacOSHardwareDetector.java | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java index 11923506e1..1c98c177d5 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java @@ -152,7 +152,7 @@ public List detectGraphicsCards() { return Collections.emptyList(); } - private static final Pattern PATTERN = Pattern.compile("\\(page size of (?\\d+) bytes\\)"); + private static final Pattern pageSizePattern = Pattern.compile("\\(page size of (?\\d+) bytes\\)"); @Override public long getFreeMemorySize() { @@ -166,31 +166,52 @@ public long getFreeMemorySize() { long pageSize; long pagesFree; long pagesInactive; + long pagesSpeculative; + long pagesPurgeable; if (statistics != null) { - Matcher matcher = PATTERN.matcher(statistics); - if (matcher.matches()) + Matcher matcher = pageSizePattern.matcher(statistics); + if (matcher.matches()) { pageSize = Long.parseLong(matcher.group("size")); - else + } else { break vmStat; - } else + } + } else { break vmStat; + } String pagesFreeStr = stats.get("Pages free"); - if (pagesFreeStr != null && pagesFreeStr.endsWith(".")) + if (pagesFreeStr != null && pagesFreeStr.endsWith(".")) { pagesFree = Long.parseUnsignedLong(pagesFreeStr, 0, pagesFreeStr.length() - 1, 10); - else + } else { break vmStat; + } String pagesInactiveStr = stats.get("Pages inactive"); - if (pagesInactiveStr != null && pagesInactiveStr.endsWith(".")) + if (pagesInactiveStr != null && pagesInactiveStr.endsWith(".")) { pagesInactive = Long.parseUnsignedLong(pagesInactiveStr, 0, pagesInactiveStr.length() - 1, 10); - else + } else { + break vmStat; + } + + String pageSpeculativeStr = stats.get("Pages speculative"); + if (pageSpeculativeStr != null && pageSpeculativeStr.endsWith(".")) { + pagesSpeculative = Long.parseLong(pageSpeculativeStr.substring(0, pageSpeculativeStr.length() - 1)); + } else { + break vmStat; + } + + String pagePurgeableStr = stats.get("Pages purgeable"); + if (pagePurgeableStr != null && pageSpeculativeStr.endsWith(".")) { + pagesPurgeable = Long.parseLong(pagePurgeableStr.substring(0, pagePurgeableStr.length() - 1)); + } else { break vmStat; + } - long available = (pagesFree + pagesInactive) * pageSize; - if (available > 0) + long available = (pagesFree - pagesSpeculative + pagesInactive + pagesPurgeable) * pageSize; + if (available > 0) { return available; + } } catch (Throwable e) { LOG.warning("Failed to parse vm_stat output", e); } From f80d8487b1017c0b82bab3bbe16d9fffc36d8f5c Mon Sep 17 00:00:00 2001 From: Damon Lu <59256766+WhatDamon@users.noreply.github.com> Date: Thu, 1 Jan 2026 22:17:49 +0800 Subject: [PATCH 07/10] update --- .../platform/macos/MacOSHardwareDetector.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java index 1c98c177d5..6b8276ba22 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java @@ -171,7 +171,7 @@ public long getFreeMemorySize() { if (statistics != null) { Matcher matcher = pageSizePattern.matcher(statistics); - if (matcher.matches()) { + if (matcher.find()) { pageSize = Long.parseLong(matcher.group("size")); } else { break vmStat; @@ -194,21 +194,21 @@ public long getFreeMemorySize() { break vmStat; } - String pageSpeculativeStr = stats.get("Pages speculative"); - if (pageSpeculativeStr != null && pageSpeculativeStr.endsWith(".")) { - pagesSpeculative = Long.parseLong(pageSpeculativeStr.substring(0, pageSpeculativeStr.length() - 1)); + String pagesSpeculativeStr = stats.get("Pages speculative"); + if (pagesSpeculativeStr != null && pagesSpeculativeStr.endsWith(".")) { + pagesSpeculative = Long.parseUnsignedLong(pagesSpeculativeStr, 0, pagesSpeculativeStr.length() - 1, 10); } else { break vmStat; } - String pagePurgeableStr = stats.get("Pages purgeable"); - if (pagePurgeableStr != null && pageSpeculativeStr.endsWith(".")) { - pagesPurgeable = Long.parseLong(pagePurgeableStr.substring(0, pagePurgeableStr.length() - 1)); + String pagesPurgeableStr = stats.get("Pages purgeable"); + if (pagesPurgeableStr != null && pagesPurgeableStr.endsWith(".")) { + pagesPurgeable = Long.parseUnsignedLong(pagesPurgeableStr, 0, pagesPurgeableStr.length() - 1, 10); } else { break vmStat; } - long available = (pagesFree - pagesSpeculative + pagesInactive + pagesPurgeable) * pageSize; + long available = (pagesFree + pagesSpeculative + pagesInactive + pagesPurgeable) * pageSize; if (available > 0) { return available; } From 41821cb427adbe70b4f212dced9aa2fae1975b09 Mon Sep 17 00:00:00 2001 From: Damon Lu <59256766+WhatDamon@users.noreply.github.com> Date: Fri, 9 Jan 2026 00:45:44 +0800 Subject: [PATCH 08/10] update --- .../hmcl/util/platform/macos/MacOSHardwareDetector.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java index 6b8276ba22..46d89ae49f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java @@ -152,7 +152,7 @@ public List detectGraphicsCards() { return Collections.emptyList(); } - private static final Pattern pageSizePattern = Pattern.compile("\\(page size of (?\\d+) bytes\\)"); + private static final Pattern PAGE_SIZE_PATTERN = Pattern.compile("\\(page size of (?\\d+) bytes\\)"); @Override public long getFreeMemorySize() { @@ -170,7 +170,7 @@ public long getFreeMemorySize() { long pagesPurgeable; if (statistics != null) { - Matcher matcher = pageSizePattern.matcher(statistics); + Matcher matcher = PAGE_SIZE_PATTERN.matcher(statistics); if (matcher.find()) { pageSize = Long.parseLong(matcher.group("size")); } else { From ba52ea20e8a9ef6db20489048517cf3c7d41a4ca Mon Sep 17 00:00:00 2001 From: Damon Lu <59256766+WhatDamon@users.noreply.github.com> Date: Tue, 27 Jan 2026 01:30:37 +0800 Subject: [PATCH 09/10] Merge remote-tracking branch 'upstream/main' into macos-memory-fix --- .cnb/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/bug-report.yml | 8 +- .github/workflows/check-update.yml | 79 - .github/workflows/gitee.yml | 21 - .github/workflows/mirror.yml | 33 + .github/workflows/release.yml | 125 ++ HMCL/build.gradle.kts | 43 +- .../java/com/jfoenix/controls/JFXButton.java | 129 ++ .../com/jfoenix/controls/JFXClippedPane.java | 60 + .../com/jfoenix/controls/JFXColorPicker.java | 145 ++ .../com/jfoenix/controls/JFXComboBox.java | 357 ++++ .../jfoenix/controls/JFXPasswordField.java | 297 ++++ .../java/com/jfoenix/controls/JFXPopup.java | 171 ++ .../java/com/jfoenix/controls/JFXRippler.java | 814 +++++++++ .../com/jfoenix/controls/JFXTextArea.java | 297 ++++ .../com/jfoenix/controls/JFXTextField.java | 299 ++++ .../com/jfoenix/controls/JFXToggleButton.java | 373 ++++ .../behavior/JFXGenericPickerBehavior.java | 46 + .../com/jfoenix/effects/JFXDepthManager.java | 122 ++ .../java/com/jfoenix/skins/JFXButtonSkin.java | 222 +++ .../com/jfoenix/skins/JFXCheckBoxSkin.java | 228 +++ .../com/jfoenix/skins/JFXColorPalette.java | 623 +++++++ .../com/jfoenix/skins/JFXColorPickerSkin.java | 258 +++ .../com/jfoenix/skins/JFXColorPickerUI.java | 629 +++++++ .../jfoenix/skins/JFXCustomColorPicker.java | 520 ++++++ .../skins/JFXCustomColorPickerDialog.java | 407 +++++ .../jfoenix/skins/JFXGenericPickerSkin.java | 242 +++ .../com/jfoenix/skins/JFXListViewSkin.java | 57 +- .../java/com/jfoenix/skins/JFXPopupSkin.java | 139 ++ .../com/jfoenix/skins/JFXRadioButtonSkin.java | 179 ++ .../jfoenix/skins/JFXToggleButtonSkin.java | 186 ++ .../com/jfoenix/transitions/CacheMemento.java | 68 + .../transitions/JFXAnimationTimer.java | 286 +++ .../com/jfoenix/transitions/JFXKeyFrame.java | 104 ++ .../com/jfoenix/transitions/JFXKeyValue.java | 157 ++ .../java/com/jfoenix/utils/JFXNodeUtils.java | 64 + .../java/org/jackhuang/hmcl/EntryPoint.java | 18 +- .../java/org/jackhuang/hmcl/Launcher.java | 49 +- .../jackhuang/hmcl/game/HMCLGameLauncher.java | 1 + .../hmcl/game/HMCLGameRepository.java | 66 +- .../hmcl/game/HMCLModpackProvider.java | 2 +- .../jackhuang/hmcl/game/LauncherHelper.java | 39 +- .../org/jackhuang/hmcl/game/LogExporter.java | 29 +- .../jackhuang/hmcl/game/ModpackHelper.java | 8 +- .../org/jackhuang/hmcl/game/OAuthServer.java | 6 + .../hmcl/java/HMCLJavaRepository.java | 14 +- .../org/jackhuang/hmcl/setting/Accounts.java | 8 +- .../org/jackhuang/hmcl/setting/Config.java | 35 +- .../hmcl/setting/DownloadProviders.java | 114 +- .../jackhuang/hmcl/setting/FontManager.java | 79 +- .../jackhuang/hmcl/setting/GlobalConfig.java | 183 +- .../jackhuang/hmcl/setting/ProxyManager.java | 3 + .../jackhuang/hmcl/setting/StyleSheets.java | 80 +- .../org/jackhuang/hmcl/setting/Theme.java | 163 -- .../hmcl/setting/VersionIconType.java | 4 +- .../hmcl/setting/VersionSetting.java | 16 + .../hmcl/terracotta/TerracottaBundle.java | 191 ++ .../hmcl/terracotta/TerracottaManager.java | 242 +-- .../hmcl/terracotta/TerracottaMetadata.java | 173 +- .../hmcl/terracotta/TerracottaNative.java | 147 -- .../hmcl/terracotta/TerracottaState.java | 57 +- .../provider/AbstractTerracottaProvider.java | 63 + .../terracotta/provider/GeneralProvider.java | 46 +- .../provider/ITerracottaProvider.java | 77 - .../terracotta/provider/MacOSProvider.java | 110 +- .../java/org/jackhuang/hmcl/theme/Theme.java | 94 + .../org/jackhuang/hmcl/theme/ThemeColor.java | 193 ++ .../java/org/jackhuang/hmcl/theme/Themes.java | 205 +++ .../org/jackhuang/hmcl/ui/Controllers.java | 10 +- .../org/jackhuang/hmcl/ui/DialogUtils.java | 155 ++ .../java/org/jackhuang/hmcl/ui/FXUtils.java | 168 +- .../jackhuang/hmcl/ui/GameCrashWindow.java | 59 +- .../org/jackhuang/hmcl/ui/InstallerItem.java | 139 +- .../java/org/jackhuang/hmcl/ui/ListPage.java | 49 - .../org/jackhuang/hmcl/ui/ListPageBase.java | 3 +- .../org/jackhuang/hmcl/ui/ListPageSkin.java | 109 -- .../java/org/jackhuang/hmcl/ui/LogWindow.java | 43 +- .../main/java/org/jackhuang/hmcl/ui/SVG.java | 27 +- .../org/jackhuang/hmcl/ui/ScrollUtils.java | 56 +- .../hmcl/ui/ToolbarListPageSkin.java | 67 +- .../org/jackhuang/hmcl/ui/UpgradeDialog.java | 4 +- .../jackhuang/hmcl/ui/WeakListenerHolder.java | 4 + .../jackhuang/hmcl/ui/WindowsNativeUtils.java | 60 + .../ui/account/AccountAdvancedListItem.java | 14 +- .../hmcl/ui/account/AccountListItemSkin.java | 15 +- .../hmcl/ui/account/AccountListPage.java | 5 +- .../account/AddAuthlibInjectorServerPane.java | 16 +- .../hmcl/ui/account/CreateAccountPane.java | 146 +- .../ui/account/OAuthAccountLoginDialog.java | 6 +- .../ui/account/OfflineAccountSkinPane.java | 19 +- .../hmcl/ui/animation/TransitionPane.java | 4 +- .../hmcl/ui/construct/AdvancedListBox.java | 8 +- .../hmcl/ui/construct/ClassTitle.java | 2 - .../hmcl/ui/construct/ComponentListCell.java | 60 +- .../jackhuang/hmcl/ui/construct/FileItem.java | 21 +- .../hmcl/ui/construct/FileSelector.java | 3 +- .../hmcl/ui/construct/FloatListCell.java | 69 - .../hmcl/ui/construct/FloatScrollBarSkin.java | 2 + .../jackhuang/hmcl/ui/construct/HintPane.java | 26 +- .../hmcl/ui/construct/IconedMenuItem.java | 5 +- .../ui/construct/IconedTwoLineListItem.java | 3 +- .../hmcl/ui/construct/ImagePickerItem.java | 6 +- .../hmcl/ui/construct/InputDialogPane.java | 43 +- .../ui/construct/JFXCheckBoxTableCell.java | 68 + .../hmcl/ui/construct/JFXHyperlink.java | 3 +- .../hmcl/ui/construct/MDListCell.java | 12 +- .../hmcl/ui/construct/MenuUpDownButton.java | 7 +- .../hmcl/ui/construct/MessageDialogPane.java | 46 +- .../hmcl/ui/construct/MultiFileItem.java | 17 +- .../hmcl/ui/construct/OptionToggleButton.java | 16 +- .../hmcl/ui/construct/PopupMenu.java | 2 + .../hmcl/ui/construct/PromptDialogPane.java | 16 +- .../hmcl/ui/construct/RipplerContainer.java | 18 +- .../ui/construct/TaskExecutorDialogPane.java | 3 + .../hmcl/ui/construct/TaskListPane.java | 49 +- .../ui/decorator/DecoratorController.java | 120 +- .../hmcl/ui/decorator/DecoratorSkin.java | 36 +- .../ui/download/AbstractInstallersPage.java | 17 +- .../hmcl/ui/download/DownloadPage.java | 27 +- .../hmcl/ui/download/InstallersPage.java | 44 +- .../hmcl/ui/download/LocalModpackPage.java | 1 + .../ModpackInstallWizardProvider.java | 11 +- .../ui/download/ModpackSelectionPage.java | 23 +- .../hmcl/ui/download/VersionsPage.java | 25 +- .../hmcl/ui/export/ExportWizardProvider.java | 20 +- .../ui/export/ModpackFileSelectionPage.java | 32 +- .../hmcl/ui/export/ModpackInfoPage.java | 5 +- .../ui/export/ModpackTypeSelectionPage.java | 5 +- .../org/jackhuang/hmcl/ui/main/AboutPage.java | 20 +- .../hmcl/ui/main/DownloadSettingsPage.java | 4 +- .../jackhuang/hmcl/ui/main/FeedbackPage.java | 39 +- .../hmcl/ui/main/JavaDownloadDialog.java | 9 +- .../hmcl/ui/main/JavaManagementPage.java | 209 ++- .../hmcl/ui/main/JavaRestorePage.java | 9 +- .../hmcl/ui/main/LauncherSettingsPage.java | 2 +- .../org/jackhuang/hmcl/ui/main/MainPage.java | 107 +- .../hmcl/ui/main/PersonalizationPage.java | 54 +- .../org/jackhuang/hmcl/ui/main/RootPage.java | 77 +- .../jackhuang/hmcl/ui/main/SettingsPage.java | 354 +++- .../jackhuang/hmcl/ui/main/SettingsView.java | 257 --- .../jackhuang/hmcl/ui/nbt/NBTEditorPage.java | 5 +- .../jackhuang/hmcl/ui/nbt/NBTTreeView.java | 18 +- .../hmcl/ui/profile/ProfileListItemSkin.java | 6 +- .../hmcl/ui/profile/ProfilePage.java | 41 +- .../terracotta/TerracottaControllerPage.java | 94 +- .../hmcl/ui/terracotta/TerracottaPage.java | 18 +- .../versions/AdvancedVersionSettingPage.java | 1 + .../hmcl/ui/versions/DatapackListPage.java | 10 +- .../ui/versions/DatapackListPageSkin.java | 8 +- .../hmcl/ui/versions/DownloadListPage.java | 96 +- .../hmcl/ui/versions/DownloadPage.java | 63 +- .../jackhuang/hmcl/ui/versions/GameItem.java | 145 +- .../hmcl/ui/versions/GameItemSkin.java | 59 - .../hmcl/ui/versions/GameListCell.java | 214 +++ .../hmcl/ui/versions/GameListItem.java | 65 +- .../hmcl/ui/versions/GameListItemSkin.java | 136 -- .../hmcl/ui/versions/GameListPage.java | 69 +- .../hmcl/ui/versions/GameListPopupMenu.java | 159 ++ .../HMCLLocalizedDownloadListPage.java | 6 +- .../hmcl/ui/versions/InstallerListPage.java | 11 +- .../hmcl/ui/versions/ModCheckUpdatesTask.java | 64 +- .../hmcl/ui/versions/ModListPage.java | 16 +- .../hmcl/ui/versions/ModListPageSkin.java | 101 +- .../hmcl/ui/versions/ModUpdatesPage.java | 23 +- .../ui/versions/ResourcepackListPage.java | 280 +++ .../hmcl/ui/versions/SchematicsPage.java | 153 +- .../hmcl/ui/versions/VersionIconDialog.java | 4 +- .../hmcl/ui/versions/VersionPage.java | 31 +- .../hmcl/ui/versions/VersionSettingsPage.java | 89 +- .../jackhuang/hmcl/ui/versions/Versions.java | 84 +- .../hmcl/ui/versions/WorldBackupsPage.java | 10 +- .../hmcl/ui/versions/WorldExportPage.java | 4 +- .../hmcl/ui/versions/WorldInfoPage.java | 605 ++++--- .../hmcl/ui/versions/WorldListItem.java | 96 - .../hmcl/ui/versions/WorldListItemSkin.java | 146 -- .../hmcl/ui/versions/WorldListPage.java | 295 ++- .../hmcl/ui/versions/WorldManagePage.java | 123 +- .../hmcl/ui/versions/WorldManageUIUtils.java | 152 ++ .../jackhuang/hmcl/upgrade/UpdateChecker.java | 2 +- .../org/jackhuang/hmcl/util/ChunkBaseApp.java | 10 +- .../jackhuang/hmcl/util/NativePatcher.java | 4 +- .../hmcl/util/RemoteImageLoader.java | 117 ++ .../org/jackhuang/hmcl/util/i18n/I18n.java | 16 +- .../hmcl/util/i18n/MinecraftWiki.java | 6 +- .../hmcl/util/i18n/SupportedLocale.java | 44 +- .../hmcl/util/i18n/translator/Translator.java | 13 +- .../util/i18n/translator/Translator_lzh.java | 51 +- HMCL/src/main/resources/assets/HMCLauncher.sh | 2 + .../src/main/resources/assets/about/deps.json | 5 + .../main/resources/assets/about/thanks.json | 5 +- HMCL/src/main/resources/assets/css/blue.css | 86 +- .../resources/assets/css/brightness-dark.css | 20 + .../resources/assets/css/brightness-light.css | 20 + HMCL/src/main/resources/assets/css/root.css | 801 ++++++--- .../resources/assets/img/github-white.png | Bin 0 -> 851 bytes .../resources/assets/img/github-white@2x.png | Bin 0 -> 1648 bytes .../resources/assets/img/legacyfabric.png | Bin 0 -> 2404 bytes .../resources/assets/img/legacyfabric@2x.png | Bin 0 -> 3575 bytes .../resources/assets/img/nbt/TAG_Byte.png | Bin 1245 -> 523 bytes .../resources/assets/img/nbt/TAG_Byte@2x.png | Bin 1839 -> 768 bytes .../assets/img/nbt/TAG_Byte_Array.png | Bin 1293 -> 441 bytes .../assets/img/nbt/TAG_Byte_Array@2x.png | Bin 2011 -> 677 bytes .../resources/assets/img/nbt/TAG_Compound.png | Bin 1305 -> 656 bytes .../assets/img/nbt/TAG_Compound@2x.png | Bin 2230 -> 1092 bytes .../resources/assets/img/nbt/TAG_Double.png | Bin 1269 -> 468 bytes .../assets/img/nbt/TAG_Double@2x.png | Bin 1944 -> 689 bytes .../resources/assets/img/nbt/TAG_Float.png | Bin 1275 -> 276 bytes .../resources/assets/img/nbt/TAG_Float@2x.png | Bin 1647 -> 300 bytes .../main/resources/assets/img/nbt/TAG_Int.png | Bin 1299 -> 236 bytes .../resources/assets/img/nbt/TAG_Int@2x.png | Bin 1613 -> 279 bytes .../assets/img/nbt/TAG_Int_Array.png | Bin 1299 -> 264 bytes .../assets/img/nbt/TAG_Int_Array@2x.png | Bin 1951 -> 306 bytes .../resources/assets/img/nbt/TAG_List.png | Bin 1293 -> 185 bytes .../resources/assets/img/nbt/TAG_List@2x.png | Bin 1961 -> 193 bytes .../resources/assets/img/nbt/TAG_Long.png | Bin 1227 -> 222 bytes .../resources/assets/img/nbt/TAG_Long@2x.png | Bin 1409 -> 248 bytes .../assets/img/nbt/TAG_Long_Array.png | Bin 1290 -> 253 bytes .../assets/img/nbt/TAG_Long_Array@2x.png | Bin 1879 -> 274 bytes .../resources/assets/img/nbt/TAG_Short.png | Bin 1227 -> 602 bytes .../resources/assets/img/nbt/TAG_Short@2x.png | Bin 2017 -> 1003 bytes .../resources/assets/img/nbt/TAG_String.png | Bin 980 -> 374 bytes .../assets/img/nbt/TAG_String@2x.png | Bin 1537 -> 627 bytes .../resources/assets/lang/I18N.properties | 126 +- .../resources/assets/lang/I18N_ar.properties | 1576 +++++++++++++++++ .../resources/assets/lang/I18N_es.properties | 54 +- .../resources/assets/lang/I18N_ja.properties | 37 +- .../resources/assets/lang/I18N_lzh.properties | 64 +- .../resources/assets/lang/I18N_ru.properties | 54 +- .../resources/assets/lang/I18N_uk.properties | 52 +- .../resources/assets/lang/I18N_zh.properties | 155 +- .../assets/lang/I18N_zh_CN.properties | 157 +- .../src/main/resources/assets/terracotta.json | 88 +- .../hmcl/setting/ThemeColorTest.java | 44 + HMCL/terracotta-template.json | 35 + HMCLBoot/build.gradle.kts | 6 +- .../main/java/org/jackhuang/hmcl/Main.java | 12 + .../hmcl/auth/offline/OfflineAccount.java | 8 +- .../download/AdaptedDownloadProvider.java | 92 - .../hmcl/download/AutoDownloadProvider.java | 85 +- .../download/BMCLAPIDownloadProvider.java | 60 +- .../download/BalancedDownloadProvider.java | 67 - .../hmcl/download/DownloadProvider.java | 75 +- .../download/DownloadProviderWrapper.java | 45 +- .../hmcl/download/LibraryAnalyzer.java | 31 +- .../hmcl/download/MojangDownloadProvider.java | 57 +- .../download/MultipleSourceVersionList.java | 5 - .../jackhuang/hmcl/download/VersionList.java | 11 - .../cleanroom/CleanroomInstallTask.java | 5 +- .../cleanroom/CleanroomVersionList.java | 4 +- .../download/fabric/FabricAPIInstallTask.java | 2 +- .../download/fabric/FabricInstallTask.java | 4 + .../download/forge/ForgeBMCLVersionList.java | 5 - .../hmcl/download/forge/ForgeInstallTask.java | 2 +- .../hmcl/download/forge/ForgeVersionList.java | 2 +- .../hmcl/download/game/GameLibrariesTask.java | 17 +- .../hmcl/download/game/GameVersionList.java | 7 +- .../java/mojang/MojangJavaDownloadTask.java | 8 +- .../java/mojang/MojangJavaRemoteVersion.java | 2 +- .../LegacyFabricAPIInstallTask.java | 61 + .../LegacyFabricAPIRemoteVersion.java | 67 + .../LegacyFabricAPIVersionList.java | 53 + .../legacyfabric/LegacyFabricInstallTask.java | 130 ++ .../LegacyFabricRemoteVersion.java | 44 + .../legacyfabric/LegacyFabricVersionList.java | 106 ++ .../liteloader/LiteLoaderBMCLVersionList.java | 13 +- .../liteloader/LiteLoaderVersionList.java | 2 +- .../neoforge/NeoForgeBMCLVersionList.java | 5 - .../neoforge/NeoForgeOfficialVersionList.java | 13 +- .../neoforge/NeoForgeRemoteVersion.java | 2 +- .../optifine/OptiFineBMCLVersionList.java | 2 +- .../download/quilt/QuiltAPIInstallTask.java | 2 +- .../hmcl/download/quilt/QuiltInstallTask.java | 4 + .../hmcl/game/DefaultGameRepository.java | 4 + .../jackhuang/hmcl/game/GameJavaVersion.java | 62 +- .../hmcl/game/JavaVersionConstraint.java | 16 +- .../jackhuang/hmcl/game/LaunchOptions.java | 82 +- .../org/jackhuang/hmcl/game/ProxyOption.java | 63 + .../jackhuang/hmcl/game/QuickPlayOption.java | 32 + .../java/org/jackhuang/hmcl/game/World.java | 182 +- .../hmcl/launch/DefaultLauncher.java | 147 +- .../java/org/jackhuang/hmcl/mod/Datapack.java | 2 +- .../org/jackhuang/hmcl/mod/LocalModFile.java | 21 +- .../hmcl/mod/MinecraftInstanceTask.java | 49 +- .../org/jackhuang/hmcl/mod/ModLoaderType.java | 3 +- .../org/jackhuang/hmcl/mod/ModManager.java | 16 +- .../java/org/jackhuang/hmcl/mod/Modpack.java | 2 +- .../hmcl/mod/ModpackInstallTask.java | 16 +- .../hmcl/mod/RemoteModRepository.java | 1 + .../curse/CurseForgeRemoteModRepository.java | 151 +- .../hmcl/mod/curse/CurseModpackProvider.java | 2 +- .../mod/mcbbs/McbbsModpackExportTask.java | 2 + .../hmcl/mod/mcbbs/McbbsModpackManifest.java | 2 +- .../hmcl/mod/modinfo/FabricModMetadata.java | 12 +- .../hmcl/mod/modinfo/ForgeNewModMetadata.java | 112 +- .../hmcl/mod/modinfo/ForgeOldModMetadata.java | 12 +- .../hmcl/mod/modinfo/LiteModMetadata.java | 29 +- .../hmcl/mod/modinfo/PackMcMeta.java | 67 +- .../hmcl/mod/modinfo/QuiltModMetadata.java | 12 +- .../mod/modrinth/ModrinthInstallTask.java | 32 +- .../mod/modrinth/ModrinthModpackProvider.java | 6 +- .../modrinth/ModrinthRemoteModRepository.java | 114 +- .../mod/multimc/MultiMCModpackProvider.java | 2 +- .../mod/server/ServerModpackManifest.java | 2 +- .../hmcl/resourcepack/ResourcepackFile.java | 30 + .../hmcl/resourcepack/ResourcepackFolder.java | 60 + .../resourcepack/ResourcepackZipFile.java | 72 + .../org/jackhuang/hmcl/task/FetchTask.java | 44 +- .../jackhuang/hmcl/task/FileDownloadTask.java | 2 +- .../jackhuang/hmcl/util/FutureCallback.java | 27 +- .../org/jackhuang/hmcl/util/Log4jLevel.java | 24 +- .../org/jackhuang/hmcl/util/MurmurHash2.java | 36 +- .../hmcl/util/io/CompressingUtils.java | 31 +- .../org/jackhuang/hmcl/util/io/FileUtils.java | 24 +- .../jackhuang/hmcl/util/io/NetworkUtils.java | 12 +- .../org/jackhuang/hmcl/util/io/Unzipper.java | 160 +- .../org/jackhuang/hmcl/util/io/Zipper.java | 44 +- .../hmcl/util/platform/CommandBuilder.java | 13 +- .../hmcl/util/platform/ManagedProcess.java | 21 +- .../hmcl/util/platform/SystemUtils.java | 7 +- .../hmcl/util/platform/windows/Dwmapi.java | 32 + .../util/platform/windows/WinConstants.java | 4 + .../hmcl/util/platform/windows/WinTypes.java | 92 + .../hmcl/util/tree/ArchiveFileTree.java | 44 +- .../util/versioning/GameVersionNumber.java | 463 +++-- .../assets/game/log4j2-1.12-debug.xml | 37 + .../assets/game/log4j2-1.7-debug.xml | 36 + .../assets/game/unlisted-versions.json | 74 +- .../resources/assets/game/version-alias.csv | 22 + .../main/resources/assets/game/versions.txt | 62 +- .../main/resources/assets/platform/amdgpu.ids | 13 +- .../jackhuang/hmcl/util/io/FileUtilsTest.java | 2 +- .../versioning/GameVersionNumberTest.java | 280 ++- buildSrc/build.gradle.kts | 1 + .../gradle/TerracottaConfigUpgradeTask.java | 167 ++ config/project.properties | 2 +- docs/Building.md | 56 - docs/Building_zh.md | 56 - docs/Contributing.md | 93 + docs/{Debug_zh.md => Contributing_zh.md} | 67 +- docs/PLATFORM.md | 6 +- docs/PLATFORM_zh.md | 6 +- docs/PLATFORM_zh_Hant.md | 6 +- docs/README.md | 78 +- docs/README_en_Qabs.md | 6 +- docs/README_es.md | 53 +- docs/README_ja.md | 54 +- docs/README_lzh.md | 71 +- docs/README_ru.md | 54 +- docs/README_uk.md | 47 +- docs/README_zh.md | 79 +- docs/README_zh_Hant.md | 78 +- gradle/libs.versions.toml | 7 +- 352 files changed, 19394 insertions(+), 6377 deletions(-) create mode 100644 .cnb/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/workflows/check-update.yml delete mode 100644 .github/workflows/gitee.yml create mode 100644 .github/workflows/mirror.yml create mode 100644 .github/workflows/release.yml create mode 100644 HMCL/src/main/java/com/jfoenix/controls/JFXButton.java create mode 100644 HMCL/src/main/java/com/jfoenix/controls/JFXClippedPane.java create mode 100644 HMCL/src/main/java/com/jfoenix/controls/JFXColorPicker.java create mode 100644 HMCL/src/main/java/com/jfoenix/controls/JFXComboBox.java create mode 100644 HMCL/src/main/java/com/jfoenix/controls/JFXPasswordField.java create mode 100644 HMCL/src/main/java/com/jfoenix/controls/JFXPopup.java create mode 100644 HMCL/src/main/java/com/jfoenix/controls/JFXRippler.java create mode 100644 HMCL/src/main/java/com/jfoenix/controls/JFXTextArea.java create mode 100644 HMCL/src/main/java/com/jfoenix/controls/JFXTextField.java create mode 100644 HMCL/src/main/java/com/jfoenix/controls/JFXToggleButton.java create mode 100644 HMCL/src/main/java/com/jfoenix/controls/behavior/JFXGenericPickerBehavior.java create mode 100644 HMCL/src/main/java/com/jfoenix/effects/JFXDepthManager.java create mode 100644 HMCL/src/main/java/com/jfoenix/skins/JFXButtonSkin.java create mode 100644 HMCL/src/main/java/com/jfoenix/skins/JFXCheckBoxSkin.java create mode 100644 HMCL/src/main/java/com/jfoenix/skins/JFXColorPalette.java create mode 100644 HMCL/src/main/java/com/jfoenix/skins/JFXColorPickerSkin.java create mode 100644 HMCL/src/main/java/com/jfoenix/skins/JFXColorPickerUI.java create mode 100644 HMCL/src/main/java/com/jfoenix/skins/JFXCustomColorPicker.java create mode 100644 HMCL/src/main/java/com/jfoenix/skins/JFXCustomColorPickerDialog.java create mode 100644 HMCL/src/main/java/com/jfoenix/skins/JFXGenericPickerSkin.java create mode 100644 HMCL/src/main/java/com/jfoenix/skins/JFXPopupSkin.java create mode 100644 HMCL/src/main/java/com/jfoenix/skins/JFXRadioButtonSkin.java create mode 100644 HMCL/src/main/java/com/jfoenix/skins/JFXToggleButtonSkin.java create mode 100644 HMCL/src/main/java/com/jfoenix/transitions/CacheMemento.java create mode 100644 HMCL/src/main/java/com/jfoenix/transitions/JFXAnimationTimer.java create mode 100644 HMCL/src/main/java/com/jfoenix/transitions/JFXKeyFrame.java create mode 100644 HMCL/src/main/java/com/jfoenix/transitions/JFXKeyValue.java create mode 100644 HMCL/src/main/java/com/jfoenix/utils/JFXNodeUtils.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/setting/Theme.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaBundle.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaNative.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/AbstractTerracottaProvider.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/ITerracottaProvider.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/theme/Theme.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeColor.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/theme/Themes.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/DialogUtils.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPage.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPageSkin.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/WindowsNativeUtils.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FloatListCell.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/JFXCheckBoxTableCell.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsView.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameItemSkin.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListCell.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListItemSkin.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListPopupMenu.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ResourcepackListPage.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListItem.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListItemSkin.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/util/RemoteImageLoader.java create mode 100644 HMCL/src/main/resources/assets/css/brightness-dark.css create mode 100644 HMCL/src/main/resources/assets/css/brightness-light.css create mode 100644 HMCL/src/main/resources/assets/img/github-white.png create mode 100644 HMCL/src/main/resources/assets/img/github-white@2x.png create mode 100644 HMCL/src/main/resources/assets/img/legacyfabric.png create mode 100644 HMCL/src/main/resources/assets/img/legacyfabric@2x.png create mode 100644 HMCL/src/main/resources/assets/lang/I18N_ar.properties create mode 100644 HMCL/src/test/java/org/jackhuang/hmcl/setting/ThemeColorTest.java create mode 100644 HMCL/terracotta-template.json delete mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/download/AdaptedDownloadProvider.java delete mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/download/BalancedDownloadProvider.java create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIInstallTask.java create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIRemoteVersion.java create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIVersionList.java create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricInstallTask.java create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricRemoteVersion.java create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricVersionList.java create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/game/ProxyOption.java create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/game/QuickPlayOption.java create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackFile.java create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackFolder.java create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackZipFile.java create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/windows/Dwmapi.java create mode 100644 HMCLCore/src/main/resources/assets/game/log4j2-1.12-debug.xml create mode 100644 HMCLCore/src/main/resources/assets/game/log4j2-1.7-debug.xml create mode 100644 HMCLCore/src/main/resources/assets/game/version-alias.csv create mode 100644 buildSrc/src/main/java/org/jackhuang/hmcl/gradle/TerracottaConfigUpgradeTask.java delete mode 100644 docs/Building.md delete mode 100644 docs/Building_zh.md create mode 100644 docs/Contributing.md rename docs/{Debug_zh.md => Contributing_zh.md} (80%) diff --git a/.cnb/ISSUE_TEMPLATE/config.yml b/.cnb/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..c10b7c3d1b --- /dev/null +++ b/.cnb/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: QQ 群 + url: https://docs.hmcl.net/groups.html + about: Hello Minecraft! Launcher 的官方 QQ 交流群。 + - name: Discord 服务器 + url: https://discord.gg/jVvC7HfM6U + about: Hello Minecraft! Launcher 的官方 Discord 服务器。 diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 2d29fd3706..3429f2f4fa 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -9,15 +9,15 @@ body: value: | 提交前请确认: - * 该问题确实是 **HMCL 的错误**,而**不是 Minecraft 非正常退出**,如果你的 Minecraft 非正常退出,请前往 [QQ 群](https://docs.hmcl.net/groups.html)/[Discord 服务器](https://discord.gg/jVvC7HfM6U) 获取帮助。 - * 你的启动器版本是**最新的预览版本**,可以点击 [此处](https://zkitefly.github.io/HMCL-Snapshot-Update/) 下载最新预览版本。 + * 该问题确实是 **HMCL 的错误**,而**不是 Minecraft 非正常退出**。如果你的 Minecraft 非正常退出,请前往 [QQ 群](https://docs.hmcl.net/groups.html)/[Discord 服务器](https://discord.gg/jVvC7HfM6U) 获取帮助。 + * 你的启动器版本是**最新的预览版本**。你可以从 [GitHub Actions](https://github.com/HMCL-dev/HMCL/actions/workflows/gradle.yml?query=branch%3Amain+event%3Apush) 或 [nightly.link](https://nightly.link/HMCL-dev/HMCL/workflows/gradle/main) 下载最新预览版本。 如果你的问题并不属于上述两类,你可以选取另一种 Issue 类型,或者直接前往 [QQ 群](https://docs.hmcl.net/groups.html)/[Discord 服务器](https://discord.gg/jVvC7HfM6U) 获取帮助。 Before submitting, please confirm: * The issue is indeed **a bug of HMCL**, not **Minecraft abnormal exit**. If your Minecraft exits abnormally, please go to the [QQ group](https://docs.hmcl.net/groups.html) or [Discord server](https://discord.gg/jVvC7HfM6U) for help. - * Your launcher is the **latest nightly build**. You can click [here](https://zkitefly.github.io/HMCL-Snapshot-Update/en) to download the latest nightly build. + * Your launcher is the **latest nightly build**. You can download the latest nightly build from [GitHub Actions](https://github.com/HMCL-dev/HMCL/actions/workflows/gradle.yml?query=branch%3Amain+event%3Apush) or [nightly.link](https://nightly.link/HMCL-dev/HMCL/workflows/gradle/main). If your issue does not fall into the above two categories, you can choose another type of issue or directly go to the [QQ group](https://docs.hmcl.net/groups.html) or [Discord server](https://discord.gg/jVvC7HfM6U) for help. - type: textarea @@ -38,7 +38,7 @@ body: attributes: label: 启动器崩溃报告 / 启动器日志文件 | Launcher Crash Report / Launcher Log File description: | - 如果你的启动器崩溃了,请将崩溃报告填入(或将文件拖入)下方。 + 如果你的启动器崩溃了,请将崩溃报告填入 (或将文件拖入) 下方。 如果你的启动器没有崩溃,请在遇到问题后**不要退出启动器**,在启动器的 “设置 → 通用 → 调试” 一栏中点击 “导出启动器日志”,并将导出的日志拖到下方的输入栏中。 **请注意:启动器崩溃报告或日志文件是诊断问题的重要依据,请务必上传!** diff --git a/.github/workflows/check-update.yml b/.github/workflows/check-update.yml deleted file mode 100644 index 45cc1b7836..0000000000 --- a/.github/workflows/check-update.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: Check Update - -on: - workflow_dispatch: -# schedule: -# - cron: '30 * * * *' - -permissions: - contents: write - -jobs: - check-update: - if: ${{ github.repository_owner == 'HMCL-dev' }} - strategy: - fail-fast: false - max-parallel: 1 - matrix: - include: - - channel: dev - task: checkUpdateDev - - channel: stable - task: checkUpdateStable - runs-on: ubuntu-latest - name: check-update-${{ matrix.channel }} - steps: - - uses: actions/checkout@v5 - - name: Set up JDK - uses: actions/setup-java@v5 - with: - distribution: 'temurin' - java-version: '25' - - name: Fetch tags - run: git fetch --all --tags - - name: Fetch last version - run: ./gradlew ${{ matrix.task }} --no-daemon --info --stacktrace - - name: Check for existing tags - run: if [ -z "$(git tag -l "$HMCL_TAG_NAME")" ]; then echo "continue=true" >> $GITHUB_ENV; fi - - name: Download artifacts - if: ${{ env.continue == 'true' }} - run: | - wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe" - wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe.sha256" - wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar" - wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256" - wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh" - wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256" - env: - GH_DOWNLOAD_BASE_URL: https://github.com/HMCL-dev/HMCL/releases/download - - name: Generate release note - if: ${{ env.continue == 'true' }} - run: | - if [ "${{ matrix.channel }}" = "stable" ]; then - echo "**This version is a stable version.**" >> RELEASE_NOTE - echo "" >> RELEASE_NOTE - fi - echo "The full changelogs can be found on the website: https://docs.hmcl.net/changelog/${{ matrix.channel }}.html" >> RELEASE_NOTE - echo "" >> RELEASE_NOTE - echo "*Notice: changelogs are written in Chinese.*" >> RELEASE_NOTE - echo "" >> RELEASE_NOTE - echo "| File Name | SHA-256 Checksum |" >> RELEASE_NOTE - echo "| --- | --- |" >> RELEASE_NOTE - echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE - echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE - echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE - env: - GH_DOWNLOAD_BASE_URL: https://github.com/HMCL-dev/HMCL/releases/download - - name: Create release - if: ${{ env.continue == 'true' }} - uses: softprops/action-gh-release@v2 - with: - body_path: RELEASE_NOTE - files: | - HMCL-${{ env.HMCL_VERSION }}.exe - HMCL-${{ env.HMCL_VERSION }}.jar - HMCL-${{ env.HMCL_VERSION }}.sh - target_commitish: ${{ env.HMCL_COMMIT_SHA }} - name: ${{ env.HMCL_TAG_NAME }} - tag_name: ${{ env.HMCL_TAG_NAME }} - prerelease: true diff --git a/.github/workflows/gitee.yml b/.github/workflows/gitee.yml deleted file mode 100644 index fb0660c490..0000000000 --- a/.github/workflows/gitee.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Sync to Gitee - -on: - push - -jobs: - run: - if: ${{ github.repository_owner == 'HMCL-dev' }} - runs-on: ubuntu-latest - steps: - - name: Mirror GitHub to Gitee - uses: Yikun/hub-mirror-action@v1.4 - with: - src: github/HMCL-dev - dst: gitee/huanghongxun - static_list: 'HMCL' - force_update: true - debug: true - dst_key: ${{ secrets.GITEE_SYNC_BOT_PRIVATE_KEY }} - dst_token: ${{ secrets.GITEE_SYNC_BOT_TOKEN }} - cache_path: /github/workspace/hub-mirror-cache diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml new file mode 100644 index 0000000000..bc7dca628d --- /dev/null +++ b/.github/workflows/mirror.yml @@ -0,0 +1,33 @@ +name: Mirror Repository + +on: + workflow_dispatch: + push: + +concurrency: + group: mirror-repository + cancel-in-progress: true + +jobs: + mirror: + strategy: + fail-fast: false + matrix: + include: + - name: Gitee + repo: gitee.com/huanghongxun/HMCL + user: 'hmcl-sync' + token: 'GITEE_SYNC_TOKEN' + - name: CNB + repo: cnb.cool/HMCL-dev/HMCL + user: 'cnb' + token: 'CNB_SYNC_TOKEN' + name: Mirror to ${{ matrix.name }} + if: ${{ github.repository == 'HMCL-dev/HMCL' }} + runs-on: ubuntu-latest + steps: + - name: Mirror GitHub to ${{ matrix.name }} + run: | + git clone --mirror "https://github.com/${{ github.repository }}.git" -- repo + cd repo + git push -f --prune "https://${{ matrix.user }}:${{ secrets[matrix.token] }}@${{ matrix.repo }}.git" "refs/heads/*:refs/heads/*" "refs/tags/*:refs/tags/*" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..6a869921e7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,125 @@ +name: Create Release + +on: + workflow_dispatch: +# schedule: +# - cron: '30 * * * *' + +permissions: + contents: write + +jobs: + check-update: + if: ${{ github.repository_owner == 'HMCL-dev' }} + strategy: + fail-fast: false + max-parallel: 1 + matrix: + include: + - channel: dev + task: checkUpdateDev + - channel: stable + task: checkUpdateStable + runs-on: ubuntu-latest + name: check-update-${{ matrix.channel }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + - name: Set up JDK + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '25' + - name: Fetch last version + run: ./gradlew ${{ matrix.task }} --no-daemon --info --stacktrace + - name: Check for existing tags + run: if [ -z "$(git tag -l "$HMCL_TAG_NAME")" ]; then echo "continue=true" >> $GITHUB_ENV; fi + - name: Download artifacts + if: ${{ env.continue == 'true' }} + run: | + wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe" + wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe.sha256" + wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar" + wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256" + wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh" + wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256" + - name: Verify artifacts + if: ${{ env.continue == 'true' }} + run: | + export JAR_SHA256=$(cat HMCL-$HMCL_VERSION.jar.sha256 | tr -d '\n') + export EXE_SHA256=$(cat HMCL-$HMCL_VERSION.exe.sha256 | tr -d '\n') + export SH_SHA256=$(cat HMCL-$HMCL_VERSION.sh.sha256 | tr -d '\n') + + echo "$JAR_SHA256 HMCL-$HMCL_VERSION.jar" | sha256sum -c + echo "$EXE_SHA256 HMCL-$HMCL_VERSION.exe" | sha256sum -c + echo "$SH_SHA256 HMCL-$HMCL_VERSION.sh" | sha256sum -c + - name: Generate release note + if: ${{ env.continue == 'true' }} + run: | + # GitHub Release Note + echo " **[Changelog](https://docs.hmcl.net/changelog/${{ matrix.channel }}.html#HMCL-$HMCL_VERSION)** (Chinese)" >> RELEASE_NOTE + echo "" >> RELEASE_NOTE + echo "| File | SHA-256 Checksum |" >> RELEASE_NOTE + echo "| --- | --- |" >> RELEASE_NOTE + echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE + echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE + echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE + + # CNB Release Note + echo "[更新日志](https://docs.hmcl.net/changelog/${{ matrix.channel }}.html#HMCL-$HMCL_VERSION)" >> CNB_RELEASE_NOTE + echo "" >> CNB_RELEASE_NOTE + echo "| 文件 | SHA-256 校验码 |" >> CNB_RELEASE_NOTE + echo "| :--- | --- |" >> CNB_RELEASE_NOTE + echo "| [HMCL-$HMCL_VERSION.exe]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> CNB_RELEASE_NOTE + echo "| [HMCL-$HMCL_VERSION.jar]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> CNB_RELEASE_NOTE + echo "| [HMCL-$HMCL_VERSION.sh]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> CNB_RELEASE_NOTE + env: + GH_DOWNLOAD_BASE_URL: https://github.com/${{ github.repository }}/releases/download + CNB_DOWNLOAD_BASE_URL: https://cnb.cool/HMCL-dev/HMCL/-/releases/download + - name: Create GitHub release + if: ${{ env.continue == 'true' }} + run: | + gh release create "${{ env.HMCL_TAG_NAME }}" \ + "HMCL-${{ env.HMCL_VERSION }}.exe" \ + "HMCL-${{ env.HMCL_VERSION }}.jar" \ + "HMCL-${{ env.HMCL_VERSION }}.sh" \ + --target "${{ env.HMCL_COMMIT_SHA }}" \ + --title "${{ env.HMCL_TAG_NAME }}" \ + --notes-file RELEASE_NOTE \ + --prerelease + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Install git-cnb + if: ${{ env.continue == 'true' }} + run: go install "cnb.cool/looc/git-cnb@$GIT_CNB_VERSION" + env: + GIT_CNB_VERSION: '1.1.2' + - name: Create CNB release + if: ${{ env.continue == 'true' }} + run: | + echo "Uploading tags to CNB" + git fetch --tags + git push "https://cnb:${{ secrets.CNB_SYNC_TOKEN }}@cnb.cool/$CNB_REPO.git" "$HMCL_TAG_NAME" + + echo "Creating CNB release" + ~/go/bin/git-cnb release create \ + --repo "$CNB_REPO" \ + --tag "$HMCL_TAG_NAME" \ + --name "HMCL $HMCL_VERSION" \ + --body "$(cat CNB_RELEASE_NOTE)" \ + --prerelease true + + echo "Uploading HMCL-$HMCL_VERSION.jar" + ~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.jar" + + echo "Uploading HMCL-$HMCL_VERSION.exe" + ~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.exe" + + echo "Uploading HMCL-$HMCL_VERSION.sh" + ~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.sh" + env: + CNB_TOKEN: ${{ secrets.CNB_SYNC_TOKEN }} + CNB_REPO: HMCL-dev/HMCL diff --git a/HMCL/build.gradle.kts b/HMCL/build.gradle.kts index 89f52fab01..156969060b 100644 --- a/HMCL/build.gradle.kts +++ b/HMCL/build.gradle.kts @@ -1,3 +1,4 @@ +import org.jackhuang.hmcl.gradle.TerracottaConfigUpgradeTask import org.jackhuang.hmcl.gradle.ci.GitHubActionUtils import org.jackhuang.hmcl.gradle.ci.JenkinsUtils import org.jackhuang.hmcl.gradle.l10n.CheckTranslations @@ -58,6 +59,7 @@ dependencies { implementation("libs:JFoenix") implementation(libs.twelvemonkeys.imageio.webp) implementation(libs.java.info) + implementation(libs.monet.fx) if (launcherExe.isBlank()) { implementation(libs.hmclauncher) @@ -117,10 +119,6 @@ tasks.checkstyleMain { exclude("**/org/jackhuang/hmcl/ui/image/apng/**") } -tasks.compileJava { - options.compilerArgs.add("--add-exports=java.base/jdk.internal.loader=ALL-UNNAMED") -} - val addOpens = listOf( "java.base/java.lang", "java.base/java.lang.reflect", @@ -128,15 +126,24 @@ val addOpens = listOf( "javafx.base/com.sun.javafx.binding", "javafx.base/com.sun.javafx.event", "javafx.base/com.sun.javafx.runtime", + "javafx.base/javafx.beans.property", "javafx.graphics/javafx.css", + "javafx.graphics/javafx.stage", + "javafx.graphics/com.sun.glass.ui", "javafx.graphics/com.sun.javafx.stage", + "javafx.graphics/com.sun.javafx.util", "javafx.graphics/com.sun.prism", "javafx.controls/com.sun.javafx.scene.control", "javafx.controls/com.sun.javafx.scene.control.behavior", + "javafx.graphics/com.sun.javafx.tk.quantum", "javafx.controls/javafx.scene.control.skin", "jdk.attach/sun.tools.attach", ) +tasks.compileJava { + options.compilerArgs.addAll(addOpens.map { "--add-exports=$it=ALL-UNNAMED" }) +} + val hmclProperties = buildList { add("hmcl.version" to project.version.toString()) add("hmcl.add-opens" to addOpens.joinToString(" ")) @@ -202,7 +209,8 @@ tasks.shadowJar { "Main-Class" to "org.jackhuang.hmcl.Main", "Multi-Release" to "true", "Add-Opens" to addOpens.joinToString(" "), - "Enable-Native-Access" to "ALL-UNNAMED" + "Enable-Native-Access" to "ALL-UNNAMED", + "Enable-Final-Field-Mutation" to "ALL-UNNAMED", ) if (launcherExe.isNotBlank()) { @@ -233,6 +241,11 @@ tasks.processResources { from(upsideDownTranslate.map { it.outputFile }) from(createLocaleNamesResourceBundle.map { it.outputDirectory }) } + + inputs.property("terracotta_version", libs.versions.terracotta) + doLast { + upgradeTerracottaConfig.get().checkValid() + } } val makeExecutables by tasks.registering { @@ -355,6 +368,26 @@ tasks.register("run") { } } +// terracotta + +val upgradeTerracottaConfig = tasks.register("upgradeTerracottaConfig") { + val destination = layout.projectDirectory.file("src/main/resources/assets/terracotta.json") + val source = layout.projectDirectory.file("terracotta-template.json"); + + classifiers.set(listOf( + "windows-x86_64", "windows-arm64", + "macos-x86_64", "macos-arm64", + "linux-x86_64", "linux-arm64", "linux-loongarch64", "linux-riscv64", + "freebsd-x86_64" + )) + + version.set(libs.versions.terracotta) + downloadURL.set($$"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz") + + templateFile.set(source) + outputFile.set(destination) +} + // Check Translations tasks.register("checkTranslations") { diff --git a/HMCL/src/main/java/com/jfoenix/controls/JFXButton.java b/HMCL/src/main/java/com/jfoenix/controls/JFXButton.java new file mode 100644 index 0000000000..43e30725c6 --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/controls/JFXButton.java @@ -0,0 +1,129 @@ +// +// Source code recreated from a .class file by IntelliJ IDEA +// (powered by Fernflower decompiler) +// + +package com.jfoenix.controls; + +import com.jfoenix.converters.ButtonTypeConverter; +import com.jfoenix.skins.JFXButtonSkin; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.css.CssMetaData; +import javafx.css.SimpleStyleableObjectProperty; +import javafx.css.Styleable; +import javafx.css.StyleableObjectProperty; +import javafx.css.StyleableProperty; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Control; +import javafx.scene.control.Labeled; +import javafx.scene.control.Skin; +import javafx.scene.paint.Paint; + +public class JFXButton extends Button { + private static final String DEFAULT_STYLE_CLASS = "jfx-button"; + + private List> STYLEABLES; + + public JFXButton() { + this.initialize(); + } + + public JFXButton(String text) { + super(text); + this.initialize(); + } + + public JFXButton(String text, Node graphic) { + super(text, graphic); + this.initialize(); + } + + private void initialize() { + this.getStyleClass().add(DEFAULT_STYLE_CLASS); + } + + protected Skin createDefaultSkin() { + return new JFXButtonSkin(this); + } + + private final ObjectProperty ripplerFill = new SimpleObjectProperty<>(this, "ripplerFill", null); + + public final ObjectProperty ripplerFillProperty() { + return this.ripplerFill; + } + + public final Paint getRipplerFill() { + return this.ripplerFillProperty().get(); + } + + public final void setRipplerFill(Paint ripplerFill) { + this.ripplerFillProperty().set(ripplerFill); + } + + private final StyleableObjectProperty buttonType = new SimpleStyleableObjectProperty<>( + JFXButton.StyleableProperties.BUTTON_TYPE, this, "buttonType", JFXButton.ButtonType.FLAT); + + public ButtonType getButtonType() { + return this.buttonType == null ? JFXButton.ButtonType.FLAT : this.buttonType.get(); + } + + public StyleableObjectProperty buttonTypeProperty() { + return this.buttonType; + } + + public void setButtonType(ButtonType type) { + this.buttonType.set(type); + } + + public List> getControlCssMetaData() { + if (this.STYLEABLES == null) { + List> styleables = new ArrayList<>(Control.getClassCssMetaData()); + styleables.addAll(getClassCssMetaData()); + styleables.addAll(Labeled.getClassCssMetaData()); + this.STYLEABLES = List.copyOf(styleables); + } + + return this.STYLEABLES; + } + + public static List> getClassCssMetaData() { + return JFXButton.StyleableProperties.CHILD_STYLEABLES; + } + + protected void layoutChildren() { + super.layoutChildren(); + this.setNeedsLayout(false); + } + + public enum ButtonType { + FLAT, + RAISED; + } + + private static final class StyleableProperties { + private static final CssMetaData BUTTON_TYPE; + private static final List> CHILD_STYLEABLES; + + static { + BUTTON_TYPE = new CssMetaData<>("-jfx-button-type", ButtonTypeConverter.getInstance(), JFXButton.ButtonType.FLAT) { + public boolean isSettable(JFXButton control) { + return control.buttonType == null || !control.buttonType.isBound(); + } + + public StyleableProperty getStyleableProperty(JFXButton control) { + return control.buttonTypeProperty(); + } + }; + List> styleables = new ArrayList<>(Control.getClassCssMetaData()); + Collections.addAll(styleables, BUTTON_TYPE); + CHILD_STYLEABLES = List.copyOf(styleables); + } + } +} diff --git a/HMCL/src/main/java/com/jfoenix/controls/JFXClippedPane.java b/HMCL/src/main/java/com/jfoenix/controls/JFXClippedPane.java new file mode 100644 index 0000000000..4dc67de307 --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/controls/JFXClippedPane.java @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.controls; + +import com.jfoenix.utils.JFXNodeUtils; +import javafx.geometry.Insets; +import javafx.scene.Node; +import javafx.scene.layout.*; +import javafx.scene.paint.Color; + +/** + * JFXClippedPane is a StackPane that clips its content if exceeding the pane bounds. + * + * @author Shadi Shaheen + * @version 1.0 + * @since 2018-06-02 + */ +public class JFXClippedPane extends StackPane { + + private final Region clip = new Region(); + + public JFXClippedPane() { + super(); + init(); + } + + public JFXClippedPane(Node... children) { + super(children); + init(); + } + + private void init() { + setClip(clip); + clip.setBackground(new Background(new BackgroundFill(Color.BLACK, new CornerRadii(2), Insets.EMPTY))); + backgroundProperty().addListener(observable -> JFXNodeUtils.updateBackground(getBackground(), clip)); + } + + @Override + protected void layoutChildren() { + super.layoutChildren(); + clip.resizeRelocate(0, 0, getWidth(), getHeight()); + } +} diff --git a/HMCL/src/main/java/com/jfoenix/controls/JFXColorPicker.java b/HMCL/src/main/java/com/jfoenix/controls/JFXColorPicker.java new file mode 100644 index 0000000000..18f32ca94b --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/controls/JFXColorPicker.java @@ -0,0 +1,145 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.controls; + +import com.jfoenix.skins.JFXColorPickerSkin; +import javafx.css.CssMetaData; +import javafx.css.SimpleStyleableBooleanProperty; +import javafx.css.Styleable; +import javafx.css.StyleableBooleanProperty; +import javafx.css.converter.BooleanConverter; +import javafx.scene.control.ColorPicker; +import javafx.scene.control.Skin; +import javafx.scene.paint.Color; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * JFXColorPicker is the metrial design implementation of color picker. + * + * @author Shadi Shaheen + * @version 1.0 + * @since 2016-03-09 + */ +public class JFXColorPicker extends ColorPicker { + + /** + * {@inheritDoc} + */ + public JFXColorPicker() { + initialize(); + } + + /** + * {@inheritDoc} + */ + public JFXColorPicker(Color color) { + super(color); + initialize(); + } + + /** + * {@inheritDoc} + */ + @Override + protected Skin createDefaultSkin() { + return new JFXColorPickerSkin(this); + } + + private void initialize() { + this.getStyleClass().add(DEFAULT_STYLE_CLASS); + } + + /** + * Initialize the style class to 'jfx-color-picker'. + *

+ * This is the selector class from which CSS can be used to style + * this control. + */ + private static final String DEFAULT_STYLE_CLASS = "jfx-color-picker"; + + private double[] preDefinedColors = null; + + public double[] getPreDefinedColors() { + return preDefinedColors; + } + + public void setPreDefinedColors(double[] preDefinedColors) { + this.preDefinedColors = preDefinedColors; + } + + /** + * disable animation on button action + */ + private final StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION, + JFXColorPicker.this, + "disableAnimation", + false); + + public final StyleableBooleanProperty disableAnimationProperty() { + return this.disableAnimation; + } + + public final Boolean isDisableAnimation() { + return disableAnimation != null && this.disableAnimationProperty().get(); + } + + public final void setDisableAnimation(final Boolean disabled) { + this.disableAnimationProperty().set(disabled); + } + + private static final class StyleableProperties { + + private static final CssMetaData DISABLE_ANIMATION = + new CssMetaData("-jfx-disable-animation", + BooleanConverter.getInstance(), false) { + @Override + public boolean isSettable(JFXColorPicker control) { + return control.disableAnimation == null || !control.disableAnimation.isBound(); + } + + @Override + public StyleableBooleanProperty getStyleableProperty(JFXColorPicker control) { + return control.disableAnimationProperty(); + } + }; + + + private static final List> CHILD_STYLEABLES; + + static { + final List> styleables = + new ArrayList<>(ColorPicker.getClassCssMetaData()); + Collections.addAll(styleables, DISABLE_ANIMATION); + CHILD_STYLEABLES = Collections.unmodifiableList(styleables); + } + } + + @Override + public List> getControlCssMetaData() { + return getClassCssMetaData(); + } + + public static List> getClassCssMetaData() { + return StyleableProperties.CHILD_STYLEABLES; + } +} diff --git a/HMCL/src/main/java/com/jfoenix/controls/JFXComboBox.java b/HMCL/src/main/java/com/jfoenix/controls/JFXComboBox.java new file mode 100644 index 0000000000..8c9b634cfb --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/controls/JFXComboBox.java @@ -0,0 +1,357 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.controls; + +import com.jfoenix.converters.base.NodeConverter; +import com.jfoenix.skins.JFXComboBoxListViewSkin; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.ObservableList; +import javafx.css.*; +import javafx.css.converter.BooleanConverter; +import javafx.css.converter.PaintConverter; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.layout.Background; +import javafx.scene.layout.BackgroundFill; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; +import javafx.scene.paint.Paint; +import javafx.util.StringConverter; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu; + +/** + * JFXComboBox is the material design implementation of a combobox. + * + * @author Shadi Shaheen + * @version 1.0 + * @since 2016-03-09 + */ +public class JFXComboBox extends ComboBox { + + /** + * {@inheritDoc} + */ + public JFXComboBox() { + initialize(); + } + + /** + * {@inheritDoc} + */ + public JFXComboBox(ObservableList items) { + super(items); + initialize(); + } + + private void initialize() { + getStyleClass().add(DEFAULT_STYLE_CLASS); + this.setCellFactory(listView -> new JFXListCell() { + @Override + public void updateItem(T item, boolean empty) { + super.updateItem(item, empty); + updateDisplayText(this, item, empty); + } + }); + + // had to refactor the code out of the skin class to allow + // customization of the button cell + this.setButtonCell(new ListCell() { + { + // fixed clearing the combo box value is causing + // java prompt text to be shown because the button cell is not updated + JFXComboBox.this.valueProperty().addListener(observable -> { + if (JFXComboBox.this.getValue() == null) { + updateItem(null, true); + } + }); + } + + @Override + protected void updateItem(T item, boolean empty) { + updateDisplayText(this, item, empty); + this.setVisible(item != null || !empty); + } + + }); + + useJFXContextMenu(editorProperty().get()); + } + + /** + * {@inheritDoc} + */ + @Override + protected Skin createDefaultSkin() { + return new JFXComboBoxListViewSkin(this); + } + + /** + * Initialize the style class to 'jfx-combo-box'. + *

+ * This is the selector class from which CSS can be used to style + * this control. + */ + private static final String DEFAULT_STYLE_CLASS = "jfx-combo-box"; + + /*************************************************************************** + * * + * Node Converter Property * + * * + **************************************************************************/ + /** + * Converts the user-typed input (when the ComboBox is + * {@link #editableProperty() editable}) to an object of type T, such that + * the input may be retrieved via the {@link #valueProperty() value} property. + */ + public ObjectProperty> nodeConverterProperty() { + return nodeConverter; + } + + private ObjectProperty> nodeConverter = new SimpleObjectProperty<>(this, "nodeConverter", + JFXComboBox.defaultNodeConverter()); + + public final void setNodeConverter(NodeConverter value) { + nodeConverterProperty().set(value); + } + + public final NodeConverter getNodeConverter() { + return nodeConverterProperty().get(); + } + + private static NodeConverter defaultNodeConverter() { + return new NodeConverter() { + @Override + public Node toNode(T object) { + if (object == null) { + return null; + } + StackPane selectedValueContainer = new StackPane(); + selectedValueContainer.getStyleClass().add("combo-box-selected-value-container"); + selectedValueContainer.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, null, null))); + Label selectedValueLabel = object instanceof Label ? new Label(((Label) object).getText()) : new Label( + object.toString()); + selectedValueLabel.setTextFill(Color.BLACK); + selectedValueContainer.getChildren().add(selectedValueLabel); + StackPane.setAlignment(selectedValueLabel, Pos.CENTER_LEFT); + StackPane.setMargin(selectedValueLabel, new Insets(0, 0, 0, 5)); + return selectedValueContainer; + } + + @SuppressWarnings("unchecked") + @Override + public T fromNode(Node node) { + return (T) node; + } + + @Override + public String toString(T object) { + if (object == null) { + return null; + } + if (object instanceof Label) { + return ((Label) object).getText(); + } + return object.toString(); + } + }; + } + + private boolean updateDisplayText(ListCell cell, T item, boolean empty) { + if (empty) { + // create empty cell + if (cell == null) { + return true; + } + cell.setGraphic(null); + cell.setText(null); + return true; + } else if (item instanceof Node) { + Node currentNode = cell.getGraphic(); + Node newNode = (Node) item; + // create a node from the selected node of the listview + // using JFXComboBox {@link #nodeConverterProperty() NodeConverter}) + NodeConverter nc = this.getNodeConverter(); + Node node = nc == null ? null : nc.toNode(item); + if (currentNode == null || !currentNode.equals(newNode)) { + cell.setText(null); + cell.setGraphic(node == null ? newNode : node); + } + return node == null; + } else { + // run item through StringConverter if it isn't null + StringConverter c = this.getConverter(); + String s = item == null ? this.getPromptText() : (c == null ? item.toString() : c.toString(item)); + cell.setText(s); + cell.setGraphic(null); + return s == null || s.isEmpty(); + } + } + + /*************************************************************************** + * * + * styleable Properties * + * * + **************************************************************************/ + + /** + * set true to show a float the prompt text when focusing the field + */ + private StyleableBooleanProperty labelFloat = new SimpleStyleableBooleanProperty(StyleableProperties.LABEL_FLOAT, + JFXComboBox.this, + "lableFloat", + false); + + public final StyleableBooleanProperty labelFloatProperty() { + return this.labelFloat; + } + + public final boolean isLabelFloat() { + return this.labelFloatProperty().get(); + } + + public final void setLabelFloat(final boolean labelFloat) { + this.labelFloatProperty().set(labelFloat); + } + + /** + * default color used when the field is unfocused + */ + private StyleableObjectProperty unFocusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNFOCUS_COLOR, + JFXComboBox.this, + "unFocusColor", + Color.rgb(77, + 77, + 77)); + + public Paint getUnFocusColor() { + return unFocusColor == null ? Color.rgb(77, 77, 77) : unFocusColor.get(); + } + + public StyleableObjectProperty unFocusColorProperty() { + return this.unFocusColor; + } + + public void setUnFocusColor(Paint color) { + this.unFocusColor.set(color); + } + + /** + * default color used when the field is focused + */ + private StyleableObjectProperty focusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.FOCUS_COLOR, + JFXComboBox.this, + "focusColor", + Color.valueOf("#4059A9")); + + public Paint getFocusColor() { + return focusColor == null ? Color.valueOf("#4059A9") : focusColor.get(); + } + + public StyleableObjectProperty focusColorProperty() { + return this.focusColor; + } + + public void setFocusColor(Paint color) { + this.focusColor.set(color); + } + + private final static class StyleableProperties { + private static final CssMetaData, Paint> UNFOCUS_COLOR = new CssMetaData, Paint>( + "-jfx-unfocus-color", + PaintConverter.getInstance(), + Color.valueOf("#A6A6A6")) { + @Override + public boolean isSettable(JFXComboBox control) { + return control.unFocusColor == null || !control.unFocusColor.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(JFXComboBox control) { + return control.unFocusColorProperty(); + } + }; + private static final CssMetaData, Paint> FOCUS_COLOR = new CssMetaData, Paint>( + "-jfx-focus-color", + PaintConverter.getInstance(), + Color.valueOf("#3f51b5")) { + @Override + public boolean isSettable(JFXComboBox control) { + return control.focusColor == null || !control.focusColor.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(JFXComboBox control) { + return control.focusColorProperty(); + } + }; + private static final CssMetaData, Boolean> LABEL_FLOAT = new CssMetaData, Boolean>( + "-jfx-label-float", + BooleanConverter.getInstance(), + false) { + @Override + public boolean isSettable(JFXComboBox control) { + return control.labelFloat == null || !control.labelFloat.isBound(); + } + + @Override + public StyleableBooleanProperty getStyleableProperty(JFXComboBox control) { + return control.labelFloatProperty(); + } + }; + + + private static final List> CHILD_STYLEABLES; + + static { + final List> styleables = new ArrayList<>( + Control.getClassCssMetaData()); + Collections.addAll(styleables, UNFOCUS_COLOR, FOCUS_COLOR, LABEL_FLOAT); + CHILD_STYLEABLES = Collections.unmodifiableList(styleables); + } + } + + // inherit the styleable properties from parent + private List> STYLEABLES; + + @Override + public List> getControlCssMetaData() { + if (STYLEABLES == null) { + final List> styleables = new ArrayList<>( + Control.getClassCssMetaData()); + styleables.addAll(getClassCssMetaData()); + styleables.addAll(Control.getClassCssMetaData()); + STYLEABLES = Collections.unmodifiableList(styleables); + } + return STYLEABLES; + } + + public static List> getClassCssMetaData() { + return StyleableProperties.CHILD_STYLEABLES; + } +} + diff --git a/HMCL/src/main/java/com/jfoenix/controls/JFXPasswordField.java b/HMCL/src/main/java/com/jfoenix/controls/JFXPasswordField.java new file mode 100644 index 0000000000..d256ba13f2 --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/controls/JFXPasswordField.java @@ -0,0 +1,297 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.controls; + +import com.jfoenix.skins.JFXPasswordFieldSkin; +import com.jfoenix.validation.base.ValidatorBase; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.css.*; +import javafx.css.converter.BooleanConverter; +import javafx.css.converter.PaintConverter; +import javafx.scene.control.Control; +import javafx.scene.control.PasswordField; +import javafx.scene.control.Skin; +import javafx.scene.control.TextField; +import javafx.scene.paint.Color; +import javafx.scene.paint.Paint; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu; + +/** + * JFXPasswordField is the material design implementation of a password Field. + * + * @author Shadi Shaheen + * @version 1.0 + * @since 2016-03-09 + */ +public class JFXPasswordField extends PasswordField { + + /** + * {@inheritDoc} + */ + public JFXPasswordField() { + initialize(); + } + + /** + * {@inheritDoc} + */ + @Override + protected Skin createDefaultSkin() { + return new JFXPasswordFieldSkin(this); + } + + private void initialize() { + this.getStyleClass().add(DEFAULT_STYLE_CLASS); + if ("dalvik".equals(System.getProperty("java.vm.name").toLowerCase(Locale.ROOT))) { + this.setStyle("-fx-skin: \"com.jfoenix.android.skins.JFXPasswordFieldSkinAndroid\";"); + } + + + useJFXContextMenu(this); + } + + /** + * Initialize the style class to 'jfx-password-field'. + *

+ * This is the selector class from which CSS can be used to style + * this control. + */ + private static final String DEFAULT_STYLE_CLASS = "jfx-password-field"; + + /*************************************************************************** + * * + * Properties * + * * + **************************************************************************/ + + /** + * holds the current active validator on the password field in case of validation error + */ + private ReadOnlyObjectWrapper activeValidator = new ReadOnlyObjectWrapper<>(); + + public ValidatorBase getActiveValidator() { + return activeValidator == null ? null : activeValidator.get(); + } + + public ReadOnlyObjectProperty activeValidatorProperty() { + return this.activeValidator.getReadOnlyProperty(); + } + + /** + * list of validators that will validate the password value upon calling + * {{@link #validate()} + */ + private ObservableList validators = FXCollections.observableArrayList(); + + public ObservableList getValidators() { + return validators; + } + + public void setValidators(ValidatorBase... validators) { + this.validators.addAll(validators); + } + + /** + * validates the password value using the list of validators provided by the user + * {{@link #setValidators(ValidatorBase...)} + * + * @return true if the value is valid else false + */ + public boolean validate() { + for (ValidatorBase validator : validators) { + if (validator.getSrcControl() == null) { + validator.setSrcControl(this); + } + validator.validate(); + if (validator.getHasErrors()) { + activeValidator.set(validator); + return false; + } + } + activeValidator.set(null); + return true; + } + + public void resetValidation() { + pseudoClassStateChanged(ValidatorBase.PSEUDO_CLASS_ERROR, false); + activeValidator.set(null); + } + + /*************************************************************************** + * * + * styleable Properties * + * * + **************************************************************************/ + + /** + * set true to show a float the prompt text when focusing the field + */ + private StyleableBooleanProperty labelFloat = new SimpleStyleableBooleanProperty(StyleableProperties.LABEL_FLOAT, JFXPasswordField.this, "lableFloat", false); + + public final StyleableBooleanProperty labelFloatProperty() { + return this.labelFloat; + } + + public final boolean isLabelFloat() { + return this.labelFloatProperty().get(); + } + + public final void setLabelFloat(final boolean labelFloat) { + this.labelFloatProperty().set(labelFloat); + } + + /** + * default color used when the field is unfocused + */ + private StyleableObjectProperty unFocusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNFOCUS_COLOR, JFXPasswordField.this, "unFocusColor", Color.rgb(77, 77, 77)); + + public Paint getUnFocusColor() { + return unFocusColor == null ? Color.rgb(77, 77, 77) : unFocusColor.get(); + } + + public StyleableObjectProperty unFocusColorProperty() { + return this.unFocusColor; + } + + public void setUnFocusColor(Paint color) { + this.unFocusColor.set(color); + } + + /** + * default color used when the field is focused + */ + private StyleableObjectProperty focusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.FOCUS_COLOR, JFXPasswordField.this, "focusColor", Color.valueOf("#4059A9")); + + public Paint getFocusColor() { + return focusColor == null ? Color.valueOf("#4059A9") : focusColor.get(); + } + + public StyleableObjectProperty focusColorProperty() { + return this.focusColor; + } + + public void setFocusColor(Paint color) { + this.focusColor.set(color); + } + + /** + * disable animation on validation + */ + private StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION, JFXPasswordField.this, "disableAnimation", false); + + public final StyleableBooleanProperty disableAnimationProperty() { + return this.disableAnimation; + } + + public final Boolean isDisableAnimation() { + return disableAnimation != null && this.disableAnimationProperty().get(); + } + + public final void setDisableAnimation(final Boolean disabled) { + this.disableAnimationProperty().set(disabled); + } + + private final static class StyleableProperties { + private static final CssMetaData UNFOCUS_COLOR = new CssMetaData("-jfx-unfocus-color", PaintConverter.getInstance(), Color.valueOf("#A6A6A6")) { + @Override + public boolean isSettable(JFXPasswordField control) { + return control.unFocusColor == null || !control.unFocusColor.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(JFXPasswordField control) { + return control.unFocusColorProperty(); + } + }; + private static final CssMetaData FOCUS_COLOR = new CssMetaData("-jfx-focus-color", PaintConverter.getInstance(), Color.valueOf("#3f51b5")) { + @Override + public boolean isSettable(JFXPasswordField control) { + return control.focusColor == null || !control.focusColor.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(JFXPasswordField control) { + return control.focusColorProperty(); + } + }; + + private static final CssMetaData LABEL_FLOAT = new CssMetaData("-jfx-label-float", BooleanConverter.getInstance(), false) { + @Override + public boolean isSettable(JFXPasswordField control) { + return control.labelFloat == null || !control.labelFloat.isBound(); + } + + @Override + public StyleableBooleanProperty getStyleableProperty(JFXPasswordField control) { + return control.labelFloatProperty(); + } + }; + + private static final CssMetaData DISABLE_ANIMATION = new CssMetaData("-fx-disable-animation", BooleanConverter.getInstance(), false) { + @Override + public boolean isSettable(JFXPasswordField control) { + return control.disableAnimation == null || !control.disableAnimation.isBound(); + } + + @Override + public StyleableBooleanProperty getStyleableProperty(JFXPasswordField control) { + return control.disableAnimationProperty(); + } + }; + + + private static final List> CHILD_STYLEABLES; + + static { + final List> styleables = new ArrayList<>(Control.getClassCssMetaData()); + Collections.addAll(styleables, UNFOCUS_COLOR, FOCUS_COLOR, LABEL_FLOAT, DISABLE_ANIMATION); + CHILD_STYLEABLES = Collections.unmodifiableList(styleables); + } + } + + // inherit the styleable properties from parent + private List> STYLEABLES; + + @Override + public List> getControlCssMetaData() { + if (STYLEABLES == null) { + final List> styleables = new ArrayList<>(Control.getClassCssMetaData()); + styleables.addAll(getClassCssMetaData()); + styleables.addAll(TextField.getClassCssMetaData()); + STYLEABLES = Collections.unmodifiableList(styleables); + } + return STYLEABLES; + } + + public static List> getClassCssMetaData() { + return StyleableProperties.CHILD_STYLEABLES; + } + +} + diff --git a/HMCL/src/main/java/com/jfoenix/controls/JFXPopup.java b/HMCL/src/main/java/com/jfoenix/controls/JFXPopup.java new file mode 100644 index 0000000000..41accd48ab --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/controls/JFXPopup.java @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.controls; + +import com.jfoenix.skins.JFXPopupSkin; +import javafx.application.Platform; +import javafx.beans.DefaultProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.geometry.Point2D; +import javafx.scene.Node; +import javafx.scene.control.PopupControl; +import javafx.scene.control.Skin; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Region; +import javafx.stage.Window; + +/// JFXPopup is the material design implementation of a popup. +/// +/// @author Shadi Shaheen +/// @version 2.0 +/// @since 2017-03-01 +@DefaultProperty(value = "popupContent") +public class JFXPopup extends PopupControl { + + public enum PopupHPosition { + RIGHT, LEFT + } + + public enum PopupVPosition { + TOP, BOTTOM + } + + /// Creates empty popup. + public JFXPopup() { + this(null); + } + + /// creates popup with a specified container and content + /// + /// @param content the node that will be shown in the popup + public JFXPopup(Region content) { + setPopupContent(content); + initialize(); + } + + private void initialize() { + this.setAutoFix(false); + this.setAutoHide(true); + this.setHideOnEscape(true); + this.setConsumeAutoHidingEvents(false); + this.getStyleClass().add(DEFAULT_STYLE_CLASS); + } + + @Override + protected Skin createDefaultSkin() { + return new JFXPopupSkin(this); + } + + /*************************************************************************** + * * + * Setters / Getters * + * * + **************************************************************************/ + + private final ObjectProperty popupContent = new SimpleObjectProperty<>(new Pane()); + + public final ObjectProperty popupContentProperty() { + return this.popupContent; + } + + public final Region getPopupContent() { + return this.popupContentProperty().get(); + } + + public final void setPopupContent(final Region popupContent) { + this.popupContentProperty().set(popupContent); + } + + /*************************************************************************** + * * + * Public API * + * * + **************************************************************************/ + + /// show the popup using the default position + public void show(Node node) { + this.show(node, PopupVPosition.TOP, PopupHPosition.LEFT, 0, 0); + } + + /// show the popup according to the specified position + /// + /// @param vAlign can be TOP/BOTTOM + /// @param hAlign can be LEFT/RIGHT + public void show(Node node, PopupVPosition vAlign, PopupHPosition hAlign) { + this.show(node, vAlign, hAlign, 0, 0); + } + + /// show the popup according to the specified position with a certain offset + /// + /// @param vAlign can be TOP/BOTTOM + /// @param hAlign can be LEFT/RIGHT + /// @param initOffsetX on the x-axis + /// @param initOffsetY on the y-axis + public void show(Node node, PopupVPosition vAlign, PopupHPosition hAlign, double initOffsetX, double initOffsetY) { + if (!isShowing()) { + if (node.getScene() == null || node.getScene().getWindow() == null) { + throw new IllegalStateException("Can not show popup. The node must be attached to a scene/window."); + } + Window parent = node.getScene().getWindow(); + final Point2D origin = node.localToScene(0, 0); + final double anchorX = parent.getX() + origin.getX() + + node.getScene().getX() + (hAlign == PopupHPosition.RIGHT ? ((Region) node).getWidth() : 0); + final double anchorY = parent.getY() + origin.getY() + + node.getScene() + .getY() + (vAlign == PopupVPosition.BOTTOM ? ((Region) node).getHeight() : 0); + this.show(parent, anchorX, anchorY); + ((JFXPopupSkin) getSkin()).reset(vAlign, hAlign, initOffsetX, initOffsetY); + Platform.runLater(() -> ((JFXPopupSkin) getSkin()).animate()); + } + } + + public void show(Window window, double x, double y, PopupVPosition vAlign, PopupHPosition hAlign, double initOffsetX, double initOffsetY) { + if (!isShowing()) { + if (window == null) { + throw new IllegalStateException("Can not show popup. The node must be attached to a scene/window."); + } + Window parent = window; + final double anchorX = parent.getX() + x + initOffsetX; + final double anchorY = parent.getY() + y + initOffsetY; + this.show(parent, anchorX, anchorY); + ((JFXPopupSkin) getSkin()).reset(vAlign, hAlign, initOffsetX, initOffsetY); + Platform.runLater(() -> ((JFXPopupSkin) getSkin()).animate()); + } + } + + @Override + public void hide() { + super.hide(); + ((JFXPopupSkin) getSkin()).init(); + } + + /*************************************************************************** + * * + * Stylesheet Handling * + * * + **************************************************************************/ + + /// Initialize the style class to 'jfx-popup'. + /// + /// This is the selector class from which CSS can be used to style + /// this control. + private static final String DEFAULT_STYLE_CLASS = "jfx-popup"; +} diff --git a/HMCL/src/main/java/com/jfoenix/controls/JFXRippler.java b/HMCL/src/main/java/com/jfoenix/controls/JFXRippler.java new file mode 100644 index 0000000000..21041510e3 --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/controls/JFXRippler.java @@ -0,0 +1,814 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.controls; + +import com.jfoenix.converters.RipplerMaskTypeConverter; +import com.jfoenix.utils.JFXNodeUtils; +import javafx.animation.*; +import javafx.beans.DefaultProperty; +import javafx.beans.binding.Bindings; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.css.*; +import javafx.css.converter.BooleanConverter; +import javafx.css.converter.PaintConverter; +import javafx.css.converter.SizeConverter; +import javafx.geometry.Bounds; +import javafx.scene.CacheHint; +import javafx.scene.Group; +import javafx.scene.Node; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; +import javafx.scene.paint.Paint; +import javafx.scene.shape.Circle; +import javafx.scene.shape.Rectangle; +import javafx.scene.shape.Shape; +import javafx.util.Duration; + +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * JFXRippler is the material design implementation of a ripple effect. + * the ripple effect can be applied to any node in the scene. JFXRippler is + * a {@link StackPane} container that holds a specified node (control node) and a ripple generator. + *

+ * UPDATE NOTES: + * - fireEventProgrammatically(Event) method has been removed as the ripple controller is + * the control itself, so you can trigger manual ripple by firing mouse event on the control + * instead of JFXRippler + * + * @author Shadi Shaheen + * @version 1.0 + * @since 2016-03-09 + */ +@DefaultProperty(value = "control") +public class JFXRippler extends StackPane { + public enum RipplerPos { + FRONT, BACK + } + + public enum RipplerMask { + CIRCLE, RECT, FIT + } + + protected RippleGenerator rippler; + protected Pane ripplerPane; + protected Node control; + + protected static final double RIPPLE_MAX_RADIUS = 300; + private static final Interpolator RIPPLE_INTERPOLATOR = Interpolator.SPLINE(0.0825, + 0.3025, + 0.0875, + 0.9975); //0.1, 0.54, 0.28, 0.95); + + private boolean forceOverlay = false; + + /// creates empty rippler node + public JFXRippler() { + this(null, RipplerMask.RECT, RipplerPos.FRONT); + } + + /// creates a rippler for the specified control + public JFXRippler(Node control) { + this(control, RipplerMask.RECT, RipplerPos.FRONT); + } + + /// creates a rippler for the specified control + /// + /// @param pos can be either FRONT/BACK (position the ripple effect infront of or behind the control) + public JFXRippler(Node control, RipplerPos pos) { + this(control, RipplerMask.RECT, pos); + } + + /// creates a rippler for the specified control and apply the specified mask to it + /// + /// @param mask can be either rectangle/cricle + public JFXRippler(Node control, RipplerMask mask) { + this(control, mask, RipplerPos.FRONT); + } + + /// creates a rippler for the specified control, mask and position. + /// + /// @param mask can be either rectangle/cricle + /// @param pos can be either FRONT/BACK (position the ripple effect infront of or behind the control) + public JFXRippler(Node control, RipplerMask mask, RipplerPos pos) { + initialize(); + + setMaskType(mask); + setPosition(pos); + createRippleUI(); + setControl(control); + + // listen to control position changed + position.addListener(observable -> updateControlPosition()); + + setPickOnBounds(false); + setCache(true); + setCacheHint(CacheHint.SPEED); + setCacheShape(true); + } + + protected final void createRippleUI() { + // create rippler panels + rippler = new RippleGenerator(); + ripplerPane = new StackPane(); + ripplerPane.setMouseTransparent(true); + ripplerPane.getChildren().add(rippler); + getChildren().add(ripplerPane); + } + + /*************************************************************************** + * * + * Setters / Getters * + * * + **************************************************************************/ + + public void setControl(Node control) { + if (control != null) { + this.control = control; + // position control + positionControl(control); + // add control listeners to generate / release ripples + initControlListeners(); + } + } + + // Override this method to create JFXRippler for a control outside the ripple + protected void positionControl(Node control) { + if (this.position.get() == RipplerPos.BACK) { + getChildren().add(control); + } else { + getChildren().add(0, control); + } + } + + protected void updateControlPosition() { + if (this.position.get() == RipplerPos.BACK) { + ripplerPane.toBack(); + } else { + ripplerPane.toFront(); + } + } + + public Node getControl() { + return control; + } + + // methods that can be changed by extending the rippler class + + /// generate the clipping mask + /// + /// @return the mask node + protected Node getMask() { + double borderWidth = ripplerPane.getBorder() != null ? ripplerPane.getBorder().getInsets().getTop() : 0; + Bounds bounds = control.getBoundsInParent(); + double width = control.getLayoutBounds().getWidth(); + double height = control.getLayoutBounds().getHeight(); + double diffMinX = Math.abs(control.getBoundsInLocal().getMinX() - control.getLayoutBounds().getMinX()); + double diffMinY = Math.abs(control.getBoundsInLocal().getMinY() - control.getLayoutBounds().getMinY()); + double diffMaxX = Math.abs(control.getBoundsInLocal().getMaxX() - control.getLayoutBounds().getMaxX()); + double diffMaxY = Math.abs(control.getBoundsInLocal().getMaxY() - control.getLayoutBounds().getMaxY()); + Node mask; + switch (getMaskType()) { + case RECT: + mask = new Rectangle(bounds.getMinX() + diffMinX - snappedLeftInset(), + bounds.getMinY() + diffMinY - snappedTopInset(), + width - 2 * borderWidth, + height - 2 * borderWidth); // -0.1 to prevent resizing the anchor pane + break; + case CIRCLE: + double radius = Math.min((width / 2) - 2 * borderWidth, (height / 2) - 2 * borderWidth); + mask = new Circle((bounds.getMinX() + diffMinX + bounds.getMaxX() - diffMaxX) / 2 - snappedLeftInset(), + (bounds.getMinY() + diffMinY + bounds.getMaxY() - diffMaxY) / 2 - snappedTopInset(), + radius, + Color.BLUE); + break; + case FIT: + mask = new Region(); + if (control instanceof Shape) { + ((Region) mask).setShape((Shape) control); + } else if (control instanceof Region) { + ((Region) mask).setShape(((Region) control).getShape()); + JFXNodeUtils.updateBackground(((Region) control).getBackground(), (Region) mask); + } + mask.resize(width, height); + mask.relocate(bounds.getMinX() + diffMinX, bounds.getMinY() + diffMinY); + break; + default: + mask = new Rectangle(bounds.getMinX() + diffMinX - snappedLeftInset(), + bounds.getMinY() + diffMinY - snappedTopInset(), + width - 2 * borderWidth, + height - 2 * borderWidth); // -0.1 to prevent resizing the anchor pane + break; + } + return mask; + } + + /** + * compute the ripple radius + * + * @return the ripple radius size + */ + protected double computeRippleRadius() { + double width2 = control.getLayoutBounds().getWidth() * control.getLayoutBounds().getWidth(); + double height2 = control.getLayoutBounds().getHeight() * control.getLayoutBounds().getHeight(); + return Math.min(Math.sqrt(width2 + height2), RIPPLE_MAX_RADIUS) * 1.1 + 5; + } + + protected void setOverLayBounds(Rectangle overlay) { + overlay.setWidth(control.getLayoutBounds().getWidth()); + overlay.setHeight(control.getLayoutBounds().getHeight()); + } + + /** + * init mouse listeners on the control + */ + protected void initControlListeners() { + // if the control got resized the overlay rect must be rest + control.layoutBoundsProperty().addListener(observable -> resetRippler()); + if (getChildren().contains(control)) { + control.boundsInParentProperty().addListener(observable -> resetRippler()); + } + control.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> { + if (event.getButton() == MouseButton.PRIMARY) + createRipple(event.getX(), event.getY()); + }); + // create fade out transition for the ripple + control.addEventHandler(MouseEvent.MOUSE_RELEASED, event -> { + if (event.getButton() == MouseButton.PRIMARY) + releaseRipple(); + }); + } + + /** + * creates Ripple effect + */ + protected void createRipple(double x, double y) { + if (!isRipplerDisabled()) { + rippler.setGeneratorCenterX(x); + rippler.setGeneratorCenterY(y); + rippler.createRipple(); + } + } + + protected void releaseRipple() { + rippler.releaseRipple(); + } + + /** + * creates Ripple effect in the center of the control + * + * @return a runnable to release the ripple when needed + */ + public Runnable createManualRipple() { + if (!isRipplerDisabled()) { + rippler.setGeneratorCenterX(control.getLayoutBounds().getWidth() / 2); + rippler.setGeneratorCenterY(control.getLayoutBounds().getHeight() / 2); + rippler.createRipple(); + return () -> { + // create fade out transition for the ripple + releaseRipple(); + }; + } + return () -> { + }; + } + + /// show/hide the ripple overlay + /// + /// @param forceOverlay used to hold the overlay after ripple action + public void setOverlayVisible(boolean visible, boolean forceOverlay) { + this.forceOverlay = forceOverlay; + setOverlayVisible(visible); + } + + /// show/hide the ripple overlay + /// NOTE: setting overlay visibility to false will reset forceOverlay to false + public void setOverlayVisible(boolean visible) { + if (visible) { + showOverlay(); + } else { + forceOverlay = false; + hideOverlay(); + } + } + + /** + * this method will be set to private in future versions of JFoenix, + * user the method {@link #setOverlayVisible(boolean)} + */ + public void showOverlay() { + if (rippler.overlayRect != null) { + rippler.overlayRect.outAnimation.stop(); + } + rippler.createOverlay(); + rippler.overlayRect.inAnimation.play(); + } + + public void hideOverlay() { + if (!forceOverlay) { + if (rippler.overlayRect != null) { + rippler.overlayRect.inAnimation.stop(); + } + if (rippler.overlayRect != null) { + rippler.overlayRect.outAnimation.play(); + } + } else { + System.err.println("Ripple Overlay is forced!"); + } + } + + /** + * Generates ripples on the screen every 0.3 seconds or whenever + * the createRipple method is called. Ripples grow and fade out + * over 0.6 seconds + */ + protected final class RippleGenerator extends Group { + + private double generatorCenterX = 0; + private double generatorCenterY = 0; + private OverLayRipple overlayRect; + private final AtomicBoolean generating = new AtomicBoolean(false); + private boolean cacheRipplerClip = false; + private boolean resetClip = false; + private final Queue ripplesQueue = new LinkedList<>(); + + RippleGenerator() { + // improve in performance, by preventing + // redrawing the parent when the ripple effect is triggered + this.setManaged(false); + this.setCache(true); + this.setCacheHint(CacheHint.SPEED); + } + + void createRipple() { + if (!generating.getAndSet(true)) { + // create overlay once then change its color later + createOverlay(); + if (this.getClip() == null || (getChildren().size() == 1 && !cacheRipplerClip) || resetClip) { + this.setClip(getMask()); + } + this.resetClip = false; + + // create the ripple effect + final Ripple ripple = new Ripple(generatorCenterX, generatorCenterY); + getChildren().add(ripple); + ripplesQueue.add(ripple); + + // animate the ripple + overlayRect.outAnimation.stop(); + overlayRect.inAnimation.play(); + ripple.inAnimation.play(); + } + } + + private void releaseRipple() { + Ripple ripple = ripplesQueue.poll(); + if (ripple != null) { + ripple.inAnimation.stop(); + ripple.outAnimation = new Timeline( + new KeyFrame(Duration.millis(Math.min(800, (0.9 * 500) / ripple.getScaleX())) + , ripple.outKeyValues)); + ripple.outAnimation.setOnFinished((event) -> getChildren().remove(ripple)); + ripple.outAnimation.play(); + if (generating.getAndSet(false)) { + if (overlayRect != null) { + overlayRect.inAnimation.stop(); + if (!forceOverlay) { + overlayRect.outAnimation.play(); + } + } + } + } + } + + void cacheRippleClip(boolean cached) { + cacheRipplerClip = cached; + } + + void createOverlay() { + if (overlayRect == null) { + overlayRect = new OverLayRipple(); + overlayRect.setClip(getMask()); + getChildren().add(0, overlayRect); + overlayRect.fillProperty().bind(Bindings.createObjectBinding(() -> { + if (getRipplerFill() instanceof Color fill) { + return new Color(fill.getRed(), + fill.getGreen(), + fill.getBlue(), + 0.2); + } else { + return Color.TRANSPARENT; + } + }, ripplerFillProperty())); + } + } + + void setGeneratorCenterX(double generatorCenterX) { + this.generatorCenterX = generatorCenterX; + } + + void setGeneratorCenterY(double generatorCenterY) { + this.generatorCenterY = generatorCenterY; + } + + private final class OverLayRipple extends Rectangle { + // Overlay ripple animations + Animation inAnimation = new Timeline(new KeyFrame(Duration.millis(300), + new KeyValue(opacityProperty(), 1, Interpolator.EASE_IN))); + + Animation outAnimation = new Timeline(new KeyFrame(Duration.millis(300), + new KeyValue(opacityProperty(), 0, Interpolator.EASE_OUT))); + + OverLayRipple() { + super(); + setOverLayBounds(this); + this.getStyleClass().add("jfx-rippler-overlay"); + // update initial position + if (JFXRippler.this.getChildrenUnmodifiable().contains(control)) { + double diffMinX = Math.abs(control.getBoundsInLocal().getMinX() - control.getLayoutBounds().getMinX()); + double diffMinY = Math.abs(control.getBoundsInLocal().getMinY() - control.getLayoutBounds().getMinY()); + Bounds bounds = control.getBoundsInParent(); + this.setX(bounds.getMinX() + diffMinX - snappedLeftInset()); + this.setY(bounds.getMinY() + diffMinY - snappedTopInset()); + } + // set initial attributes + setOpacity(0); + setCache(true); + setCacheHint(CacheHint.SPEED); + setCacheShape(true); + setManaged(false); + } + } + + private final class Ripple extends Circle { + + KeyValue[] outKeyValues; + Animation outAnimation = null; + Animation inAnimation = null; + + private Ripple(double centerX, double centerY) { + super(centerX, + centerY, + getRipplerRadius() == Region.USE_COMPUTED_SIZE ? + computeRippleRadius() : getRipplerRadius(), null); + setCache(true); + setCacheHint(CacheHint.SPEED); + setCacheShape(true); + setManaged(false); + setSmooth(true); + + KeyValue[] inKeyValues = new KeyValue[isRipplerRecenter() ? 4 : 2]; + outKeyValues = new KeyValue[isRipplerRecenter() ? 5 : 3]; + + inKeyValues[0] = new KeyValue(scaleXProperty(), 0.9, RIPPLE_INTERPOLATOR); + inKeyValues[1] = new KeyValue(scaleYProperty(), 0.9, RIPPLE_INTERPOLATOR); + + outKeyValues[0] = new KeyValue(this.scaleXProperty(), 1, RIPPLE_INTERPOLATOR); + outKeyValues[1] = new KeyValue(this.scaleYProperty(), 1, RIPPLE_INTERPOLATOR); + outKeyValues[2] = new KeyValue(this.opacityProperty(), 0, RIPPLE_INTERPOLATOR); + + if (isRipplerRecenter()) { + double dx = (control.getLayoutBounds().getWidth() / 2 - centerX) / 1.55; + double dy = (control.getLayoutBounds().getHeight() / 2 - centerY) / 1.55; + inKeyValues[2] = outKeyValues[3] = new KeyValue(translateXProperty(), + Math.signum(dx) * Math.min(Math.abs(dx), + this.getRadius() / 2), + RIPPLE_INTERPOLATOR); + inKeyValues[3] = outKeyValues[4] = new KeyValue(translateYProperty(), + Math.signum(dy) * Math.min(Math.abs(dy), + this.getRadius() / 2), + RIPPLE_INTERPOLATOR); + } + inAnimation = new Timeline(new KeyFrame(Duration.ZERO, + new KeyValue(scaleXProperty(), + 0, + RIPPLE_INTERPOLATOR), + new KeyValue(scaleYProperty(), + 0, + RIPPLE_INTERPOLATOR), + new KeyValue(translateXProperty(), + 0, + RIPPLE_INTERPOLATOR), + new KeyValue(translateYProperty(), + 0, + RIPPLE_INTERPOLATOR), + new KeyValue(opacityProperty(), + 1, + RIPPLE_INTERPOLATOR) + ), new KeyFrame(Duration.millis(900), inKeyValues)); + + setScaleX(0); + setScaleY(0); + if (getRipplerFill() instanceof Color fill) { + Color circleColor = new Color(fill.getRed(), + fill.getGreen(), + fill.getBlue(), + 0.3); + setStroke(circleColor); + setFill(circleColor); + } else { + setStroke(getRipplerFill()); + setFill(getRipplerFill()); + } + } + } + + public void clear() { + getChildren().clear(); + rippler.overlayRect = null; + generating.set(false); + } + } + + private void resetOverLay() { + if (rippler.overlayRect != null) { + rippler.overlayRect.inAnimation.stop(); + final RippleGenerator.OverLayRipple oldOverlay = rippler.overlayRect; + rippler.overlayRect.outAnimation.setOnFinished((finish) -> rippler.getChildren().remove(oldOverlay)); + rippler.overlayRect.outAnimation.play(); + rippler.overlayRect = null; + } + } + + private void resetClip() { + this.rippler.resetClip = true; + } + + protected void resetRippler() { + resetOverLay(); + resetClip(); + } + + /*************************************************************************** + * * + * Stylesheet Handling * + * * + **************************************************************************/ + + /** + * Initialize the style class to 'jfx-rippler'. + *

+ * This is the selector class from which CSS can be used to style + * this control. + */ + private static final String DEFAULT_STYLE_CLASS = "jfx-rippler"; + + private void initialize() { + this.getStyleClass().add(DEFAULT_STYLE_CLASS); + } + + /** + * the ripple recenter property, by default it's false. + * if true the ripple effect will show gravitational pull to the center of its control + */ + private StyleableBooleanProperty ripplerRecenter; + + public boolean isRipplerRecenter() { + return ripplerRecenter != null && ripplerRecenter.get(); + } + + public StyleableBooleanProperty ripplerRecenterProperty() { + if (this.ripplerRecenter == null) { + this.ripplerRecenter = new SimpleStyleableBooleanProperty( + StyleableProperties.RIPPLER_RECENTER, + JFXRippler.this, + "ripplerRecenter", + false); + } + return this.ripplerRecenter; + } + + public void setRipplerRecenter(boolean recenter) { + ripplerRecenterProperty().set(recenter); + } + + /** + * the ripple radius size, by default it will be automatically computed. + */ + private StyleableDoubleProperty ripplerRadius; + + public double getRipplerRadius() { + return ripplerRadius == null ? Region.USE_COMPUTED_SIZE : ripplerRadius.get(); + } + + public StyleableDoubleProperty ripplerRadiusProperty() { + if (this.ripplerRadius == null) { + this.ripplerRadius = new SimpleStyleableDoubleProperty( + StyleableProperties.RIPPLER_RADIUS, + JFXRippler.this, + "ripplerRadius", + Region.USE_COMPUTED_SIZE); + } + return this.ripplerRadius; + } + + public void setRipplerRadius(double radius) { + ripplerRadiusProperty().set(radius); + } + + private static final Color DEFAULT_RIPPLER_FILL = Color.rgb(0, 200, 255); + + /** + * the default color of the ripple effect + */ + private StyleableObjectProperty ripplerFill; + + public Paint getRipplerFill() { + return ripplerFill == null ? DEFAULT_RIPPLER_FILL : ripplerFill.get(); + } + + public StyleableObjectProperty ripplerFillProperty() { + if (this.ripplerFill == null) { + this.ripplerFill = new SimpleStyleableObjectProperty<>(StyleableProperties.RIPPLER_FILL, + JFXRippler.this, + "ripplerFill", + DEFAULT_RIPPLER_FILL); + } + return this.ripplerFill; + } + + public void setRipplerFill(Paint color) { + ripplerFillProperty().set(color); + } + + /// mask property used for clipping the rippler. + /// can be either CIRCLE/RECT + private StyleableObjectProperty maskType; + + public RipplerMask getMaskType() { + return maskType == null ? RipplerMask.RECT : maskType.get(); + } + + public StyleableObjectProperty maskTypeProperty() { + if (this.maskType == null) { + this.maskType = new SimpleStyleableObjectProperty<>( + StyleableProperties.MASK_TYPE, + JFXRippler.this, + "maskType", + RipplerMask.RECT); + } + return this.maskType; + } + + public void setMaskType(RipplerMask type) { + if (this.maskType != null || type != RipplerMask.RECT) + maskTypeProperty().set(type); + } + + /** + * the ripple disable, by default it's false. + * if true the ripple effect will be hidden + */ + private StyleableBooleanProperty ripplerDisabled; + + public boolean isRipplerDisabled() { + return ripplerDisabled != null && ripplerDisabled.get(); + } + + public StyleableBooleanProperty ripplerDisabledProperty() { + if (this.ripplerDisabled == null) { + this.ripplerDisabled = new SimpleStyleableBooleanProperty( + StyleableProperties.RIPPLER_DISABLED, + JFXRippler.this, + "ripplerDisabled", + false); + } + return this.ripplerDisabled; + } + + public void setRipplerDisabled(boolean disabled) { + ripplerDisabledProperty().set(disabled); + } + + /** + * indicates whether the ripple effect is infront of or behind the node + */ + protected ObjectProperty position = new SimpleObjectProperty<>(); + + public void setPosition(RipplerPos pos) { + this.position.set(pos); + } + + public RipplerPos getPosition() { + return position == null ? RipplerPos.FRONT : position.get(); + } + + public ObjectProperty positionProperty() { + return this.position; + } + + private static final class StyleableProperties { + private static final CssMetaData RIPPLER_RECENTER = + new CssMetaData<>("-jfx-rippler-recenter", + BooleanConverter.getInstance(), false) { + @Override + public boolean isSettable(JFXRippler control) { + return control.ripplerRecenter == null || !control.ripplerRecenter.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(JFXRippler control) { + return control.ripplerRecenterProperty(); + } + }; + private static final CssMetaData RIPPLER_DISABLED = + new CssMetaData<>("-jfx-rippler-disabled", + BooleanConverter.getInstance(), false) { + @Override + public boolean isSettable(JFXRippler control) { + return control.ripplerDisabled == null || !control.ripplerDisabled.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(JFXRippler control) { + return control.ripplerDisabledProperty(); + } + }; + private static final CssMetaData RIPPLER_FILL = + new CssMetaData<>("-jfx-rippler-fill", + PaintConverter.getInstance(), DEFAULT_RIPPLER_FILL) { + @Override + public boolean isSettable(JFXRippler control) { + return control.ripplerFill == null || !control.ripplerFill.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(JFXRippler control) { + return control.ripplerFillProperty(); + } + }; + private static final CssMetaData RIPPLER_RADIUS = + new CssMetaData<>("-jfx-rippler-radius", + SizeConverter.getInstance(), Region.USE_COMPUTED_SIZE) { + @Override + public boolean isSettable(JFXRippler control) { + return control.ripplerRadius == null || !control.ripplerRadius.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(JFXRippler control) { + return control.ripplerRadiusProperty(); + } + }; + private static final CssMetaData MASK_TYPE = + new CssMetaData<>("-jfx-mask-type", + RipplerMaskTypeConverter.getInstance(), RipplerMask.RECT) { + @Override + public boolean isSettable(JFXRippler control) { + return control.maskType == null || !control.maskType.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(JFXRippler control) { + return control.maskTypeProperty(); + } + }; + + private static final List> STYLEABLES; + + static { + final List> styleables = + new ArrayList<>(StackPane.getClassCssMetaData()); + Collections.addAll(styleables, + RIPPLER_RECENTER, + RIPPLER_RADIUS, + RIPPLER_FILL, + MASK_TYPE, + RIPPLER_DISABLED + ); + STYLEABLES = Collections.unmodifiableList(styleables); + } + } + + @Override + public List> getCssMetaData() { + return getClassCssMetaData(); + } + + public static List> getClassCssMetaData() { + return StyleableProperties.STYLEABLES; + } +} diff --git a/HMCL/src/main/java/com/jfoenix/controls/JFXTextArea.java b/HMCL/src/main/java/com/jfoenix/controls/JFXTextArea.java new file mode 100644 index 0000000000..df7409941d --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/controls/JFXTextArea.java @@ -0,0 +1,297 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.controls; + +import com.jfoenix.skins.JFXTextAreaSkin; +import com.jfoenix.validation.base.ValidatorBase; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.css.*; +import javafx.css.converter.BooleanConverter; +import javafx.css.converter.PaintConverter; +import javafx.scene.control.Control; +import javafx.scene.control.Skin; +import javafx.scene.control.TextArea; +import javafx.scene.paint.Color; +import javafx.scene.paint.Paint; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu; + +/** + * JFXTextArea is the material design implementation of a text area. + * + * @author Shadi Shaheen + * @version 1.0 + * @since 2016-03-09 + */ +public class JFXTextArea extends TextArea { + /** + * Initialize the style class to 'jfx-text-field'. + *

+ * This is the selector class from which CSS can be used to style + * this control. + */ + private static final String DEFAULT_STYLE_CLASS = "jfx-text-area"; + + /** + * {@inheritDoc} + */ + public JFXTextArea() { + initialize(); + } + + /** + * {@inheritDoc} + */ + public JFXTextArea(String text) { + super(text); + initialize(); + } + + /** + * {@inheritDoc} + */ + @Override + protected Skin createDefaultSkin() { + return new JFXTextAreaSkin(this); + } + + private void initialize() { + this.getStyleClass().add(DEFAULT_STYLE_CLASS); + if ("dalvik".equalsIgnoreCase(System.getProperty("java.vm.name"))) { + this.setStyle("-fx-skin: \"com.jfoenix.android.skins.JFXTextAreaSkinAndroid\";"); + } + + useJFXContextMenu(this); + } + + /*************************************************************************** + * * + * Properties * + * * + **************************************************************************/ + + /** + * holds the current active validator on the text area in case of validation error + */ + private ReadOnlyObjectWrapper activeValidator = new ReadOnlyObjectWrapper<>(); + + public ValidatorBase getActiveValidator() { + return activeValidator == null ? null : activeValidator.get(); + } + + public ReadOnlyObjectProperty activeValidatorProperty() { + return this.activeValidator.getReadOnlyProperty(); + } + + /** + * list of validators that will validate the text value upon calling + * {{@link #validate()} + */ + private ObservableList validators = FXCollections.observableArrayList(); + + public ObservableList getValidators() { + return validators; + } + + public void setValidators(ValidatorBase... validators) { + this.validators.addAll(validators); + } + + /** + * validates the text value using the list of validators provided by the user + * {{@link #setValidators(ValidatorBase...)} + * + * @return true if the value is valid else false + */ + public boolean validate() { + for (ValidatorBase validator : validators) { + if (validator.getSrcControl() == null) { + validator.setSrcControl(this); + } + validator.validate(); + if (validator.getHasErrors()) { + activeValidator.set(validator); + return false; + } + } + activeValidator.set(null); + return true; + } + + public void resetValidation() { + pseudoClassStateChanged(ValidatorBase.PSEUDO_CLASS_ERROR, false); + activeValidator.set(null); + } + + /*************************************************************************** + * * + * styleable Properties * + * * + **************************************************************************/ + + /** + * set true to show a float the prompt text when focusing the field + */ + private StyleableBooleanProperty labelFloat = new SimpleStyleableBooleanProperty(StyleableProperties.LABEL_FLOAT, JFXTextArea.this, "lableFloat", false); + + public final StyleableBooleanProperty labelFloatProperty() { + return this.labelFloat; + } + + public final boolean isLabelFloat() { + return this.labelFloatProperty().get(); + } + + public final void setLabelFloat(final boolean labelFloat) { + this.labelFloatProperty().set(labelFloat); + } + + /** + * default color used when the text area is unfocused + */ + private StyleableObjectProperty unFocusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNFOCUS_COLOR, JFXTextArea.this, "unFocusColor", Color.rgb(77, 77, 77)); + + public Paint getUnFocusColor() { + return unFocusColor == null ? Color.rgb(77, 77, 77) : unFocusColor.get(); + } + + public StyleableObjectProperty unFocusColorProperty() { + return this.unFocusColor; + } + + public void setUnFocusColor(Paint color) { + this.unFocusColor.set(color); + } + + /** + * default color used when the text area is focused + */ + private StyleableObjectProperty focusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.FOCUS_COLOR, JFXTextArea.this, "focusColor", Color.valueOf("#4059A9")); + + public Paint getFocusColor() { + return focusColor == null ? Color.valueOf("#4059A9") : focusColor.get(); + } + + public StyleableObjectProperty focusColorProperty() { + return this.focusColor; + } + + public void setFocusColor(Paint color) { + this.focusColor.set(color); + } + + /** + * disable animation on validation + */ + private StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION, JFXTextArea.this, "disableAnimation", false); + + public final StyleableBooleanProperty disableAnimationProperty() { + return this.disableAnimation; + } + + public final Boolean isDisableAnimation() { + return disableAnimation != null && this.disableAnimationProperty().get(); + } + + public final void setDisableAnimation(final Boolean disabled) { + this.disableAnimationProperty().set(disabled); + } + + private final static class StyleableProperties { + private static final CssMetaData UNFOCUS_COLOR = new CssMetaData("-jfx-unfocus-color", PaintConverter.getInstance(), Color.rgb(77, 77, 77)) { + @Override + public boolean isSettable(JFXTextArea control) { + return control.unFocusColor == null || !control.unFocusColor.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(JFXTextArea control) { + return control.unFocusColorProperty(); + } + }; + private static final CssMetaData FOCUS_COLOR = new CssMetaData("-jfx-focus-color", PaintConverter.getInstance(), Color.valueOf("#4059A9")) { + @Override + public boolean isSettable(JFXTextArea control) { + return control.focusColor == null || !control.focusColor.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(JFXTextArea control) { + return control.focusColorProperty(); + } + }; + private static final CssMetaData LABEL_FLOAT = new CssMetaData("-jfx-label-float", BooleanConverter.getInstance(), false) { + @Override + public boolean isSettable(JFXTextArea control) { + return control.labelFloat == null || !control.labelFloat.isBound(); + } + + @Override + public StyleableBooleanProperty getStyleableProperty(JFXTextArea control) { + return control.labelFloatProperty(); + } + }; + + private static final CssMetaData DISABLE_ANIMATION = new CssMetaData("-jfx-disable-animation", BooleanConverter.getInstance(), false) { + @Override + public boolean isSettable(JFXTextArea control) { + return control.disableAnimation == null || !control.disableAnimation.isBound(); + } + + @Override + public StyleableBooleanProperty getStyleableProperty(JFXTextArea control) { + return control.disableAnimationProperty(); + } + }; + + private static final List> CHILD_STYLEABLES; + + static { + final List> styleables = new ArrayList<>(Control.getClassCssMetaData()); + Collections.addAll(styleables, UNFOCUS_COLOR, FOCUS_COLOR, LABEL_FLOAT, DISABLE_ANIMATION); + CHILD_STYLEABLES = Collections.unmodifiableList(styleables); + } + } + + // inherit the styleable properties from parent + private List> STYLEABLES; + + @Override + public List> getControlCssMetaData() { + if (STYLEABLES == null) { + final List> styleables = new ArrayList<>(Control.getClassCssMetaData()); + styleables.addAll(getClassCssMetaData()); + styleables.addAll(TextArea.getClassCssMetaData()); + STYLEABLES = Collections.unmodifiableList(styleables); + } + return STYLEABLES; + } + + public static List> getClassCssMetaData() { + return StyleableProperties.CHILD_STYLEABLES; + } +} diff --git a/HMCL/src/main/java/com/jfoenix/controls/JFXTextField.java b/HMCL/src/main/java/com/jfoenix/controls/JFXTextField.java new file mode 100644 index 0000000000..cb4e2d6af0 --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/controls/JFXTextField.java @@ -0,0 +1,299 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.controls; + +import com.jfoenix.skins.JFXTextFieldSkin; +import com.jfoenix.validation.base.ValidatorBase; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.css.*; +import javafx.css.converter.BooleanConverter; +import javafx.css.converter.PaintConverter; +import javafx.scene.control.Control; +import javafx.scene.control.Skin; +import javafx.scene.control.TextField; +import javafx.scene.paint.Color; +import javafx.scene.paint.Paint; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu; + +/** + * JFXTextField is the material design implementation of a text Field. + * + * @author Shadi Shaheen + * @version 1.0 + * @since 2016-03-09 + */ +public class JFXTextField extends TextField { + /** + * Initialize the style class to 'jfx-text-field'. + *

+ * This is the selector class from which CSS can be used to style + * this control. + */ + private static final String DEFAULT_STYLE_CLASS = "jfx-text-field"; + + /** + * {@inheritDoc} + */ + public JFXTextField() { + initialize(); + } + + /** + * {@inheritDoc} + */ + public JFXTextField(String text) { + super(text); + initialize(); + } + + /** + * {@inheritDoc} + */ + @Override + protected Skin createDefaultSkin() { + return new JFXTextFieldSkin(this); + } + + private void initialize() { + this.getStyleClass().add(DEFAULT_STYLE_CLASS); + if ("dalvik".equalsIgnoreCase(System.getProperty("java.vm.name"))) { + this.setStyle("-fx-skin: \"com.jfoenix.android.skins.JFXTextFieldSkinAndroid\";"); + } + + useJFXContextMenu(this); + } + + /*************************************************************************** + * * + * Properties * + * * + **************************************************************************/ + + /** + * holds the current active validator on the text field in case of validation error + */ + private ReadOnlyObjectWrapper activeValidator = new ReadOnlyObjectWrapper<>(); + + public ValidatorBase getActiveValidator() { + return activeValidator == null ? null : activeValidator.get(); + } + + public ReadOnlyObjectProperty activeValidatorProperty() { + return this.activeValidator.getReadOnlyProperty(); + } + + /** + * list of validators that will validate the text value upon calling + * {{@link #validate()} + */ + private ObservableList validators = FXCollections.observableArrayList(); + + public ObservableList getValidators() { + return validators; + } + + public void setValidators(ValidatorBase... validators) { + this.validators.addAll(validators); + } + + /** + * validates the text value using the list of validators provided by the user + * {{@link #setValidators(ValidatorBase...)} + * + * @return true if the value is valid else false + */ + public boolean validate() { + for (ValidatorBase validator : validators) { + if (validator.getSrcControl() == null) { + validator.setSrcControl(this); + } + validator.validate(); + if (validator.getHasErrors()) { + activeValidator.set(validator); + return false; + } + } + activeValidator.set(null); + return true; + } + + public void resetValidation() { + pseudoClassStateChanged(ValidatorBase.PSEUDO_CLASS_ERROR, false); + activeValidator.set(null); + } + + /*************************************************************************** + * * + * styleable Properties * + * * + **************************************************************************/ + + /** + * set true to show a float the prompt text when focusing the field + */ + private StyleableBooleanProperty labelFloat = new SimpleStyleableBooleanProperty(StyleableProperties.LABEL_FLOAT, JFXTextField.this, "lableFloat", false); + + public final StyleableBooleanProperty labelFloatProperty() { + return this.labelFloat; + } + + public final boolean isLabelFloat() { + return this.labelFloatProperty().get(); + } + + public final void setLabelFloat(final boolean labelFloat) { + this.labelFloatProperty().set(labelFloat); + } + + /** + * default color used when the field is unfocused + */ + private StyleableObjectProperty unFocusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNFOCUS_COLOR, JFXTextField.this, "unFocusColor", Color.rgb(77, 77, 77)); + + public Paint getUnFocusColor() { + return unFocusColor == null ? Color.rgb(77, 77, 77) : unFocusColor.get(); + } + + public StyleableObjectProperty unFocusColorProperty() { + return this.unFocusColor; + } + + public void setUnFocusColor(Paint color) { + this.unFocusColor.set(color); + } + + /** + * default color used when the field is focused + */ + private StyleableObjectProperty focusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.FOCUS_COLOR, JFXTextField.this, "focusColor", Color.valueOf("#4059A9")); + + public Paint getFocusColor() { + return focusColor == null ? Color.valueOf("#4059A9") : focusColor.get(); + } + + public StyleableObjectProperty focusColorProperty() { + return this.focusColor; + } + + public void setFocusColor(Paint color) { + this.focusColor.set(color); + } + + /** + * disable animation on validation + */ + private StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION, JFXTextField.this, "disableAnimation", false); + + public final StyleableBooleanProperty disableAnimationProperty() { + return this.disableAnimation; + } + + public final Boolean isDisableAnimation() { + return disableAnimation != null && this.disableAnimationProperty().get(); + } + + public final void setDisableAnimation(final Boolean disabled) { + this.disableAnimationProperty().set(disabled); + } + + private final static class StyleableProperties { + private static final CssMetaData UNFOCUS_COLOR = new CssMetaData("-jfx-unfocus-color", PaintConverter.getInstance(), Color.valueOf("#A6A6A6")) { + @Override + public boolean isSettable(JFXTextField control) { + return control.unFocusColor == null || !control.unFocusColor.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(JFXTextField control) { + return control.unFocusColorProperty(); + } + }; + private static final CssMetaData FOCUS_COLOR = new CssMetaData("-jfx-focus-color", PaintConverter.getInstance(), Color.valueOf("#3f51b5")) { + @Override + public boolean isSettable(JFXTextField control) { + return control.focusColor == null || !control.focusColor.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(JFXTextField control) { + return control.focusColorProperty(); + } + }; + private static final CssMetaData LABEL_FLOAT = new CssMetaData("-jfx-label-float", BooleanConverter.getInstance(), false) { + @Override + public boolean isSettable(JFXTextField control) { + return control.labelFloat == null || !control.labelFloat.isBound(); + } + + @Override + public StyleableBooleanProperty getStyleableProperty(JFXTextField control) { + return control.labelFloatProperty(); + } + }; + + private static final CssMetaData DISABLE_ANIMATION = new CssMetaData("-jfx-disable-animation", BooleanConverter.getInstance(), false) { + @Override + public boolean isSettable(JFXTextField control) { + return control.disableAnimation == null || !control.disableAnimation.isBound(); + } + + @Override + public StyleableBooleanProperty getStyleableProperty(JFXTextField control) { + return control.disableAnimationProperty(); + } + }; + + + private static final List> CHILD_STYLEABLES; + + static { + final List> styleables = new ArrayList<>(Control.getClassCssMetaData()); + Collections.addAll(styleables, UNFOCUS_COLOR, FOCUS_COLOR, LABEL_FLOAT, DISABLE_ANIMATION); + CHILD_STYLEABLES = Collections.unmodifiableList(styleables); + } + } + + // inherit the styleable properties from parent + private List> STYLEABLES; + + @Override + public List> getControlCssMetaData() { + if (STYLEABLES == null) { + final List> styleables = new ArrayList<>(Control.getClassCssMetaData()); + styleables.addAll(getClassCssMetaData()); + styleables.addAll(TextField.getClassCssMetaData()); + STYLEABLES = Collections.unmodifiableList(styleables); + } + return STYLEABLES; + } + + public static List> getClassCssMetaData() { + return StyleableProperties.CHILD_STYLEABLES; + } +} + diff --git a/HMCL/src/main/java/com/jfoenix/controls/JFXToggleButton.java b/HMCL/src/main/java/com/jfoenix/controls/JFXToggleButton.java new file mode 100644 index 0000000000..60187da4e9 --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/controls/JFXToggleButton.java @@ -0,0 +1,373 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.controls; + +import com.jfoenix.skins.JFXToggleButtonSkin; +import javafx.css.*; +import javafx.css.converter.BooleanConverter; +import javafx.css.converter.PaintConverter; +import javafx.scene.control.Skin; +import javafx.scene.control.ToggleButton; +import javafx.scene.paint.Color; +import javafx.scene.paint.Paint; +import org.jackhuang.hmcl.ui.animation.AnimationUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * JFXToggleButton is the material design implementation of a toggle button. + * important CSS Selectors: + *

+ * .jfx-toggle-button{ + * -fx-toggle-color: color-value; + * -fx-untoggle-color: color-value; + * -fx-toggle-line-color: color-value; + * -fx-untoggle-line-color: color-value; + * } + *

+ * To change the rippler color when toggled: + *

+ * .jfx-toggle-button .jfx-rippler{ + * -fx-rippler-fill: color-value; + * } + *

+ * .jfx-toggle-button:selected .jfx-rippler{ + * -fx-rippler-fill: color-value; + * } + * + * @author Shadi Shaheen + * @version 1.0 + * @since 2016-03-09 + */ +public class JFXToggleButton extends ToggleButton { + + /** + * {@inheritDoc} + */ + public JFXToggleButton() { + initialize(); + } + + /** + * {@inheritDoc} + */ + @Override + protected Skin createDefaultSkin() { + return new JFXToggleButtonSkin(this); + } + + private void initialize() { + this.getStyleClass().add(DEFAULT_STYLE_CLASS); + // it's up for the user to add this behavior +// toggleColor.addListener((o, oldVal, newVal) -> { +// // update line color in case not set by the user +// if(newVal instanceof Color) +// toggleLineColor.set(((Color)newVal).desaturate().desaturate().brighter()); +// }); + } + + /*************************************************************************** + * * + * styleable Properties * + * * + **************************************************************************/ + + /** + * Initialize the style class to 'jfx-toggle-button'. + *

+ * This is the selector class from which CSS can be used to style + * this control. + */ + private static final String DEFAULT_STYLE_CLASS = "jfx-toggle-button"; + + /** + * default color used when the button is toggled + */ + private final StyleableObjectProperty toggleColor = new SimpleStyleableObjectProperty<>(StyleableProperties.TOGGLE_COLOR, + JFXToggleButton.this, + "toggleColor", + Color.valueOf( + "#009688")); + + public Paint getToggleColor() { + return toggleColor == null ? Color.valueOf("#009688") : toggleColor.get(); + } + + public StyleableObjectProperty toggleColorProperty() { + return this.toggleColor; + } + + public void setToggleColor(Paint color) { + this.toggleColor.set(color); + } + + /** + * default color used when the button is not toggled + */ + private StyleableObjectProperty untoggleColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNTOGGLE_COLOR, + JFXToggleButton.this, + "unToggleColor", + Color.valueOf( + "#FAFAFA")); + + public Paint getUnToggleColor() { + return untoggleColor == null ? Color.valueOf("#FAFAFA") : untoggleColor.get(); + } + + public StyleableObjectProperty unToggleColorProperty() { + return this.untoggleColor; + } + + public void setUnToggleColor(Paint color) { + this.untoggleColor.set(color); + } + + /** + * default line color used when the button is toggled + */ + private final StyleableObjectProperty toggleLineColor = new SimpleStyleableObjectProperty<>( + StyleableProperties.TOGGLE_LINE_COLOR, + JFXToggleButton.this, + "toggleLineColor", + Color.valueOf("#77C2BB")); + + public Paint getToggleLineColor() { + return toggleLineColor == null ? Color.valueOf("#77C2BB") : toggleLineColor.get(); + } + + public StyleableObjectProperty toggleLineColorProperty() { + return this.toggleLineColor; + } + + public void setToggleLineColor(Paint color) { + this.toggleLineColor.set(color); + } + + /** + * default line color used when the button is not toggled + */ + private final StyleableObjectProperty untoggleLineColor = new SimpleStyleableObjectProperty<>( + StyleableProperties.UNTOGGLE_LINE_COLOR, + JFXToggleButton.this, + "unToggleLineColor", + Color.valueOf("#999999")); + + public Paint getUnToggleLineColor() { + return untoggleLineColor == null ? Color.valueOf("#999999") : untoggleLineColor.get(); + } + + public StyleableObjectProperty unToggleLineColorProperty() { + return this.untoggleLineColor; + } + + public void setUnToggleLineColor(Paint color) { + this.untoggleLineColor.set(color); + } + + /** + * Default size of the toggle button. + */ + private final StyleableDoubleProperty size = new SimpleStyleableDoubleProperty( + StyleableProperties.SIZE, + JFXToggleButton.this, + "size", + 10.0); + + public double getSize() { + return size.get(); + } + + public StyleableDoubleProperty sizeProperty() { + return this.size; + } + + public void setSize(double size) { + this.size.set(size); + } + + /** + * Disable the visual indicator for focus + */ + private final StyleableBooleanProperty disableVisualFocus = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_VISUAL_FOCUS, + JFXToggleButton.this, + "disableVisualFocus", + false); + + public final StyleableBooleanProperty disableVisualFocusProperty() { + return this.disableVisualFocus; + } + + public final Boolean isDisableVisualFocus() { + return disableVisualFocus != null && this.disableVisualFocusProperty().get(); + } + + public final void setDisableVisualFocus(final Boolean disabled) { + this.disableVisualFocusProperty().set(disabled); + } + + + /** + * disable animation on button action + */ + private final StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION, + JFXToggleButton.this, + "disableAnimation", + !AnimationUtils.isAnimationEnabled()); + + public final StyleableBooleanProperty disableAnimationProperty() { + return this.disableAnimation; + } + + public final Boolean isDisableAnimation() { + return disableAnimation != null && this.disableAnimationProperty().get(); + } + + public final void setDisableAnimation(final Boolean disabled) { + this.disableAnimationProperty().set(disabled); + } + + private static final class StyleableProperties { + private static final CssMetaData TOGGLE_COLOR = + new CssMetaData<>("-jfx-toggle-color", + PaintConverter.getInstance(), Color.valueOf("#009688")) { + @Override + public boolean isSettable(JFXToggleButton control) { + return control.toggleColor == null || !control.toggleColor.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(JFXToggleButton control) { + return control.toggleColorProperty(); + } + }; + + private static final CssMetaData UNTOGGLE_COLOR = + new CssMetaData<>("-jfx-untoggle-color", + PaintConverter.getInstance(), Color.valueOf("#FAFAFA")) { + @Override + public boolean isSettable(JFXToggleButton control) { + return control.untoggleColor == null || !control.untoggleColor.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(JFXToggleButton control) { + return control.unToggleColorProperty(); + } + }; + + private static final CssMetaData TOGGLE_LINE_COLOR = + new CssMetaData<>("-jfx-toggle-line-color", + PaintConverter.getInstance(), Color.valueOf("#77C2BB")) { + @Override + public boolean isSettable(JFXToggleButton control) { + return control.toggleLineColor == null || !control.toggleLineColor.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(JFXToggleButton control) { + return control.toggleLineColorProperty(); + } + }; + + private static final CssMetaData UNTOGGLE_LINE_COLOR = + new CssMetaData<>("-jfx-untoggle-line-color", + PaintConverter.getInstance(), Color.valueOf("#999999")) { + @Override + public boolean isSettable(JFXToggleButton control) { + return control.untoggleLineColor == null || !control.untoggleLineColor.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(JFXToggleButton control) { + return control.unToggleLineColorProperty(); + } + }; + + private static final CssMetaData SIZE = + new CssMetaData<>("-jfx-size", + StyleConverter.getSizeConverter(), 10.0) { + @Override + public boolean isSettable(JFXToggleButton control) { + return !control.size.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(JFXToggleButton control) { + return control.sizeProperty(); + } + }; + private static final CssMetaData DISABLE_VISUAL_FOCUS = + new CssMetaData<>("-jfx-disable-visual-focus", + BooleanConverter.getInstance(), false) { + @Override + public boolean isSettable(JFXToggleButton control) { + return control.disableVisualFocus == null || !control.disableVisualFocus.isBound(); + } + + @Override + public StyleableBooleanProperty getStyleableProperty(JFXToggleButton control) { + return control.disableVisualFocusProperty(); + } + }; + + private static final CssMetaData DISABLE_ANIMATION = + new CssMetaData<>("-jfx-disable-animation", + BooleanConverter.getInstance(), false) { + @Override + public boolean isSettable(JFXToggleButton control) { + return control.disableAnimation == null || !control.disableAnimation.isBound(); + } + + @Override + public StyleableBooleanProperty getStyleableProperty(JFXToggleButton control) { + return control.disableAnimationProperty(); + } + }; + + private static final List> CHILD_STYLEABLES; + + static { + final List> styleables = + new ArrayList<>(ToggleButton.getClassCssMetaData()); + Collections.addAll(styleables, + SIZE, + TOGGLE_COLOR, + UNTOGGLE_COLOR, + TOGGLE_LINE_COLOR, + UNTOGGLE_LINE_COLOR, + DISABLE_VISUAL_FOCUS, + DISABLE_ANIMATION + ); + CHILD_STYLEABLES = Collections.unmodifiableList(styleables); + } + } + + @Override + public List> getControlCssMetaData() { + return getClassCssMetaData(); + } + + public static List> getClassCssMetaData() { + return StyleableProperties.CHILD_STYLEABLES; + } + +} diff --git a/HMCL/src/main/java/com/jfoenix/controls/behavior/JFXGenericPickerBehavior.java b/HMCL/src/main/java/com/jfoenix/controls/behavior/JFXGenericPickerBehavior.java new file mode 100644 index 0000000000..ca7b67a41e --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/controls/behavior/JFXGenericPickerBehavior.java @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.controls.behavior; + +import com.sun.javafx.scene.control.behavior.ComboBoxBaseBehavior; +import javafx.scene.control.ComboBoxBase; +import javafx.scene.control.PopupControl; + +/** + * @author Shadi Shaheen + * @version 2.0 + * @since 2017-10-05 + */ +public class JFXGenericPickerBehavior extends ComboBoxBaseBehavior { + + public JFXGenericPickerBehavior(ComboBoxBase var1) { + super(var1); + } + + public void onAutoHide(PopupControl var1) { + if (!var1.isShowing() && this.getNode().isShowing()) { + this.getNode().hide(); + } + if (!this.getNode().isShowing()) { + super.onAutoHide(var1); + } + } + +} diff --git a/HMCL/src/main/java/com/jfoenix/effects/JFXDepthManager.java b/HMCL/src/main/java/com/jfoenix/effects/JFXDepthManager.java new file mode 100644 index 0000000000..6047706bb8 --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/effects/JFXDepthManager.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2016 JFoenix + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package com.jfoenix.effects; + +import javafx.scene.Node; +import javafx.scene.effect.BlurType; +import javafx.scene.effect.DropShadow; +import javafx.scene.layout.Pane; +import javafx.scene.paint.Color; + +/** + * it will create a shadow effect for a given node and a specified depth level. + * depth levels are {0,1,2,3,4,5} + * + * @author Shadi Shaheen + * @version 1.0 + * @since 2016-03-09 + */ +public final class JFXDepthManager { + private JFXDepthManager() { + throw new AssertionError(); + } + + private static final DropShadow[] depth = new DropShadow[] { + new DropShadow(BlurType.GAUSSIAN, Color.TRANSPARENT, 0, 0, 0, 0), + new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.12), 4, 0, 0, 1), + new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.16), 6, 0, 0, 2), + new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.20), 10, 0, 0, 3), + new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.24), 14, 0, 0, 4), + new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.28), 20, 0, 0, 6) + }; + + /** + * this method is used to add shadow effect to the node, + * however the shadow is not real ( gets affected with node transformations) + *

+ * use {@link #createMaterialNode(Node, int)} instead to generate a real shadow + */ + public static void setDepth(Node control, int level) { + level = level < 0 ? 0 : level; + level = level > 5 ? 5 : level; + control.setEffect(new DropShadow(BlurType.GAUSSIAN, + depth[level].getColor(), + depth[level].getRadius(), + depth[level].getSpread(), + depth[level].getOffsetX(), + depth[level].getOffsetY())); + } + + public static int getLevels() { + return depth.length; + } + + public static DropShadow getShadowAt(int level) { + return depth[level]; + } + + /** + * this method will generate a new container node that prevent + * control transformation to be applied to the shadow effect + * (which makes it looks as a real shadow) + */ + public static Node createMaterialNode(Node control, int level) { + Node container = new Pane(control) { + @Override + protected double computeMaxWidth(double height) { + return computePrefWidth(height); + } + + @Override + protected double computeMaxHeight(double width) { + return computePrefHeight(width); + } + + @Override + protected double computePrefWidth(double height) { + return control.prefWidth(height); + } + + @Override + protected double computePrefHeight(double width) { + return control.prefHeight(width); + } + }; + container.getStyleClass().add("depth-container"); + container.setPickOnBounds(false); + level = level < 0 ? 0 : level; + level = level > 5 ? 5 : level; + container.setEffect(new DropShadow(BlurType.GAUSSIAN, + depth[level].getColor(), + depth[level].getRadius(), + depth[level].getSpread(), + depth[level].getOffsetX(), + depth[level].getOffsetY())); + return container; + } + + public static void pop(Node control) { + control.setEffect(new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.26), 5, 0.05, 0, 1)); + } + +} + diff --git a/HMCL/src/main/java/com/jfoenix/skins/JFXButtonSkin.java b/HMCL/src/main/java/com/jfoenix/skins/JFXButtonSkin.java new file mode 100644 index 0000000000..b8d5dc2207 --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/skins/JFXButtonSkin.java @@ -0,0 +1,222 @@ +// +// Source code recreated from a .class file by IntelliJ IDEA +// (powered by Fernflower decompiler) +// + +package com.jfoenix.skins; + +import com.jfoenix.adapters.skins.ButtonSkin; +import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXRippler; +import com.jfoenix.effects.JFXDepthManager; +import com.jfoenix.transitions.CachedTransition; +import javafx.animation.Interpolator; +import javafx.animation.KeyFrame; +import javafx.animation.KeyValue; +import javafx.animation.Timeline; +import javafx.animation.Transition; +import javafx.beans.binding.Bindings; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.geometry.Insets; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.effect.DropShadow; +import javafx.scene.layout.Background; +import javafx.scene.layout.BackgroundFill; +import javafx.scene.layout.CornerRadii; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; +import javafx.scene.shape.Shape; +import javafx.util.Duration; +import org.jackhuang.hmcl.ui.FXUtils; + +public class JFXButtonSkin extends ButtonSkin { + private final StackPane buttonContainer = new StackPane(); + private final JFXRippler buttonRippler = new JFXRippler(new StackPane()) { + protected Node getMask() { + StackPane mask = new StackPane(); + mask.shapeProperty().bind(JFXButtonSkin.this.buttonContainer.shapeProperty()); + mask.backgroundProperty().bind(Bindings.createObjectBinding(() -> new Background( + new BackgroundFill(Color.WHITE, + JFXButtonSkin.this.buttonContainer.backgroundProperty().get() != null && !JFXButtonSkin.this.buttonContainer.getBackground().getFills().isEmpty() + ? JFXButtonSkin.this.buttonContainer.getBackground().getFills().get(0).getRadii() + : JFXButtonSkin.this.defaultRadii, JFXButtonSkin.this.buttonContainer.backgroundProperty().get() != null && !JFXButtonSkin.this.buttonContainer.getBackground().getFills().isEmpty() ? JFXButtonSkin.this.buttonContainer.getBackground().getFills().get(0).getInsets() : Insets.EMPTY)), + JFXButtonSkin.this.buttonContainer.backgroundProperty())); + mask.resize(JFXButtonSkin.this.buttonContainer.getWidth() - JFXButtonSkin.this.buttonContainer.snappedRightInset() - JFXButtonSkin.this.buttonContainer.snappedLeftInset(), JFXButtonSkin.this.buttonContainer.getHeight() - JFXButtonSkin.this.buttonContainer.snappedBottomInset() - JFXButtonSkin.this.buttonContainer.snappedTopInset()); + return mask; + } + + private void initListeners() { + this.ripplerPane.setOnMousePressed((event) -> { + if (JFXButtonSkin.this.releaseManualRippler != null) { + JFXButtonSkin.this.releaseManualRippler.run(); + } + + JFXButtonSkin.this.releaseManualRippler = null; + this.createRipple(event.getX(), event.getY()); + }); + } + }; + private Transition clickedAnimation; + private final CornerRadii defaultRadii = new CornerRadii(3.0); + private boolean invalid = true; + private Runnable releaseManualRippler = null; + + public JFXButtonSkin(JFXButton button) { + super(button); + this.getSkinnable().armedProperty().addListener((o, oldVal, newVal) -> { + if (newVal) { + this.releaseManualRippler = this.buttonRippler.createManualRipple(); + if (this.clickedAnimation != null) { + this.clickedAnimation.setRate(1.0); + this.clickedAnimation.play(); + } + } else { + if (this.releaseManualRippler != null) { + this.releaseManualRippler.run(); + } + + if (this.clickedAnimation != null) { + this.clickedAnimation.setRate(-1.0); + this.clickedAnimation.play(); + } + } + + }); + this.buttonContainer.getChildren().add(this.buttonRippler); + button.buttonTypeProperty().addListener((o, oldVal, newVal) -> this.updateButtonType(newVal)); + button.setOnMousePressed((e) -> { + if (this.clickedAnimation != null) { + this.clickedAnimation.setRate(1.0F); + this.clickedAnimation.play(); + } + }); + button.setOnMouseReleased((e) -> { + if (this.clickedAnimation != null) { + this.clickedAnimation.setRate(-1.0F); + this.clickedAnimation.play(); + } + }); + + ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(button); + if (focusVisibleProperty == null) { + focusVisibleProperty = button.focusedProperty(); + } + focusVisibleProperty.addListener((o, oldVal, newVal) -> { + if (newVal) { + if (!this.getSkinnable().isPressed()) { + this.buttonRippler.showOverlay(); + } + } else { + this.buttonRippler.hideOverlay(); + } + }); + button.pressedProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.hideOverlay()); + button.setPickOnBounds(false); + this.buttonContainer.setPickOnBounds(false); + this.buttonContainer.shapeProperty().bind(this.getSkinnable().shapeProperty()); + this.buttonContainer.borderProperty().bind(this.getSkinnable().borderProperty()); + this.buttonContainer.backgroundProperty().bind(Bindings.createObjectBinding(() -> { + if (button.getBackground() == null || this.isJavaDefaultBackground(button.getBackground()) || this.isJavaDefaultClickedBackground(button.getBackground())) { + button.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, this.defaultRadii, null))); + } + + try { + return new Background(new BackgroundFill(this.getSkinnable().getBackground() != null ? this.getSkinnable().getBackground().getFills().get(0).getFill() : Color.TRANSPARENT, this.getSkinnable().getBackground() != null ? this.getSkinnable().getBackground().getFills().get(0).getRadii() : this.defaultRadii, Insets.EMPTY)); + } catch (Exception var3) { + return this.getSkinnable().getBackground(); + } + }, this.getSkinnable().backgroundProperty())); + button.ripplerFillProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.setRipplerFill(newVal)); + if (button.getBackground() == null || this.isJavaDefaultBackground(button.getBackground())) { + button.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, this.defaultRadii, null))); + } + + this.updateButtonType(button.getButtonType()); + this.updateChildren(); + } + + protected void updateChildren() { + super.updateChildren(); + if (this.buttonContainer != null) { + this.getChildren().add(0, this.buttonContainer); + } + + for (int i = 1; i < this.getChildren().size(); ++i) { + this.getChildren().get(i).setMouseTransparent(true); + } + } + + protected void layoutChildren(double x, double y, double w, double h) { + if (this.invalid) { + if (((JFXButton) this.getSkinnable()).getRipplerFill() == null) { + for (int i = this.getChildren().size() - 1; i >= 1; --i) { + if (this.getChildren().get(i) instanceof Shape shape) { + this.buttonRippler.setRipplerFill(shape.getFill()); + shape.fillProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.setRipplerFill(newVal)); + break; + } + + if (this.getChildren().get(i) instanceof Label label) { + this.buttonRippler.setRipplerFill(label.getTextFill()); + label.textFillProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.setRipplerFill(newVal)); + break; + } + } + } else { + this.buttonRippler.setRipplerFill(((JFXButton) this.getSkinnable()).getRipplerFill()); + } + + this.invalid = false; + } + + double shift = 1.0F; + this.buttonContainer.resizeRelocate(this.getSkinnable().getLayoutBounds().getMinX() - shift, this.getSkinnable().getLayoutBounds().getMinY() - shift, this.getSkinnable().getWidth() + (double) 2.0F * shift, this.getSkinnable().getHeight() + (double) 2.0F * shift); + this.layoutLabelInArea(x, y, w, h); + } + + private boolean isJavaDefaultBackground(Background background) { + try { + String firstFill = background.getFills().get(0).getFill().toString(); + return "0xffffffba".equals(firstFill) || "0xffffffbf".equals(firstFill) || "0xffffff12".equals(firstFill) || "0xffffffbd".equals(firstFill); + } catch (Exception var3) { + return false; + } + } + + private boolean isJavaDefaultClickedBackground(Background background) { + try { + return "0x039ed3ff".equals(background.getFills().get(0).getFill().toString()); + } catch (Exception var3) { + return false; + } + } + + private void updateButtonType(JFXButton.ButtonType type) { + switch (type) { + case RAISED -> { + JFXDepthManager.setDepth(this.buttonContainer, 2); + this.clickedAnimation = new ButtonClickTransition(); + } + case FLAT -> this.buttonContainer.setEffect(null); + } + } + + private class ButtonClickTransition extends CachedTransition { + public ButtonClickTransition() { + super(JFXButtonSkin.this.buttonContainer, new Timeline( + new KeyFrame(Duration.ZERO, + new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).radiusProperty(), JFXDepthManager.getShadowAt(2).radiusProperty().get(), Interpolator.EASE_BOTH), + new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).spreadProperty(), JFXDepthManager.getShadowAt(2).spreadProperty().get(), Interpolator.EASE_BOTH), + new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetXProperty(), JFXDepthManager.getShadowAt(2).offsetXProperty().get(), Interpolator.EASE_BOTH), + new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetYProperty(), JFXDepthManager.getShadowAt(2).offsetYProperty().get(), Interpolator.EASE_BOTH)), + new KeyFrame(Duration.millis(1000.0F), + new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).radiusProperty(), JFXDepthManager.getShadowAt(5).radiusProperty().get(), Interpolator.EASE_BOTH), + new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).spreadProperty(), JFXDepthManager.getShadowAt(5).spreadProperty().get(), Interpolator.EASE_BOTH), + new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetXProperty(), JFXDepthManager.getShadowAt(5).offsetXProperty().get(), Interpolator.EASE_BOTH), + new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetYProperty(), JFXDepthManager.getShadowAt(5).offsetYProperty().get(), Interpolator.EASE_BOTH)))); + this.setCycleDuration(Duration.seconds(0.2)); + this.setDelay(Duration.seconds(0.0F)); + } + } +} diff --git a/HMCL/src/main/java/com/jfoenix/skins/JFXCheckBoxSkin.java b/HMCL/src/main/java/com/jfoenix/skins/JFXCheckBoxSkin.java new file mode 100644 index 0000000000..cb9837699e --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/skins/JFXCheckBoxSkin.java @@ -0,0 +1,228 @@ +// +// Source code recreated from a .class file by IntelliJ IDEA +// (powered by FernFlower decompiler) +// + +package com.jfoenix.skins; + +import com.jfoenix.controls.JFXCheckBox; +import com.jfoenix.controls.JFXRippler; +import com.jfoenix.controls.JFXRippler.RipplerMask; +import com.jfoenix.controls.JFXRippler.RipplerPos; +import com.jfoenix.transitions.CachedTransition; +import com.jfoenix.transitions.JFXFillTransition; +import com.jfoenix.utils.JFXNodeUtils; +import javafx.animation.Interpolator; +import javafx.animation.KeyFrame; +import javafx.animation.KeyValue; +import javafx.animation.Timeline; +import javafx.animation.Transition; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.geometry.HPos; +import javafx.geometry.Insets; +import javafx.geometry.VPos; +import javafx.scene.control.CheckBox; +import javafx.scene.control.skin.CheckBoxSkin; +import javafx.scene.layout.AnchorPane; +import javafx.scene.layout.Background; +import javafx.scene.layout.BackgroundFill; +import javafx.scene.layout.Border; +import javafx.scene.layout.BorderStroke; +import javafx.scene.layout.BorderStrokeStyle; +import javafx.scene.layout.BorderWidths; +import javafx.scene.layout.CornerRadii; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; +import javafx.scene.shape.SVGPath; +import javafx.util.Duration; +import org.jackhuang.hmcl.theme.Themes; +import org.jackhuang.hmcl.ui.FXUtils; + +public class JFXCheckBoxSkin extends CheckBoxSkin { + private final StackPane box = new StackPane(); + private final StackPane mark = new StackPane(); + private final double lineThick = 2.0; + private final double padding = 10.0; + private final JFXRippler rippler; + private final AnchorPane container = new AnchorPane(); + private final double labelOffset = -8.0; + private final Transition transition; + private boolean invalid = true; + private JFXFillTransition select; + + public JFXCheckBoxSkin(JFXCheckBox control) { + super(control); + this.box.setMinSize(18.0, 18.0); + this.box.setPrefSize(18.0, 18.0); + this.box.setMaxSize(18.0, 18.0); + this.box.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, new CornerRadii(2.0), Insets.EMPTY))); + this.box.setBorder(new Border(new BorderStroke(Themes.getColorScheme().getOnSurfaceVariant(), BorderStrokeStyle.SOLID, new CornerRadii(2.0), new BorderWidths(this.lineThick)))); + StackPane boxContainer = new StackPane(); + boxContainer.getChildren().add(this.box); + boxContainer.setPadding(new Insets(this.padding)); + this.rippler = new JFXRippler(boxContainer, RipplerMask.CIRCLE, RipplerPos.BACK); + this.updateRippleColor(); + SVGPath shape = new SVGPath(); + shape.setContent("M384 690l452-452 60 60-512 512-238-238 60-60z"); + this.mark.setShape(shape); + this.mark.setMaxSize(15.0, 12.0); + this.mark.setStyle("-fx-background-color:-monet-on-primary; -fx-border-color:-monet-on-primary; -fx-border-width:2px;"); + this.mark.setVisible(false); + this.mark.setScaleX(0.0); + this.mark.setScaleY(0.0); + boxContainer.getChildren().add(this.mark); + this.container.getChildren().add(this.rippler); + AnchorPane.setRightAnchor(this.rippler, this.labelOffset); + control.selectedProperty().addListener((o, oldVal, newVal) -> { + this.updateRippleColor(); + this.playSelectAnimation(newVal); + }); + + ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(control); + if (focusVisibleProperty == null) + focusVisibleProperty = control.focusedProperty(); + focusVisibleProperty.addListener((o, oldVal, newVal) -> { + if (newVal) { + if (!this.getSkinnable().isPressed()) { + this.rippler.showOverlay(); + } + } else { + this.rippler.hideOverlay(); + } + }); + control.pressedProperty().addListener((o, oldVal, newVal) -> this.rippler.hideOverlay()); + this.updateChildren(); + this.registerChangeListener(control.checkedColorProperty(), ignored -> { + if (select != null) { + select.stop(); + } + this.createFillTransition(); + updateColors(); + }); + this.registerChangeListener(control.unCheckedColorProperty(), ignored -> updateColors()); + this.transition = new CheckBoxTransition(); + this.createFillTransition(); + } + + private void updateRippleColor() { + var control = (JFXCheckBox) this.getSkinnable(); + this.rippler.setRipplerFill(control.isSelected() + ? control.getCheckedColor() + : control.getUnCheckedColor()); + } + + private void updateColors() { + var control = (JFXCheckBox) getSkinnable(); + boolean isSelected = control.isSelected(); + JFXNodeUtils.updateBackground(box.getBackground(), box, isSelected ? control.getCheckedColor() : Color.TRANSPARENT); + rippler.setRipplerFill(isSelected ? control.getCheckedColor() : control.getUnCheckedColor()); + final BorderStroke borderStroke = box.getBorder().getStrokes().get(0); + box.setBorder(new Border(new BorderStroke( + isSelected ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(), + borderStroke.getTopStyle(), + borderStroke.getRadii(), + borderStroke.getWidths()))); + } + + protected void updateChildren() { + super.updateChildren(); + if (this.container != null) { + this.getChildren().remove(1); + this.getChildren().add(this.container); + } + } + + protected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { + return super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset) + this.snapSizeX(this.box.minWidth(-1.0)) + this.labelOffset + 2.0 * this.padding; + } + + protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { + return super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset) + this.snapSizeY(this.box.prefWidth(-1.0)) + this.labelOffset + 2.0 * this.padding; + } + + protected void layoutChildren(double x, double y, double w, double h) { + CheckBox checkBox = this.getSkinnable(); + double boxWidth = this.snapSizeX(this.container.prefWidth(-1.0)); + double boxHeight = this.snapSizeY(this.container.prefHeight(-1.0)); + double computeWidth = Math.min(checkBox.prefWidth(-1.0), checkBox.minWidth(-1.0)) + this.labelOffset + 2.0 * this.padding; + double labelWidth = Math.min(computeWidth - boxWidth, w - this.snapSizeX(boxWidth)) + this.labelOffset + 2.0 * this.padding; + double labelHeight = Math.min(checkBox.prefHeight(labelWidth), h); + double maxHeight = Math.max(boxHeight, labelHeight); + double xOffset = computeXOffset(w, labelWidth + boxWidth, checkBox.getAlignment().getHpos()) + x; + double yOffset = computeYOffset(h, maxHeight, checkBox.getAlignment().getVpos()) + x; + if (this.invalid) { + if (this.getSkinnable().isSelected()) { + this.playSelectAnimation(true); + } + + this.invalid = false; + } + + this.layoutLabelInArea(xOffset + boxWidth, yOffset, labelWidth, maxHeight, checkBox.getAlignment()); + this.container.resize(boxWidth, boxHeight); + this.positionInArea(this.container, xOffset, yOffset, boxWidth, maxHeight, 0.0, checkBox.getAlignment().getHpos(), checkBox.getAlignment().getVpos()); + } + + static double computeXOffset(double width, double contentWidth, HPos hpos) { + return switch (hpos) { + case LEFT -> 0.0; + case CENTER -> (width - contentWidth) / 2.0; + case RIGHT -> width - contentWidth; + }; + } + + static double computeYOffset(double height, double contentHeight, VPos vpos) { + return switch (vpos) { + case TOP -> 0.0; + case CENTER -> (height - contentHeight) / 2.0; + case BOTTOM -> height - contentHeight; + default -> 0.0; + }; + } + + private void playSelectAnimation(Boolean selection) { + if (selection == null) { + selection = false; + } + + JFXCheckBox control = (JFXCheckBox) this.getSkinnable(); + this.transition.setRate(selection ? 1.0 : -1.0); + this.select.setRate(selection ? 1.0 : -1.0); + this.transition.play(); + this.select.play(); + this.box.setBorder(new Border(new BorderStroke( + selection ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(), + BorderStrokeStyle.SOLID, + new CornerRadii(2.0), + new BorderWidths(this.lineThick)))); + } + + private void createFillTransition() { + this.select = new JFXFillTransition(Duration.millis(120.0), this.box, Color.TRANSPARENT, + (Color) ((JFXCheckBox) this.getSkinnable()).getCheckedColor()); + this.select.setInterpolator(Interpolator.EASE_OUT); + } + + private final class CheckBoxTransition extends CachedTransition { + CheckBoxTransition() { + super(JFXCheckBoxSkin.this.mark, + new Timeline( + new KeyFrame(Duration.ZERO, + new KeyValue(JFXCheckBoxSkin.this.mark.visibleProperty(), false, Interpolator.EASE_OUT), + new KeyValue(JFXCheckBoxSkin.this.mark.scaleXProperty(), (double) 0.5F, Interpolator.EASE_OUT), + new KeyValue(JFXCheckBoxSkin.this.mark.scaleYProperty(), (double) 0.5F, Interpolator.EASE_OUT)), + new KeyFrame(Duration.millis(400.0), + new KeyValue(JFXCheckBoxSkin.this.mark.visibleProperty(), true, Interpolator.EASE_OUT), + new KeyValue(JFXCheckBoxSkin.this.mark.scaleXProperty(), (double) 0.5F, Interpolator.EASE_OUT), + new KeyValue(JFXCheckBoxSkin.this.mark.scaleYProperty(), (double) 0.5F, Interpolator.EASE_OUT)), + new KeyFrame(Duration.millis(1000.0), + new KeyValue(JFXCheckBoxSkin.this.mark.visibleProperty(), true, Interpolator.EASE_OUT), + new KeyValue(JFXCheckBoxSkin.this.mark.scaleXProperty(), 1, Interpolator.EASE_OUT), + new KeyValue(JFXCheckBoxSkin.this.mark.scaleYProperty(), 1, Interpolator.EASE_OUT)) + ) + ); + this.setCycleDuration(Duration.seconds(0.12)); + this.setDelay(Duration.seconds(0.05)); + } + } +} diff --git a/HMCL/src/main/java/com/jfoenix/skins/JFXColorPalette.java b/HMCL/src/main/java/com/jfoenix/skins/JFXColorPalette.java new file mode 100644 index 0000000000..60e41ea806 --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/skins/JFXColorPalette.java @@ -0,0 +1,623 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.skins; + +import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXColorPicker; +import com.jfoenix.utils.JFXNodeUtils; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener.Change; +import javafx.collections.ObservableList; +import javafx.event.ActionEvent; +import javafx.event.Event; +import javafx.geometry.Bounds; +import javafx.geometry.NodeOrientation; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.control.PopupControl; +import javafx.scene.control.Tooltip; +import javafx.scene.input.KeyEvent; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.*; +import javafx.scene.paint.Color; +import javafx.scene.shape.Rectangle; +import javafx.scene.shape.StrokeType; + +import java.util.List; + +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +/** + * @author Shadi Shaheen FUTURE WORK: this UI will get re-designed to match material design guidlines + */ +final class JFXColorPalette extends Region { + + private static final int SQUARE_SIZE = 15; + + // package protected for testing purposes + JFXColorGrid colorPickerGrid; + final JFXButton customColorLink = new JFXButton(i18n("color.custom")); + JFXCustomColorPickerDialog customColorDialog = null; + + private final JFXColorPicker colorPicker; + private final GridPane customColorGrid = new GridPane(); + private final Label customColorLabel = new Label(i18n("color.recent")); + + private PopupControl popupControl; + private ColorSquare focusedSquare; + + private Color mouseDragColor = null; + private boolean dragDetected = false; + + private final ColorSquare hoverSquare = new ColorSquare(); + + public JFXColorPalette(final JFXColorPicker colorPicker) { + getStyleClass().add("color-palette-region"); + this.colorPicker = colorPicker; + colorPickerGrid = new JFXColorGrid(); + colorPickerGrid.getChildren().get(0).requestFocus(); + customColorLabel.setAlignment(Pos.CENTER_LEFT); + customColorLink.setPrefWidth(colorPickerGrid.prefWidth(-1)); + customColorLink.setAlignment(Pos.CENTER); + customColorLink.setFocusTraversable(true); + customColorLink.setOnAction(ev -> { + if (customColorDialog == null) { + customColorDialog = new JFXCustomColorPickerDialog(popupControl); + customColorDialog.customColorProperty().addListener((ov, t1, t2) -> { + colorPicker.setValue(customColorDialog.customColorProperty().get()); + }); + customColorDialog.setOnSave(() -> { + Color customColor = customColorDialog.customColorProperty().get(); + buildCustomColors(); + colorPicker.getCustomColors().add(customColor); + updateSelection(customColor); + Event.fireEvent(colorPicker, new ActionEvent()); + colorPicker.hide(); + }); + } + customColorDialog.setCurrentColor(colorPicker.valueProperty().get()); + if (popupControl != null) { + popupControl.setAutoHide(false); + } + customColorDialog.show(); + customColorDialog.setOnHidden(event -> { + if (popupControl != null) { + popupControl.setAutoHide(true); + } + }); + }); + + initNavigation(); + customColorGrid.getStyleClass().add("color-picker-grid"); + customColorGrid.setVisible(false); + + buildCustomColors(); + + colorPicker.getCustomColors().addListener((Change change) -> buildCustomColors()); + VBox paletteBox = new VBox(); + paletteBox.getStyleClass().add("color-palette"); + paletteBox.getChildren().addAll(colorPickerGrid); + if (colorPicker.getPreDefinedColors() == null) { + paletteBox.getChildren().addAll(customColorLabel, customColorGrid, customColorLink); + } + + hoverSquare.setMouseTransparent(true); + hoverSquare.getStyleClass().addAll("hover-square"); + setFocusedSquare(null); + + getChildren().addAll(paletteBox, hoverSquare); + } + + private void setFocusedSquare(ColorSquare square) { + hoverSquare.setVisible(square != null); + + if (square == focusedSquare) { + return; + } + focusedSquare = square; + + hoverSquare.setVisible(focusedSquare != null); + if (focusedSquare == null) { + return; + } + + if (!focusedSquare.isFocused()) { + focusedSquare.requestFocus(); + } + + hoverSquare.rectangle.setFill(focusedSquare.rectangle.getFill()); + + Bounds b = square.localToScene(square.getLayoutBounds()); + + double x = b.getMinX(); + double y = b.getMinY(); + + double xAdjust; + double scaleAdjust = hoverSquare.getScaleX() == 1.0 ? 0 : hoverSquare.getWidth() / 4.0; + + if (colorPicker.getEffectiveNodeOrientation() == NodeOrientation.RIGHT_TO_LEFT) { + x = focusedSquare.getLayoutX(); + xAdjust = -focusedSquare.getWidth() + scaleAdjust; + } else { + xAdjust = focusedSquare.getWidth() / 2.0 + scaleAdjust; + } + + hoverSquare.setLayoutX(snapPositionX(x) - xAdjust); + hoverSquare.setLayoutY(snapPositionY(y) - focusedSquare.getHeight() / 2.0 + (hoverSquare.getScaleY() == 1.0 ? 0 : focusedSquare.getHeight() / 4.0)); + } + + private void buildCustomColors() { + final ObservableList customColors = colorPicker.getCustomColors(); + customColorGrid.getChildren().clear(); + if (customColors.isEmpty()) { + customColorLabel.setVisible(false); + customColorLabel.setManaged(false); + customColorGrid.setVisible(false); + customColorGrid.setManaged(false); + return; + } else { + customColorLabel.setVisible(true); + customColorLabel.setManaged(true); + customColorGrid.setVisible(true); + customColorGrid.setManaged(true); + } + + int customColumnIndex = 0; + int customRowIndex = 0; + int remainingSquares = customColors.size() % NUM_OF_COLUMNS; + int numEmpty = (remainingSquares == 0) ? 0 : NUM_OF_COLUMNS - remainingSquares; + + for (int i = 0; i < customColors.size(); i++) { + Color c = customColors.get(i); + ColorSquare square = new ColorSquare(c, i, true); + customColorGrid.add(square, customColumnIndex, customRowIndex); + customColumnIndex++; + if (customColumnIndex == NUM_OF_COLUMNS) { + customColumnIndex = 0; + customRowIndex++; + } + } + for (int i = 0; i < numEmpty; i++) { + ColorSquare emptySquare = new ColorSquare(); + customColorGrid.add(emptySquare, customColumnIndex, customRowIndex); + customColumnIndex++; + } + requestLayout(); + } + + private void initNavigation() { + setOnKeyPressed(ke -> { + switch (ke.getCode()) { + case SPACE: + case ENTER: + // select the focused color + if (focusedSquare != null) { + focusedSquare.selectColor(ke); + } + ke.consume(); + break; + default: // no-op + } + }); + } + + public void setPopupControl(PopupControl pc) { + this.popupControl = pc; + } + + public JFXColorGrid getColorGrid() { + return colorPickerGrid; + } + + public boolean isCustomColorDialogShowing() { + return customColorDialog != null && customColorDialog.isVisible(); + } + + class ColorSquare extends StackPane { + Rectangle rectangle; + boolean isEmpty; + + public ColorSquare() { + this(null, -1, false); + } + + public ColorSquare(Color color, int index) { + this(color, index, false); + } + + public ColorSquare(Color color, int index, boolean isCustom) { + // Add style class to handle selected color square + getStyleClass().add("color-square"); + if (color != null) { + setFocusTraversable(true); + focusedProperty().addListener((s, ov, nv) -> setFocusedSquare(nv ? this : null)); + addEventHandler(MouseEvent.MOUSE_ENTERED, event -> setFocusedSquare(ColorSquare.this)); + addEventHandler(MouseEvent.MOUSE_EXITED, event -> setFocusedSquare(null)); + addEventHandler(MouseEvent.MOUSE_RELEASED, event -> { + if (!dragDetected && event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 1) { + if (!isEmpty) { + Color fill = (Color) rectangle.getFill(); + colorPicker.setValue(fill); + colorPicker.fireEvent(new ActionEvent()); + updateSelection(fill); + event.consume(); + } + colorPicker.hide(); + } + }); + } + rectangle = new Rectangle(SQUARE_SIZE, SQUARE_SIZE); + if (color == null) { + rectangle.setFill(Color.WHITE); + isEmpty = true; + } else { + rectangle.setFill(color); + } + + rectangle.setStrokeType(StrokeType.INSIDE); + + String tooltipStr = JFXNodeUtils.colorToHex(color); + Tooltip.install(this, new Tooltip((tooltipStr == null) ? "" : tooltipStr)); + + rectangle.getStyleClass().add("color-rect"); + getChildren().add(rectangle); + } + + public void selectColor(KeyEvent event) { + if (rectangle.getFill() != null) { + if (rectangle.getFill() instanceof Color) { + colorPicker.setValue((Color) rectangle.getFill()); + colorPicker.fireEvent(new ActionEvent()); + } + event.consume(); + } + colorPicker.hide(); + } + } + + // The skin can update selection if colorpicker value changes.. + public void updateSelection(Color color) { + setFocusedSquare(null); + + for (ColorSquare c : colorPickerGrid.getSquares()) { + if (c.rectangle.getFill().equals(color)) { + setFocusedSquare(c); + return; + } + } + // check custom colors + for (Node n : customColorGrid.getChildren()) { + ColorSquare c = (ColorSquare) n; + if (c.rectangle.getFill().equals(color)) { + setFocusedSquare(c); + return; + } + } + } + + class JFXColorGrid extends GridPane { + + private final List squares; + final int NUM_OF_COLORS; + final int NUM_OF_ROWS; + + public JFXColorGrid() { + getStyleClass().add("color-picker-grid"); + setId("ColorCustomizerColorGrid"); + int columnIndex = 0; + int rowIndex = 0; + squares = FXCollections.observableArrayList(); + double[] limitedColors = colorPicker.getPreDefinedColors(); + limitedColors = limitedColors == null ? RAW_VALUES : limitedColors; + NUM_OF_COLORS = limitedColors.length / 3; + NUM_OF_ROWS = (int) Math.ceil((double) NUM_OF_COLORS / (double) NUM_OF_COLUMNS); + final int numColors = limitedColors.length / 3; + Color[] colors = new Color[numColors]; + for (int i = 0; i < numColors; i++) { + colors[i] = new Color(limitedColors[i * 3] / 255, + limitedColors[(i * 3) + 1] / 255, limitedColors[(i * 3) + 2] / 255, + 1.0); + ColorSquare cs = new ColorSquare(colors[i], i); + squares.add(cs); + } + + for (ColorSquare square : squares) { + add(square, columnIndex, rowIndex); + columnIndex++; + if (columnIndex == NUM_OF_COLUMNS) { + columnIndex = 0; + rowIndex++; + } + } + setOnMouseDragged(t -> { + if (!dragDetected) { + dragDetected = true; + mouseDragColor = colorPicker.getValue(); + } + int xIndex = clamp(0, + (int) t.getX() / (SQUARE_SIZE + 1), NUM_OF_COLUMNS - 1); + int yIndex = clamp(0, + (int) t.getY() / (SQUARE_SIZE + 1), NUM_OF_ROWS - 1); + int index = xIndex + yIndex * NUM_OF_COLUMNS; + colorPicker.setValue((Color) squares.get(index).rectangle.getFill()); + updateSelection(colorPicker.getValue()); + }); + addEventHandler(MouseEvent.MOUSE_RELEASED, t -> { + if (colorPickerGrid.getBoundsInLocal().contains(t.getX(), t.getY())) { + updateSelection(colorPicker.getValue()); + colorPicker.fireEvent(new ActionEvent()); + colorPicker.hide(); + } else { + // restore color as mouse release happened outside the grid. + if (mouseDragColor != null) { + colorPicker.setValue(mouseDragColor); + updateSelection(mouseDragColor); + } + } + dragDetected = false; + }); + } + + public List getSquares() { + return squares; + } + + @Override + protected double computePrefWidth(double height) { + return (SQUARE_SIZE + 1) * NUM_OF_COLUMNS; + } + + @Override + protected double computePrefHeight(double width) { + return (SQUARE_SIZE + 1) * NUM_OF_ROWS; + } + } + + private static final int NUM_OF_COLUMNS = 10; + private static final double[] RAW_VALUES = { + // WARNING: always make sure the number of colors is a divisable by NUM_OF_COLUMNS + 250, 250, 250, // first row + 245, 245, 245, + 238, 238, 238, + 224, 224, 224, + 189, 189, 189, + 158, 158, 158, + 117, 117, 117, + 97, 97, 97, + 66, 66, 66, + 33, 33, 33, + // second row + 236, 239, 241, + 207, 216, 220, + 176, 190, 197, + 144, 164, 174, + 120, 144, 156, + 96, 125, 139, + 84, 110, 122, + 69, 90, 100, + 55, 71, 79, + 38, 50, 56, + // third row + 255, 235, 238, + 255, 205, 210, + 239, 154, 154, + 229, 115, 115, + 239, 83, 80, + 244, 67, 54, + 229, 57, 53, + 211, 47, 47, + 198, 40, 40, + 183, 28, 28, + // forth row + 252, 228, 236, + 248, 187, 208, + 244, 143, 177, + 240, 98, 146, + 236, 64, 122, + 233, 30, 99, + 216, 27, 96, + 194, 24, 91, + 173, 20, 87, + 136, 14, 79, + // fifth row + 243, 229, 245, + 225, 190, 231, + 206, 147, 216, + 186, 104, 200, + 171, 71, 188, + 156, 39, 176, + 142, 36, 170, + 123, 31, 162, + 106, 27, 154, + 74, 20, 140, + // sixth row + 237, 231, 246, + 209, 196, 233, + 179, 157, 219, + 149, 117, 205, + 126, 87, 194, + 103, 58, 183, + 94, 53, 177, + 81, 45, 168, + 69, 39, 160, + 49, 27, 146, + // seventh row + 232, 234, 246, + 197, 202, 233, + 159, 168, 218, + 121, 134, 203, + 92, 107, 192, + 63, 81, 181, + 57, 73, 171, + 48, 63, 159, + 40, 53, 147, + 26, 35, 126, + // eigth row + 227, 242, 253, + 187, 222, 251, + 144, 202, 249, + 100, 181, 246, + 66, 165, 245, + 33, 150, 243, + 30, 136, 229, + 25, 118, 210, + 21, 101, 192, + 13, 71, 161, + // ninth row + 225, 245, 254, + 179, 229, 252, + 129, 212, 250, + 79, 195, 247, + 41, 182, 246, + 3, 169, 244, + 3, 155, 229, + 2, 136, 209, + 2, 119, 189, + 1, 87, 155, + // tenth row + 224, 247, 250, + 178, 235, 242, + 128, 222, 234, + 77, 208, 225, + 38, 198, 218, + 0, 188, 212, + 0, 172, 193, + 0, 151, 167, + 0, 131, 143, + 0, 96, 100, + // eleventh row + 224, 242, 241, + 178, 223, 219, + 128, 203, 196, + 77, 182, 172, + 38, 166, 154, + 0, 150, 136, + 0, 137, 123, + 0, 121, 107, + 0, 105, 92, + 0, 77, 64, + // twelfth row + 232, 245, 233, + 200, 230, 201, + 165, 214, 167, + 129, 199, 132, + 102, 187, 106, + 76, 175, 80, + 67, 160, 71, + 56, 142, 60, + 46, 125, 50, + 27, 94, 32, + + // thirteenth row + 241, 248, 233, + 220, 237, 200, + 197, 225, 165, + 174, 213, 129, + 156, 204, 101, + 139, 195, 74, + 124, 179, 66, + 104, 159, 56, + 85, 139, 47, + 51, 105, 30, + // fourteenth row + 249, 251, 231, + 240, 244, 195, + 230, 238, 156, + 220, 231, 117, + 212, 225, 87, + 205, 220, 57, + 192, 202, 51, + 175, 180, 43, + 158, 157, 36, + 130, 119, 23, + + // fifteenth row + 255, 253, 231, + 255, 249, 196, + 255, 245, 157, + 255, 241, 118, + 255, 238, 88, + 255, 235, 59, + 253, 216, 53, + 251, 192, 45, + 249, 168, 37, + 245, 127, 23, + + // sixteenth row + 255, 248, 225, + 255, 236, 179, + 255, 224, 130, + 255, 213, 79, + 255, 202, 40, + 255, 193, 7, + 255, 179, 0, + 255, 160, 0, + 255, 143, 0, + 255, 111, 0, + + // seventeenth row + 255, 243, 224, + 255, 224, 178, + 255, 204, 128, + 255, 183, 77, + 255, 167, 38, + 255, 152, 0, + 251, 140, 0, + 245, 124, 0, + 239, 108, 0, + 230, 81, 0, + + // eighteenth row + 251, 233, 231, + 255, 204, 188, + 255, 171, 145, + 255, 138, 101, + 255, 112, 67, + 255, 87, 34, + 244, 81, 30, + 230, 74, 25, + 216, 67, 21, + 191, 54, 12, + + // nineteenth row + 239, 235, 233, + 215, 204, 200, + 188, 170, 164, + 161, 136, 127, + 141, 110, 99, + 121, 85, 72, + 109, 76, 65, + 93, 64, 55, + 78, 52, 46, + 62, 39, 35, + }; + + private static int clamp(int min, int value, int max) { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; + } +} diff --git a/HMCL/src/main/java/com/jfoenix/skins/JFXColorPickerSkin.java b/HMCL/src/main/java/com/jfoenix/skins/JFXColorPickerSkin.java new file mode 100644 index 0000000000..953e2d6d93 --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/skins/JFXColorPickerSkin.java @@ -0,0 +1,258 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.skins; + +import com.jfoenix.controls.JFXClippedPane; +import com.jfoenix.controls.JFXColorPicker; +import com.jfoenix.controls.JFXRippler; +import com.jfoenix.effects.JFXDepthManager; +import com.jfoenix.utils.JFXNodeUtils; +import javafx.animation.Interpolator; +import javafx.animation.KeyFrame; +import javafx.animation.KeyValue; +import javafx.animation.Timeline; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.css.*; +import javafx.css.converter.BooleanConverter; +import javafx.geometry.Insets; +import javafx.scene.Node; +import javafx.scene.control.ColorPicker; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import javafx.scene.control.skin.ComboBoxPopupControl; +import javafx.scene.layout.Background; +import javafx.scene.layout.BackgroundFill; +import javafx.scene.layout.CornerRadii; +import javafx.scene.paint.Color; +import javafx.scene.shape.Circle; +import javafx.util.Duration; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * @author Shadi Shaheen + */ +public final class JFXColorPickerSkin extends JFXGenericPickerSkin { + + private final Label displayNode; + private final JFXClippedPane colorBox; + private JFXColorPalette popupContent; + StyleableBooleanProperty colorLabelVisible = new SimpleStyleableBooleanProperty(StyleableProperties.COLOR_LABEL_VISIBLE, + JFXColorPickerSkin.this, + "colorLabelVisible", + true); + + private final ObjectProperty textFill = new SimpleObjectProperty<>(Color.BLACK); + private final ObjectProperty colorBoxBackground = new SimpleObjectProperty<>(null); + + public JFXColorPickerSkin(final ColorPicker colorPicker) { + super(colorPicker); + + // create displayNode + displayNode = new Label(""); + displayNode.getStyleClass().add("color-label"); + displayNode.setMouseTransparent(true); + displayNode.textFillProperty().bind(textFill); + + // label graphic + colorBox = new JFXClippedPane(displayNode); + colorBox.getStyleClass().add("color-box"); + colorBox.setManaged(false); + colorBox.backgroundProperty().bind(colorBoxBackground); + initColor(); + final JFXRippler rippler = new JFXRippler(colorBox, JFXRippler.RipplerMask.FIT); + rippler.ripplerFillProperty().bind(textFill); + getChildren().setAll(rippler); + JFXDepthManager.setDepth(getSkinnable(), 1); + getSkinnable().setPickOnBounds(false); + + colorPicker.focusedProperty().addListener(observable -> { + if (colorPicker.isFocused()) { + if (!getSkinnable().isPressed()) { + rippler.setOverlayVisible(true); + } + } else { + rippler.setOverlayVisible(false); + } + }); + + // add listeners + registerChangeListener(colorPicker.valueProperty(), obs -> updateColor()); + + colorLabelVisible.addListener(invalidate -> { + if (colorLabelVisible.get()) { + displayNode.setText(JFXNodeUtils.colorToHex(getSkinnable().getValue())); + } else { + displayNode.setText(""); + } + }); + } + + @Override + protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { + double width = 100; + String displayNodeText = displayNode.getText(); + displayNode.setText("#DDDDDD"); + width = Math.max(width, super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset)); + displayNode.setText(displayNodeText); + return width + rightInset + leftInset; + } + + @Override + protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { + if (colorBox == null) { + reflectUpdateDisplayArea(); + } + return topInset + colorBox.prefHeight(width) + bottomInset; + } + + @Override + protected void layoutChildren(double x, double y, double w, double h) { + super.layoutChildren(x, y, w, h); + double hInsets = snappedLeftInset() + snappedRightInset(); + double vInsets = snappedTopInset() + snappedBottomInset(); + double width = w + hInsets; + double height = h + vInsets; + colorBox.resizeRelocate(0, 0, width, height); + } + + @Override + protected Node getPopupContent() { + if (popupContent == null) { + popupContent = new JFXColorPalette((JFXColorPicker) getSkinnable()); + } + return popupContent; + } + + @Override + public void show() { + super.show(); + final ColorPicker colorPicker = (ColorPicker) getSkinnable(); + popupContent.updateSelection(colorPicker.getValue()); + } + + @Override + public Node getDisplayNode() { + return displayNode; + } + + private void updateColor() { + final ColorPicker colorPicker = (ColorPicker) getSkinnable(); + Color color = colorPicker.getValue(); + Color circleColor = color == null ? Color.WHITE : color; + // update picker box color + if (((JFXColorPicker) getSkinnable()).isDisableAnimation()) { + JFXNodeUtils.updateBackground(colorBox.getBackground(), colorBox, circleColor); + } else { + Circle colorCircle = new Circle(); + colorCircle.setFill(circleColor); + colorCircle.setManaged(false); + colorCircle.setLayoutX(colorBox.getWidth() / 4); + colorCircle.setLayoutY(colorBox.getHeight() / 2); + colorBox.getChildren().add(colorCircle); + Timeline animateColor = new Timeline(new KeyFrame(Duration.millis(240), + new KeyValue(colorCircle.radiusProperty(), + 200, + Interpolator.EASE_BOTH))); + animateColor.setOnFinished((finish) -> { + colorBoxBackground.set(new Background(new BackgroundFill(colorCircle.getFill(), new CornerRadii(3), Insets.EMPTY))); + colorBox.getChildren().remove(colorCircle); + }); + animateColor.play(); + } + // update label color + textFill.set(circleColor.grayscale().getRed() < 0.5 ? Color.valueOf( + "rgba(255, 255, 255, 0.87)") : Color.valueOf("rgba(0, 0, 0, 0.87)")); + if (colorLabelVisible.get()) { + displayNode.setText(JFXNodeUtils.colorToHex(circleColor)); + } else { + displayNode.setText(""); + } + } + + private void initColor() { + final ColorPicker colorPicker = (ColorPicker) getSkinnable(); + Color color = colorPicker.getValue(); + Color circleColor = color == null ? Color.WHITE : color; + // update picker box color + colorBoxBackground.set(new Background(new BackgroundFill(circleColor, new CornerRadii(3), Insets.EMPTY))); + // update label color + textFill.set(circleColor.grayscale().getRed() < 0.5 ? Color.valueOf( + "rgba(255, 255, 255, 0.87)") : Color.valueOf("rgba(0, 0, 0, 0.87)")); + if (colorLabelVisible.get()) { + displayNode.setText(JFXNodeUtils.colorToHex(circleColor)); + } else { + displayNode.setText(""); + } + } + + /*************************************************************************** + * * + * Stylesheet Handling * + * * + **************************************************************************/ + + private static final class StyleableProperties { + private static final CssMetaData COLOR_LABEL_VISIBLE = + new CssMetaData("-fx-color-label-visible", + BooleanConverter.getInstance(), Boolean.TRUE) { + + @Override + public boolean isSettable(ColorPicker n) { + final JFXColorPickerSkin skin = (JFXColorPickerSkin) n.getSkin(); + return skin.colorLabelVisible == null || !skin.colorLabelVisible.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(ColorPicker n) { + final JFXColorPickerSkin skin = (JFXColorPickerSkin) n.getSkin(); + return skin.colorLabelVisible; + } + }; + private static final List> STYLEABLES; + + static { + final List> styleables = + new ArrayList<>(ComboBoxPopupControl.getClassCssMetaData()); + styleables.add(COLOR_LABEL_VISIBLE); + STYLEABLES = Collections.unmodifiableList(styleables); + } + } + + public static List> getClassCssMetaData() { + return StyleableProperties.STYLEABLES; + } + + @Override + public List> getCssMetaData() { + return getClassCssMetaData(); + } + + protected TextField getEditor() { + return null; + } + + protected javafx.util.StringConverter getConverter() { + return null; + } +} diff --git a/HMCL/src/main/java/com/jfoenix/skins/JFXColorPickerUI.java b/HMCL/src/main/java/com/jfoenix/skins/JFXColorPickerUI.java new file mode 100644 index 0000000000..d060daf97d --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/skins/JFXColorPickerUI.java @@ -0,0 +1,629 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.skins; + +import com.jfoenix.effects.JFXDepthManager; +import com.jfoenix.transitions.CachedTransition; +import javafx.animation.Animation.Status; +import javafx.animation.*; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Point2D; +import javafx.scene.Node; +import javafx.scene.effect.ColorAdjust; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.image.PixelWriter; +import javafx.scene.image.WritableImage; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.Pane; +import javafx.scene.paint.Color; +import javafx.scene.shape.Circle; +import javafx.scene.shape.Path; +import javafx.util.Duration; + +/** + * @author Shadi Shaheen & Bassel El Mabsout this UI allows the user to pick a color using HSL color system + */ +final class JFXColorPickerUI extends Pane { + + private CachedTransition selectorTransition; + private int pickerSize = 400; + // sl circle selector size + private final int selectorSize = 20; + private final double centerX; + private final double centerY; + private final double huesRadius; + private final double slRadius; + private double currentHue = 0; + + private final ImageView huesCircleView; + private final ImageView slCircleView; + private final Pane colorSelector; + private final Pane selector; + private CurveTransition colorsTransition; + + public JFXColorPickerUI(int pickerSize) { + JFXDepthManager.setDepth(this, 1); + + this.pickerSize = pickerSize; + this.centerX = (double) pickerSize / 2; + this.centerY = (double) pickerSize / 2; + final double pickerRadius = (double) pickerSize / 2; + this.huesRadius = pickerRadius * 0.9; + final double huesSmallR = pickerRadius * 0.8; + final double huesLargeR = pickerRadius; + this.slRadius = pickerRadius * 0.7; + + // Create Hues Circle + huesCircleView = new ImageView(getHuesCircle(pickerSize, pickerSize)); + // clip to smooth the edges + Circle outterCircle = new Circle(centerX, centerY, huesLargeR - 2); + Circle innterCircle = new Circle(centerX, centerY, huesSmallR + 2); + huesCircleView.setClip(Path.subtract(outterCircle, innterCircle)); + this.getChildren().add(huesCircleView); + + // create Hues Circle Selector + Circle r1 = new Circle(pickerRadius - huesSmallR); + Circle r2 = new Circle(pickerRadius - huesRadius); + colorSelector = new Pane(); + colorSelector.setStyle("-fx-border-color:#424242; -fx-border-width:1px; -fx-background-color:rgba(255, 255, 255, 0.87);"); + colorSelector.setPrefSize(pickerRadius - huesSmallR, pickerRadius - huesSmallR); + colorSelector.setShape(Path.subtract(r1, r2)); + colorSelector.setCache(true); + colorSelector.setMouseTransparent(true); + colorSelector.setPickOnBounds(false); + this.getChildren().add(colorSelector); + + // add Hues Selection Listeners + huesCircleView.addEventHandler(MouseEvent.MOUSE_DRAGGED, (event) -> { + if (colorsTransition != null) { + colorsTransition.stop(); + } + double dx = event.getX() - centerX; + double dy = event.getY() - centerY; + double theta = Math.atan2(dy, dx); + double x = centerX + huesRadius * Math.cos(theta); + double y = centerY + huesRadius * Math.sin(theta); + colorSelector.setRotate(90 + Math.toDegrees(Math.atan2(dy, dx))); + colorSelector.setTranslateX(x - colorSelector.getPrefWidth() / 2); + colorSelector.setTranslateY(y - colorSelector.getPrefHeight() / 2); + }); + huesCircleView.addEventHandler(MouseEvent.MOUSE_PRESSED, (event) -> { + double dx = event.getX() - centerX; + double dy = event.getY() - centerY; + double theta = Math.atan2(dy, dx); + double x = centerX + huesRadius * Math.cos(theta); + double y = centerY + huesRadius * Math.sin(theta); + colorsTransition = new CurveTransition(new Point2D(colorSelector.getTranslateX() + colorSelector.getPrefWidth() / 2, + colorSelector.getTranslateY() + colorSelector.getPrefHeight() / 2), + new Point2D(x, y)); + colorsTransition.play(); + }); + colorSelector.translateXProperty() + .addListener((o, oldVal, newVal) -> updateHSLCircleColor((int) (newVal.intValue() + colorSelector.getPrefWidth() / 2), + (int) (colorSelector.getTranslateY() + colorSelector + .getPrefHeight() / 2))); + colorSelector.translateYProperty() + .addListener((o, oldVal, newVal) -> updateHSLCircleColor((int) (colorSelector.getTranslateX() + colorSelector + .getPrefWidth() / 2), (int) (newVal.intValue() + colorSelector.getPrefHeight() / 2))); + + + // Create SL Circle + slCircleView = new ImageView(getSLCricle(pickerSize, pickerSize)); + slCircleView.setClip(new Circle(centerX, centerY, slRadius - 2)); + slCircleView.setPickOnBounds(false); + this.getChildren().add(slCircleView); + + // create SL Circle Selector + selector = new Pane(); + Circle c1 = new Circle(selectorSize / 2); + Circle c2 = new Circle((selectorSize / 2) * 0.5); + selector.setShape(Path.subtract(c1, c2)); + selector.setStyle( + "-fx-border-color:#424242; -fx-border-width:1px;-fx-background-color:rgba(255, 255, 255, 0.87);"); + selector.setPrefSize(selectorSize, selectorSize); + selector.setMinSize(selectorSize, selectorSize); + selector.setMaxSize(selectorSize, selectorSize); + selector.setCache(true); + selector.setMouseTransparent(true); + this.getChildren().add(selector); + + + // add SL selection Listeners + slCircleView.addEventHandler(MouseEvent.MOUSE_DRAGGED, (event) -> { + if (selectorTransition != null) { + selectorTransition.stop(); + } + if (Math.pow(event.getX() - centerX, 2) + Math.pow(event.getY() - centerY, 2) < Math.pow(slRadius - 2, 2)) { + selector.setTranslateX(event.getX() - selector.getPrefWidth() / 2); + selector.setTranslateY(event.getY() - selector.getPrefHeight() / 2); + } else { + double dx = event.getX() - centerX; + double dy = event.getY() - centerY; + double theta = Math.atan2(dy, dx); + double x = centerX + (slRadius - 2) * Math.cos(theta); + double y = centerY + (slRadius - 2) * Math.sin(theta); + selector.setTranslateX(x - selector.getPrefWidth() / 2); + selector.setTranslateY(y - selector.getPrefHeight() / 2); + } + }); + slCircleView.addEventHandler(MouseEvent.MOUSE_PRESSED, (event) -> { + selectorTransition = new CachedTransition(selector, new Timeline(new KeyFrame(Duration.millis(1000), + new KeyValue(selector.translateXProperty(), + event.getX() - selector.getPrefWidth() / 2, + Interpolator.EASE_BOTH), + new KeyValue(selector.translateYProperty(), + event.getY() - selector.getPrefHeight() / 2, + Interpolator.EASE_BOTH)))) { + { + setCycleDuration(Duration.millis(160)); + setDelay(Duration.seconds(0)); + } + }; + selectorTransition.play(); + }); + // add slCircleView listener + selector.translateXProperty() + .addListener((o, oldVal, newVal) -> setColorAtLocation(newVal.intValue() + selectorSize / 2, + (int) selector.getTranslateY() + selectorSize / 2)); + selector.translateYProperty() + .addListener((o, oldVal, newVal) -> setColorAtLocation((int) selector.getTranslateX() + selectorSize / 2, + newVal.intValue() + selectorSize / 2)); + + + // initial color selection + double dx = 20 - centerX; + double dy = 20 - centerY; + double theta = Math.atan2(dy, dx); + double x = centerX + huesRadius * Math.cos(theta); + double y = centerY + huesRadius * Math.sin(theta); + colorSelector.setRotate(90 + Math.toDegrees(Math.atan2(dy, dx))); + colorSelector.setTranslateX(x - colorSelector.getPrefWidth() / 2); + colorSelector.setTranslateY(y - colorSelector.getPrefHeight() / 2); + selector.setTranslateX(centerX - selector.getPrefWidth() / 2); + selector.setTranslateY(centerY - selector.getPrefHeight() / 2); + } + + /** + * List of Color Nodes that needs to be updated when picking a color + */ + private final ObservableList colorNodes = FXCollections.observableArrayList(); + + public void addColorSelectionNode(Node... nodes) { + colorNodes.addAll(nodes); + } + + public void removeColorSelectionNode(Node... nodes) { + colorNodes.removeAll(nodes); + } + + private void updateHSLCircleColor(int x, int y) { + // transform color to HSL space + Color color = huesCircleView.getImage().getPixelReader().getColor(x, y); + double max = Math.max(color.getRed(), Math.max(color.getGreen(), color.getBlue())); + double min = Math.min(color.getRed(), Math.min(color.getGreen(), color.getBlue())); + double hue = 0; + if (max != min) { + double d = max - min; + if (max == color.getRed()) { + hue = (color.getGreen() - color.getBlue()) / d + (color.getGreen() < color.getBlue() ? 6 : 0); + } else if (max == color.getGreen()) { + hue = (color.getBlue() - color.getRed()) / d + 2; + } else if (max == color.getBlue()) { + hue = (color.getRed() - color.getGreen()) / d + 4; + } + hue /= 6; + } + currentHue = map(hue, 0, 1, 0, 255); + + // refresh the HSL circle + refreshHSLCircle(); + } + + private void refreshHSLCircle() { + ColorAdjust colorAdjust = new ColorAdjust(); + colorAdjust.setHue(map(currentHue + (currentHue < 127.5 ? 1 : -1) * 127.5, 0, 255, -1, 1)); + slCircleView.setEffect(colorAdjust); + setColorAtLocation((int) selector.getTranslateX() + selectorSize / 2, + (int) selector.getTranslateY() + selectorSize / 2); + } + + + /** + * this method is used to move selectors to a certain color + */ + private boolean allowColorChange = true; + private ParallelTransition pTrans; + + public void moveToColor(Color color) { + allowColorChange = false; + double max = Math.max(color.getRed(), + Math.max(color.getGreen(), color.getBlue())), min = Math.min(color.getRed(), + Math.min(color.getGreen(), + color.getBlue())); + double hue = 0; + double l = (max + min) / 2; + double s = 0; + if (max == min) { + hue = s = 0; // achromatic + } else { + double d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + if (max == color.getRed()) { + hue = (color.getGreen() - color.getBlue()) / d + (color.getGreen() < color.getBlue() ? 6 : 0); + } else if (max == color.getGreen()) { + hue = (color.getBlue() - color.getRed()) / d + 2; + } else if (max == color.getBlue()) { + hue = (color.getRed() - color.getGreen()) / d + 4; + } + hue /= 6; + } + currentHue = map(hue, 0, 1, 0, 255); + + // Animate Hue + double theta = map(currentHue, 0, 255, -Math.PI, Math.PI); + double x = centerX + huesRadius * Math.cos(theta); + double y = centerY + huesRadius * Math.sin(theta); + colorsTransition = new CurveTransition( + new Point2D( + colorSelector.getTranslateX() + colorSelector.getPrefWidth() / 2, + colorSelector.getTranslateY() + colorSelector.getPrefHeight() / 2 + ), + new Point2D(x, y)); + + // Animate SL + s = map(s, 0, 1, 0, 255); + l = map(l, 0, 1, 0, 255); + Point2D point = getPointFromSL((int) s, (int) l, slRadius); + double pX = centerX - point.getX(); + double pY = centerY - point.getY(); + + double endPointX; + double endPointY; + if (Math.pow(pX - centerX, 2) + Math.pow(pY - centerY, 2) < Math.pow(slRadius - 2, 2)) { + endPointX = pX - selector.getPrefWidth() / 2; + endPointY = pY - selector.getPrefHeight() / 2; + } else { + double dx = pX - centerX; + double dy = pY - centerY; + theta = Math.atan2(dy, dx); + x = centerX + (slRadius - 2) * Math.cos(theta); + y = centerY + (slRadius - 2) * Math.sin(theta); + endPointX = x - selector.getPrefWidth() / 2; + endPointY = y - selector.getPrefHeight() / 2; + } + selectorTransition = new CachedTransition(selector, new Timeline(new KeyFrame(Duration.millis(1000), + new KeyValue(selector.translateXProperty(), + endPointX, + Interpolator.EASE_BOTH), + new KeyValue(selector.translateYProperty(), + endPointY, + Interpolator.EASE_BOTH)))) { + { + setCycleDuration(Duration.millis(160)); + setDelay(Duration.seconds(0)); + } + }; + + if (pTrans != null) { + pTrans.stop(); + } + pTrans = new ParallelTransition(colorsTransition, selectorTransition); + pTrans.setOnFinished((finish) -> { + if (pTrans.getStatus() == Status.STOPPED) { + allowColorChange = true; + } + }); + pTrans.play(); + + refreshHSLCircle(); + } + + private void setColorAtLocation(int x, int y) { + if (allowColorChange) { + Color color = getColorAtLocation(x, y); + String colorString = "rgb(" + color.getRed() * 255 + "," + color.getGreen() * 255 + "," + color.getBlue() * 255 + ");"; + for (Node node : colorNodes) + node.setStyle("-fx-background-color:" + colorString + "; -fx-fill:" + colorString + ";"); + } + } + + private Color getColorAtLocation(double x, double y) { + double dy = x - centerX; + double dx = y - centerY; + return getColor(dx, dy); + } + + private Image getHuesCircle(int width, int height) { + WritableImage raster = new WritableImage(width, height); + PixelWriter pixelWriter = raster.getPixelWriter(); + Point2D center = new Point2D((double) width / 2, (double) height / 2); + double rsmall = 0.8 * width / 2; + double rbig = (double) width / 2; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + double dx = x - center.getX(); + double dy = y - center.getY(); + double distance = Math.sqrt((dx * dx) + (dy * dy)); + double o = Math.atan2(dy, dx); + if (distance > rsmall && distance < rbig) { + double H = map(o, -Math.PI, Math.PI, 0, 255); + double S = 255; + double L = 152; + pixelWriter.setColor(x, y, HSL2RGB(H, S, L)); + } + } + } + return raster; + } + + private Image getSLCricle(int width, int height) { + WritableImage raster = new WritableImage(width, height); + PixelWriter pixelWriter = raster.getPixelWriter(); + Point2D center = new Point2D((double) width / 2, (double) height / 2); + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + double dy = x - center.getX(); + double dx = y - center.getY(); + pixelWriter.setColor(x, y, getColor(dx, dy)); + } + } + return raster; + } + + private double clamp(double from, double small, double big) { + return Math.min(Math.max(from, small), big); + } + + private Color getColor(double dx, double dy) { + double distance = Math.sqrt((dx * dx) + (dy * dy)); + double rverysmall = 0.65 * ((double) pickerSize / 2); + Color pixelColor = Color.BLUE; + + if (distance <= rverysmall * 1.1) { + double angle = -Math.PI / 2.; + double angle1 = angle + 2 * Math.PI / 3.; + double angle2 = angle1 + 2 * Math.PI / 3.; + double x1 = rverysmall * Math.sin(angle1); + double y1 = rverysmall * Math.cos(angle1); + double x2 = rverysmall * Math.sin(angle2); + double y2 = rverysmall * Math.cos(angle2); + dx += 0.01; + double[] circle = circleFrom3Points(new Point2D(x1, y1), new Point2D(x2, y2), new Point2D(dx, dy)); + double xArc = circle[0]; + double yArc = 0; + double arcR = circle[2]; + double Arco = Math.atan2(dx - xArc, dy - yArc); + double Arco1 = Math.atan2(x1 - xArc, y1 - yArc); + double Arco2 = Math.atan2(x2 - xArc, y2 - yArc); + + double finalX = xArc > 0 ? xArc - arcR : xArc + arcR; + + double saturation = map(finalX, -rverysmall, rverysmall, 255, 0); + + double lightness = 255; + double diffAngle = Arco2 - Arco1; + double diffArco = Arco - Arco1; + if (dx < x1) { + diffAngle = diffAngle < 0 ? 2 * Math.PI + diffAngle : diffAngle; + diffAngle = Math.abs(2 * Math.PI - diffAngle); + diffArco = diffArco < 0 ? 2 * Math.PI + diffArco : diffArco; + diffArco = Math.abs(2 * Math.PI - diffArco); + } + lightness = map(diffArco, 0, diffAngle, 0, 255); + + + if (distance > rverysmall) { + saturation = 255 - saturation; + if (lightness < 0 && dy < 0) { + lightness = 255; + } + } + lightness = clamp(lightness, 0, 255); + if ((saturation < 10 && dx < x1) || (saturation > 240 && dx > x1)) { + saturation = 255 - saturation; + } + saturation = clamp(saturation, 0, 255); + pixelColor = HSL2RGB(currentHue, saturation, lightness); + } + return pixelColor; + } + + /*************************************************************************** + * * + * Hues Animation * + * * + **************************************************************************/ + + private final class CurveTransition extends Transition { + Point2D from; + double fromTheta; + double toTheta; + + public CurveTransition(Point2D from, Point2D to) { + this.from = from; + double fromDx = from.getX() - centerX; + double fromDy = from.getY() - centerY; + fromTheta = Math.atan2(fromDy, fromDx); + double toDx = to.getX() - centerX; + double toDy = to.getY() - centerY; + toTheta = Math.atan2(toDy, toDx); + setInterpolator(Interpolator.EASE_BOTH); + setDelay(Duration.millis(0)); + setCycleDuration(Duration.millis(240)); + } + + @Override + protected void interpolate(double frac) { + double dif = Math.min(Math.abs(toTheta - fromTheta), 2 * Math.PI - Math.abs(toTheta - fromTheta)); + if (dif == 2 * Math.PI - Math.abs(toTheta - fromTheta)) { + int dir = -1; + if (toTheta < fromTheta) { + dir = 1; + } + dif = dir * dif; + } else { + dif = toTheta - fromTheta; + } + + Point2D newP = rotate(from, new Point2D(centerX, centerY), frac * dif); + colorSelector.setRotate(90 + Math.toDegrees(Math.atan2(newP.getY() - centerY, newP.getX() - centerX))); + colorSelector.setTranslateX(newP.getX() - colorSelector.getPrefWidth() / 2); + colorSelector.setTranslateY(newP.getY() - colorSelector.getPrefHeight() / 2); + } + } + + /*************************************************************************** + * * + * Util methods * + * * + **************************************************************************/ + + private double map(double val, double min1, double max1, double min2, double max2) { + return min2 + (max2 - min2) * ((val - min1) / (max1 - min1)); + } + + private Color HSL2RGB(double hue, double sat, double lum) { + hue = map(hue, 0, 255, 0, 359); + sat = map(sat, 0, 255, 0, 1); + lum = map(lum, 0, 255, 0, 1); + double v; + double red, green, blue; + double m; + double sv; + int sextant; + double fract, vsf, mid1, mid2; + + red = lum; // default to gray + green = lum; + blue = lum; + v = (lum <= 0.5) ? (lum * (1.0 + sat)) : (lum + sat - lum * sat); + m = lum + lum - v; + sv = (v - m) / v; + hue /= 60.0; //get into range 0..6 + sextant = (int) Math.floor(hue); // int32 rounds up or down. + fract = hue - sextant; + vsf = v * sv * fract; + mid1 = m + vsf; + mid2 = v - vsf; + + if (v > 0) { + switch (sextant) { + case 0: + red = v; + green = mid1; + blue = m; + break; + case 1: + red = mid2; + green = v; + blue = m; + break; + case 2: + red = m; + green = v; + blue = mid1; + break; + case 3: + red = m; + green = mid2; + blue = v; + break; + case 4: + red = mid1; + green = m; + blue = v; + break; + case 5: + red = v; + green = m; + blue = mid2; + break; + } + } + return new Color(red, green, blue, 1); + } + + private double[] circleFrom3Points(Point2D a, Point2D b, Point2D c) { + double ax, ay, bx, by, cx, cy, x1, y11, dx1, dy1, x2, y2, dx2, dy2, ox, oy, dx, dy, radius; // Variables Used and to Declared + ax = a.getX(); + ay = a.getY(); //first Point X and Y + bx = b.getX(); + by = b.getY(); // Second Point X and Y + cx = c.getX(); + cy = c.getY(); // Third Point X and Y + + ////****************Following are Basic Procedure**********************/// + x1 = (bx + ax) / 2; + y11 = (by + ay) / 2; + dy1 = bx - ax; + dx1 = -(by - ay); + + x2 = (cx + bx) / 2; + y2 = (cy + by) / 2; + dy2 = cx - bx; + dx2 = -(cy - by); + + ox = (y11 * dx1 * dx2 + x2 * dx1 * dy2 - x1 * dy1 * dx2 - y2 * dx1 * dx2) / (dx1 * dy2 - dy1 * dx2); + oy = (ox - x1) * dy1 / dx1 + y11; + + dx = ox - ax; + dy = oy - ay; + radius = Math.sqrt(dx * dx + dy * dy); + return new double[]{ox, oy, radius}; + } + + private Point2D getPointFromSL(int saturation, int lightness, double radius) { + double dy = map(saturation, 0, 255, -radius, radius); + double angle = 0.; + double angle1 = angle + 2 * Math.PI / 3.; + double angle2 = angle1 + 2 * Math.PI / 3.; + double x1 = radius * Math.sin(angle1); + double y1 = radius * Math.cos(angle1); + double x2 = radius * Math.sin(angle2); + double y2 = radius * Math.cos(angle2); + double dx = 0; + double[] circle = circleFrom3Points(new Point2D(x1, y1), new Point2D(dx, dy), new Point2D(x2, y2)); + double xArc = circle[0]; + double yArc = circle[1]; + double arcR = circle[2]; + double Arco1 = Math.atan2(x1 - xArc, y1 - yArc); + double Arco2 = Math.atan2(x2 - xArc, y2 - yArc); + double ArcoFinal = map(lightness, 0, 255, Arco2, Arco1); + double finalX = xArc + arcR * Math.sin(ArcoFinal); + double finalY = yArc + arcR * Math.cos(ArcoFinal); + if (dy < y1) { + ArcoFinal = map(lightness, 0, 255, Arco1, Arco2 + 2 * Math.PI); + finalX = -xArc - arcR * Math.sin(ArcoFinal); + finalY = yArc + arcR * Math.cos(ArcoFinal); + } + return new Point2D(finalX, finalY); + } + + private Point2D rotate(Point2D a, Point2D center, double angle) { + double resultX = center.getX() + (a.getX() - center.getX()) * Math.cos(angle) - (a.getY() - center.getY()) * Math + .sin(angle); + double resultY = center.getY() + (a.getX() - center.getX()) * Math.sin(angle) + (a.getY() - center.getY()) * Math + .cos(angle); + return new Point2D(resultX, resultY); + } + +} diff --git a/HMCL/src/main/java/com/jfoenix/skins/JFXCustomColorPicker.java b/HMCL/src/main/java/com/jfoenix/skins/JFXCustomColorPicker.java new file mode 100644 index 0000000000..196ed1e5dc --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/skins/JFXCustomColorPicker.java @@ -0,0 +1,520 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.skins; + +import com.jfoenix.effects.JFXDepthManager; +import com.jfoenix.transitions.CachedTransition; +import javafx.animation.KeyFrame; +import javafx.animation.KeyValue; +import javafx.animation.Timeline; +import javafx.beans.binding.Bindings; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.geometry.Point2D; +import javafx.scene.Node; +import javafx.scene.effect.DropShadow; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.Pane; +import javafx.scene.paint.Color; +import javafx.scene.shape.*; +import javafx.scene.transform.Rotate; +import javafx.util.Duration; + +import java.util.ArrayList; + +import static javafx.animation.Interpolator.EASE_BOTH; + +/// @author Shadi Shaheen +final class JFXCustomColorPicker extends Pane { + + ObjectProperty selectedPath = new SimpleObjectProperty<>(); + private MoveTo startPoint; + private CubicCurveTo curve0To; + private CubicCurveTo outerCircleCurveTo; + private CubicCurveTo curve1To; + private CubicCurveTo innerCircleCurveTo; + private final ArrayList curves = new ArrayList<>(); + + private final double distance = 200; + private final double centerX = distance; + private final double centerY = distance; + private final double radius = 110; + + private static final int shapesNumber = 13; + private final ArrayList shapes = new ArrayList<>(); + private CachedTransition showAnimation; + private final JFXColorPickerUI hslColorPicker; + + public JFXCustomColorPicker() { + this.setPickOnBounds(false); + this.setMinSize(distance * 2, distance * 2); + + final DoubleProperty rotationAngle = new SimpleDoubleProperty(2.1); + + // draw recent colors shape using cubic curves + init(rotationAngle, centerX + 53, centerY + 162); + + hslColorPicker = new JFXColorPickerUI((int) distance); + hslColorPicker.setLayoutX(centerX - distance / 2); + hslColorPicker.setLayoutY(centerY - distance / 2); + this.getChildren().add(hslColorPicker); + // add recent colors shapes + final int shapesStartIndex = this.getChildren().size(); + final int shapesEndIndex = shapesStartIndex + shapesNumber; + for (int i = 0; i < shapesNumber; i++) { + final double angle = 2 * i * Math.PI / shapesNumber; + final RecentColorPath path = new RecentColorPath(startPoint, + curve0To, + outerCircleCurveTo, + curve1To, + innerCircleCurveTo); + shapes.add(path); + path.setPickOnBounds(false); + final Rotate rotate = new Rotate(Math.toDegrees(angle), centerX, centerY); + path.getTransforms().add(rotate); + this.getChildren().add(shapesStartIndex, path); + path.setFill(Color.valueOf(getDefaultColor(i))); + path.setFocusTraversable(true); + path.addEventHandler(MouseEvent.MOUSE_CLICKED, (event) -> { + path.requestFocus(); + selectedPath.set(path); + }); + } + + // add selection listeners + selectedPath.addListener((o, oldVal, newVal) -> { + if (oldVal != null) { + hslColorPicker.removeColorSelectionNode(oldVal); + oldVal.playTransition(-1); + } + // re-arrange children + while (this.getChildren().indexOf(newVal) != shapesEndIndex - 1) { + final Node temp = this.getChildren().get(shapesEndIndex - 1); + this.getChildren().remove(shapesEndIndex - 1); + this.getChildren().add(shapesStartIndex, temp); + } + // update path fill according to the color picker + newVal.setStroke(Color.rgb(255, 255, 255, 0.87)); + newVal.playTransition(1); + hslColorPicker.moveToColor((Color) newVal.getFill()); + hslColorPicker.addColorSelectionNode(newVal); + }); + // init selection + selectedPath.set((RecentColorPath) this.getChildren().get(shapesStartIndex)); + } + + public int getShapesNumber() { + return shapesNumber; + } + + public int getSelectedIndex() { + if (selectedPath.get() != null) { + return shapes.indexOf(selectedPath.get()); + } + return -1; + } + + public void setColor(final Color color) { + shapes.get(getSelectedIndex()).setFill(color); + hslColorPicker.moveToColor(color); + } + + public Color getColor(final int index) { + if (index >= 0 && index < shapes.size()) { + return (Color) shapes.get(index).getFill(); + } else { + return Color.WHITE; + } + } + + public void preAnimate() { + final CubicCurve firstCurve = curves.get(0); + final double x = firstCurve.getStartX(); + final double y = firstCurve.getStartY(); + firstCurve.setStartX(centerX); + firstCurve.setStartY(centerY); + + final CubicCurve secondCurve = curves.get(1); + final double x1 = secondCurve.getStartX(); + final double y1 = secondCurve.getStartY(); + secondCurve.setStartX(centerX); + secondCurve.setStartY(centerY); + + final double cx1 = firstCurve.getControlX1(); + final double cy1 = firstCurve.getControlY1(); + firstCurve.setControlX1(centerX + radius); + firstCurve.setControlY1(centerY + radius / 2); + + final KeyFrame keyFrame = new KeyFrame(Duration.millis(1000), + new KeyValue(firstCurve.startXProperty(), x, EASE_BOTH), + new KeyValue(firstCurve.startYProperty(), y, EASE_BOTH), + new KeyValue(secondCurve.startXProperty(), x1, EASE_BOTH), + new KeyValue(secondCurve.startYProperty(), y1, EASE_BOTH), + new KeyValue(firstCurve.controlX1Property(), cx1, EASE_BOTH), + new KeyValue(firstCurve.controlY1Property(), cy1, EASE_BOTH) + ); + final Timeline timeline = new Timeline(keyFrame); + showAnimation = new CachedTransition(this, timeline) { + { + setCycleDuration(Duration.millis(240)); + setDelay(Duration.millis(0)); + } + }; + } + + public void animate() { + showAnimation.play(); + } + + private void init(final DoubleProperty rotationAngle, final double initControlX1, final double initControlY1) { + + final Circle innerCircle = new Circle(centerX, centerY, radius, Color.TRANSPARENT); + final Circle outerCircle = new Circle(centerX, centerY, radius * 2, Color.web("blue", 0.5)); + + // Create a composite shape of 4 cubic curves + // create 2 cubic curves of the shape + createQuadraticCurve(rotationAngle, initControlX1, initControlY1); + + // inner circle curve + final CubicCurve innerCircleCurve = new CubicCurve(); + innerCircleCurve.startXProperty().bind(curves.get(0).startXProperty()); + innerCircleCurve.startYProperty().bind(curves.get(0).startYProperty()); + innerCircleCurve.endXProperty().bind(curves.get(1).startXProperty()); + innerCircleCurve.endYProperty().bind(curves.get(1).startYProperty()); + curves.get(0).startXProperty().addListener((o, oldVal, newVal) -> { + final Point2D controlPoint = makeControlPoint(newVal.doubleValue(), + curves.get(0).getStartY(), + innerCircle, + shapesNumber, + -1); + innerCircleCurve.setControlX1(controlPoint.getX()); + innerCircleCurve.setControlY1(controlPoint.getY()); + }); + curves.get(0).startYProperty().addListener((o, oldVal, newVal) -> { + final Point2D controlPoint = makeControlPoint(curves.get(0).getStartX(), + newVal.doubleValue(), + innerCircle, + shapesNumber, + -1); + innerCircleCurve.setControlX1(controlPoint.getX()); + innerCircleCurve.setControlY1(controlPoint.getY()); + }); + curves.get(1).startXProperty().addListener((o, oldVal, newVal) -> { + final Point2D controlPoint = makeControlPoint(newVal.doubleValue(), + curves.get(1).getStartY(), + innerCircle, + shapesNumber, + 1); + innerCircleCurve.setControlX2(controlPoint.getX()); + innerCircleCurve.setControlY2(controlPoint.getY()); + }); + curves.get(1).startYProperty().addListener((o, oldVal, newVal) -> { + final Point2D controlPoint = makeControlPoint(curves.get(1).getStartX(), + newVal.doubleValue(), + innerCircle, + shapesNumber, + 1); + innerCircleCurve.setControlX2(controlPoint.getX()); + innerCircleCurve.setControlY2(controlPoint.getY()); + }); + Point2D controlPoint = makeControlPoint(curves.get(0).getStartX(), + curves.get(0).getStartY(), + innerCircle, + shapesNumber, + -1); + innerCircleCurve.setControlX1(controlPoint.getX()); + innerCircleCurve.setControlY1(controlPoint.getY()); + controlPoint = makeControlPoint(curves.get(1).getStartX(), + curves.get(1).getStartY(), + innerCircle, + shapesNumber, + 1); + innerCircleCurve.setControlX2(controlPoint.getX()); + innerCircleCurve.setControlY2(controlPoint.getY()); + + // outer circle curve + final CubicCurve outerCircleCurve = new CubicCurve(); + outerCircleCurve.startXProperty().bind(curves.get(0).endXProperty()); + outerCircleCurve.startYProperty().bind(curves.get(0).endYProperty()); + outerCircleCurve.endXProperty().bind(curves.get(1).endXProperty()); + outerCircleCurve.endYProperty().bind(curves.get(1).endYProperty()); + controlPoint = makeControlPoint(curves.get(0).getEndX(), + curves.get(0).getEndY(), + outerCircle, + shapesNumber, + -1); + outerCircleCurve.setControlX1(controlPoint.getX()); + outerCircleCurve.setControlY1(controlPoint.getY()); + controlPoint = makeControlPoint(curves.get(1).getEndX(), curves.get(1).getEndY(), outerCircle, shapesNumber, 1); + outerCircleCurve.setControlX2(controlPoint.getX()); + outerCircleCurve.setControlY2(controlPoint.getY()); + + startPoint = new MoveTo(); + startPoint.xProperty().bind(curves.get(0).startXProperty()); + startPoint.yProperty().bind(curves.get(0).startYProperty()); + + curve0To = new CubicCurveTo(); + curve0To.controlX1Property().bind(curves.get(0).controlX1Property()); + curve0To.controlY1Property().bind(curves.get(0).controlY1Property()); + curve0To.controlX2Property().bind(curves.get(0).controlX2Property()); + curve0To.controlY2Property().bind(curves.get(0).controlY2Property()); + curve0To.xProperty().bind(curves.get(0).endXProperty()); + curve0To.yProperty().bind(curves.get(0).endYProperty()); + + outerCircleCurveTo = new CubicCurveTo(); + outerCircleCurveTo.controlX1Property().bind(outerCircleCurve.controlX1Property()); + outerCircleCurveTo.controlY1Property().bind(outerCircleCurve.controlY1Property()); + outerCircleCurveTo.controlX2Property().bind(outerCircleCurve.controlX2Property()); + outerCircleCurveTo.controlY2Property().bind(outerCircleCurve.controlY2Property()); + outerCircleCurveTo.xProperty().bind(outerCircleCurve.endXProperty()); + outerCircleCurveTo.yProperty().bind(outerCircleCurve.endYProperty()); + + curve1To = new CubicCurveTo(); + curve1To.controlX1Property().bind(curves.get(1).controlX2Property()); + curve1To.controlY1Property().bind(curves.get(1).controlY2Property()); + curve1To.controlX2Property().bind(curves.get(1).controlX1Property()); + curve1To.controlY2Property().bind(curves.get(1).controlY1Property()); + curve1To.xProperty().bind(curves.get(1).startXProperty()); + curve1To.yProperty().bind(curves.get(1).startYProperty()); + + innerCircleCurveTo = new CubicCurveTo(); + innerCircleCurveTo.controlX1Property().bind(innerCircleCurve.controlX2Property()); + innerCircleCurveTo.controlY1Property().bind(innerCircleCurve.controlY2Property()); + innerCircleCurveTo.controlX2Property().bind(innerCircleCurve.controlX1Property()); + innerCircleCurveTo.controlY2Property().bind(innerCircleCurve.controlY1Property()); + innerCircleCurveTo.xProperty().bind(innerCircleCurve.startXProperty()); + innerCircleCurveTo.yProperty().bind(innerCircleCurve.startYProperty()); + } + + private void createQuadraticCurve(final DoubleProperty rotationAngle, final double initControlX1, final double initControlY1) { + for (int i = 0; i < 2; i++) { + + double angle = 2 * i * Math.PI / shapesNumber; + double xOffset = radius * Math.cos(angle); + double yOffset = radius * Math.sin(angle); + final double startx = centerX + xOffset; + final double starty = centerY + yOffset; + + final double diffStartCenterX = startx - centerX; + final double diffStartCenterY = starty - centerY; + final double sinRotAngle = Math.sin(rotationAngle.get()); + final double cosRotAngle = Math.cos(rotationAngle.get()); + final double startXR = cosRotAngle * diffStartCenterX - sinRotAngle * diffStartCenterY + centerX; + final double startYR = sinRotAngle * diffStartCenterX + cosRotAngle * diffStartCenterY + centerY; + + angle = 2 * i * Math.PI / shapesNumber; + xOffset = distance * Math.cos(angle); + yOffset = distance * Math.sin(angle); + + final double endx = centerX + xOffset; + final double endy = centerY + yOffset; + + final CubicCurve curvedLine = new CubicCurve(); + curvedLine.setStartX(startXR); + curvedLine.setStartY(startYR); + curvedLine.setControlX1(startXR); + curvedLine.setControlY1(startYR); + curvedLine.setControlX2(endx); + curvedLine.setControlY2(endy); + curvedLine.setEndX(endx); + curvedLine.setEndY(endy); + curvedLine.setStroke(Color.FORESTGREEN); + curvedLine.setStrokeWidth(1); + curvedLine.setStrokeLineCap(StrokeLineCap.ROUND); + curvedLine.setFill(Color.TRANSPARENT); + curvedLine.setMouseTransparent(true); + rotationAngle.addListener((o, oldVal, newVal) -> { + final double newstartXR = ((cosRotAngle * diffStartCenterX) - (sinRotAngle * diffStartCenterY)) + centerX; + final double newstartYR = (sinRotAngle * diffStartCenterX) + (cosRotAngle * diffStartCenterY) + centerY; + curvedLine.setStartX(newstartXR); + curvedLine.setStartY(newstartYR); + }); + + curves.add(curvedLine); + + if (i == 0) { + curvedLine.setControlX1(initControlX1); + curvedLine.setControlY1(initControlY1); + } else { + final CubicCurve firstCurve = curves.get(0); + final double curveTheta = 2 * curves.indexOf(curvedLine) * Math.PI / shapesNumber; + + curvedLine.controlX1Property().bind(Bindings.createDoubleBinding(() -> { + final double a = firstCurve.getControlX1() - centerX; + final double b = Math.sin(curveTheta) * (firstCurve.getControlY1() - centerY); + return ((Math.cos(curveTheta) * a) - b) + centerX; + }, firstCurve.controlX1Property(), firstCurve.controlY1Property())); + + curvedLine.controlY1Property().bind(Bindings.createDoubleBinding(() -> { + final double a = Math.sin(curveTheta) * (firstCurve.getControlX1() - centerX); + final double b = Math.cos(curveTheta) * (firstCurve.getControlY1() - centerY); + return a + b + centerY; + }, firstCurve.controlX1Property(), firstCurve.controlY1Property())); + + + curvedLine.controlX2Property().bind(Bindings.createDoubleBinding(() -> { + final double a = firstCurve.getControlX2() - centerX; + final double b = firstCurve.getControlY2() - centerY; + return ((Math.cos(curveTheta) * a) - (Math.sin(curveTheta) * b)) + centerX; + }, firstCurve.controlX2Property(), firstCurve.controlY2Property())); + + curvedLine.controlY2Property().bind(Bindings.createDoubleBinding(() -> { + final double a = Math.sin(curveTheta) * (firstCurve.getControlX2() - centerX); + final double b = Math.cos(curveTheta) * (firstCurve.getControlY2() - centerY); + return a + b + centerY; + }, firstCurve.controlX2Property(), firstCurve.controlY2Property())); + } + } + } + + private String getDefaultColor(final int i) { + String color = "#FFFFFF"; + switch (i) { + case 0: + color = "#8F3F7E"; + break; + case 1: + color = "#B5305F"; + break; + case 2: + color = "#CE584A"; + break; + case 3: + color = "#DB8D5C"; + break; + case 4: + color = "#DA854E"; + break; + case 5: + color = "#E9AB44"; + break; + case 6: + color = "#FEE435"; + break; + case 7: + color = "#99C286"; + break; + case 8: + color = "#01A05E"; + break; + case 9: + color = "#4A8895"; + break; + case 10: + color = "#16669B"; + break; + case 11: + color = "#2F65A5"; + break; + case 12: + color = "#4E6A9C"; + break; + default: + break; + } + return color; + } + + private Point2D rotate(final Point2D a, final Point2D center, final double angle) { + final double resultX = center.getX() + (a.getX() - center.getX()) * Math.cos(angle) - (a.getY() - center.getY()) * Math + .sin(angle); + final double resultY = center.getY() + (a.getX() - center.getX()) * Math.sin(angle) + (a.getY() - center.getY()) * Math + .cos(angle); + return new Point2D(resultX, resultY); + } + + private Point2D makeControlPoint(final double endX, final double endY, final Circle circle, final int numSegments, int direction) { + final double controlPointDistance = (4.0 / 3.0) * Math.tan(Math.PI / (2 * numSegments)) * circle.getRadius(); + final Point2D center = new Point2D(circle.getCenterX(), circle.getCenterY()); + final Point2D end = new Point2D(endX, endY); + Point2D perp = rotate(center, end, direction * Math.PI / 2.); + Point2D diff = perp.subtract(end); + diff = diff.normalize(); + diff = scale(diff, controlPointDistance); + return end.add(diff); + } + + private Point2D scale(final Point2D a, final double scale) { + return new Point2D(a.getX() * scale, a.getY() * scale); + } + + final class RecentColorPath extends Path { + PathClickTransition transition; + + RecentColorPath(final PathElement... elements) { + super(elements); + this.setStrokeLineCap(StrokeLineCap.ROUND); + this.setStrokeWidth(0); + this.setStrokeType(StrokeType.CENTERED); + this.setCache(true); + JFXDepthManager.setDepth(this, 2); + this.transition = new PathClickTransition(this); + } + + void playTransition(final double rate) { + transition.setRate(rate); + transition.play(); + } + } + + private final class PathClickTransition extends CachedTransition { + PathClickTransition(final Path path) { + super(JFXCustomColorPicker.this, new Timeline( + new KeyFrame(Duration.ZERO, + new KeyValue(((DropShadow) path.getEffect()).radiusProperty(), + JFXDepthManager.getShadowAt(2).radiusProperty().get(), + EASE_BOTH), + new KeyValue(((DropShadow) path.getEffect()).spreadProperty(), + JFXDepthManager.getShadowAt(2).spreadProperty().get(), + EASE_BOTH), + new KeyValue(((DropShadow) path.getEffect()).offsetXProperty(), + JFXDepthManager.getShadowAt(2).offsetXProperty().get(), + EASE_BOTH), + new KeyValue(((DropShadow) path.getEffect()).offsetYProperty(), + JFXDepthManager.getShadowAt(2).offsetYProperty().get(), + EASE_BOTH), + new KeyValue(path.strokeWidthProperty(), 0, EASE_BOTH) + ), + new KeyFrame(Duration.millis(1000), + new KeyValue(((DropShadow) path.getEffect()).radiusProperty(), + JFXDepthManager.getShadowAt(5).radiusProperty().get(), + EASE_BOTH), + new KeyValue(((DropShadow) path.getEffect()).spreadProperty(), + JFXDepthManager.getShadowAt(5).spreadProperty().get(), + EASE_BOTH), + new KeyValue(((DropShadow) path.getEffect()).offsetXProperty(), + JFXDepthManager.getShadowAt(5).offsetXProperty().get(), + EASE_BOTH), + new KeyValue(((DropShadow) path.getEffect()).offsetYProperty(), + JFXDepthManager.getShadowAt(5).offsetYProperty().get(), + EASE_BOTH), + new KeyValue(path.strokeWidthProperty(), 2, EASE_BOTH) + ) + ) + ); + // reduce the number to increase the shifting , increase number to reduce shifting + setCycleDuration(Duration.millis(120)); + setDelay(Duration.seconds(0)); + setAutoReverse(false); + } + } +} diff --git a/HMCL/src/main/java/com/jfoenix/skins/JFXCustomColorPickerDialog.java b/HMCL/src/main/java/com/jfoenix/skins/JFXCustomColorPickerDialog.java new file mode 100644 index 0000000000..a78ccd17cb --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/skins/JFXCustomColorPickerDialog.java @@ -0,0 +1,407 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.skins; + +import com.jfoenix.controls.*; +import com.jfoenix.svg.SVGGlyph; +import com.jfoenix.transitions.JFXFillTransition; +import javafx.animation.*; +import javafx.application.Platform; +import javafx.beans.InvalidationListener; +import javafx.beans.Observable; +import javafx.beans.binding.Bindings; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.event.EventHandler; +import javafx.geometry.Insets; +import javafx.geometry.Rectangle2D; +import javafx.scene.Scene; +import javafx.scene.control.Tab; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.*; +import javafx.scene.paint.Color; +import javafx.stage.*; +import javafx.util.Duration; +import org.jackhuang.hmcl.setting.StyleSheets; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * @author Shadi Shaheen + */ +public class JFXCustomColorPickerDialog extends StackPane { + private final Stage dialog = new Stage(); + // used for concurrency control and preventing FX-thread over use + private final AtomicInteger concurrencyController = new AtomicInteger(-1); + + private final ObjectProperty currentColorProperty = new SimpleObjectProperty<>(Color.WHITE); + private final ObjectProperty customColorProperty = new SimpleObjectProperty<>(Color.TRANSPARENT); + private Runnable onSave; + + private final Scene customScene; + private final JFXCustomColorPicker curvedColorPicker; + private ParallelTransition paraTransition; + private final JFXDecorator pickerDecorator; + private boolean systemChange = false; + private boolean userChange = false; + private boolean initOnce = true; + private final Runnable initRun; + + public JFXCustomColorPickerDialog(Window owner) { + getStyleClass().add("custom-color-dialog"); + if (owner != null) { + dialog.initOwner(owner); + } + dialog.initModality(Modality.APPLICATION_MODAL); + dialog.initStyle(StageStyle.TRANSPARENT); + dialog.setResizable(false); + + // create JFX Decorator + pickerDecorator = new JFXDecorator(dialog, this, false, false, false); + pickerDecorator.setOnCloseButtonAction(this::updateColor); + pickerDecorator.setPickOnBounds(false); + customScene = new Scene(pickerDecorator, Color.TRANSPARENT); + StyleSheets.init(customScene); + curvedColorPicker = new JFXCustomColorPicker(); + + StackPane pane = new StackPane(curvedColorPicker); + pane.setPadding(new Insets(18)); + + VBox container = new VBox(); + container.getChildren().add(pane); + + JFXTabPane tabs = new JFXTabPane(); + + JFXTextField rgbField = new JFXTextField(); + JFXTextField hsbField = new JFXTextField(); + JFXTextField hexField = new JFXTextField(); + + rgbField.getStyleClass().add("custom-color-field"); + rgbField.setPromptText("RGB Color"); + rgbField.textProperty().addListener((o, oldVal, newVal) -> updateColorFromUserInput(newVal)); + + hsbField.getStyleClass().add("custom-color-field"); + hsbField.setPromptText("HSB Color"); + hsbField.textProperty().addListener((o, oldVal, newVal) -> updateColorFromUserInput(newVal)); + + hexField.getStyleClass().add("custom-color-field"); + hexField.setPromptText("#HEX Color"); + hexField.textProperty().addListener((o, oldVal, newVal) -> updateColorFromUserInput(newVal)); + + StackPane tabContent = new StackPane(); + tabContent.getChildren().add(rgbField); + tabContent.setMinHeight(100); + + Tab rgbTab = new Tab("RGB"); + rgbTab.setContent(tabContent); + Tab hsbTab = new Tab("HSB"); + hsbTab.setContent(hsbField); + Tab hexTab = new Tab("HEX"); + hexTab.setContent(hexField); + + tabs.getTabs().add(hexTab); + tabs.getTabs().add(rgbTab); + tabs.getTabs().add(hsbTab); + + curvedColorPicker.selectedPath.addListener((o, oldVal, newVal) -> { + if (paraTransition != null) { + paraTransition.stop(); + } + Region tabsHeader = (Region) tabs.lookup(".tab-header-background"); + pane.backgroundProperty().unbind(); + tabsHeader.backgroundProperty().unbind(); + JFXFillTransition fillTransition = new JFXFillTransition(Duration.millis(240), + pane, + (Color) oldVal.getFill(), + (Color) newVal.getFill()); + JFXFillTransition tabsFillTransition = new JFXFillTransition(Duration.millis(240), + tabsHeader, + (Color) oldVal.getFill(), + (Color) newVal.getFill()); + paraTransition = new ParallelTransition(fillTransition, tabsFillTransition); + paraTransition.setOnFinished((finish) -> { + tabsHeader.backgroundProperty().bind(Bindings.createObjectBinding(() -> { + return new Background(new BackgroundFill(newVal.getFill(), CornerRadii.EMPTY, Insets.EMPTY)); + }, newVal.fillProperty())); + pane.backgroundProperty().bind(Bindings.createObjectBinding(() -> { + return new Background(new BackgroundFill(newVal.getFill(), CornerRadii.EMPTY, Insets.EMPTY)); + }, newVal.fillProperty())); + }); + paraTransition.play(); + }); + + initRun = () -> { + // change tabs labels font color according to the selected color + pane.backgroundProperty().addListener((o, oldVal, newVal) -> { + if (concurrencyController.getAndSet(1) == -1) { + Color fontColor = ((Color) newVal.getFills().get(0).getFill()).grayscale() + .getRed() > 0.5 ? Color.valueOf( + "rgba(40, 40, 40, 0.87)") : Color.valueOf("rgba(255, 255, 255, 0.87)"); +// for (Node tabNode : tabs.lookupAll(".tab")) { +// for (Node node : tabNode.lookupAll(".tab-label")) { +// ((Label) node).setTextFill(fontColor); +// } +// for (Node node : tabNode.lookupAll(".jfx-rippler")) { +// ((JFXRippler) node).setRipplerFill(fontColor); +// } +// } +// ((Pane) tabs.lookup(".tab-selected-line")).setBackground(new Background(new BackgroundFill(fontColor, CornerRadii.EMPTY, Insets.EMPTY))); + pickerDecorator.lookupAll(".jfx-decorator-button").forEach(button -> { + ((JFXButton) button).setRipplerFill(fontColor); + ((SVGGlyph) ((JFXButton) button).getGraphic()).setFill(fontColor); + }); + + Color newColor = (Color) newVal.getFills().get(0).getFill(); + String hex = String.format("#%02X%02X%02X", + (int) (newColor.getRed() * 255), + (int) (newColor.getGreen() * 255), + (int) (newColor.getBlue() * 255)); + String rgb = String.format("rgba(%d, %d, %d, 1)", + (int) (newColor.getRed() * 255), + (int) (newColor.getGreen() * 255), + (int) (newColor.getBlue() * 255)); + String hsb = String.format("hsl(%d, %d%%, %d%%)", + (int) (newColor.getHue()), + (int) (newColor.getSaturation() * 100), + (int) (newColor.getBrightness() * 100)); + + if (!userChange) { + systemChange = true; + rgbField.setText(rgb); + hsbField.setText(hsb); + hexField.setText(hex); + systemChange = false; + } + concurrencyController.getAndSet(-1); + } + }); + + // initial selected colors + Platform.runLater(() -> { + pane.setBackground(new Background(new BackgroundFill(curvedColorPicker.getColor(curvedColorPicker.getSelectedIndex()), + CornerRadii.EMPTY, + Insets.EMPTY))); + ((Region) tabs.lookup(".tab-header-background")).setBackground(new Background(new BackgroundFill( + curvedColorPicker.getColor(curvedColorPicker.getSelectedIndex()), + CornerRadii.EMPTY, + Insets.EMPTY))); + Region tabsHeader = (Region) tabs.lookup(".tab-header-background"); + pane.backgroundProperty().unbind(); + tabsHeader.backgroundProperty().unbind(); + tabsHeader.backgroundProperty().bind(Bindings.createObjectBinding(() -> { + return new Background(new BackgroundFill(curvedColorPicker.selectedPath.get().getFill(), + CornerRadii.EMPTY, + Insets.EMPTY)); + }, curvedColorPicker.selectedPath.get().fillProperty())); + pane.backgroundProperty().bind(Bindings.createObjectBinding(() -> { + return new Background(new BackgroundFill(curvedColorPicker.selectedPath.get().getFill(), + CornerRadii.EMPTY, + Insets.EMPTY)); + }, curvedColorPicker.selectedPath.get().fillProperty())); + + // bind text field line color + rgbField.focusColorProperty().bind(Bindings.createObjectBinding(() -> { + return pane.getBackground().getFills().get(0).getFill(); + }, pane.backgroundProperty())); + hsbField.focusColorProperty().bind(Bindings.createObjectBinding(() -> { + return pane.getBackground().getFills().get(0).getFill(); + }, pane.backgroundProperty())); + hexField.focusColorProperty().bind(Bindings.createObjectBinding(() -> { + return pane.getBackground().getFills().get(0).getFill(); + }, pane.backgroundProperty())); + + + ((Pane) pickerDecorator.lookup(".jfx-decorator-buttons-container")).backgroundProperty() + .bind(Bindings.createObjectBinding(() -> { + return new Background(new BackgroundFill( + pane.getBackground() + .getFills() + .get(0) + .getFill(), + CornerRadii.EMPTY, + Insets.EMPTY)); + }, pane.backgroundProperty())); + + ((Pane) pickerDecorator.lookup(".jfx-decorator-content-container")).borderProperty() + .bind(Bindings.createObjectBinding(() -> { + return new Border(new BorderStroke( + pane.getBackground() + .getFills() + .get(0) + .getFill(), + BorderStrokeStyle.SOLID, + CornerRadii.EMPTY, + new BorderWidths(0, + 4, + 4, + 4))); + }, pane.backgroundProperty())); + }); + }; + + + container.getChildren().add(tabs); + + this.getChildren().add(container); + this.setPadding(new Insets(0)); + + dialog.setScene(customScene); + final EventHandler keyEventListener = key -> { + switch (key.getCode()) { + case ESCAPE: + close(); + break; + case ENTER: + updateColor(); + break; + default: + break; + } + }; + dialog.addEventHandler(KeyEvent.ANY, keyEventListener); + } + + private void updateColor() { + close(); + this.customColorProperty.set(curvedColorPicker.getColor(curvedColorPicker.getSelectedIndex())); + this.onSave.run(); + } + + private void updateColorFromUserInput(String colorWebString) { + if (!systemChange) { + userChange = true; + try { + curvedColorPicker.setColor(Color.valueOf(colorWebString)); + } catch (IllegalArgumentException ignored) { + // if color is not valid then do nothing + } + userChange = false; + } + } + + private void close() { + dialog.setScene(null); + dialog.close(); + } + + public void setCurrentColor(Color currentColor) { + this.currentColorProperty.set(currentColor); + } + + Color getCurrentColor() { + return currentColorProperty.get(); + } + + ObjectProperty customColorProperty() { + return customColorProperty; + } + + void setCustomColor(Color color) { + customColorProperty.set(color); + } + + Color getCustomColor() { + return customColorProperty.get(); + } + + public Runnable getOnSave() { + return onSave; + } + + public void setOnSave(Runnable onSave) { + this.onSave = onSave; + } + + public void setOnHidden(EventHandler onHidden) { + dialog.setOnHidden(onHidden); + } + + public void show() { + dialog.setOpacity(0); + // pickerDecorator.setOpacity(0); + if (dialog.getOwner() != null) { + dialog.widthProperty().addListener(positionAdjuster); + dialog.heightProperty().addListener(positionAdjuster); + positionAdjuster.invalidated(null); + } + if (dialog.getScene() == null) { + dialog.setScene(customScene); + } + curvedColorPicker.preAnimate(); + dialog.show(); + if (initOnce) { + initRun.run(); + initOnce = false; + } + + Timeline timeline = new Timeline(new KeyFrame(Duration.millis(120), + new KeyValue(dialog.opacityProperty(), + 1, + Interpolator.EASE_BOTH))); + timeline.setOnFinished((finish) -> curvedColorPicker.animate()); + timeline.play(); + } + + + // add option to show color picker using JFX Dialog + private InvalidationListener positionAdjuster = new InvalidationListener() { + @Override + public void invalidated(Observable ignored) { + if (Double.isNaN(dialog.getWidth()) || Double.isNaN(dialog.getHeight())) { + return; + } + dialog.widthProperty().removeListener(positionAdjuster); + dialog.heightProperty().removeListener(positionAdjuster); + fixPosition(); + } + }; + + private void fixPosition() { + Window w = dialog.getOwner(); + Screen s = com.sun.javafx.util.Utils.getScreen(w); + Rectangle2D sb = s.getBounds(); + double xR = w.getX() + w.getWidth(); + double xL = w.getX() - dialog.getWidth(); + double x; + double y; + if (sb.getMaxX() >= xR + dialog.getWidth()) { + x = xR; + } else if (sb.getMinX() <= xL) { + x = xL; + } else { + x = Math.max(sb.getMinX(), sb.getMaxX() - dialog.getWidth()); + } + y = Math.max(sb.getMinY(), Math.min(sb.getMaxY() - dialog.getHeight(), w.getY())); + dialog.setX(x); + dialog.setY(y); + } + + @Override + public void layoutChildren() { + super.layoutChildren(); + if (dialog.getMinWidth() > 0 && dialog.getMinHeight() > 0) { + return; + } + double minWidth = Math.max(0, computeMinWidth(getHeight()) + (dialog.getWidth() - customScene.getWidth())); + double minHeight = Math.max(0, computeMinHeight(getWidth()) + (dialog.getHeight() - customScene.getHeight())); + dialog.setMinWidth(minWidth); + dialog.setMinHeight(minHeight); + } +} diff --git a/HMCL/src/main/java/com/jfoenix/skins/JFXGenericPickerSkin.java b/HMCL/src/main/java/com/jfoenix/skins/JFXGenericPickerSkin.java new file mode 100644 index 0000000000..bc61774ddd --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/skins/JFXGenericPickerSkin.java @@ -0,0 +1,242 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.skins; + +import com.jfoenix.adapters.ReflectionHelper; +import com.jfoenix.controls.behavior.JFXGenericPickerBehavior; +import com.sun.javafx.binding.ExpressionHelper; +import com.sun.javafx.event.EventHandlerManager; +import com.sun.javafx.stage.WindowEventDispatcher; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.ReadOnlyBooleanPropertyBase; +import javafx.beans.value.ChangeListener; +import javafx.event.EventHandler; +import javafx.event.EventType; +import javafx.scene.control.ComboBoxBase; +import javafx.scene.control.PopupControl; +import javafx.scene.control.TextField; +import javafx.scene.control.skin.ComboBoxBaseSkin; +import javafx.scene.control.skin.ComboBoxPopupControl; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.Pane; +import javafx.stage.Window; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public abstract class JFXGenericPickerSkin extends ComboBoxPopupControl { + + private final EventHandler mouseEnteredEventHandler; + private final EventHandler mousePressedEventHandler; + private final EventHandler mouseReleasedEventHandler; + private final EventHandler mouseExitedEventHandler; + + protected JFXGenericPickerBehavior behavior; + + // reference of the arrow button node in getChildren (not the actual field) + protected Pane arrowButton; + protected PopupControl popup; + + public JFXGenericPickerSkin(ComboBoxBase comboBoxBase) { + super(comboBoxBase); + behavior = new JFXGenericPickerBehavior(comboBoxBase); + + removeParentFakeFocusListener(comboBoxBase); + + this.mouseEnteredEventHandler = event -> behavior.mouseEntered(event); + this.mousePressedEventHandler = event -> { + behavior.mousePressed(event); + event.consume(); + }; + this.mouseReleasedEventHandler = event -> { + behavior.mouseReleased(event); + event.consume(); + }; + this.mouseExitedEventHandler = event -> behavior.mouseExited(event); + + arrowButton = (Pane) getChildren().get(0); + + parentArrowEventHandlerTerminator.accept("mouseEnteredEventHandler", MouseEvent.MOUSE_ENTERED); + parentArrowEventHandlerTerminator.accept("mousePressedEventHandler", MouseEvent.MOUSE_PRESSED); + parentArrowEventHandlerTerminator.accept("mouseReleasedEventHandler", MouseEvent.MOUSE_RELEASED); + parentArrowEventHandlerTerminator.accept("mouseExitedEventHandler", MouseEvent.MOUSE_EXITED); + this.unregisterChangeListeners(comboBoxBase.editableProperty()); + + updateArrowButtonListeners(); + registerChangeListener(comboBoxBase.editableProperty(), obs -> { + updateArrowButtonListeners(); + reflectUpdateDisplayArea(); + }); + + removeParentPopupHandlers(); + + popup = ReflectionHelper.getFieldContent(ComboBoxPopupControl.class, this, "popup"); + } + + @Override + public void dispose() { + super.dispose(); + if (this.behavior != null) { + this.behavior.dispose(); + } + } + + + /*************************************************************************** + * * + * Reflections internal API * + * * + **************************************************************************/ + + private final BiConsumer> parentArrowEventHandlerTerminator = (handlerName, eventType) -> { + try { + EventHandler handler = ReflectionHelper.getFieldContent(ComboBoxBaseSkin.class, this, handlerName); + arrowButton.removeEventHandler(eventType, handler); + } catch (Exception e) { + e.printStackTrace(); + } + }; + + private static final VarHandle READ_ONLY_BOOLEAN_PROPERTY_BASE_HELPER = + findVarHandle(ReadOnlyBooleanPropertyBase.class, "helper", ExpressionHelper.class); + + /// @author Glavo + private static VarHandle findVarHandle(Class targetClass, String fieldName, Class type) { + try { + return MethodHandles.privateLookupIn(targetClass, MethodHandles.lookup()).findVarHandle(targetClass, fieldName, type); + } catch (NoSuchFieldException | IllegalAccessException e) { + LOG.warning("Failed to get var handle", e); + return null; + } + } + + private void removeParentFakeFocusListener(ComboBoxBase comboBoxBase) { + // handle FakeFocusField cast exception + try { + final ReadOnlyBooleanProperty focusedProperty = comboBoxBase.focusedProperty(); + //noinspection unchecked + ExpressionHelper value = (ExpressionHelper) READ_ONLY_BOOLEAN_PROPERTY_BASE_HELPER.get(focusedProperty); + ChangeListener[] changeListeners = ReflectionHelper.getFieldContent(value.getClass(), value, "changeListeners"); + // remove parent focus listener to prevent editor class cast exception + for (int i = changeListeners.length - 1; i > 0; i--) { + if (changeListeners[i] != null && changeListeners[i].getClass().getName().contains("ComboBoxPopupControl")) { + focusedProperty.removeListener(changeListeners[i]); + break; + } + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + private void removeParentPopupHandlers() { + try { + PopupControl popup = ReflectionHelper.invoke(ComboBoxPopupControl.class, this, "getPopup"); + popup.setOnAutoHide(event -> behavior.onAutoHide(popup)); + WindowEventDispatcher dispatcher = ReflectionHelper.invoke(Window.class, popup, "getInternalEventDispatcher"); + Map compositeEventHandlersMap = ReflectionHelper.getFieldContent(EventHandlerManager.class, dispatcher.getEventHandlerManager(), "eventHandlerMap"); + compositeEventHandlersMap.remove(MouseEvent.MOUSE_CLICKED); +// CompositeEventHandler compositeEventHandler = (CompositeEventHandler) compositeEventHandlersMap.get(MouseEvent.MOUSE_CLICKED); +// Object obj = fieldConsumer.apply(()->CompositeEventHandler.class.getDeclaredField("firstRecord"),compositeEventHandler); +// EventHandler handler = (EventHandler) fieldConsumer.apply(() -> obj.getClass().getDeclaredField("eventHandler"), obj); +// popup.removeEventHandler(MouseEvent.MOUSE_CLICKED, handler); + popup.addEventHandler(MouseEvent.MOUSE_CLICKED, click -> behavior.onAutoHide(popup)); + } catch (Exception e) { + e.printStackTrace(); + } + } + + private void updateArrowButtonListeners() { + if (getSkinnable().isEditable()) { + arrowButton.addEventHandler(MouseEvent.MOUSE_ENTERED, mouseEnteredEventHandler); + arrowButton.addEventHandler(MouseEvent.MOUSE_PRESSED, mousePressedEventHandler); + arrowButton.addEventHandler(MouseEvent.MOUSE_RELEASED, mouseReleasedEventHandler); + arrowButton.addEventHandler(MouseEvent.MOUSE_EXITED, mouseExitedEventHandler); + } else { + arrowButton.removeEventHandler(MouseEvent.MOUSE_ENTERED, mouseEnteredEventHandler); + arrowButton.removeEventHandler(MouseEvent.MOUSE_PRESSED, mousePressedEventHandler); + arrowButton.removeEventHandler(MouseEvent.MOUSE_RELEASED, mouseReleasedEventHandler); + arrowButton.removeEventHandler(MouseEvent.MOUSE_EXITED, mouseExitedEventHandler); + } + } + + + /*************************************************************************** + * * + * Reflections internal API for ComboBoxPopupControl * + * * + **************************************************************************/ + + private final HashMap parentCachedMethods = new HashMap<>(); + + Function methodSupplier = name -> { + if (!parentCachedMethods.containsKey(name)) { + try { + Method method = ComboBoxPopupControl.class.getDeclaredMethod(name); + method.setAccessible(true); + parentCachedMethods.put(name, method); + } catch (Exception e) { + e.printStackTrace(); + } + } + return parentCachedMethods.get(name); + }; + + final Consumer methodInvoker = method -> { + try { + method.invoke(this); + } catch (Exception e) { + e.printStackTrace(); + } + }; + + final Function methodReturnInvoker = method -> { + try { + return method.invoke(this); + } catch (Exception e) { + e.printStackTrace(); + } + return null; + }; + + protected void reflectUpdateDisplayArea() { + methodInvoker.accept(methodSupplier.apply("updateDisplayArea")); + } + + protected void reflectSetTextFromTextFieldIntoComboBoxValue() { + methodInvoker.accept(methodSupplier.apply("setTextFromTextFieldIntoComboBoxValue")); + } + + protected TextField reflectGetEditableInputNode() { + return (TextField) methodReturnInvoker.apply(methodSupplier.apply("getEditableInputNode")); + } + + protected void reflectUpdateDisplayNode() { + methodInvoker.accept(methodSupplier.apply("updateDisplayNode")); + } +} diff --git a/HMCL/src/main/java/com/jfoenix/skins/JFXListViewSkin.java b/HMCL/src/main/java/com/jfoenix/skins/JFXListViewSkin.java index 63cccd21fe..4f8f0624d8 100644 --- a/HMCL/src/main/java/com/jfoenix/skins/JFXListViewSkin.java +++ b/HMCL/src/main/java/com/jfoenix/skins/JFXListViewSkin.java @@ -20,26 +20,24 @@ */ package com.jfoenix.skins; -import com.jfoenix.adapters.skins.ListViewSkin; import com.jfoenix.controls.JFXListView; import com.jfoenix.effects.JFXDepthManager; import javafx.scene.control.ListCell; +import javafx.scene.control.skin.ListViewSkin; import javafx.scene.control.skin.VirtualFlow; -import javafx.scene.layout.Region; import org.jackhuang.hmcl.ui.FXUtils; -// https://github.com/HMCL-dev/HMCL/issues/4720 public class JFXListViewSkin extends ListViewSkin { - private final VirtualFlow> flow; - - @SuppressWarnings("unchecked") public JFXListViewSkin(final JFXListView listView) { super(listView); - flow = (VirtualFlow>) getChildren().get(0); + VirtualFlow> flow = getVirtualFlow(); JFXDepthManager.setDepth(flow, listView.depthProperty().get()); listView.depthProperty().addListener((o, oldVal, newVal) -> JFXDepthManager.setDepth(flow, newVal)); - FXUtils.smoothScrolling(flow); + + if (!Boolean.TRUE.equals(listView.getProperties().get("no-smooth-scrolling"))) { + FXUtils.smoothScrolling(flow); + } } @Override @@ -47,47 +45,4 @@ protected double computePrefWidth(double height, double topInset, double rightIn return 200; } - @Override - protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { - final int itemsCount = getSkinnable().getItems().size(); - if (getSkinnable().maxHeightProperty().isBound() || itemsCount <= 0) { - return super.computePrefHeight(width, topInset, rightInset, bottomInset, leftInset); - } - - final double fixedCellSize = getSkinnable().getFixedCellSize(); - double computedHeight = fixedCellSize != Region.USE_COMPUTED_SIZE ? - fixedCellSize * itemsCount + snapVerticalInsets() : estimateHeight(); - double height = super.computePrefHeight(width, topInset, rightInset, bottomInset, leftInset); - if (height > computedHeight) { - height = computedHeight; - } - - if (getSkinnable().getMaxHeight() > 0 && computedHeight > getSkinnable().getMaxHeight()) { - return getSkinnable().getMaxHeight(); - } - - return height; - } - - private double estimateHeight() { - // compute the border/padding for the list - double borderWidth = snapVerticalInsets(); - // compute the gap between list cells - - JFXListView listview = (JFXListView) getSkinnable(); - double gap = listview.isExpanded() ? ((JFXListView) getSkinnable()).getVerticalGap() * (getSkinnable().getItems() - .size()) : 0; - // compute the height of each list cell - double cellsHeight = 0; - for (int i = 0; i < flow.getCellCount(); i++) { - ListCell cell = flow.getCell(i); - cellsHeight += cell.getHeight(); - } - return cellsHeight + gap + borderWidth; - } - - private double snapVerticalInsets() { - return getSkinnable().snappedBottomInset() + getSkinnable().snappedTopInset(); - } - } diff --git a/HMCL/src/main/java/com/jfoenix/skins/JFXPopupSkin.java b/HMCL/src/main/java/com/jfoenix/skins/JFXPopupSkin.java new file mode 100644 index 0000000000..c1daa775ac --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/skins/JFXPopupSkin.java @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2016 JFoenix + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package com.jfoenix.skins; + +import com.jfoenix.controls.JFXPopup; +import com.jfoenix.controls.JFXPopup.PopupHPosition; +import com.jfoenix.controls.JFXPopup.PopupVPosition; +import com.jfoenix.effects.JFXDepthManager; +import javafx.animation.*; +import javafx.animation.Animation.Status; +import javafx.scene.Node; +import javafx.scene.control.Skin; +import javafx.scene.layout.*; +import javafx.scene.transform.Scale; +import javafx.util.Duration; +import org.jackhuang.hmcl.ui.animation.AnimationUtils; +import org.jackhuang.hmcl.ui.animation.Motion; + +/// # Material Design Popup Skin +/// TODO: REWORK +/// +/// @author Shadi Shaheen +/// @version 2.0 +/// @since 2017-03-01 +public class JFXPopupSkin implements Skin { + + protected JFXPopup control; + protected StackPane container = new StackPane(); + protected Region popupContent; + protected Node root; + + private Animation animation; + protected Scale scale; + + public JFXPopupSkin(JFXPopup control) { + this.control = control; + // set scale y to 0.01 instead of 0 to allow layout of the content, + // otherwise it will cause exception in traverse engine, when focusing the 1st node + scale = new Scale(1.0, 0.01, 0, 0); + popupContent = control.getPopupContent(); + container.getStyleClass().add("jfx-popup-container"); + container.getChildren().add(popupContent); + container.getTransforms().add(scale); + container.setOpacity(0); + root = JFXDepthManager.createMaterialNode(container, 4); + animation = AnimationUtils.isAnimationEnabled() ? getAnimation() : null; + } + + public void reset(PopupVPosition vAlign, PopupHPosition hAlign, double offsetX, double offsetY) { + // postion the popup according to its animation + scale.setPivotX(hAlign == PopupHPosition.RIGHT ? container.getWidth() : 0); + scale.setPivotY(vAlign == PopupVPosition.BOTTOM ? container.getHeight() : 0); + control.setX(control.getX() + (hAlign == PopupHPosition.RIGHT ? -container.getWidth() + offsetX : offsetX)); + control.setY(control.getY() + (vAlign == PopupVPosition.BOTTOM ? -container.getHeight() + offsetY : offsetY)); + } + + public final void animate() { + if (animation != null) { + if (animation.getStatus() == Status.STOPPED) { + container.setOpacity(1); + animation.playFromStart(); + } + } else { + container.setOpacity(1); + popupContent.setOpacity(1); + scale.setX(1.0); + scale.setY(1.0); + } + } + + @Override + public JFXPopup getSkinnable() { + return control; + } + + @Override + public Node getNode() { + return root; + } + + @Override + public void dispose() { + if (animation != null) { + animation.stop(); + animation = null; + } + container = null; + control = null; + popupContent = null; + root = null; + } + + protected Animation getAnimation() { + Interpolator interpolator = Motion.EASE; + return new Timeline( + new KeyFrame( + Duration.ZERO, + new KeyValue(popupContent.opacityProperty(), 0, interpolator), + new KeyValue(scale.xProperty(), 0, interpolator), + new KeyValue(scale.yProperty(), 0, interpolator) + ), + new KeyFrame(Motion.SHORT4, + new KeyValue(popupContent.opacityProperty(), 0, interpolator), + new KeyValue(scale.xProperty(), 1, interpolator) + ), + new KeyFrame(Motion.MEDIUM2, + new KeyValue(popupContent.opacityProperty(), 1, interpolator), + new KeyValue(scale.yProperty(), 1, interpolator) + ) + ); + } + + public void init() { + if (animation != null) + animation.stop(); + container.setOpacity(0); + scale.setX(1.0); + scale.setY(0.01); + } +} diff --git a/HMCL/src/main/java/com/jfoenix/skins/JFXRadioButtonSkin.java b/HMCL/src/main/java/com/jfoenix/skins/JFXRadioButtonSkin.java new file mode 100644 index 0000000000..8e7a1f73e2 --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/skins/JFXRadioButtonSkin.java @@ -0,0 +1,179 @@ +// +// Source code recreated from a .class file by IntelliJ IDEA +// (powered by FernFlower decompiler) +// + +package com.jfoenix.skins; + +import com.jfoenix.controls.JFXRadioButton; +import com.jfoenix.controls.JFXRippler; +import com.jfoenix.controls.JFXRippler.RipplerMask; +import javafx.animation.Interpolator; +import javafx.animation.KeyFrame; +import javafx.animation.KeyValue; +import javafx.animation.Timeline; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.geometry.HPos; +import javafx.geometry.Insets; +import javafx.geometry.VPos; +import javafx.scene.control.RadioButton; +import javafx.scene.control.skin.RadioButtonSkin; +import javafx.scene.layout.AnchorPane; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; +import javafx.scene.shape.Circle; +import javafx.scene.text.Text; +import javafx.util.Duration; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.animation.AnimationUtils; + +public class JFXRadioButtonSkin extends RadioButtonSkin { + private static final double PADDING = 15.0; + + private boolean invalid = true; + private final JFXRippler rippler; + private final Circle radio; + private final Circle dot; + private Timeline timeline; + private final AnchorPane container = new AnchorPane(); + private final double labelOffset = -10.0; + + public JFXRadioButtonSkin(JFXRadioButton control) { + super(control); + double radioRadius = 7.0; + this.radio = new Circle(radioRadius); + this.radio.getStyleClass().setAll("radio"); + this.radio.setStrokeWidth(2.0); + this.radio.setFill(Color.TRANSPARENT); + + this.dot = new Circle(4); + this.dot.getStyleClass().setAll("dot"); + this.dot.fillProperty().bind(control.selectedColorProperty()); + this.dot.setScaleX(0.0); + this.dot.setScaleY(0.0); + + StackPane boxContainer = new StackPane(); + boxContainer.getChildren().addAll(this.radio, this.dot); + boxContainer.setPadding(new Insets(PADDING)); + this.rippler = new JFXRippler(boxContainer, RipplerMask.CIRCLE); + this.container.getChildren().add(this.rippler); + AnchorPane.setRightAnchor(this.rippler, this.labelOffset); + + this.updateChildren(); + ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(control); + if (focusVisibleProperty == null) { + focusVisibleProperty = control.focusedProperty(); + } + + focusVisibleProperty.addListener((o, oldVal, newVal) -> { + if (newVal) { + if (!this.getSkinnable().isPressed()) { + this.rippler.showOverlay(); + } + } else { + this.rippler.hideOverlay(); + } + }); + control.pressedProperty().addListener((o, oldVal, newVal) -> this.rippler.hideOverlay()); + this.registerChangeListener(control.selectedColorProperty(), ignored -> updateColors()); + this.registerChangeListener(control.unSelectedColorProperty(), ignored -> updateColors()); + this.registerChangeListener(control.selectedProperty(), ignored -> { + updateColors(); + this.playAnimation(); + }); + } + + protected void updateChildren() { + super.updateChildren(); + if (this.radio != null) { + this.removeRadio(); + this.getChildren().add(this.container); + } + } + + protected void layoutChildren(double x, double y, double w, double h) { + RadioButton radioButton = this.getSkinnable(); + double contWidth = this.snapSizeX(this.container.prefWidth(-1.0)) + (double) (this.invalid ? 2 : 0); + double contHeight = this.snapSizeY(this.container.prefHeight(-1.0)) + (double) (this.invalid ? 2 : 0); + double computeWidth = Math.min(radioButton.prefWidth(-1.0), radioButton.minWidth(-1.0)) + this.labelOffset + 2.0 * this.PADDING; + double labelWidth = Math.min(computeWidth - contWidth, w - this.snapSizeX(contWidth)) + this.labelOffset + 2.0 * PADDING; + double labelHeight = Math.min(radioButton.prefHeight(labelWidth), h); + double maxHeight = Math.max(contHeight, labelHeight); + double xOffset = computeXOffset(w, labelWidth + contWidth, radioButton.getAlignment().getHpos()) + x; + double yOffset = computeYOffset(h, maxHeight, radioButton.getAlignment().getVpos()) + x; + if (this.invalid) { + this.initializeComponents(); + this.invalid = false; + } + + this.layoutLabelInArea(xOffset + contWidth, yOffset, labelWidth, maxHeight, radioButton.getAlignment()); + ((Text) this.getChildren().get(this.getChildren().get(0) instanceof Text ? 0 : 1)).textProperty().set(this.getSkinnable().textProperty().get()); + this.container.resize(this.snapSizeX(contWidth), this.snapSizeY(contHeight)); + this.positionInArea(this.container, xOffset, yOffset, contWidth, maxHeight, 0.0, radioButton.getAlignment().getHpos(), radioButton.getAlignment().getVpos()); + } + + private void initializeComponents() { + this.updateColors(); + this.playAnimation(); + } + + private void playAnimation() { + if (AnimationUtils.isAnimationEnabled()) { + if (this.timeline == null) { + this.timeline = new Timeline( + new KeyFrame(Duration.ZERO, + new KeyValue(this.dot.scaleXProperty(), 0, Interpolator.EASE_BOTH), + new KeyValue(this.dot.scaleYProperty(), 0, Interpolator.EASE_BOTH)), + new KeyFrame(Duration.millis(200.0), + new KeyValue(this.dot.scaleXProperty(), 1, Interpolator.EASE_BOTH), + new KeyValue(this.dot.scaleYProperty(), 1, Interpolator.EASE_BOTH)) + ); + } else { + this.timeline.stop(); + } + this.timeline.setRate(this.getSkinnable().isSelected() ? 1.0 : -1.0); + this.timeline.play(); + } else { + double endScale = this.getSkinnable().isSelected() ? 1.0 : 0.0; + this.dot.setScaleX(endScale); + this.dot.setScaleY(endScale); + } + } + + private void removeRadio() { + this.getChildren().removeIf(node -> "radio".equals(node.getStyleClass().get(0))); + } + + private void updateColors() { + var control = (JFXRadioButton) getSkinnable(); + boolean isSelected = control.isSelected(); + Color unSelectedColor = control.getUnSelectedColor(); + Color selectedColor = control.getSelectedColor(); + rippler.setRipplerFill(isSelected ? selectedColor : unSelectedColor); + radio.setStroke(isSelected ? selectedColor : unSelectedColor); + } + + protected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { + return super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset) + this.snapSizeX(this.radio.minWidth(-1.0)) + this.labelOffset + 2.0 * PADDING; + } + + protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { + return super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset) + this.snapSizeX(this.radio.prefWidth(-1.0)) + this.labelOffset + 2.0 * PADDING; + } + + static double computeXOffset(double width, double contentWidth, HPos hpos) { + return switch (hpos) { + case LEFT -> 0.0; + case CENTER -> (width - contentWidth) / 2.0; + case RIGHT -> width - contentWidth; + }; + } + + static double computeYOffset(double height, double contentHeight, VPos vpos) { + return switch (vpos) { + case TOP, BASELINE -> 0.0; + case CENTER -> (height - contentHeight) / 2.0; + case BOTTOM -> height - contentHeight; + }; + } +} diff --git a/HMCL/src/main/java/com/jfoenix/skins/JFXToggleButtonSkin.java b/HMCL/src/main/java/com/jfoenix/skins/JFXToggleButtonSkin.java new file mode 100644 index 0000000000..0548204d7c --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/skins/JFXToggleButtonSkin.java @@ -0,0 +1,186 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.skins; + +import com.jfoenix.controls.JFXRippler; +import com.jfoenix.controls.JFXRippler.RipplerMask; +import com.jfoenix.controls.JFXRippler.RipplerPos; +import com.jfoenix.controls.JFXToggleButton; +import com.jfoenix.effects.JFXDepthManager; +import com.jfoenix.transitions.JFXAnimationTimer; +import com.jfoenix.transitions.JFXKeyFrame; +import com.jfoenix.transitions.JFXKeyValue; +import javafx.animation.Interpolator; +import javafx.geometry.Insets; +import javafx.scene.Cursor; +import javafx.scene.control.skin.ToggleButtonSkin; +import javafx.scene.layout.StackPane; +import javafx.scene.shape.Circle; +import javafx.scene.shape.Line; +import javafx.scene.shape.StrokeLineCap; +import javafx.util.Duration; + +/** + *

Material Design ToggleButton Skin

+ * + * @author Shadi Shaheen + * @version 1.0 + * @since 2016-03-09 + */ +public class JFXToggleButtonSkin extends ToggleButtonSkin { + + + private Runnable releaseManualRippler = null; + + private JFXAnimationTimer timer; + private final Circle circle; + private final Line line; + + public JFXToggleButtonSkin(JFXToggleButton toggleButton) { + super(toggleButton); + + double circleRadius = toggleButton.getSize(); + + line = new Line(); + line.setStroke(getSkinnable().isSelected() ? toggleButton.getToggleLineColor() : toggleButton.getUnToggleLineColor()); + line.setStartX(0); + line.setStartY(0); + line.setEndX(circleRadius * 2 + 2); + line.setEndY(0); + line.setStrokeWidth(circleRadius * 1.5); + line.setStrokeLineCap(StrokeLineCap.ROUND); + line.setSmooth(true); + + circle = new Circle(); + circle.setFill(getSkinnable().isSelected() ? toggleButton.getToggleColor() : toggleButton.getUnToggleColor()); + circle.setCenterX(-circleRadius); + circle.setCenterY(0); + circle.setRadius(circleRadius); + circle.setSmooth(true); + JFXDepthManager.setDepth(circle, 1); + + StackPane circlePane = new StackPane(); + circlePane.getChildren().add(circle); + circlePane.setPadding(new Insets(circleRadius * 1.5)); + + JFXRippler rippler = new JFXRippler(circlePane, RipplerMask.CIRCLE, RipplerPos.BACK); + rippler.setRipplerFill(getSkinnable().isSelected() ? toggleButton.getToggleLineColor() : toggleButton.getUnToggleLineColor()); + rippler.setTranslateX(computeTranslation(circleRadius, line)); + + final StackPane main = new StackPane(); + main.getChildren().setAll(line, rippler); + main.setCursor(Cursor.HAND); + + // show focus traversal effect + getSkinnable().armedProperty().addListener((o, oldVal, newVal) -> { + if (newVal) { + releaseManualRippler = rippler.createManualRipple(); + } else if (releaseManualRippler != null) { + releaseManualRippler.run(); + } + }); + toggleButton.focusedProperty().addListener((o, oldVal, newVal) -> { + if (!toggleButton.isDisableVisualFocus()) { + if (newVal) { + if (!getSkinnable().isPressed()) { + rippler.setOverlayVisible(true); + } + } else { + rippler.setOverlayVisible(false); + } + } + }); + toggleButton.pressedProperty().addListener(observable -> rippler.setOverlayVisible(false)); + + // add change listener to selected property + getSkinnable().selectedProperty().addListener(observable -> { + rippler.setRipplerFill(toggleButton.isSelected() ? toggleButton.getToggleLineColor() : toggleButton.getUnToggleLineColor()); + if (!toggleButton.isDisableAnimation()) { + timer.reverseAndContinue(); + } else { + rippler.setTranslateX(computeTranslation(circleRadius, line)); + } + }); + + getSkinnable().setGraphic(main); + + timer = new JFXAnimationTimer( + new JFXKeyFrame(Duration.millis(100), + JFXKeyValue.builder() + .setTarget(rippler.translateXProperty()) + .setEndValueSupplier(() -> computeTranslation(circleRadius, line)) + .setInterpolator(Interpolator.EASE_BOTH) + .setAnimateCondition(() -> !((JFXToggleButton) getSkinnable()).isDisableAnimation()) + .build(), + + JFXKeyValue.builder() + .setTarget(line.strokeProperty()) + .setEndValueSupplier(() -> getSkinnable().isSelected() ? + ((JFXToggleButton) getSkinnable()).getToggleLineColor() + : ((JFXToggleButton) getSkinnable()).getUnToggleLineColor()) + .setInterpolator(Interpolator.EASE_BOTH) + .setAnimateCondition(() -> !((JFXToggleButton) getSkinnable()).isDisableAnimation()) + .build(), + + JFXKeyValue.builder() + .setTarget(circle.fillProperty()) + .setEndValueSupplier(() -> getSkinnable().isSelected() ? + ((JFXToggleButton) getSkinnable()).getToggleColor() + : ((JFXToggleButton) getSkinnable()).getUnToggleColor()) + .setInterpolator(Interpolator.EASE_BOTH) + .setAnimateCondition(() -> !((JFXToggleButton) getSkinnable()).isDisableAnimation()) + .build() + ) + ); + timer.setCacheNodes(circle, line); + + registerChangeListener(toggleButton.toggleColorProperty(), observableValue -> { + if (getSkinnable().isSelected()) { + circle.setFill(((JFXToggleButton) getSkinnable()).getToggleColor()); + } + }); + registerChangeListener(toggleButton.unToggleColorProperty(), observableValue -> { + if (!getSkinnable().isSelected()) { + circle.setFill(((JFXToggleButton) getSkinnable()).getUnToggleColor()); + } + }); + registerChangeListener(toggleButton.toggleLineColorProperty(), observableValue -> { + if (getSkinnable().isSelected()) { + line.setStroke(((JFXToggleButton) getSkinnable()).getToggleLineColor()); + } + }); + registerChangeListener(toggleButton.unToggleColorProperty(), observableValue -> { + if (!getSkinnable().isSelected()) { + line.setStroke(((JFXToggleButton) getSkinnable()).getUnToggleLineColor()); + } + }); + } + + private double computeTranslation(double circleRadius, Line line) { + return (getSkinnable().isSelected() ? 1 : -1) * ((line.getLayoutBounds().getWidth() / 2) - circleRadius + 2); + } + + @Override + public void dispose() { + super.dispose(); + timer.dispose(); + timer = null; + } +} diff --git a/HMCL/src/main/java/com/jfoenix/transitions/CacheMemento.java b/HMCL/src/main/java/com/jfoenix/transitions/CacheMemento.java new file mode 100644 index 0000000000..cbc25fc7bc --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/transitions/CacheMemento.java @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.transitions; + +import javafx.scene.CacheHint; +import javafx.scene.Node; +import javafx.scene.layout.Region; + +import java.util.concurrent.atomic.AtomicBoolean; + +public final class CacheMemento { + private boolean cache; + private boolean cacheShape; + private boolean snapToPixel; + private CacheHint cacheHint = CacheHint.DEFAULT; + private final Node node; + private final AtomicBoolean isCached = new AtomicBoolean(false); + + public CacheMemento(Node node) { + this.node = node; + } + + /** + * this method will cache the node only if it wasn't cached before + */ + public void cache() { + if (!isCached.getAndSet(true)) { + this.cache = node.isCache(); + this.cacheHint = node.getCacheHint(); + node.setCache(true); + node.setCacheHint(CacheHint.SPEED); + if (node instanceof Region region) { + this.cacheShape = region.isCacheShape(); + this.snapToPixel = region.isSnapToPixel(); + region.setCacheShape(true); + region.setSnapToPixel(true); + } + } + } + + public void restore() { + if (isCached.getAndSet(false)) { + node.setCache(cache); + node.setCacheHint(cacheHint); + if (node instanceof Region region) { + region.setCacheShape(cacheShape); + region.setSnapToPixel(snapToPixel); + } + } + } +} diff --git a/HMCL/src/main/java/com/jfoenix/transitions/JFXAnimationTimer.java b/HMCL/src/main/java/com/jfoenix/transitions/JFXAnimationTimer.java new file mode 100644 index 0000000000..623a998eaa --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/transitions/JFXAnimationTimer.java @@ -0,0 +1,286 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.transitions; + +import javafx.animation.AnimationTimer; +import javafx.beans.value.WritableValue; +import javafx.scene.Node; +import javafx.util.Duration; + +import java.util.*; +import java.util.function.Supplier; + +/** + * Custom AnimationTimer that can be created the same way as a timeline, + * however it doesn't behave the same yet. it only animates in one direction, + * it doesn't support animation 0 -> 1 -> 0.5 + * + * @author Shadi Shaheen + * @version 1.0 + * @since 2017-09-21 + */ + +public class JFXAnimationTimer extends AnimationTimer { + + private Set animationHandlers = new HashSet<>(); + private long startTime = -1; + private boolean running = false; + private List caches = new ArrayList<>(); + private double totalElapsedMilliseconds; + + public JFXAnimationTimer(JFXKeyFrame... keyFrames) { + for (JFXKeyFrame keyFrame : keyFrames) { + Duration duration = keyFrame.getDuration(); + final Set> keyValuesSet = keyFrame.getValues(); + if (!keyValuesSet.isEmpty()) { + animationHandlers.add(new AnimationHandler(duration, keyFrame.getAnimateCondition(), keyFrame.getValues())); + } + } + } + + private final HashMap mutableFrames = new HashMap<>(); + + public void addKeyFrame(JFXKeyFrame keyFrame) throws Exception { + if (isRunning()) { + throw new Exception("Can't update animation timer while running"); + } + Duration duration = keyFrame.getDuration(); + final Set> keyValuesSet = keyFrame.getValues(); + if (!keyValuesSet.isEmpty()) { + final AnimationHandler handler = new AnimationHandler(duration, keyFrame.getAnimateCondition(), keyFrame.getValues()); + animationHandlers.add(handler); + mutableFrames.put(keyFrame, handler); + } + } + + public void removeKeyFrame(JFXKeyFrame keyFrame) throws Exception { + if (isRunning()) { + throw new Exception("Can't update animation timer while running"); + } + AnimationHandler handler = mutableFrames.get(keyFrame); + animationHandlers.remove(handler); + } + + @Override + public void start() { + super.start(); + running = true; + startTime = -1; + for (AnimationHandler animationHandler : animationHandlers) { + animationHandler.init(); + } + for (CacheMemento cache : caches) { + cache.cache(); + } + } + + @Override + public void handle(long now) { + startTime = startTime == -1 ? now : startTime; + totalElapsedMilliseconds = (now - startTime) / 1000000.0; + boolean stop = true; + for (AnimationHandler handler : animationHandlers) { + handler.animate(totalElapsedMilliseconds); + if (!handler.finished) { + stop = false; + } + } + if (stop) { + this.stop(); + } + } + + /** + * this method will pause the timer and reverse the animation if the timer already + * started otherwise it will start the animation. + */ + public void reverseAndContinue() { + if (isRunning()) { + super.stop(); + for (AnimationHandler handler : animationHandlers) { + handler.reverse(totalElapsedMilliseconds); + } + startTime = -1; + super.start(); + } else { + start(); + } + } + + @Override + public void stop() { + super.stop(); + running = false; + for (AnimationHandler handler : animationHandlers) { + handler.clear(); + } + for (CacheMemento cache : caches) { + cache.restore(); + } + if (onFinished != null) { + onFinished.run(); + } + } + + public void applyEndValues() { + if (isRunning()) { + super.stop(); + } + for (AnimationHandler handler : animationHandlers) { + handler.applyEndValues(); + } + startTime = -1; + } + + public boolean isRunning() { + return running; + } + + private Runnable onFinished = null; + + public void setOnFinished(Runnable onFinished) { + this.onFinished = onFinished; + } + + public void setCacheNodes(Node... nodesToCache) { + caches.clear(); + if (nodesToCache != null) { + for (Node node : nodesToCache) { + caches.add(new CacheMemento(node)); + } + } + } + + public void dispose() { + caches.clear(); + for (AnimationHandler handler : animationHandlers) { + handler.dispose(); + } + animationHandlers.clear(); + } + + static class AnimationHandler { + private final double duration; + private double currentDuration; + private final Set> keyValues; + private Supplier animationCondition = null; + private boolean finished = false; + + private final HashMap, Object> initialValuesMap = new HashMap<>(); + private final HashMap, Object> endValuesMap = new HashMap<>(); + + AnimationHandler(Duration duration, Supplier animationCondition, Set> keyValues) { + this.duration = duration.toMillis(); + currentDuration = this.duration; + this.keyValues = keyValues; + this.animationCondition = animationCondition; + } + + public void init() { + finished = animationCondition != null && !animationCondition.get(); + for (JFXKeyValue keyValue : keyValues) { + if (keyValue.getTarget() != null) { + // replaced putIfAbsent for mobile compatibility + if (!initialValuesMap.containsKey(keyValue.getTarget())) { + initialValuesMap.put(keyValue.getTarget(), keyValue.getTarget().getValue()); + } + if (!endValuesMap.containsKey(keyValue.getTarget())) { + endValuesMap.put(keyValue.getTarget(), keyValue.getEndValue()); + } + } + } + } + + void reverse(double now) { + finished = animationCondition != null && !animationCondition.get(); + currentDuration = duration - (currentDuration - now); + // update initial values + for (JFXKeyValue keyValue : keyValues) { + final WritableValue target = keyValue.getTarget(); + if (target != null) { + initialValuesMap.put(target, target.getValue()); + endValuesMap.put(target, keyValue.getEndValue()); + } + } + } + + // now in milliseconds + @SuppressWarnings({"unchecked"}) + public void animate(double now) { + // if animate condition for the key frame is not met then do nothing + if (finished) { + return; + } + if (now <= currentDuration) { + for (JFXKeyValue keyValue : keyValues) { + if (keyValue.isValid()) { + @SuppressWarnings("rawtypes") final WritableValue target = keyValue.getTarget(); + final Object endValue = endValuesMap.get(target); + if (endValue != null && target != null && !target.getValue().equals(endValue)) { + target.setValue(keyValue.getInterpolator().interpolate(initialValuesMap.get(target), endValue, now / currentDuration)); + } + } + } + } else { + if (!finished) { + finished = true; + for (JFXKeyValue keyValue : keyValues) { + if (keyValue.isValid()) { + @SuppressWarnings("rawtypes") final WritableValue target = keyValue.getTarget(); + if (target != null) { + // set updated end value instead of cached + final Object endValue = keyValue.getEndValue(); + if (endValue != null) { + target.setValue(endValue); + } + } + } + } + currentDuration = duration; + } + } + } + + @SuppressWarnings("unchecked") + public void applyEndValues() { + for (JFXKeyValue keyValue : keyValues) { + if (keyValue.isValid()) { + @SuppressWarnings("rawtypes") final WritableValue target = keyValue.getTarget(); + if (target != null) { + final Object endValue = keyValue.getEndValue(); + if (endValue != null && !target.getValue().equals(endValue)) { + target.setValue(endValue); + } + } + } + } + } + + public void clear() { + initialValuesMap.clear(); + endValuesMap.clear(); + } + + void dispose() { + clear(); + keyValues.clear(); + } + } +} diff --git a/HMCL/src/main/java/com/jfoenix/transitions/JFXKeyFrame.java b/HMCL/src/main/java/com/jfoenix/transitions/JFXKeyFrame.java new file mode 100644 index 0000000000..8c46455c15 --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/transitions/JFXKeyFrame.java @@ -0,0 +1,104 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.transitions; + +import javafx.util.Duration; + +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.function.Supplier; + +/** + * @author Shadi Shaheen + * @version 1.0 + * @since 2017-09-21 + */ + +public class JFXKeyFrame { + + private Duration duration; + private Set> keyValues = new CopyOnWriteArraySet<>(); + private Supplier animateCondition = null; + + public JFXKeyFrame(Duration duration, JFXKeyValue... keyValues) { + this.duration = duration; + for (final JFXKeyValue keyValue : keyValues) { + if (keyValue != null) { + this.keyValues.add(keyValue); + } + } + } + + private JFXKeyFrame() { + + } + + public final Duration getDuration() { + return duration; + } + + public final Set> getValues() { + return keyValues; + } + + public Supplier getAnimateCondition() { + return animateCondition; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private Duration duration; + private final Set> keyValues = new CopyOnWriteArraySet<>(); + private Supplier animateCondition = null; + + private Builder() { + } + + public Builder setDuration(Duration duration) { + this.duration = duration; + return this; + } + + public Builder setKeyValues(JFXKeyValue... keyValues) { + for (final JFXKeyValue keyValue : keyValues) { + if (keyValue != null) { + this.keyValues.add(keyValue); + } + } + return this; + } + + public Builder setAnimateCondition(Supplier animateCondition) { + this.animateCondition = animateCondition; + return this; + } + + public JFXKeyFrame build() { + JFXKeyFrame jFXKeyFrame = new JFXKeyFrame(); + jFXKeyFrame.duration = this.duration; + jFXKeyFrame.keyValues = this.keyValues; + jFXKeyFrame.animateCondition = this.animateCondition; + return jFXKeyFrame; + } + } +} diff --git a/HMCL/src/main/java/com/jfoenix/transitions/JFXKeyValue.java b/HMCL/src/main/java/com/jfoenix/transitions/JFXKeyValue.java new file mode 100644 index 0000000000..6532a5f300 --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/transitions/JFXKeyValue.java @@ -0,0 +1,157 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.transitions; + +import javafx.animation.Interpolator; +import javafx.beans.value.WritableValue; + +import java.util.function.Supplier; + +/** + * @author Shadi Shaheen + * @version 1.0 + * @since 2017-09-21 + */ + +public final class JFXKeyValue { + + private WritableValue target; + private Supplier> targetSupplier; + private Supplier endValueSupplier; + private T endValue; + private Supplier animateCondition = () -> true; + private Interpolator interpolator; + + private JFXKeyValue() { + } + + // this builder is created to ensure type inference from method arguments + public static Builder builder() { + return new Builder(); + } + + public T getEndValue() { + return endValue == null ? endValueSupplier.get() : endValue; + } + + public WritableValue getTarget() { + return target == null ? targetSupplier.get() : target; + } + + public Interpolator getInterpolator() { + return interpolator; + } + + public boolean isValid() { + return animateCondition == null || animateCondition.get(); + } + + public static final class Builder { + public JFXKeyValueBuilder setTarget(WritableValue target) { + JFXKeyValueBuilder builder = new JFXKeyValueBuilder<>(); + builder.setTarget(target); + return builder; + } + + public JFXKeyValueBuilder setTargetSupplier(Supplier> targetSupplier) { + JFXKeyValueBuilder builder = new JFXKeyValueBuilder<>(); + builder.setTargetSupplier(targetSupplier); + return builder; + } + + public JFXKeyValueBuilder setEndValueSupplier(Supplier endValueSupplier) { + JFXKeyValueBuilder builder = new JFXKeyValueBuilder<>(); + builder.setEndValueSupplier(endValueSupplier); + return builder; + } + + public JFXKeyValueBuilder setEndValue(T endValue) { + JFXKeyValueBuilder builder = new JFXKeyValueBuilder<>(); + builder.setEndValue(endValue); + return builder; + } + + public JFXKeyValueBuilder setAnimateCondition(Supplier animateCondition) { + JFXKeyValueBuilder builder = new JFXKeyValueBuilder<>(); + builder.setAnimateCondition(animateCondition); + return builder; + } + + public JFXKeyValueBuilder setInterpolator(Interpolator interpolator) { + JFXKeyValueBuilder builder = new JFXKeyValueBuilder<>(); + builder.setInterpolator(interpolator); + return builder; + } + } + + public static final class JFXKeyValueBuilder { + + private WritableValue target; + private Supplier> targetSupplier; + private Supplier endValueSupplier; + private T endValue; + private Supplier animateCondition = () -> true; + private Interpolator interpolator = Interpolator.EASE_BOTH; + + private JFXKeyValueBuilder() { + } + + public JFXKeyValueBuilder setTarget(WritableValue target) { + this.target = target; + return this; + } + + public JFXKeyValueBuilder setTargetSupplier(Supplier> targetSupplier) { + this.targetSupplier = targetSupplier; + return this; + } + + public JFXKeyValueBuilder setEndValueSupplier(Supplier endValueSupplier) { + this.endValueSupplier = endValueSupplier; + return this; + } + + public JFXKeyValueBuilder setEndValue(T endValue) { + this.endValue = endValue; + return this; + } + + public JFXKeyValueBuilder setAnimateCondition(Supplier animateCondition) { + this.animateCondition = animateCondition; + return this; + } + + public JFXKeyValueBuilder setInterpolator(Interpolator interpolator) { + this.interpolator = interpolator; + return this; + } + + public JFXKeyValue build() { + JFXKeyValue jFXKeyValue = new JFXKeyValue<>(); + jFXKeyValue.target = this.target; + jFXKeyValue.interpolator = this.interpolator; + jFXKeyValue.targetSupplier = this.targetSupplier; + jFXKeyValue.endValue = this.endValue; + jFXKeyValue.endValueSupplier = this.endValueSupplier; + jFXKeyValue.animateCondition = this.animateCondition; + return jFXKeyValue; + } + } +} diff --git a/HMCL/src/main/java/com/jfoenix/utils/JFXNodeUtils.java b/HMCL/src/main/java/com/jfoenix/utils/JFXNodeUtils.java new file mode 100644 index 0000000000..2a08dcfa08 --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/utils/JFXNodeUtils.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.jfoenix.utils; + +import javafx.scene.layout.Background; +import javafx.scene.layout.BackgroundFill; +import javafx.scene.layout.Region; +import javafx.scene.paint.Color; +import javafx.scene.paint.Paint; + +import java.util.Locale; + +/// @author Shadi Shaheen +/// @version 1.0 +/// @since 2017-02-11 +public final class JFXNodeUtils { + + public static void updateBackground(Background newBackground, Region nodeToUpdate) { + updateBackground(newBackground, nodeToUpdate, Color.BLACK); + } + + public static void updateBackground(Background newBackground, Region nodeToUpdate, Paint fill) { + if (newBackground != null && !newBackground.getFills().isEmpty()) { + final BackgroundFill[] fills = new BackgroundFill[newBackground.getFills().size()]; + for (int i = 0; i < newBackground.getFills().size(); i++) { + BackgroundFill bf = newBackground.getFills().get(i); + fills[i] = new BackgroundFill(fill, bf.getRadii(), bf.getInsets()); + } + nodeToUpdate.setBackground(new Background(fills)); + } + } + + public static String colorToHex(Color c) { + if (c != null) { + return String.format((Locale) null, "#%02X%02X%02X", + Math.round(c.getRed() * 255), + Math.round(c.getGreen() * 255), + Math.round(c.getBlue() * 255)); + } else { + return null; + } + } + + private JFXNodeUtils() { + } + +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java b/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java index 7f13f54e31..83623c86d0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java @@ -50,10 +50,12 @@ public static void main(String[] args) { LOG.start(Metadata.HMCL_CURRENT_DIRECTORY.resolve("logs")); setupJavaFXVMOptions(); - checkDirectoryPath(); - if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && !isInsideMacAppBundle()) - initIcon(); + if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { + System.getProperties().putIfAbsent("apple.awt.application.appearance", "system"); + if (!isInsideMacAppBundle()) + initIcon(); + } checkJavaFX(); verifyJavaFX(); @@ -192,16 +194,6 @@ private static void initIcon() { } } - private static void checkDirectoryPath() { - String currentDir = System.getProperty("user.dir", ""); - if (currentDir.contains("!")) { - LOG.error("The current working path contains an exclamation mark: " + currentDir); - // No Chinese translation because both Swing and JavaFX cannot render Chinese character properly when exclamation mark exists in the path. - showErrorAndExit("Exclamation mark(!) is not allowed in the path where HMCL is in.\n" - + "The path is " + currentDir); - } - } - private static void checkJavaFX() { try { SelfDependencyPatcher.patch(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java b/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java index a558c4b748..a2c583e6f6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java @@ -20,11 +20,13 @@ import javafx.application.Application; import javafx.application.Platform; import javafx.beans.value.ObservableBooleanValue; +import javafx.geometry.Rectangle2D; import javafx.scene.control.Alert; import javafx.scene.control.Alert.AlertType; import javafx.scene.control.ButtonType; import javafx.scene.input.Clipboard; import javafx.scene.input.DataFormat; +import javafx.stage.Screen; import javafx.stage.Stage; import org.jackhuang.hmcl.setting.ConfigHolder; import org.jackhuang.hmcl.setting.SambaException; @@ -54,6 +56,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.text.DecimalFormat; import java.util.ArrayList; import java.util.Collections; import java.util.Objects; @@ -79,6 +82,18 @@ public void start(Stage primaryStage) { LOG.info("Dark Mode: " + Optional.ofNullable(FXUtils.DARK_MODE).map(ObservableBooleanValue::get).orElse(false)); LOG.info("Reduced Motion: " + Objects.requireNonNullElse(FXUtils.REDUCED_MOTION, false)); + if (Screen.getScreens().isEmpty()) { + LOG.info("No screen"); + } else { + StringBuilder builder = new StringBuilder("Screens:"); + int count = 0; + for (Screen screen : Screen.getScreens()) { + builder.append("\n - Screen ").append(++count).append(": "); + appendScreen(builder, screen); + } + LOG.info(builder.toString()); + } + try { try { ConfigHolder.init(); @@ -131,6 +146,38 @@ public void start(Stage primaryStage) { } } + private static void appendScreen(StringBuilder builder, Screen screen) { + Rectangle2D bounds = screen.getBounds(); + double scale = screen.getOutputScaleX(); + + builder.append(Math.round(bounds.getWidth() * scale)); + builder.append('x'); + builder.append(Math.round(bounds.getHeight() * scale)); + + DecimalFormat decimalFormat = new DecimalFormat("#.##"); + + if (scale != 1.0) { + builder.append(" @ "); + builder.append(decimalFormat.format(scale)); + builder.append('x'); + } + + double dpi = screen.getDpi(); + builder.append(' '); + builder.append(decimalFormat.format(dpi)); + builder.append("dpi"); + + builder.append(" in ") + .append(Math.round(Math.sqrt(bounds.getWidth() * bounds.getWidth() + bounds.getHeight() * bounds.getHeight()) / dpi)) + .append('"'); + + builder.append(" (").append(decimalFormat.format(bounds.getMinX())) + .append(", ").append(decimalFormat.format(bounds.getMinY())) + .append(", ").append(decimalFormat.format(bounds.getMaxX())) + .append(", ").append(decimalFormat.format(bounds.getMaxY())) + .append(")"); + } + private static ButtonType showAlert(AlertType alertType, String contentText, ButtonType... buttons) { return new Alert(alertType, contentText, buttons).showAndWait().orElse(null); } @@ -206,7 +253,7 @@ private static void checkConfigOwner() { if (Files.exists(mcDir)) files.add(mcDir.toString()); - String command = new CommandBuilder().add("sudo", "chown", "-R", userName).addAll(files).toString(); + String command = new CommandBuilder().addAll("sudo", "chown", "-R", userName).addAll(files).toString(); ButtonType copyAndExit = new ButtonType(i18n("button.copy_and_exit")); if (showAlert(AlertType.ERROR, diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java index e19fc0721d..434b739b6f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java @@ -111,6 +111,7 @@ private static String normalizedLanguageTag(Locale locale, GameVersionNumber gam String region = locale.getCountry(); return switch (LocaleUtils.getRootLanguage(locale)) { + case "ar" -> "ar_SA"; case "es" -> "es_ES"; case "ja" -> "ja_JP"; case "ru" -> "ru_RU"; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java index a859aa2843..2426a1ced2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -25,20 +25,20 @@ import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.event.Event; import org.jackhuang.hmcl.event.EventManager; +import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.mod.ModAdviser; import org.jackhuang.hmcl.mod.Modpack; import org.jackhuang.hmcl.mod.ModpackConfiguration; import org.jackhuang.hmcl.mod.ModpackProvider; import org.jackhuang.hmcl.setting.Profile; -import org.jackhuang.hmcl.util.FileSaver; import org.jackhuang.hmcl.setting.VersionIconType; import org.jackhuang.hmcl.setting.VersionSetting; import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.util.FileSaver; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; -import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jackhuang.hmcl.util.platform.SystemInfo; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; @@ -46,6 +46,7 @@ import org.jetbrains.annotations.Nullable; import java.io.IOException; +import java.net.Proxy; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; @@ -55,8 +56,8 @@ import java.util.stream.Stream; import static org.jackhuang.hmcl.setting.ConfigHolder.config; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.Pair.pair; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; public final class HMCLGameRepository extends DefaultGameRepository { private final Profile profile; @@ -174,7 +175,7 @@ public void duplicateVersion(String srcId, String dstId, boolean copySaves) thro } Files.copy(fromJson, toJson); - JsonUtils.writeToJsonFile(toJson, fromVersion.setId(dstId)); + JsonUtils.writeToJsonFile(toJson, fromVersion.setId(dstId).setJar(dstId)); VersionSetting oldVersionSetting = getVersionSetting(srcId).clone(); GameDirectoryType originalGameDirType = oldVersionSetting.getGameDirType(); @@ -321,6 +322,8 @@ public Image getVersionIconImage(String id) { return VersionIconType.FABRIC.getIcon(); else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.QUILT)) return VersionIconType.QUILT.getIcon(); + else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC)) + return VersionIconType.LEGACY_FABRIC.getIcon(); else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE)) return VersionIconType.NEO_FORGE.getIcon(); else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.FORGE)) @@ -338,7 +341,7 @@ else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.OPTIFINE)) GameVersionNumber versionNumber = GameVersionNumber.asGameVersion(gameVersion); if (versionNumber.isAprilFools()) { return VersionIconType.APRIL_FOOLS.getIcon(); - } else if (versionNumber instanceof GameVersionNumber.Snapshot) { + } else if (versionNumber instanceof GameVersionNumber.LegacySnapshot) { return VersionIconType.COMMAND.getIcon(); } else if (versionNumber instanceof GameVersionNumber.Old) { return VersionIconType.CRAFT_TABLE.getIcon(); @@ -387,7 +390,7 @@ public void globalizeVersionSetting(String id) { vs.setUsesGlobal(true); } - public LaunchOptions getLaunchOptions(String version, JavaRuntime javaVersion, Path gameDir, List javaAgents, List javaArguments, boolean makeLaunchScript) { + public LaunchOptions.Builder getLaunchOptions(String version, JavaRuntime javaVersion, Path gameDir, List javaAgents, List javaArguments, boolean makeLaunchScript) { VersionSetting vs = getVersionSetting(version); LaunchOptions.Builder builder = new LaunchOptions.Builder() @@ -418,8 +421,8 @@ public LaunchOptions getLaunchOptions(String version, JavaRuntime javaVersion, P .setWidth(vs.getWidth()) .setHeight(vs.getHeight()) .setFullscreen(vs.isFullscreen()) - .setServerIp(vs.getServerIp()) .setWrapper(vs.getWrapper()) + .setProxyOption(getProxyOption()) .setPreLaunchCommand(vs.getPreLaunchCommand()) .setPostExitCommand(vs.getPostExitCommand()) .setNoGeneratedJVMArgs(vs.isNoJVMArgs()) @@ -428,21 +431,15 @@ public LaunchOptions getLaunchOptions(String version, JavaRuntime javaVersion, P .setNativesDir(vs.getNativesDir()) .setProcessPriority(vs.getProcessPriority()) .setRenderer(vs.getRenderer()) + .setEnableDebugLogOutput(vs.isEnableDebugLogOutput()) .setUseNativeGLFW(vs.isUseNativeGLFW()) .setUseNativeOpenAL(vs.isUseNativeOpenAL()) .setDaemon(!makeLaunchScript && vs.getLauncherVisibility().isDaemon()) .setJavaAgents(javaAgents) .setJavaArguments(javaArguments); - if (config().hasProxy()) { - builder.setProxyType(config().getProxyType()); - builder.setProxyHost(config().getProxyHost()); - builder.setProxyPort(config().getProxyPort()); - - if (config().hasProxyAuth()) { - builder.setProxyUser(config().getProxyUser()); - builder.setProxyPass(config().getProxyPass()); - } + if (StringUtils.isNotBlank(vs.getServerIp())) { + builder.setQuickPlayOption(new QuickPlayOption.MultiPlayer(vs.getServerIp())); } Path json = getModpackConfiguration(version); @@ -460,7 +457,7 @@ public LaunchOptions getLaunchOptions(String version, JavaRuntime javaVersion, P if (vs.isAutoMemory() && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx"))) builder.setMaxMemory(null); - return builder.create(); + return builder; } @Override @@ -554,4 +551,39 @@ public static long getAllocatedMemory(long minimum, long available, boolean auto return minimum; } } + + public static ProxyOption getProxyOption() { + if (!config().hasProxy() || config().getProxyType() == null) { + return ProxyOption.Default.INSTANCE; + } + + return switch (config().getProxyType()) { + case DIRECT -> ProxyOption.Direct.INSTANCE; + case HTTP, SOCKS -> { + String proxyHost = config().getProxyHost(); + int proxyPort = config().getProxyPort(); + + if (StringUtils.isBlank(proxyHost) || proxyPort < 0 || proxyPort > 0xFFFF) { + yield ProxyOption.Default.INSTANCE; + } + + String proxyUser = config().getProxyUser(); + String proxyPass = config().getProxyPass(); + + if (StringUtils.isBlank(proxyUser)) { + proxyUser = null; + proxyPass = null; + } else if (proxyPass == null) { + proxyPass = ""; + } + + if (config().getProxyType() == Proxy.Type.HTTP) { + yield new ProxyOption.Http(proxyHost, proxyPort, proxyUser, proxyPass); + } else { + yield new ProxyOption.Socks(proxyHost, proxyPort, proxyUser, proxyPass); + } + } + default -> ProxyOption.Default.INSTANCE; + }; + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackProvider.java index cec33afcc6..b96bd92c1c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackProvider.java @@ -79,7 +79,7 @@ public Modpack readManifest(ZipArchiveReader file, Path path, Charset encoding) private final static class HMCLModpack extends Modpack { @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name) { + public Task getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name, String iconUrl) { return new HMCLModpackInstallTask(((HMCLGameRepository) dependencyManager.getGameRepository()).getProfile(), zipFile, this, name); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java index 54a547f839..62f435842d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -22,6 +22,7 @@ import org.jackhuang.hmcl.Launcher; import org.jackhuang.hmcl.auth.*; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException; +import org.jackhuang.hmcl.auth.offline.OfflineAccount; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.download.LibraryAnalyzer; @@ -51,6 +52,7 @@ import java.io.FileNotFoundException; import java.io.IOException; +import java.lang.ref.WeakReference; import java.net.SocketTimeoutException; import java.net.URI; import java.nio.file.AccessDeniedException; @@ -60,7 +62,6 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; -import java.lang.ref.WeakReference; import static javafx.application.Platform.runLater; import static javafx.application.Platform.setImplicitExit; @@ -81,6 +82,8 @@ public final class LauncherHelper { private final VersionSetting setting; private LauncherVisibility launcherVisibility; private boolean showLogs; + private QuickPlayOption quickPlayOption; + private boolean disableOfflineSkin = false; public LauncherHelper(Profile profile, Account account, String selectedVersion) { this.profile = Objects.requireNonNull(profile); @@ -111,6 +114,14 @@ public void setKeep() { launcherVisibility = LauncherVisibility.KEEP; } + public void setQuickPlayOption(QuickPlayOption quickPlayOption) { + this.quickPlayOption = quickPlayOption; + } + + public void setDisableOfflineSkin() { + disableOfflineSkin = true; + } + public void launch() { FXUtils.checkFxUserThread(); @@ -188,8 +199,15 @@ private void launch0() { .thenComposeAsync(() -> gameVersion.map(s -> new GameVerificationFixTask(dependencyManager, s, version.get())).orElse(null)) .thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in")) .thenComposeAsync(authInfo -> Task.supplyAsync(() -> { - LaunchOptions launchOptions = repository.getLaunchOptions( + LaunchOptions.Builder launchOptionsBuilder = repository.getLaunchOptions( selectedVersion, javaVersionRef.get(), profile.getGameDir(), javaAgents, javaArguments, scriptFile != null); + if (disableOfflineSkin) { + launchOptionsBuilder.setDaemon(false); + } + if (quickPlayOption != null) { + launchOptionsBuilder.setQuickPlayOption(quickPlayOption); + } + LaunchOptions launchOptions = launchOptionsBuilder.create(); LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModsDirectory(selectedVersion), 10)); @@ -307,13 +325,13 @@ public void onStop(boolean success, TaskExecutor executor) { message = i18n("launch.failed.command_too_long"); } else if (ex instanceof ExecutionPolicyLimitException) { Controllers.prompt(new PromptDialogPane.Builder(i18n("launch.failed.execution_policy"), - (result, resolve, reject) -> { + (result, handler) -> { if (CommandBuilder.setExecutionPolicy()) { LOG.info("Set the ExecutionPolicy for the scope 'CurrentUser' to 'RemoteSigned'"); - resolve.run(); + handler.resolve(); } else { LOG.warning("Failed to set ExecutionPolicy"); - reject.accept(i18n("launch.failed.execution_policy.failed_to_set")); + handler.reject(i18n("launch.failed.execution_policy.failed_to_set")); } }) .addQuestion(new PromptDialogPane.Builder.HintQuestion(i18n("launch.failed.execution_policy.hint"))) @@ -371,7 +389,7 @@ private static Task checkGameState(Profile profile, VersionSetting int targetJavaVersionMajor = Integer.parseInt(setting.getJavaVersion()); GameJavaVersion minimumJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion); - if (minimumJavaVersion != null && targetJavaVersionMajor < minimumJavaVersion.getMajorVersion()) { + if (minimumJavaVersion != null && targetJavaVersionMajor < minimumJavaVersion.majorVersion()) { Controllers.dialog( i18n("launch.failed.java_version_too_low"), i18n("message.error"), @@ -616,7 +634,7 @@ && isCompatibleWithX86Java()) { private static CompletableFuture downloadJava(GameJavaVersion javaVersion, Profile profile) { CompletableFuture future = new CompletableFuture<>(); Controllers.dialog(new MessageDialogPane.Builder( - i18n("launch.advice.require_newer_java_version", javaVersion.getMajorVersion()), + i18n("launch.advice.require_newer_java_version", javaVersion.majorVersion()), i18n("message.warning"), MessageType.QUESTION) .yesOrNo(() -> { @@ -639,10 +657,13 @@ private static CompletableFuture downloadJava(GameJavaVersion javaV return future; } - private static Task logIn(Account account) { + private Task logIn(Account account) { return Task.composeAsync(() -> { try { - return Task.completed(account.logIn()); + if (disableOfflineSkin && account instanceof OfflineAccount offlineAccount) + return Task.completed(offlineAccount.logInWithoutSkin()); + else + return Task.completed(account.logIn()); } catch (CredentialExpiredException e) { LOG.info("Credential has expired", e); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java index 3cb59219e4..3ace4df057 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java @@ -17,21 +17,19 @@ */ package org.jackhuang.hmcl.game; -import org.jackhuang.hmcl.util.io.IOUtils; -import org.jackhuang.hmcl.util.logging.Logger; import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.io.IOUtils; import org.jackhuang.hmcl.util.io.Zipper; +import org.jackhuang.hmcl.util.logging.Logger; import org.jackhuang.hmcl.util.platform.OperatingSystem; import java.io.BufferedReader; import java.io.IOException; import java.io.UncheckedIOException; -import java.lang.management.ManagementFactory; import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.attribute.BasicFileAttributes; -import java.nio.file.attribute.FileTime; +import java.nio.file.PathMatcher; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -43,7 +41,9 @@ public final class LogExporter { private LogExporter() { } - public static CompletableFuture exportLogs(Path zipFile, DefaultGameRepository gameRepository, String versionId, String logs, String launchScript) { + public static CompletableFuture exportLogs( + Path zipFile, DefaultGameRepository gameRepository, String versionId, String logs, String launchScript, + PathMatcher logMatcher) { Path runDirectory = gameRepository.getRunDirectory(versionId); Path baseDirectory = gameRepository.getBaseDirectory(); List versions = new ArrayList<>(); @@ -64,11 +64,11 @@ public static CompletableFuture exportLogs(Path zipFile, DefaultGameReposi } return CompletableFuture.runAsync(() -> { - try (Zipper zipper = new Zipper(zipFile)) { - processLogs(runDirectory.resolve("liteconfig"), "*.log", "liteconfig", zipper); - processLogs(runDirectory.resolve("logs"), "*.log", "logs", zipper); - processLogs(runDirectory, "*.log", "runDirectory", zipper); - processLogs(runDirectory.resolve("crash-reports"), "*.txt", "crash-reports", zipper); + try (Zipper zipper = new Zipper(zipFile, true)) { + processLogs(runDirectory.resolve("liteconfig"), "*.log", "liteconfig", zipper, logMatcher); + processLogs(runDirectory.resolve("logs"), "*.log", "logs", zipper, logMatcher); + processLogs(runDirectory, "*.log", "runDirectory", zipper, logMatcher); + processLogs(runDirectory.resolve("crash-reports"), "*.txt", "crash-reports", zipper, logMatcher); zipper.putTextFile(LOG.getLogs(), "hmcl.log"); zipper.putTextFile(logs, "minecraft.log"); @@ -86,14 +86,11 @@ public static CompletableFuture exportLogs(Path zipFile, DefaultGameReposi }); } - private static void processLogs(Path directory, String fileExtension, String logDirectory, Zipper zipper) { + private static void processLogs(Path directory, String fileExtension, String logDirectory, Zipper zipper, PathMatcher logMatcher) { try (DirectoryStream stream = Files.newDirectoryStream(directory, fileExtension)) { - long processStartTime = ManagementFactory.getRuntimeMXBean().getStartTime(); - for (Path file : stream) { if (Files.isRegularFile(file)) { - FileTime time = Files.readAttributes(file, BasicFileAttributes.class).lastModifiedTime(); - if (time.toMillis() >= processStartTime) { + if (logMatcher == null || logMatcher.matches(file)) { try (BufferedReader reader = IOUtils.newBufferedReaderMaybeNativeEncoding(file)) { zipper.putLines(reader.lines().map(Logger::filterForbiddenToken), file.getFileName().toString()); } catch (IOException e) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java index 019d8bc4fa..de71d44e78 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java @@ -188,7 +188,7 @@ public static Task getInstallManuallyCreatedModpackTask(Profile profile, Path }); } - public static Task getInstallTask(Profile profile, Path zipFile, String name, Modpack modpack) { + public static Task getInstallTask(Profile profile, Path zipFile, String name, Modpack modpack, String iconUrl) { profile.getRepository().markVersionAsModpack(name); ExceptionalRunnable success = () -> { @@ -208,17 +208,17 @@ public static Task getInstallTask(Profile profile, Path zipFile, String name, }; if (modpack.getManifest() instanceof MultiMCInstanceConfiguration) - return modpack.getInstallTask(profile.getDependency(), zipFile, name) + return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl) .whenComplete(Schedulers.defaultScheduler(), success, failure) .thenComposeAsync(createMultiMCPostInstallTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name)) .withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download")); else if (modpack.getManifest() instanceof McbbsModpackManifest) - return modpack.getInstallTask(profile.getDependency(), zipFile, name) + return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl) .whenComplete(Schedulers.defaultScheduler(), success, failure) .thenComposeAsync(createMcbbsPostInstallTask(profile, (McbbsModpackManifest) modpack.getManifest(), name)) .withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download")); else - return modpack.getInstallTask(profile.getDependency(), zipFile, name) + return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl) .whenComplete(Schedulers.javafx(), success, failure) .withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download")); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java index 1513bfca2d..8e5325b041 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java @@ -152,6 +152,12 @@ public void grantDeviceCode(String userCode, String verificationURI) { @Override public void openBrowser(String url) throws IOException { lastlyOpenedURL = url; + + try { + Thread.sleep(1500); + } catch (InterruptedException ignored) { + } + FXUtils.openLink(url); onOpenBrowser.fireEvent(new OpenBrowserEvent(this, url)); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/java/HMCLJavaRepository.java b/HMCL/src/main/java/org/jackhuang/hmcl/java/HMCLJavaRepository.java index b0100219ea..07b10fc038 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/java/HMCLJavaRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/java/HMCLJavaRepository.java @@ -30,7 +30,9 @@ import org.jetbrains.annotations.Nullable; import java.io.IOException; -import java.nio.file.*; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.*; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -57,7 +59,7 @@ public Path getJavaDir(Platform platform, String name) { } public Path getJavaDir(Platform platform, GameJavaVersion gameJavaVersion) { - return getJavaDir(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.getComponent()); + return getJavaDir(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.component()); } @Override @@ -66,7 +68,7 @@ public Path getManifestFile(Platform platform, String name) { } public Path getManifestFile(Platform platform, GameJavaVersion gameJavaVersion) { - return getManifestFile(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.getComponent()); + return getManifestFile(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.component()); } public boolean isInstalled(Platform platform, String name) { @@ -74,7 +76,7 @@ public boolean isInstalled(Platform platform, String name) { } public boolean isInstalled(Platform platform, GameJavaVersion gameJavaVersion) { - return isInstalled(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.getComponent()); + return isInstalled(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.component()); } public @Nullable Path getJavaExecutable(Platform platform, String name) { @@ -94,7 +96,7 @@ public boolean isInstalled(Platform platform, GameJavaVersion gameJavaVersion) { } public @Nullable Path getJavaExecutable(Platform platform, GameJavaVersion gameJavaVersion) { - return getJavaExecutable(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.getComponent()); + return getJavaExecutable(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.component()); } private static void getAllJava(List list, Platform platform, Path platformRoot, boolean isManaged) { @@ -169,7 +171,7 @@ public Task getDownloadJavaTask(DownloadProvider downloadProvider, Map update = new LinkedHashMap<>(); update.put("provider", "mojang"); - update.put("component", gameJavaVersion.getComponent()); + update.put("component", gameJavaVersion.component()); Map files = new LinkedHashMap<>(); result.remoteFiles.getFiles().forEach((path, file) -> { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java index edc1d4e331..7b78cb8f9a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java @@ -414,6 +414,12 @@ public static String localizeErrorMessage(Exception exception) { return i18n("account.failed.no_character"); } else if (exception instanceof ServerDisconnectException) { if (exception.getCause() instanceof SSLException) { + if (exception.getCause().getMessage() != null && exception.getCause().getMessage().contains("Remote host terminated")) { + return i18n("account.failed.connect_authentication_server"); + } + if (exception.getCause().getMessage() != null && (exception.getCause().getMessage().contains("No name matching") || exception.getCause().getMessage().contains("No subject alternative DNS name matching"))) { + return i18n("account.failed.dns"); + } return i18n("account.failed.ssl"); } else { return i18n("account.failed.connect_authentication_server"); @@ -465,7 +471,7 @@ public static String localizeErrorMessage(Exception exception) { } else if (exception instanceof MicrosoftService.NoMinecraftJavaEditionProfileException) { return i18n("account.methods.microsoft.error.no_character"); } else if (exception instanceof MicrosoftService.NoXuiException) { - return i18n("account.methods.microsoft.error.add_family_probably"); + return i18n("account.methods.microsoft.error.add_family"); } else if (exception instanceof OAuthServer.MicrosoftAuthenticationNotSupportedException) { return i18n("account.methods.microsoft.snapshot"); } else if (exception instanceof OAuthAccount.WrongAccountException) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java index 0cf008a423..9d75154153 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java @@ -34,12 +34,12 @@ import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.java.JavaRuntime; +import org.jackhuang.hmcl.theme.ThemeColor; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.gson.*; import org.jackhuang.hmcl.util.i18n.SupportedLocale; import org.jetbrains.annotations.Nullable; -import java.lang.reflect.*; import java.net.Proxy; import java.nio.file.Path; import java.util.*; @@ -276,19 +276,34 @@ public void setLogLines(Integer logLines) { // UI + @SerializedName("themeBrightness") + private final StringProperty themeBrightness = new SimpleStringProperty("light"); + + public StringProperty themeBrightnessProperty() { + return themeBrightness; + } + + public String getThemeBrightness() { + return themeBrightness.get(); + } + + public void setThemeBrightness(String themeBrightness) { + this.themeBrightness.set(themeBrightness); + } + @SerializedName("theme") - private final ObjectProperty theme = new SimpleObjectProperty<>(); + private final ObjectProperty themeColor = new SimpleObjectProperty<>(ThemeColor.DEFAULT); - public ObjectProperty themeProperty() { - return theme; + public ObjectProperty themeColorProperty() { + return themeColor; } - public Theme getTheme() { - return theme.get(); + public ThemeColor getThemeColor() { + return themeColor.get(); } - public void setTheme(Theme theme) { - this.theme.set(theme); + public void setThemeColor(ThemeColor themeColor) { + this.themeColor.set(themeColor); } @SerializedName("fontFamily") @@ -478,7 +493,7 @@ public void setDownloadThreads(int downloadThreads) { } @SerializedName("downloadType") - private final StringProperty downloadType = new SimpleStringProperty(DownloadProviders.DEFAULT_RAW_PROVIDER_ID); + private final StringProperty downloadType = new SimpleStringProperty(DownloadProviders.DEFAULT_DIRECT_PROVIDER_ID); public StringProperty downloadTypeProperty() { return downloadType; @@ -508,7 +523,7 @@ public void setAutoChooseDownloadType(boolean autoChooseDownloadType) { } @SerializedName("versionListSource") - private final StringProperty versionListSource = new SimpleStringProperty("balanced"); + private final StringProperty versionListSource = new SimpleStringProperty(DownloadProviders.DEFAULT_AUTO_PROVIDER_ID); public StringProperty versionListSourceProperty() { return versionListSource; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/DownloadProviders.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/DownloadProviders.java index 66b9973a71..796b9372a1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/DownloadProviders.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/DownloadProviders.java @@ -21,9 +21,10 @@ import org.jackhuang.hmcl.download.*; import org.jackhuang.hmcl.task.DownloadException; import org.jackhuang.hmcl.task.FetchTask; -import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.i18n.I18n; +import org.jackhuang.hmcl.util.i18n.LocaleUtils; import org.jackhuang.hmcl.util.io.ResponseCodeException; import javax.net.ssl.SSLHandshakeException; @@ -31,104 +32,88 @@ import java.net.SocketTimeoutException; import java.net.URI; import java.nio.file.AccessDeniedException; -import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.concurrent.CancellationException; import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY; +import static org.jackhuang.hmcl.util.Pair.pair; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public final class DownloadProviders { private DownloadProviders() { } - private static final DownloadProviderWrapper provider; + public static final String DEFAULT_AUTO_PROVIDER_ID = "balanced"; + public static final String DEFAULT_DIRECT_PROVIDER_ID = "mojang"; - public static final Map providersById; - public static final Map rawProviders; - private static final AdaptedDownloadProvider fileDownloadProvider = new AdaptedDownloadProvider(); + private static final DownloadProviderWrapper PROVIDER_WRAPPER; - private static final MojangDownloadProvider MOJANG; - private static final BMCLAPIDownloadProvider BMCLAPI; - - public static final String DEFAULT_PROVIDER_ID = "balanced"; - public static final String DEFAULT_RAW_PROVIDER_ID = "bmclapi"; - - @SuppressWarnings("unused") - private static final InvalidationListener observer; + private static final DownloadProvider DEFAULT_PROVIDER; + public static final Map DIRECT_PROVIDERS; + public static final Map AUTO_PROVIDERS; static { - String bmclapiRoot = "https://bmclapi2.bangbang93.com"; - String bmclapiRootOverride = System.getProperty("hmcl.bmclapi.override"); - if (bmclapiRootOverride != null) bmclapiRoot = bmclapiRootOverride; - - MOJANG = new MojangDownloadProvider(); - BMCLAPI = new BMCLAPIDownloadProvider(bmclapiRoot); - rawProviders = Map.of( - "mojang", MOJANG, - "bmclapi", BMCLAPI - ); + String bmclapiRoot = System.getProperty("hmcl.bmclapi.override", "https://bmclapi2.bangbang93.com"); + BMCLAPIDownloadProvider bmclapiRaw = new BMCLAPIDownloadProvider(bmclapiRoot); - AdaptedDownloadProvider fileProvider = new AdaptedDownloadProvider(); - fileProvider.setDownloadProviderCandidates(List.of(BMCLAPI, MOJANG)); - BalancedDownloadProvider balanced = new BalancedDownloadProvider(MOJANG, BMCLAPI); + DownloadProvider mojang = new MojangDownloadProvider(); + DownloadProvider bmclapi = new AutoDownloadProvider(bmclapiRaw, mojang); - providersById = Map.of( - "official", new AutoDownloadProvider(MOJANG, fileProvider), - "balanced", new AutoDownloadProvider(balanced, fileProvider), - "mirror", new AutoDownloadProvider(BMCLAPI, fileProvider)); + DEFAULT_PROVIDER = mojang; + DIRECT_PROVIDERS = Lang.mapOf( + pair("mojang", mojang), + pair("bmclapi", bmclapi) + ); - observer = FXUtils.observeWeak(() -> { - FetchTask.setDownloadExecutorConcurrency( - config().getAutoDownloadThreads() ? DEFAULT_CONCURRENCY : config().getDownloadThreads()); - }, config().autoDownloadThreadsProperty(), config().downloadThreadsProperty()); + AUTO_PROVIDERS = Lang.mapOf( + pair("balanced", LocaleUtils.IS_CHINA_MAINLAND ? bmclapi : mojang), + pair("official", LocaleUtils.IS_CHINA_MAINLAND ? new AutoDownloadProvider( + List.of(mojang, bmclapiRaw), + List.of(bmclapiRaw, mojang) + ) : mojang), + pair("mirror", bmclapi) + ); - provider = new DownloadProviderWrapper(MOJANG); + PROVIDER_WRAPPER = new DownloadProviderWrapper(DEFAULT_PROVIDER); } static void init() { + InvalidationListener onChangeDownloadThreads = observable -> { + FetchTask.setDownloadExecutorConcurrency(config().getAutoDownloadThreads() + ? DEFAULT_CONCURRENCY + : config().getDownloadThreads()); + }; + config().autoDownloadThreadsProperty().addListener(onChangeDownloadThreads); + config().downloadThreadsProperty().addListener(onChangeDownloadThreads); + onChangeDownloadThreads.invalidated(null); + InvalidationListener onChangeDownloadSource = observable -> { - String versionListSource = Objects.requireNonNullElse(config().getVersionListSource(), ""); if (config().isAutoChooseDownloadType()) { - DownloadProvider currentDownloadProvider = providersById.get(versionListSource); - if (currentDownloadProvider == null) - currentDownloadProvider = Objects.requireNonNull(providersById.get(DEFAULT_PROVIDER_ID), - "default provider is null"); - - provider.setProvider(currentDownloadProvider); + String versionListSource = config().getVersionListSource(); + DownloadProvider downloadProvider = versionListSource != null + ? AUTO_PROVIDERS.getOrDefault(versionListSource, DEFAULT_PROVIDER) + : DEFAULT_PROVIDER; + PROVIDER_WRAPPER.setProvider(downloadProvider); } else { - provider.setProvider(fileDownloadProvider); + String downloadType = config().getDownloadType(); + PROVIDER_WRAPPER.setProvider(downloadType != null + ? DIRECT_PROVIDERS.getOrDefault(downloadType, DEFAULT_PROVIDER) + : DEFAULT_PROVIDER); } }; config().versionListSourceProperty().addListener(onChangeDownloadSource); config().autoChooseDownloadTypeProperty().addListener(onChangeDownloadSource); - + config().downloadTypeProperty().addListener(onChangeDownloadSource); onChangeDownloadSource.invalidated(null); - - FXUtils.onChangeAndOperate(config().downloadTypeProperty(), downloadType -> { - DownloadProvider primary = Objects.requireNonNullElseGet( - rawProviders.get(Objects.requireNonNullElse(downloadType, "")), - () -> rawProviders.get(DEFAULT_RAW_PROVIDER_ID)); - - List providers = new ArrayList<>(rawProviders.size()); - providers.add(primary); - for (DownloadProvider provider : rawProviders.values()) { - if (provider != primary) - providers.add(provider); - } - - fileDownloadProvider.setDownloadProviderCandidates(providers); - }); } /** * Get current primary preferred download provider */ public static DownloadProvider getDownloadProvider() { - return provider; + return PROVIDER_WRAPPER; } public static String localizeErrorMessage(Throwable exception) { @@ -149,7 +134,10 @@ public static String localizeErrorMessage(Throwable exception) { return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.access_denied", ((AccessDeniedException) exception.getCause()).getFile()); } else if (exception.getCause() instanceof ArtifactMalformedException) { return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.artifact_malformed"); - } else if (exception.getCause() instanceof SSLHandshakeException) { + } else if (exception.getCause() instanceof SSLHandshakeException && !(exception.getCause().getMessage() != null && exception.getCause().getMessage().contains("Remote host terminated"))) { + if (exception.getCause().getMessage() != null && (exception.getCause().getMessage().contains("No name matching") || exception.getCause().getMessage().contains("No subject alternative DNS name matching"))) { + return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.dns.pollution"); + } return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.ssl_handshake"); } else { return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause()); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/FontManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/FontManager.java index a73a60257b..cdda72f11a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/FontManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/FontManager.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.setting; import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.scene.text.Font; import org.jackhuang.hmcl.Metadata; @@ -94,31 +95,26 @@ public final class FontManager { return null; }); - private static final ObjectProperty fontProperty; + private static final ObjectProperty font = new SimpleObjectProperty<>(); static { + updateFont(); + LOG.info("Font: " + (font.get() != null ? font.get().family() : "System")); + } + + private static void updateFont() { String fontFamily = config().getLauncherFontFamily(); if (fontFamily == null) fontFamily = System.getProperty("hmcl.font.override"); if (fontFamily == null) fontFamily = System.getenv("HMCL_FONT"); - FontReference fontReference; if (fontFamily == null) { Font defaultFont = DEFAULT_FONT.get(); - fontReference = defaultFont != null ? new FontReference(defaultFont) : null; - } else - fontReference = new FontReference(fontFamily); - - fontProperty = new SimpleObjectProperty<>(fontReference); - - LOG.info("Font: " + (fontReference != null ? fontReference.getFamily() : "System")); - fontProperty.addListener((obs, oldValue, newValue) -> { - if (newValue != null) - config().setLauncherFontFamily(newValue.getFamily()); - else - config().setLauncherFontFamily(null); - }); + font.set(defaultFont != null ? new FontReference(defaultFont) : null); + } else { + font.set(new FontReference(fontFamily)); + } } private static Font tryLoadDefaultFont(Path dir) { @@ -231,64 +227,35 @@ public static Font findByFcMatch(String pattern) { } } - public static ObjectProperty fontProperty() { - return fontProperty; + public static ReadOnlyObjectProperty fontProperty() { + return font; } public static FontReference getFont() { - return fontProperty.get(); - } - - public static void setFont(FontReference font) { - fontProperty.set(font); + return font.get(); } public static void setFontFamily(String fontFamily) { - setFont(fontFamily != null ? new FontReference(fontFamily) : null); + config().setLauncherFontFamily(fontFamily); + updateFont(); } // https://github.com/HMCL-dev/HMCL/issues/4072 - public static final class FontReference { - private final @NotNull String family; - private final @Nullable String style; + public record FontReference(@NotNull String family, @Nullable String style) { + public FontReference { + Objects.requireNonNull(family); + } public FontReference(@NotNull String family) { - this.family = Objects.requireNonNull(family); - this.style = null; + this(family, null); } public FontReference(@NotNull Font font) { - this.family = font.getFamily(); - this.style = font.getStyle(); - } - - public @NotNull String getFamily() { - return family; - } - - public @Nullable String getStyle() { - return style; - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof FontReference)) - return false; - FontReference that = (FontReference) o; - return Objects.equals(family, that.family) && Objects.equals(style, that.style); - } - - @Override - public int hashCode() { - return Objects.hash(family, style); - } - - @Override - public String toString() { - return String.format("FontReference[family='%s', style='%s']", family, style); + this(font.getFamily(), font.getStyle()); } } private FontManager() { } } + diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GlobalConfig.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GlobalConfig.java index 8b5c08ed63..1c43366e4f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GlobalConfig.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GlobalConfig.java @@ -19,131 +19,109 @@ import com.google.gson.*; import com.google.gson.annotations.JsonAdapter; -import javafx.beans.InvalidationListener; -import javafx.beans.Observable; +import com.google.gson.annotations.SerializedName; import javafx.beans.property.*; import javafx.collections.FXCollections; import javafx.collections.ObservableSet; -import org.jackhuang.hmcl.util.javafx.ObservableHelper; -import org.jackhuang.hmcl.util.javafx.PropertyUtils; +import org.jackhuang.hmcl.util.gson.ObservableSetting; import org.jetbrains.annotations.Nullable; -import java.lang.reflect.Type; import java.util.*; -@JsonAdapter(GlobalConfig.Serializer.class) -public final class GlobalConfig implements Observable { +@JsonAdapter(GlobalConfig.Adapter.class) +public final class GlobalConfig extends ObservableSetting { @Nullable public static GlobalConfig fromJson(String json) throws JsonParseException { - GlobalConfig loaded = Config.CONFIG_GSON.fromJson(json, GlobalConfig.class); - if (loaded == null) { - return null; - } - GlobalConfig instance = new GlobalConfig(); - PropertyUtils.copyProperties(loaded, instance); - instance.unknownFields.putAll(loaded.unknownFields); - return instance; + return Config.CONFIG_GSON.fromJson(json, GlobalConfig.class); } - private final IntegerProperty agreementVersion = new SimpleIntegerProperty(); - - private final IntegerProperty terracottaAgreementVersion = new SimpleIntegerProperty(); - - private final IntegerProperty platformPromptVersion = new SimpleIntegerProperty(); - - private final IntegerProperty logRetention = new SimpleIntegerProperty(); - - private final BooleanProperty enableOfflineAccount = new SimpleBooleanProperty(false); - - private final StringProperty fontAntiAliasing = new SimpleStringProperty(); - - private final ObservableSet userJava = FXCollections.observableSet(new LinkedHashSet<>()); - - private final ObservableSet disabledJava = FXCollections.observableSet(new LinkedHashSet<>()); - - private final Map unknownFields = new HashMap<>(); - - private final transient ObservableHelper helper = new ObservableHelper(this); - public GlobalConfig() { - PropertyUtils.attachListener(this, helper); - } - - @Override - public void addListener(InvalidationListener listener) { - helper.addListener(listener); - } - - @Override - public void removeListener(InvalidationListener listener) { - helper.removeListener(listener); + register(); } public String toJson() { return Config.CONFIG_GSON.toJson(this); } - public int getAgreementVersion() { - return agreementVersion.get(); - } + @SerializedName("agreementVersion") + private final IntegerProperty agreementVersion = new SimpleIntegerProperty(); public IntegerProperty agreementVersionProperty() { return agreementVersion; } + public int getAgreementVersion() { + return agreementVersion.get(); + } + public void setAgreementVersion(int agreementVersion) { this.agreementVersion.set(agreementVersion); } - public int getTerracottaAgreementVersion() { - return terracottaAgreementVersion.get(); - } + @SerializedName("terracottaAgreementVersion") + private final IntegerProperty terracottaAgreementVersion = new SimpleIntegerProperty(); public IntegerProperty terracottaAgreementVersionProperty() { return terracottaAgreementVersion; } + public int getTerracottaAgreementVersion() { + return terracottaAgreementVersion.get(); + } + public void setTerracottaAgreementVersion(int terracottaAgreementVersion) { this.terracottaAgreementVersion.set(terracottaAgreementVersion); } - public int getPlatformPromptVersion() { - return platformPromptVersion.get(); - } + @SerializedName("platformPromptVersion") + private final IntegerProperty platformPromptVersion = new SimpleIntegerProperty(); public IntegerProperty platformPromptVersionProperty() { return platformPromptVersion; } + public int getPlatformPromptVersion() { + return platformPromptVersion.get(); + } + public void setPlatformPromptVersion(int platformPromptVersion) { this.platformPromptVersion.set(platformPromptVersion); } - public int getLogRetention() { - return logRetention.get(); - } + @SerializedName("logRetention") + private final IntegerProperty logRetention = new SimpleIntegerProperty(20); public IntegerProperty logRetentionProperty() { return logRetention; } + public int getLogRetention() { + return logRetention.get(); + } + public void setLogRetention(int logRetention) { this.logRetention.set(logRetention); } - public boolean isEnableOfflineAccount() { - return enableOfflineAccount.get(); - } + @SerializedName("enableOfflineAccount") + private final BooleanProperty enableOfflineAccount = new SimpleBooleanProperty(false); public BooleanProperty enableOfflineAccountProperty() { return enableOfflineAccount; } + public boolean isEnableOfflineAccount() { + return enableOfflineAccount.get(); + } + public void setEnableOfflineAccount(boolean value) { enableOfflineAccount.set(value); } + @SerializedName("fontAntiAliasing") + private final StringProperty fontAntiAliasing = new SimpleStringProperty(); + public StringProperty fontAntiAliasingProperty() { return fontAntiAliasing; } @@ -156,89 +134,24 @@ public void setFontAntiAliasing(String value) { this.fontAntiAliasing.set(value); } + @SerializedName("userJava") + private final ObservableSet userJava = FXCollections.observableSet(new LinkedHashSet<>()); + public ObservableSet getUserJava() { return userJava; } + @SerializedName("disabledJava") + private final ObservableSet disabledJava = FXCollections.observableSet(new LinkedHashSet<>()); + public ObservableSet getDisabledJava() { return disabledJava; } - public static final class Serializer implements JsonSerializer, JsonDeserializer { - private static final Set knownFields = new HashSet<>(Arrays.asList( - "agreementVersion", - "terracottaAgreementVersion", - "platformPromptVersion", - "logRetention", - "userJava", - "disabledJava", - "enableOfflineAccount", - "fontAntiAliasing" - )); - - @Override - public JsonElement serialize(GlobalConfig src, Type typeOfSrc, JsonSerializationContext context) { - if (src == null) { - return JsonNull.INSTANCE; - } - - JsonObject jsonObject = new JsonObject(); - jsonObject.add("agreementVersion", context.serialize(src.getAgreementVersion())); - jsonObject.add("terracottaAgreementVersion", context.serialize(src.getTerracottaAgreementVersion())); - jsonObject.add("platformPromptVersion", context.serialize(src.getPlatformPromptVersion())); - jsonObject.add("logRetention", context.serialize(src.getLogRetention())); - jsonObject.add("fontAntiAliasing", context.serialize(src.getFontAntiAliasing())); - if (src.enableOfflineAccount.get()) - jsonObject.addProperty("enableOfflineAccount", true); - - if (!src.getUserJava().isEmpty()) - jsonObject.add("userJava", context.serialize(src.getUserJava())); - - if (!src.getDisabledJava().isEmpty()) - jsonObject.add("disabledJava", context.serialize(src.getDisabledJava())); - - for (Map.Entry entry : src.unknownFields.entrySet()) { - jsonObject.add(entry.getKey(), context.serialize(entry.getValue())); - } - - return jsonObject; - } - + static final class Adapter extends ObservableSetting.Adapter { @Override - public GlobalConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { - if (!(json instanceof JsonObject)) return null; - - JsonObject obj = (JsonObject) json; - - GlobalConfig config = new GlobalConfig(); - config.setAgreementVersion(Optional.ofNullable(obj.get("agreementVersion")).map(JsonElement::getAsInt).orElse(0)); - config.setTerracottaAgreementVersion(Optional.ofNullable(obj.get("terracottaAgreementVersion")).map(JsonElement::getAsInt).orElse(0)); - config.setPlatformPromptVersion(Optional.ofNullable(obj.get("platformPromptVersion")).map(JsonElement::getAsInt).orElse(0)); - config.setLogRetention(Optional.ofNullable(obj.get("logRetention")).map(JsonElement::getAsInt).orElse(20)); - config.setEnableOfflineAccount(Optional.ofNullable(obj.get("enableOfflineAccount")).map(JsonElement::getAsBoolean).orElse(false)); - config.setFontAntiAliasing(Optional.ofNullable(obj.get("fontAntiAliasing")).map(JsonElement::getAsString).orElse(null)); - - JsonElement userJava = obj.get("userJava"); - if (userJava != null && userJava.isJsonArray()) { - for (JsonElement element : userJava.getAsJsonArray()) { - config.userJava.add(element.getAsString()); - } - } - - JsonElement disabledJava = obj.get("disabledJava"); - if (disabledJava != null && disabledJava.isJsonArray()) { - for (JsonElement element : disabledJava.getAsJsonArray()) { - config.disabledJava.add(element.getAsString()); - } - } - - for (Map.Entry entry : obj.entrySet()) { - if (!knownFields.contains(entry.getKey())) { - config.unknownFields.put(entry.getKey(), context.deserialize(entry.getValue(), Object.class)); - } - } - - return config; + protected GlobalConfig createInstance() { + return new GlobalConfig(); } } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/ProxyManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/ProxyManager.java index 5df3828a90..624613bd84 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/ProxyManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/ProxyManager.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.setting; import javafx.beans.InvalidationListener; +import org.jackhuang.hmcl.task.FetchTask; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jetbrains.annotations.NotNull; @@ -114,6 +115,8 @@ protected PasswordAuthentication getPasswordAuthentication() { config().hasProxyAuthProperty().addListener(updateAuthenticator); config().proxyUserProperty().addListener(updateAuthenticator); config().proxyPassProperty().addListener(updateAuthenticator); + + FetchTask.notifyInitialized(); } private static abstract class AbstractProxySelector extends ProxySelector { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/StyleSheets.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/StyleSheets.java index e4041a5ab2..f9d2d54902 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/StyleSheets.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/StyleSheets.java @@ -22,6 +22,12 @@ 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.Theme; +import org.jackhuang.hmcl.theme.ThemeColor; +import org.jackhuang.hmcl.theme.Themes; import org.jackhuang.hmcl.ui.FXUtils; import java.io.IOException; @@ -33,7 +39,6 @@ import java.util.Base64; import java.util.Locale; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** @@ -42,6 +47,7 @@ public final class StyleSheets { private static final int FONT_STYLE_SHEET_INDEX = 0; private static final int THEME_STYLE_SHEET_INDEX = 1; + private static final int BRIGHTNESS_SHEET_INDEX = 2; private static final ObservableList stylesheets; @@ -49,12 +55,16 @@ public final class StyleSheets { String[] array = new String[]{ getFontStyleSheet(), getThemeStyleSheet(), + getBrightnessStyleSheet(), "/assets/css/root.css" }; stylesheets = FXCollections.observableList(Arrays.asList(array)); FontManager.fontProperty().addListener(o -> stylesheets.set(FONT_STYLE_SHEET_INDEX, getFontStyleSheet())); - config().themeProperty().addListener(o -> stylesheets.set(THEME_STYLE_SHEET_INDEX, getThemeStyleSheet())); + Themes.colorSchemeProperty().addListener(o -> { + stylesheets.set(THEME_STYLE_SHEET_INDEX, getThemeStyleSheet()); + stylesheets.set(BRIGHTNESS_SHEET_INDEX, getBrightnessStyleSheet()); + }); } private static String toStyleSheetUri(String styleSheet, String fallback) { @@ -80,11 +90,11 @@ private static String getFontStyleSheet() { final String defaultCss = "/assets/css/font.css"; final FontManager.FontReference font = FontManager.getFont(); - if (font == null || "System".equals(font.getFamily())) + if (font == null || "System".equals(font.family())) return defaultCss; - String fontFamily = font.getFamily(); - String style = font.getStyle(); + String fontFamily = font.family(); + String style = font.style(); String weight = null; String posture = null; @@ -126,31 +136,55 @@ else if (style.contains("bold")) return toStyleSheetUri(builder.toString(), defaultCss); } - private static String rgba(Color color, double opacity) { - return String.format("rgba(%d, %d, %d, %.1f)", - (int) Math.ceil(color.getRed() * 256), - (int) Math.ceil(color.getGreen() * 256), - (int) Math.ceil(color.getBlue() * 256), - opacity); + 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"); } private static String getThemeStyleSheet() { final String blueCss = "/assets/css/blue.css"; - Theme theme = config().getTheme(); - if (theme == null || theme.getPaint().equals(Theme.BLUE.getPaint())) + if (Theme.DEFAULT.equals(Themes.getTheme())) return blueCss; - return toStyleSheetUri(".root {" + - "-fx-base-color:" + theme.getColor() + ';' + - "-fx-base-darker-color: derive(-fx-base-color, -10%);" + - "-fx-base-check-color: derive(-fx-base-color, 30%);" + - "-fx-rippler-color:" + rgba(theme.getPaint(), 0.3) + ';' + - "-fx-base-rippler-color: derive(" + rgba(theme.getPaint(), 0.3) + ", 100%);" + - "-fx-base-disabled-text-fill:" + rgba(theme.getForegroundColor(), 0.7) + ";" + - "-fx-base-text-fill:" + Theme.getColorDisplayName(theme.getForegroundColor()) + ";" + - "-theme-thumb:" + rgba(theme.getPaint(), 0.7) + ";" + - '}', 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.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(), blueCss); } public static void init(Scene scene) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Theme.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Theme.java deleted file mode 100644 index 45f2bb2677..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Theme.java +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.setting; - -import com.google.gson.annotations.JsonAdapter; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonWriter; -import javafx.beans.binding.Bindings; -import javafx.beans.binding.ObjectBinding; -import javafx.scene.paint.Color; - -import java.io.IOException; -import java.util.Locale; -import java.util.Objects; -import java.util.Optional; - -import static org.jackhuang.hmcl.setting.ConfigHolder.config; - -@JsonAdapter(Theme.TypeAdapter.class) -public final class Theme { - public static final Theme BLUE = new Theme("blue", "#5C6BC0"); - public static final Color BLACK = Color.web("#292929"); - public static final Color[] SUGGESTED_COLORS = new Color[]{ - Color.web("#3D6DA3"), // blue - Color.web("#283593"), // dark blue - Color.web("#43A047"), // green - Color.web("#E67E22"), // orange - Color.web("#9C27B0"), // purple - Color.web("#B71C1C") // red - }; - - public static Theme getTheme() { - Theme theme = config().getTheme(); - return theme == null ? BLUE : theme; - } - - private final Color paint; - private final String color; - private final String name; - - Theme(String name, String color) { - this.name = name; - this.color = Objects.requireNonNull(color); - this.paint = Color.web(color); - } - - public String getName() { - return name; - } - - public String getColor() { - return color; - } - - public Color getPaint() { - return paint; - } - - public boolean isCustom() { - return name.startsWith("#"); - } - - public boolean isLight() { - return paint.grayscale().getRed() >= 0.5; - } - - public Color getForegroundColor() { - return isLight() ? Color.BLACK : Color.WHITE; - } - - public static Theme custom(String color) { - if (!color.startsWith("#")) - throw new IllegalArgumentException(); - return new Theme(color, color); - } - - public static Optional getTheme(String name) { - if (name == null) - return Optional.empty(); - else if (name.startsWith("#")) - try { - Color.web(name); - return Optional.of(custom(name)); - } catch (IllegalArgumentException ignore) { - } - else { - String color = null; - switch (name.toLowerCase(Locale.ROOT)) { - case "blue": - return Optional.of(BLUE); - case "darker_blue": - color = "#283593"; - break; - case "green": - color = "#43A047"; - break; - case "orange": - color = "#E67E22"; - break; - case "purple": - color = "#9C27B0"; - break; - case "red": - color = "#F44336"; - } - if (color != null) - return Optional.of(new Theme(name, color)); - } - - return Optional.empty(); - } - - public static String getColorDisplayName(Color c) { - return c != null ? String.format("#%02X%02X%02X", Math.round(c.getRed() * 255.0D), Math.round(c.getGreen() * 255.0D), Math.round(c.getBlue() * 255.0D)) : null; - } - - private static ObjectBinding FOREGROUND_FILL; - - public static ObjectBinding foregroundFillBinding() { - if (FOREGROUND_FILL == null) - FOREGROUND_FILL = Bindings.createObjectBinding( - () -> Theme.getTheme().getForegroundColor(), - config().themeProperty() - ); - - return FOREGROUND_FILL; - } - - public static Color blackFill() { - return BLACK; - } - - public static Color whiteFill() { - return Color.WHITE; - } - - public static class TypeAdapter extends com.google.gson.TypeAdapter { - @Override - public void write(JsonWriter out, Theme value) throws IOException { - out.value(value.getName().toLowerCase(Locale.ROOT)); - } - - @Override - public Theme read(JsonReader in) throws IOException { - return getTheme(in.nextString()).orElse(Theme.BLUE); - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/VersionIconType.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/VersionIconType.java index 09675738c3..dd19336b8c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/VersionIconType.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/VersionIconType.java @@ -36,7 +36,9 @@ public enum VersionIconType { FURNACE("/assets/img/furnace.png"), QUILT("/assets/img/quilt.png"), APRIL_FOOLS("/assets/img/april_fools.png"), - CLEANROOM("/assets/img/cleanroom.png"); + CLEANROOM("/assets/img/cleanroom.png"), + LEGACY_FABRIC("/assets/img/legacyfabric.png") + ; // Please append new items at last diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/VersionSetting.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/VersionSetting.java index edb51582e5..3443e57c6a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/VersionSetting.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/VersionSetting.java @@ -459,6 +459,20 @@ public void setShowLogs(boolean showLogs) { showLogsProperty.set(showLogs); } + private final BooleanProperty enableDebugLogOutputProperty = new SimpleBooleanProperty(this, "enableDebugLogOutput", false); + + public BooleanProperty enableDebugLogOutputProperty() { + return enableDebugLogOutputProperty; + } + + public boolean isEnableDebugLogOutput() { + return enableDebugLogOutputProperty.get(); + } + + public void setEnableDebugLogOutput(boolean u) { + this.enableDebugLogOutputProperty.set(u); + } + // Minecraft settings. private final StringProperty serverIpProperty = new SimpleStringProperty(this, "serverIp", ""); @@ -776,6 +790,7 @@ public JsonElement serialize(VersionSetting src, Type typeOfSrc, JsonSerializati obj.addProperty("notCheckJVM", src.isNotCheckJVM()); obj.addProperty("notPatchNatives", src.isNotPatchNatives()); obj.addProperty("showLogs", src.isShowLogs()); + obj.addProperty("enableDebugLogOutput", src.isEnableDebugLogOutput()); obj.addProperty("gameDir", src.getGameDir()); obj.addProperty("launcherVisibility", src.getLauncherVisibility().ordinal()); obj.addProperty("processPriority", src.getProcessPriority().ordinal()); @@ -847,6 +862,7 @@ public VersionSetting deserialize(JsonElement json, Type typeOfT, JsonDeserializ vs.setNotCheckJVM(Optional.ofNullable(obj.get("notCheckJVM")).map(JsonElement::getAsBoolean).orElse(false)); vs.setNotPatchNatives(Optional.ofNullable(obj.get("notPatchNatives")).map(JsonElement::getAsBoolean).orElse(false)); vs.setShowLogs(Optional.ofNullable(obj.get("showLogs")).map(JsonElement::getAsBoolean).orElse(false)); + vs.setEnableDebugLogOutput(Optional.ofNullable(obj.get("enableDebugLogOutput")).map(JsonElement::getAsBoolean).orElse(false)); vs.setLauncherVisibility(parseJsonPrimitive(obj.getAsJsonPrimitive("launcherVisibility"), LauncherVisibility.class, LauncherVisibility.HIDE)); vs.setProcessPriority(parseJsonPrimitive(obj.getAsJsonPrimitive("processPriority"), ProcessPriority.class, ProcessPriority.NORMAL)); vs.setUseNativeGLFW(Optional.ofNullable(obj.get("useNativeGLFW")).map(JsonElement::getAsBoolean).orElse(false)); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaBundle.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaBundle.java new file mode 100644 index 0000000000..727edde9b8 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaBundle.java @@ -0,0 +1,191 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.terracotta; + +import kala.compress.archivers.tar.TarArchiveEntry; +import org.jackhuang.hmcl.download.ArtifactMalformedException; +import org.jackhuang.hmcl.task.FetchTask; +import org.jackhuang.hmcl.task.FileDownloadTask; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider; +import org.jackhuang.hmcl.util.DigestUtils; +import org.jackhuang.hmcl.util.io.ChecksumMismatchException; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.logging.Logger; +import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jackhuang.hmcl.util.tree.TarFileTree; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.DigestInputStream; +import java.security.DigestOutputStream; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; + +public final class TerracottaBundle { + private final Path root; + + private final List links; + + private final FileDownloadTask.IntegrityCheck hash; + + private final Map files; + + public TerracottaBundle(Path root, List links, FileDownloadTask.IntegrityCheck hash, Map files) { + this.root = root; + this.links = links; + this.hash = hash; + this.files = files; + } + + public Task download(AbstractTerracottaProvider.DownloadContext context) { + return Task.supplyAsync(() -> Files.createTempFile("terracotta-", ".tar.gz")) + .thenComposeAsync(Schedulers.javafx(), pkg -> { + FileDownloadTask download = new FileDownloadTask(links, pkg, hash) { + @Override + protected Context getContext(HttpResponse response, boolean checkETag, String bmclapiHash) throws IOException { + FetchTask.Context delegate = super.getContext(response, checkETag, bmclapiHash); + return new Context() { + @Override + public void withResult(boolean success) { + delegate.withResult(success); + } + + @Override + public void write(byte[] buffer, int offset, int len) throws IOException { + context.checkCancellation(); + delegate.write(buffer, offset, len); + } + + @Override + public void close() throws IOException { + if (isSuccess()) { + context.checkCancellation(); + } + + delegate.close(); + } + }; + } + }; + + context.bindProgress(download.progressProperty()); + return download.thenSupplyAsync(() -> pkg); + }); + } + + public Task install(Path pkg) { + return Task.runAsync(() -> { + Files.createDirectories(root); + FileUtils.cleanDirectory(root); + + try (TarFileTree tree = TarFileTree.open(pkg)) { + for (Map.Entry entry : files.entrySet()) { + String file = entry.getKey(); + FileDownloadTask.IntegrityCheck check = entry.getValue(); + + Path path = root.resolve(file); + TarArchiveEntry archive = tree.getEntry("/" + file); + if (archive == null) { + throw new ArtifactMalformedException(String.format("Expecting %s file in terracotta bundle.", file)); + } + + MessageDigest digest = DigestUtils.getDigest(check.getAlgorithm()); + try ( + InputStream is = tree.getInputStream(archive); + OutputStream os = new DigestOutputStream(Files.newOutputStream(path), digest) + ) { + is.transferTo(os); + } + + String hash = HexFormat.of().formatHex(digest.digest()); + if (!check.getChecksum().equalsIgnoreCase(hash)) { + throw new ChecksumMismatchException(check.getAlgorithm(), check.getChecksum(), hash); + } + + switch (OperatingSystem.CURRENT_OS) { + case LINUX, MACOS, FREEBSD -> Files.setPosixFilePermissions(path, FileUtils.parsePosixFilePermission(archive.getMode())); + } + } + } + }).whenComplete(exception -> { + if (exception != null) { + FileUtils.deleteDirectory(root); + } + }); + } + + public Path locate(String file) { + FileDownloadTask.IntegrityCheck check = files.get(file); + if (check == null) { + throw new AssertionError(String.format("Expecting %s file in terracotta bundle.", file)); + } + return root.resolve(file).toAbsolutePath(); + } + + public AbstractTerracottaProvider.Status status() throws IOException { + if (Files.exists(root) && isLocalBundleValid()) { + return AbstractTerracottaProvider.Status.READY; + } + + try { + if (TerracottaMetadata.hasLegacyVersionFiles()) { + return AbstractTerracottaProvider.Status.LEGACY_VERSION; + } + } catch (IOException e) { + Logger.LOG.warning("Cannot determine whether legacy versions exist.", e); + } + return AbstractTerracottaProvider.Status.NOT_EXIST; + } + + private boolean isLocalBundleValid() throws IOException { // FIXME: Make control flow clearer. + long total = 0; + byte[] buffer = new byte[8192]; + + for (Map.Entry entry : files.entrySet()) { + Path path = root.resolve(entry.getKey()); + FileDownloadTask.IntegrityCheck check = entry.getValue(); + if (!Files.isReadable(path)) { + return false; + } + + MessageDigest digest = DigestUtils.getDigest(check.getAlgorithm()); + try (InputStream is = new DigestInputStream(Files.newInputStream(path), digest)) { + int n; + while ((n = is.read(buffer)) >= 0) { + total += n; + if (total >= 50 * 1024 * 1024) { // >=50MB + return false; + } + } + } + if (!HexFormat.of().formatHex(digest.digest()).equalsIgnoreCase(check.getChecksum())) { + return false; + } + } + return true; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaManager.java index ea70608608..f93b11d7c0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaManager.java @@ -18,7 +18,6 @@ package org.jackhuang.hmcl.terracotta; import com.google.gson.JsonObject; -import com.google.gson.reflect.TypeToken; import javafx.application.Platform; import javafx.beans.property.ReadOnlyDoubleWrapper; import javafx.beans.property.ReadOnlyObjectProperty; @@ -29,24 +28,26 @@ import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider; +import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider; import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.util.FXThread; import org.jackhuang.hmcl.util.InvocationDispatcher; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.io.HttpRequest; import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.platform.ManagedProcess; import org.jackhuang.hmcl.util.platform.SystemUtils; -import org.jackhuang.hmcl.util.tree.TarFileTree; -import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Map; import java.util.concurrent.CancellationException; import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.LockSupport; @@ -70,9 +71,10 @@ private TerracottaManager() { switch (TerracottaMetadata.PROVIDER.status()) { case NOT_EXIST -> setState(new TerracottaState.Uninitialized(false)); case LEGACY_VERSION -> setState(new TerracottaState.Uninitialized(true)); - case READY -> launch(setState(new TerracottaState.Launching())); + case READY -> launch(setState(new TerracottaState.Launching()), false); } } catch (Exception e) { + LOG.warning("Cannot initialize Terracotta.", e); compareAndSet(TerracottaState.Bootstrap.INSTANCE, new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN)); } }); @@ -82,114 +84,145 @@ public static ReadOnlyObjectProperty stateProperty() { return STATE.getReadOnlyProperty(); } - static { - Lang.thread(() -> { - while (true) { - TerracottaState state = STATE_V.get(); - if (!(state instanceof TerracottaState.PortSpecific portSpecific)) { - LockSupport.parkNanos(500_000); + private static final Thread DAEMON = Lang.thread(TerracottaManager::runBackground, "Terracotta Background Daemon", true); + + @FXThread // Written in FXThread, read-only on background daemon + private static volatile boolean daemonRunning = false; + + private static void runBackground() { + final long ACTIVE = TimeUnit.MILLISECONDS.toNanos(500); + final long BACKGROUND = TimeUnit.SECONDS.toMillis(15); + + while (true) { + if (daemonRunning) { + LockSupport.parkNanos(ACTIVE); + } else { + long deadline = System.currentTimeMillis() + BACKGROUND; + do { + LockSupport.parkUntil(deadline); + } while (!daemonRunning && System.currentTimeMillis() < deadline - 100); + } + + if (!(STATE_V.get() instanceof TerracottaState.PortSpecific state)) { + continue; + } + int port = state.port; + int index = state instanceof TerracottaState.Ready ready ? ready.index : Integer.MIN_VALUE; + + TerracottaState next; + try { + TerracottaState.Ready object = HttpRequest.GET(String.format("http://127.0.0.1:%d/state", port)) + .retry(5) + .getJson(TerracottaState.Ready.class); + if (object.index <= index) { continue; } + object.port = port; + next = object; + } catch (Exception e) { + LOG.warning("Cannot fetch state from Terracotta.", e); + next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA); + } - int port = portSpecific.port; - int index = state instanceof TerracottaState.Ready ready ? ready.index : Integer.MIN_VALUE; - - TerracottaState next; - try { - next = new GetTask(URI.create(String.format("http://127.0.0.1:%d/state", port))) - .setSignificance(Task.TaskSignificance.MINOR) - .thenApplyAsync(jsonString -> { - TerracottaState.Ready object = JsonUtils.fromNonNullJson(jsonString, TypeToken.get(TerracottaState.Ready.class)); - if (object.index <= index) { - return null; - } - - object.port = port; - return object; - }) - .setSignificance(Task.TaskSignificance.MINOR) - .run(); - } catch (Exception e) { - LOG.warning("Cannot fetch state from Terracotta.", e); - next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA); - } + compareAndSet(state, next); + } + } - if (next != null) { - compareAndSet(state, next); - } + @FXThread + public static void switchDaemon(boolean active) { + FXUtils.checkFxUserThread(); - LockSupport.parkNanos(500_000); + boolean dr = daemonRunning; + if (dr != active) { + daemonRunning = active; + if (active) { + LockSupport.unpark(DAEMON); } - }, "Terracotta Background Daemon", true); + } } - public static boolean validate(Path file) { - return FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME); + private static AbstractTerracottaProvider getProvider() { + AbstractTerracottaProvider provider = TerracottaMetadata.PROVIDER; + if (provider == null) { + throw new AssertionError("Terracotta Provider must NOT be null."); + } + return provider; } - public static TerracottaState.Preparing install(@Nullable Path file) { + public static boolean isInvalidBundle(Path file) { + return !FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME); + } + + @FXThread + public static TerracottaState.Preparing download() { FXUtils.checkFxUserThread(); TerracottaState state = STATE_V.get(); - if (!(state instanceof TerracottaState.Uninitialized || - state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence() || - state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable()) + if (!(state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable()) ) { return null; } - if (file != null && !FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME)) { - return null; - } - - TerracottaState.Preparing preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1)); + TerracottaState.Preparing preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1), true); - Task.supplyAsync(Schedulers.io(), () -> { - return file != null ? TarFileTree.open(file) : null; - }).thenComposeAsync(Schedulers.javafx(), tree -> { - return getProvider().install(preparing, tree).whenComplete(exception -> { - if (tree != null) { - tree.close(); - } - if (exception != null) { - throw exception; - } - }); - }).whenComplete(exception -> { - if (exception == null) { - try { - TerracottaMetadata.removeLegacyVersionFiles(); - } catch (IOException e) { - LOG.warning("Unable to remove legacy terracotta files.", e); - } + Task.composeAsync(() -> getProvider().download(preparing)) + .thenComposeAsync(pkg -> { + if (!preparing.requestInstallFence()) { + return null; + } - TerracottaState.Launching launching = new TerracottaState.Launching(); - if (compareAndSet(preparing, launching)) { - launch(launching); - } - } else if (exception instanceof CancellationException) { - } else if (exception instanceof ITerracottaProvider.ArchiveFileMissingException) { - LOG.warning("Cannot install terracotta from local package.", exception); - compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL)); - } else if (exception instanceof DownloadException) { - compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.NETWORK)); - } else { - compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL)); - } - }).start(); + return getProvider().install(pkg).thenRunAsync(() -> { + TerracottaState.Launching launching = new TerracottaState.Launching(); + if (compareAndSet(preparing, launching)) { + launch(launching, true); + } + }); + }).whenComplete(exception -> { + if (exception instanceof CancellationException) { + // no-op + } else if (exception instanceof DownloadException) { + compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.NETWORK)); + } else { + compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL)); + } + }).start(); return compareAndSet(state, preparing) ? preparing : null; } - private static ITerracottaProvider getProvider() { - ITerracottaProvider provider = TerracottaMetadata.PROVIDER; - if (provider == null) { - throw new AssertionError("Terracotta Provider must NOT be null."); + @FXThread + public static TerracottaState.Preparing install(Path bundle) { + FXUtils.checkFxUserThread(); + if (isInvalidBundle(bundle)) { + return null; } - return provider; + + TerracottaState state = STATE_V.get(); + TerracottaState.Preparing preparing; + if (state instanceof TerracottaState.Preparing previousPreparing && previousPreparing.requestInstallFence()) { + preparing = previousPreparing; + } else if (state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable()) { + preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1), false); + } else { + return null; + } + + Task.composeAsync(() -> getProvider().install(bundle)) + .thenRunAsync(() -> { + TerracottaState.Launching launching = new TerracottaState.Launching(); + if (compareAndSet(preparing, launching)) { + launch(launching, true); + } + }) + .whenComplete(exception -> { + compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL)); + }).start(); + + return state != preparing && compareAndSet(state, preparing) ? preparing : null; } - public static TerracottaState recover(@Nullable Path file) { + @FXThread + public static TerracottaState recover() { FXUtils.checkFxUserThread(); TerracottaState state = STATE_V.get(); @@ -198,21 +231,23 @@ public static TerracottaState recover(@Nullable Path file) { } try { + // FIXME: A temporary limit has been employed in TerracottaBundle#checkExisting, making + // hash check accept 50MB at most. Calling it on JavaFX should be safe. return switch (getProvider().status()) { - case NOT_EXIST, LEGACY_VERSION -> install(file); + case NOT_EXIST, LEGACY_VERSION -> download(); case READY -> { TerracottaState.Launching launching = setState(new TerracottaState.Launching()); - launch(launching); + launch(launching, false); yield launching; } }; - } catch (NullPointerException | IOException e) { + } catch (RuntimeException | IOException e) { LOG.warning("Cannot determine Terracotta state.", e); return setState(new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN)); } } - private static void launch(TerracottaState.Launching state) { + private static void launch(TerracottaState.Launching state, boolean removeLegacy) { Task.supplyAsync(() -> { Path path = Files.createTempDirectory(String.format("hmcl-terracotta-%d", ThreadLocalRandom.current().nextLong())).resolve("http").toAbsolutePath(); ManagedProcess process = new ManagedProcess(new ProcessBuilder(getProvider().ofCommandLine(path))); @@ -230,7 +265,7 @@ private static void launch(TerracottaState.Launching state) { if (exitTime == -1) { exitTime = System.currentTimeMillis(); } else if (System.currentTimeMillis() - exitTime >= 10000) { - throw new IllegalStateException("Process has exited for 10s."); + throw new IllegalStateException(String.format("Process has exited for 10s, code = %s", process.getExitCode())); } } } @@ -238,6 +273,9 @@ private static void launch(TerracottaState.Launching state) { TerracottaState next; if (exception == null) { next = new TerracottaState.Unknown(port); + if (removeLegacy) { + TerracottaMetadata.removeLegacyVersionFiles(); + } } else { next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA); } @@ -272,23 +310,27 @@ private static String getPlayerName() { public static TerracottaState.HostScanning setScanning() { TerracottaState state = STATE_V.get(); if (state instanceof TerracottaState.PortSpecific portSpecific) { - new GetTask(NetworkUtils.toURI(String.format( - "http://127.0.0.1:%d/state/scanning?player=%s", portSpecific.port, getPlayerName())) - ).setSignificance(Task.TaskSignificance.MINOR).start(); + String uri = NetworkUtils.withQuery(String.format("http://127.0.0.1:%d/state/scanning", portSpecific.port), Map.of( + "player", getPlayerName() + )); + new GetTask(uri).setSignificance(Task.TaskSignificance.MINOR).start(); return new TerracottaState.HostScanning(-1, -1, null); } return null; } - public static Task setGuesting(String room) { + public static Task setGuesting(String room) { TerracottaState state = STATE_V.get(); if (state instanceof TerracottaState.PortSpecific portSpecific) { - return new GetTask(NetworkUtils.toURI(String.format( - "http://127.0.0.1:%d/state/guesting?room=%s&player=%s", portSpecific.port, room, getPlayerName() - ))) + String uri = NetworkUtils.withQuery(String.format("http://127.0.0.1:%d/state/guesting", portSpecific.port), Map.of( + "room", room, + "player", getPlayerName() + )); + + return new GetTask(uri) .setSignificance(Task.TaskSignificance.MINOR) - .thenSupplyAsync(() -> new TerracottaState.GuestStarting(-1, -1, null)) + .thenSupplyAsync(() -> new TerracottaState.GuestConnecting(-1, -1, null)) .setSignificance(Task.TaskSignificance.MINOR); } else { return null; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaMetadata.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaMetadata.java index e35387e93c..b7ad8d8c4e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaMetadata.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaMetadata.java @@ -20,10 +20,10 @@ import com.google.gson.annotations.SerializedName; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.task.FileDownloadTask; +import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider; import org.jackhuang.hmcl.terracotta.provider.GeneralProvider; -import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider; import org.jackhuang.hmcl.terracotta.provider.MacOSProvider; -import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.gson.JsonSerializable; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.i18n.LocaleUtils; import org.jackhuang.hmcl.util.i18n.LocalizedText; @@ -32,6 +32,8 @@ import org.jackhuang.hmcl.util.platform.Architecture; import org.jackhuang.hmcl.util.platform.OSVersion; import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jackhuang.hmcl.util.versioning.VersionNumber; +import org.jackhuang.hmcl.util.versioning.VersionRange; import org.jetbrains.annotations.Nullable; import java.io.IOException; @@ -40,11 +42,11 @@ import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; -import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -52,46 +54,60 @@ public final class TerracottaMetadata { private TerracottaMetadata() { } - public record Link(@SerializedName("desc") LocalizedText description, String link) { + private record Options(String version, String classifier) { + public String replace(String value) { + return value.replace("${version}", version).replace("${classifier}", classifier); + } + } + + @JsonSerializable + public record Link( + @SerializedName("desc") LocalizedText description, + @SerializedName("link") String link + ) { } + @JsonSerializable + private record Package( + @SerializedName("hash") String hash, + @SerializedName("files") Map files + ) { + } + + @JsonSerializable private record Config( - @SerializedName("version_legacy") String legacy, - @SerializedName("version_recent") List recent, @SerializedName("version_latest") String latest, - @SerializedName("classifiers") Map classifiers, + @SerializedName("packages") Map pkgs, @SerializedName("downloads") List downloads, @SerializedName("downloads_CN") List downloadsCN, @SerializedName("links") List links ) { - private @Nullable TerracottaNative of(String classifier) { - String hash = this.classifiers.get(classifier); - if (hash == null) + private @Nullable TerracottaBundle resolve(Options options) { + Package pkg = pkgs.get(options.classifier); + if (pkg == null) { return null; + } - if (!hash.startsWith("sha256:")) - throw new IllegalArgumentException(String.format("Invalid hash value %s for classifier %s.", hash, classifier)); - hash = hash.substring("sha256:".length()); + Stream stream = downloads.stream(), streamCN = downloadsCN.stream(); + List links = (LocaleUtils.IS_CHINA_MAINLAND ? Stream.concat(streamCN, stream) : Stream.concat(stream, streamCN)) + .map(link -> URI.create(options.replace(link))) + .toList(); - List links = new ArrayList<>(this.downloads.size() + this.downloadsCN.size()); - for (String download : LocaleUtils.IS_CHINA_MAINLAND - ? Lang.merge(this.downloadsCN, this.downloads) - : Lang.merge(this.downloads, this.downloadsCN)) { - links.add(URI.create(download.replace("${version}", this.latest).replace("${classifier}", classifier))); - } + Map files = pkg.files.entrySet().stream().collect(Collectors.toUnmodifiableMap( + Map.Entry::getKey, + entry -> new FileDownloadTask.IntegrityCheck("SHA-512", entry.getValue()) + )); - return new TerracottaNative( - Collections.unmodifiableList(links), - Metadata.DEPENDENCIES_DIRECTORY.resolve( - String.format("terracotta/%s/terracotta-%s-%s", this.latest, this.latest, classifier) - ).toAbsolutePath(), - new FileDownloadTask.IntegrityCheck("SHA-256", hash) + return new TerracottaBundle( + Metadata.DEPENDENCIES_DIRECTORY.resolve(options.replace("terracotta/${version}")).toAbsolutePath(), + links, new FileDownloadTask.IntegrityCheck("SHA-512", pkg.hash), + files ); } } - public static final ITerracottaProvider PROVIDER; + public static final AbstractTerracottaProvider PROVIDER; public static final String PACKAGE_NAME; public static final List PACKAGE_LINKS; public static final String FEEDBACK_LINK = NetworkUtils.withQuery("https://docs.hmcl.net/multiplayer/feedback.html", Map.of( @@ -99,8 +115,6 @@ private record Config( "launcher_version", Metadata.VERSION )); - private static final Pattern LEGACY; - private static final List RECENT; private static final String LATEST; static { @@ -111,95 +125,80 @@ private record Config( throw new ExceptionInInitializerError(e); } - LEGACY = Pattern.compile(config.legacy); - RECENT = config.recent; LATEST = config.latest; - ProviderContext context = locateProvider(config); - PROVIDER = context != null ? context.provider() : null; - PACKAGE_NAME = context != null ? String.format("terracotta-%s-%s-pkg.tar.gz", config.latest, context.branch) : null; - - if (context != null) { - List packageLinks = new ArrayList<>(config.links.size()); - for (Link link : config.links) { - packageLinks.add(new Link( - link.description, - link.link.replace("${version}", LATEST) - .replace("${classifier}", context.branch) - )); - } + Options options = new Options(config.latest, OperatingSystem.CURRENT_OS.getCheckedName() + "-" + Architecture.SYSTEM_ARCH.getCheckedName()); + TerracottaBundle bundle = config.resolve(options); + AbstractTerracottaProvider provider; + if (bundle == null || (provider = locateProvider(bundle, options)) == null) { + PROVIDER = null; + PACKAGE_NAME = null; + PACKAGE_LINKS = null; + } else { + PROVIDER = provider; + PACKAGE_NAME = options.replace("terracotta-${version}-${classifier}-pkg.tar.gz"); + List packageLinks = config.links.stream() + .map(link -> new Link(link.description, options.replace(link.link))) + .collect(Collectors.toList()); Collections.shuffle(packageLinks); PACKAGE_LINKS = Collections.unmodifiableList(packageLinks); - } else { - PACKAGE_LINKS = null; - } - } - - private record ProviderContext(ITerracottaProvider provider, String branch) { - ProviderContext(ITerracottaProvider provider, String system, String arch) { - this(provider, system + "-" + arch); } } @Nullable - private static ProviderContext locateProvider(Config config) { - String arch = Architecture.SYSTEM_ARCH.getCheckedName(); + private static AbstractTerracottaProvider locateProvider(TerracottaBundle bundle, Options options) { + String prefix = options.replace("terracotta-${version}-${classifier}"); + + // FIXME: As HMCL is a cross-platform application, developers may mistakenly locate + // non-existent files in non-native platform logic without assertion errors during debugging. return switch (OperatingSystem.CURRENT_OS) { case WINDOWS -> { if (!OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_10)) yield null; - TerracottaNative target = config.of("windows-%s.exe".formatted(arch)); - yield target != null - ? new ProviderContext(new GeneralProvider(target), "windows", arch) - : null; - } - case LINUX -> { - TerracottaNative target = config.of("linux-%s".formatted(arch)); - yield target != null - ? new ProviderContext(new GeneralProvider(target), "linux", arch) - : null; - } - case MACOS -> { - TerracottaNative installer = config.of("macos-%s.pkg".formatted(arch)); - TerracottaNative binary = config.of("macos-%s".formatted(arch)); - - yield installer != null && binary != null - ? new ProviderContext(new MacOSProvider(installer, binary), "macos", arch) - : null; + yield new GeneralProvider(bundle, bundle.locate(prefix + ".exe")); } + case LINUX, FREEBSD -> new GeneralProvider(bundle, bundle.locate(prefix)); + case MACOS -> new MacOSProvider( + bundle, bundle.locate(prefix), bundle.locate(prefix + ".pkg") + ); default -> null; }; } - public static void removeLegacyVersionFiles() throws IOException { - try (DirectoryStream terracotta = Files.newDirectoryStream(Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta").toAbsolutePath())) { - for (Path path : terracotta) { - String name = FileUtils.getName(path); - if (LATEST.equals(name) || RECENT.contains(name) || !LEGACY.matcher(name).matches()) { - continue; - } + public static void removeLegacyVersionFiles() { + try (DirectoryStream terracotta = collectLegacyVersionFiles()) { + if (terracotta == null) + return; + for (Path path : terracotta) { try { FileUtils.deleteDirectory(path); } catch (IOException e) { LOG.warning(String.format("Unable to remove legacy terracotta files: %s", path), e); } } + } catch (IOException e) { + LOG.warning("Unable to remove legacy terracotta files.", e); } } public static boolean hasLegacyVersionFiles() throws IOException { - try (DirectoryStream terracotta = Files.newDirectoryStream(Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta").toAbsolutePath())) { - for (Path path : terracotta) { - String name = FileUtils.getName(path); - if (!LATEST.equals(name) && (RECENT.contains(name) || LEGACY.matcher(name).matches())) { - return true; - } - } + try (DirectoryStream terracotta = collectLegacyVersionFiles()) { + return terracotta != null && terracotta.iterator().hasNext(); } + } + + private static @Nullable DirectoryStream collectLegacyVersionFiles() throws IOException { + Path terracottaDir = Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta"); + if (Files.notExists(terracottaDir)) + return null; - return false; + VersionRange range = VersionNumber.atMost(LATEST); + return Files.newDirectoryStream(terracottaDir, path -> { + String name = FileUtils.getName(path); + return !LATEST.equals(name) && range.contains(VersionNumber.asVersion(name)); + }); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaNative.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaNative.java deleted file mode 100644 index 079537978c..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaNative.java +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2025 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.terracotta; - -import kala.compress.archivers.tar.TarArchiveEntry; -import org.jackhuang.hmcl.task.FileDownloadTask; -import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider; -import org.jackhuang.hmcl.util.DigestUtils; -import org.jackhuang.hmcl.util.io.FileUtils; -import org.jackhuang.hmcl.util.logging.Logger; -import org.jackhuang.hmcl.util.tree.TarFileTree; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.URI; -import java.net.http.HttpResponse; -import java.nio.file.Files; -import java.nio.file.Path; -import java.security.MessageDigest; -import java.util.HexFormat; -import java.util.List; -import java.util.concurrent.CancellationException; - -public final class TerracottaNative { - private final List links; - private final FileDownloadTask.IntegrityCheck checking; - private final Path path; - - public TerracottaNative(List links, Path path, FileDownloadTask.IntegrityCheck checking) { - this.links = links; - this.path = path; - this.checking = checking; - } - - public Path getPath() { - return path; - } - - public Task install(ITerracottaProvider.Context context, @Nullable TarFileTree tree) { - if (tree == null) { - return new FileDownloadTask(links, path, checking) { - @Override - protected Context getContext(HttpResponse response, boolean checkETag, String bmclapiHash) throws IOException { - Context delegate = super.getContext(response, checkETag, bmclapiHash); - return new Context() { - @Override - public void withResult(boolean success) { - delegate.withResult(success); - } - - @Override - public void write(byte[] buffer, int offset, int len) throws IOException { - if (!context.hasInstallFence()) { - throw new CancellationException("User has installed terracotta from local archives."); - } - delegate.write(buffer, offset, len); - } - - @Override - public void close() throws IOException { - if (isSuccess() && !context.requestInstallFence()) { - throw new CancellationException(); - } - - delegate.close(); - } - }; - } - }; - } - - return Task.runAsync(() -> { - String name = FileUtils.getName(path); - TarArchiveEntry entry = tree.getRoot().getFiles().get(name); - if (entry == null) { - throw new ITerracottaProvider.ArchiveFileMissingException("Cannot exact entry: " + name); - } - - if (!context.requestInstallFence()) { - throw new CancellationException(); - } - - Files.createDirectories(path.toAbsolutePath().getParent()); - - MessageDigest digest = DigestUtils.getDigest(checking.getAlgorithm()); - try ( - InputStream stream = tree.getInputStream(entry); - OutputStream os = Files.newOutputStream(path) - ) { - stream.transferTo(new OutputStream() { - @Override - public void write(int b) throws IOException { - os.write(b); - digest.update((byte) b); - } - - @Override - public void write(byte @NotNull [] buffer, int offset, int len) throws IOException { - os.write(buffer, offset, len); - digest.update(buffer, offset, len); - } - }); - } - String checksum = HexFormat.of().formatHex(digest.digest()); - if (!checksum.equalsIgnoreCase(checking.getChecksum())) { - Files.delete(path); - throw new ITerracottaProvider.ArchiveFileMissingException("Incorrect checksum (" + checking.getAlgorithm() + "), expected: " + checking.getChecksum() + ", actual: " + checksum); - } - }); - } - - public ITerracottaProvider.Status status() throws IOException { - if (Files.exists(path)) { - if (DigestUtils.digestToString(checking.getAlgorithm(), path).equalsIgnoreCase(checking.getChecksum())) { - return ITerracottaProvider.Status.READY; - } - } - - try { - if (TerracottaMetadata.hasLegacyVersionFiles()) { - return ITerracottaProvider.Status.LEGACY_VERSION; - } - } catch (IOException e) { - Logger.LOG.warning("Cannot determine whether legacy versions exist."); - } - return ITerracottaProvider.Status.NOT_EXIST; - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaState.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaState.java index bce764013c..25fa43e18f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaState.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaState.java @@ -21,15 +21,16 @@ import com.google.gson.annotations.SerializedName; import javafx.beans.property.ReadOnlyDoubleProperty; import javafx.beans.property.ReadOnlyDoubleWrapper; -import javafx.beans.value.ObservableValue; +import javafx.beans.value.ObservableDoubleValue; import org.jackhuang.hmcl.terracotta.profile.TerracottaProfile; -import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider; +import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider; import org.jackhuang.hmcl.util.gson.JsonSubtype; import org.jackhuang.hmcl.util.gson.JsonType; import org.jackhuang.hmcl.util.gson.TolerableValidationException; import org.jackhuang.hmcl.util.gson.Validation; import java.util.List; +import java.util.concurrent.CancellationException; import java.util.concurrent.atomic.AtomicBoolean; public abstract sealed class TerracottaState { @@ -63,32 +64,38 @@ public boolean hasLegacy() { } } - public static final class Preparing extends TerracottaState implements ITerracottaProvider.Context { + public static final class Preparing extends TerracottaState implements AbstractTerracottaProvider.DownloadContext { private final ReadOnlyDoubleWrapper progress; - private final AtomicBoolean installFence = new AtomicBoolean(false); + private final AtomicBoolean hasInstallFence; - Preparing(ReadOnlyDoubleWrapper progress) { + Preparing(ReadOnlyDoubleWrapper progress, boolean hasInstallFence) { this.progress = progress; + this.hasInstallFence = new AtomicBoolean(hasInstallFence); } public ReadOnlyDoubleProperty progressProperty() { return progress.getReadOnlyProperty(); } - @Override - public void bindProgress(ObservableValue value) { - progress.bind(value); + public boolean requestInstallFence() { + return hasInstallFence.compareAndSet(true, false); + } + + public boolean hasInstallFence() { + return hasInstallFence.get(); } @Override - public boolean requestInstallFence() { - return installFence.compareAndSet(false, true); + public void bindProgress(ObservableDoubleValue progress) { + this.progress.bind(progress); } @Override - public boolean hasInstallFence() { - return !installFence.get(); + public void checkCancellation() { + if (!hasInstallFence()) { + throw new CancellationException("User has installed terracotta from local archives."); + } } } @@ -112,7 +119,7 @@ protected PortSpecific(int port) { @JsonSubtype(clazz = HostScanning.class, name = "host-scanning"), @JsonSubtype(clazz = HostStarting.class, name = "host-starting"), @JsonSubtype(clazz = HostOK.class, name = "host-ok"), - @JsonSubtype(clazz = GuestStarting.class, name = "guest-connecting"), + @JsonSubtype(clazz = GuestConnecting.class, name = "guest-connecting"), @JsonSubtype(clazz = GuestStarting.class, name = "guest-starting"), @JsonSubtype(clazz = GuestOK.class, name = "guest-ok"), @JsonSubtype(clazz = Exception.class, name = "exception"), @@ -202,9 +209,31 @@ public boolean isForkOf(TerracottaState state) { } } + public static final class GuestConnecting extends Ready { + GuestConnecting(int port, int index, String state) { + super(port, index, state); + } + } + public static final class GuestStarting extends Ready { - GuestStarting(int port, int index, String state) { + public enum Difficulty { + UNKNOWN, + EASIEST, + SIMPLE, + MEDIUM, + TOUGH + } + + @SerializedName("difficulty") + private final Difficulty difficulty; + + GuestStarting(int port, int index, String state, Difficulty difficulty) { super(port, index, state); + this.difficulty = difficulty; + } + + public Difficulty getDifficulty() { + return difficulty; } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/AbstractTerracottaProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/AbstractTerracottaProvider.java new file mode 100644 index 0000000000..2e812b8d07 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/AbstractTerracottaProvider.java @@ -0,0 +1,63 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.terracotta.provider; + +import javafx.beans.value.ObservableDoubleValue; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.terracotta.TerracottaBundle; +import org.jackhuang.hmcl.util.FXThread; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CancellationException; + +public abstract class AbstractTerracottaProvider { + public enum Status { + NOT_EXIST, + LEGACY_VERSION, + READY + } + + public interface DownloadContext { + @FXThread + void bindProgress(ObservableDoubleValue progress); + + void checkCancellation() throws CancellationException; + } + + protected final TerracottaBundle bundle; + + protected AbstractTerracottaProvider(TerracottaBundle bundle) { + this.bundle = bundle; + } + + public Status status() throws IOException { + return bundle.status(); + } + + public final Task download(DownloadContext progress) { + return bundle.download(progress); + } + + public Task install(Path pkg) throws IOException { + return bundle.install(pkg); + } + + public abstract List ofCommandLine(Path portTransfer); +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/GeneralProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/GeneralProvider.java index 7e585e55a9..ca918eb9ca 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/GeneralProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/GeneralProvider.java @@ -17,51 +17,21 @@ */ package org.jackhuang.hmcl.terracotta.provider; -import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.terracotta.TerracottaNative; -import org.jackhuang.hmcl.util.platform.OperatingSystem; -import org.jackhuang.hmcl.util.tree.TarFileTree; -import org.jetbrains.annotations.Nullable; +import org.jackhuang.hmcl.terracotta.TerracottaBundle; -import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.attribute.PosixFilePermission; import java.util.List; -import java.util.Set; -public final class GeneralProvider implements ITerracottaProvider { - private final TerracottaNative target; +public final class GeneralProvider extends AbstractTerracottaProvider { + private final Path executable; - public GeneralProvider(TerracottaNative target) { - this.target = target; + public GeneralProvider(TerracottaBundle bundle, Path executable) { + super(bundle); + this.executable = executable; } @Override - public Status status() throws IOException { - return target.status(); - } - - @Override - public Task install(Context context, @Nullable TarFileTree tree) throws IOException { - Task task = target.install(context, tree); - context.bindProgress(task.progressProperty()); - if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) { - task = task.thenRunAsync(() -> Files.setPosixFilePermissions(target.getPath(), Set.of( - PosixFilePermission.OWNER_READ, - PosixFilePermission.OWNER_WRITE, - PosixFilePermission.OWNER_EXECUTE, - PosixFilePermission.GROUP_READ, - PosixFilePermission.GROUP_EXECUTE, - PosixFilePermission.OTHERS_READ, - PosixFilePermission.OTHERS_EXECUTE - ))); - } - return task; - } - - @Override - public List ofCommandLine(Path path) { - return List.of(target.getPath().toString(), "--hmcl", path.toString()); + public List ofCommandLine(Path portTransfer) { + return List.of(executable.toString(), "--hmcl", portTransfer.toString()); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/ITerracottaProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/ITerracottaProvider.java deleted file mode 100644 index be6b8dd520..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/ITerracottaProvider.java +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2025 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.terracotta.provider; - -import javafx.beans.value.ObservableValue; -import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.util.tree.TarFileTree; -import org.jetbrains.annotations.Nullable; - -import java.io.IOException; -import java.nio.file.Path; -import java.util.List; - -public interface ITerracottaProvider { - enum Status { - NOT_EXIST, - LEGACY_VERSION, - READY - } - - interface Context { - void bindProgress(ObservableValue value); - - boolean requestInstallFence(); - - boolean hasInstallFence(); - } - - abstract class ProviderException extends IOException { - public ProviderException(String message) { - super(message); - } - - public ProviderException(String message, Throwable cause) { - super(message, cause); - } - - public ProviderException(Throwable cause) { - super(cause); - } - } - - final class ArchiveFileMissingException extends ProviderException { - public ArchiveFileMissingException(String message) { - super(message); - } - - public ArchiveFileMissingException(String message, Throwable cause) { - super(message, cause); - } - - public ArchiveFileMissingException(Throwable cause) { - super(cause); - } - } - - Status status() throws IOException; - - Task install(Context context, @Nullable TarFileTree tree) throws IOException; - - List ofCommandLine(Path path); -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/MacOSProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/MacOSProvider.java index abb6195b48..83debfc0a0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/MacOSProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/MacOSProvider.java @@ -19,103 +19,77 @@ import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.terracotta.TerracottaNative; +import org.jackhuang.hmcl.terracotta.TerracottaBundle; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.ManagedProcess; import org.jackhuang.hmcl.util.platform.SystemUtils; -import org.jackhuang.hmcl.util.tree.TarFileTree; -import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; -import java.nio.file.attribute.PosixFilePermission; import java.util.List; -import java.util.Set; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -public final class MacOSProvider implements ITerracottaProvider { - public final TerracottaNative installer, binary; +public final class MacOSProvider extends AbstractTerracottaProvider { + private final Path executable, installer; - public MacOSProvider(TerracottaNative installer, TerracottaNative binary) { + public MacOSProvider(TerracottaBundle bundle, Path executable, Path installer) { + super(bundle); + this.executable = executable; this.installer = installer; - this.binary = binary; } @Override public Status status() throws IOException { - assert binary != null; - if (!Files.exists(Path.of("/Applications/terracotta.app"))) { return Status.NOT_EXIST; } - return binary.status(); + return bundle.status(); } @Override - public Task install(Context context, @Nullable TarFileTree tree) throws IOException { - assert installer != null && binary != null; - - Task installerTask = installer.install(context, tree); - Task binaryTask = binary.install(context, tree); - context.bindProgress(installerTask.progressProperty().add(binaryTask.progressProperty()).multiply(0.4)); // (1 + 1) * 0.4 = 0.8 - - return Task.allOf( - installerTask.thenComposeAsync(() -> { - Path osascript = SystemUtils.which("osascript"); - if (osascript == null) { - throw new IllegalStateException("Cannot locate 'osascript' system executable on MacOS for installing Terracotta."); - } - - Path pkg = Files.createTempDirectory(Metadata.HMCL_GLOBAL_DIRECTORY, "terracotta-pkg") - .toRealPath() - .resolve(FileUtils.getName(installer.getPath())); - Files.copy(installer.getPath(), pkg, StandardCopyOption.REPLACE_EXISTING); - - ManagedProcess process = new ManagedProcess(new ProcessBuilder( - osascript.toString(), "-e", String.format( - "do shell script \"installer -pkg '%s' -target /\" with prompt \"%s\" with administrator privileges", - pkg, i18n("terracotta.sudo_installing") - ))); - process.pumpInputStream(SystemUtils::onLogLine); - process.pumpErrorStream(SystemUtils::onLogLine); - - return Task.fromCompletableFuture(process.getProcess().onExit()).thenRunAsync(() -> { - try { - FileUtils.cleanDirectory(pkg.getParent()); - } catch (IOException e) { - LOG.warning("Cannot remove temporary Terracotta package file.", e); - } - - if (process.getExitCode() != 0) { - throw new IllegalStateException(String.format( - "Cannot install Terracotta %s: system installer exited with code %d", - pkg, - process.getExitCode() - )); - } - }); - }), - binaryTask.thenRunAsync(() -> Files.setPosixFilePermissions(binary.getPath(), Set.of( - PosixFilePermission.OWNER_READ, - PosixFilePermission.OWNER_WRITE, - PosixFilePermission.OWNER_EXECUTE, - PosixFilePermission.GROUP_READ, - PosixFilePermission.GROUP_EXECUTE, - PosixFilePermission.OTHERS_READ, - PosixFilePermission.OTHERS_EXECUTE - ))) - ); + public Task install(Path pkg) throws IOException { + return super.install(pkg).thenComposeAsync(() -> { + Path osascript = SystemUtils.which("osascript"); + if (osascript == null) { + throw new IllegalStateException("Cannot locate 'osascript' system executable on MacOS for installing Terracotta."); + } + + Path movedInstaller = Files.createTempDirectory(Metadata.HMCL_GLOBAL_DIRECTORY, "terracotta-pkg") + .toRealPath() + .resolve(FileUtils.getName(installer)); + Files.copy(installer, movedInstaller, StandardCopyOption.REPLACE_EXISTING); + + ManagedProcess process = new ManagedProcess(new ProcessBuilder( + osascript.toString(), "-e", String.format( + "do shell script \"installer -pkg '%s' -target /\" with prompt \"%s\" with administrator privileges", + movedInstaller, i18n("terracotta.sudo_installing") + ))); + process.pumpInputStream(SystemUtils::onLogLine); + process.pumpErrorStream(SystemUtils::onLogLine); + + return Task.fromCompletableFuture(process.getProcess().onExit()).thenRunAsync(() -> { + try { + FileUtils.cleanDirectory(movedInstaller.getParent()); + } catch (IOException e) { + LOG.warning("Cannot remove temporary Terracotta package file.", e); + } + + if (process.getExitCode() != 0) { + throw new IllegalStateException(String.format( + "Cannot install Terracotta %s: system installer exited with code %d", movedInstaller, process.getExitCode() + )); + } + }); + }); } @Override public List ofCommandLine(Path path) { - assert binary != null; - - return List.of(binary.getPath().toString(), "--hmcl", path.toString()); + return List.of(executable.toString(), "--hmcl", path.toString()); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/Theme.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/Theme.java new file mode 100644 index 0000000000..e8d3371166 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/theme/Theme.java @@ -0,0 +1,94 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.theme; + +import org.glavo.monetfx.*; + +import java.util.*; + +/// @author Glavo +public final class Theme { + + public static final Theme DEFAULT = new Theme(ThemeColor.DEFAULT, Brightness.DEFAULT, ColorStyle.FIDELITY, Contrast.DEFAULT); + + private final ThemeColor primaryColorSeed; + private final Brightness brightness; + private final ColorStyle colorStyle; + private final Contrast contrast; + + public Theme(ThemeColor primaryColorSeed, + Brightness brightness, + ColorStyle colorStyle, + Contrast contrast + ) { + this.primaryColorSeed = primaryColorSeed; + this.brightness = brightness; + this.colorStyle = colorStyle; + this.contrast = contrast; + } + + public ColorScheme toColorScheme() { + return ColorScheme.newBuilder() + .setPrimaryColorSeed(primaryColorSeed.color()) + .setColorStyle(colorStyle) + .setBrightness(brightness) + .setSpecVersion(ColorSpecVersion.SPEC_2025) + .setContrast(contrast) + .build(); + } + + public ThemeColor primaryColorSeed() { + return primaryColorSeed; + } + + public Brightness brightness() { + return brightness; + } + + public ColorStyle colorStyle() { + return colorStyle; + } + + public Contrast contrast() { + return contrast; + } + + @Override + public boolean equals(Object obj) { + return obj == this || obj instanceof Theme that + && this.primaryColorSeed.color().equals(that.primaryColorSeed.color()) + && this.brightness.equals(that.brightness) + && this.colorStyle.equals(that.colorStyle) + && this.contrast.equals(that.contrast); + } + + @Override + public int hashCode() { + return Objects.hash(primaryColorSeed, brightness, colorStyle, contrast); + } + + @Override + public String toString() { + return "Theme[" + + "primaryColorSeed=" + primaryColorSeed + ", " + + "brightness=" + brightness + ", " + + "colorStyle=" + colorStyle + ", " + + "contrast=" + contrast + ']'; + } + +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeColor.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeColor.java new file mode 100644 index 0000000000..1ce2264b44 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeColor.java @@ -0,0 +1,193 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.theme; + +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import javafx.beans.InvalidationListener; +import javafx.beans.Observable; +import javafx.beans.WeakListener; +import javafx.beans.property.Property; +import javafx.scene.control.ColorPicker; +import javafx.scene.paint.Color; +import org.jackhuang.hmcl.util.gson.JsonSerializable; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.util.List; +import java.util.Objects; + +/// @author Glavo +@JsonAdapter(ThemeColor.TypeAdapter.class) +@JsonSerializable +public record ThemeColor(@NotNull String name, @NotNull Color color) { + + public static final ThemeColor DEFAULT = new ThemeColor("blue", Color.web("#5C6BC0")); + + public static final List STANDARD_COLORS = List.of( + DEFAULT, + new ThemeColor("darker_blue", Color.web("#283593")), + new ThemeColor("green", Color.web("#43A047")), + new ThemeColor("orange", Color.web("#E67E22")), + new ThemeColor("purple", Color.web("#9C27B0")), + new ThemeColor("red", Color.web("#B71C1C")) + ); + + public static String getColorDisplayName(Color c) { + return c != null ? String.format("#%02X%02X%02X", + Math.round(c.getRed() * 255.0D), + Math.round(c.getGreen() * 255.0D), + Math.round(c.getBlue() * 255.0D)) + : null; + } + + public static String getColorDisplayNameWithOpacity(Color c, double opacity) { + return c != null ? String.format("#%02X%02X%02X%02X", + Math.round(c.getRed() * 255.0D), + Math.round(c.getGreen() * 255.0D), + Math.round(c.getBlue() * 255.0D), + Math.round(opacity * 255.0)) + : null; + } + + public static @Nullable ThemeColor of(String name) { + if (name == null) + return null; + + if (!name.startsWith("#")) { + for (ThemeColor color : STANDARD_COLORS) { + if (name.equalsIgnoreCase(color.name())) + return color; + } + } + + try { + return new ThemeColor(name, Color.web(name)); + } catch (IllegalArgumentException e) { + return null; + } + } + + @Contract("null -> null; !null -> !null") + public static ThemeColor of(Color color) { + return color != null ? new ThemeColor(getColorDisplayName(color), color) : null; + } + + private static final class BidirectionalBinding implements InvalidationListener, WeakListener { + private final WeakReference colorPickerRef; + private final WeakReference> propertyRef; + private final int hashCode; + + private boolean updating = false; + + private BidirectionalBinding(ColorPicker colorPicker, Property property) { + this.colorPickerRef = new WeakReference<>(colorPicker); + this.propertyRef = new WeakReference<>(property); + this.hashCode = System.identityHashCode(colorPicker) ^ System.identityHashCode(property); + } + + @Override + public void invalidated(Observable sourceProperty) { + if (!updating) { + final ColorPicker colorPicker = colorPickerRef.get(); + final Property property = propertyRef.get(); + + if (colorPicker == null || property == null) { + if (colorPicker != null) { + colorPicker.valueProperty().removeListener(this); + } + + if (property != null) { + property.removeListener(this); + } + } else { + updating = true; + try { + if (property == sourceProperty) { + ThemeColor newValue = property.getValue(); + colorPicker.setValue(newValue != null ? newValue.color() : null); + } else { + Color newValue = colorPicker.getValue(); + property.setValue(newValue != null ? ThemeColor.of(newValue) : null); + } + } finally { + updating = false; + } + } + } + } + + @Override + public boolean wasGarbageCollected() { + return colorPickerRef.get() == null || propertyRef.get() == null; + } + + @Override + public int hashCode() { + return hashCode; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (!(o instanceof BidirectionalBinding that)) + return false; + + final ColorPicker colorPicker = this.colorPickerRef.get(); + final Property property = this.propertyRef.get(); + + final ColorPicker thatColorPicker = that.colorPickerRef.get(); + final Property thatProperty = that.propertyRef.get(); + + if (colorPicker == null || property == null || thatColorPicker == null || thatProperty == null) + return false; + + return colorPicker == thatColorPicker && property == thatProperty; + } + } + + public static void bindBidirectional(ColorPicker colorPicker, Property property) { + var binding = new BidirectionalBinding(colorPicker, property); + + colorPicker.valueProperty().removeListener(binding); + property.removeListener(binding); + + ThemeColor themeColor = property.getValue(); + colorPicker.setValue(themeColor != null ? themeColor.color() : null); + + colorPicker.valueProperty().addListener(binding); + property.addListener(binding); + } + + static final class TypeAdapter extends com.google.gson.TypeAdapter { + @Override + public void write(JsonWriter out, ThemeColor value) throws IOException { + out.value(value.name()); + } + + @Override + public ThemeColor read(JsonReader in) throws IOException { + return Objects.requireNonNullElse(of(in.nextString()), ThemeColor.DEFAULT); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/Themes.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/Themes.java new file mode 100644 index 0000000000..4a855977f6 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/theme/Themes.java @@ -0,0 +1,205 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.theme; + +import com.sun.jna.Pointer; +import javafx.beans.Observable; +import javafx.beans.binding.Bindings; +import javafx.beans.binding.BooleanBinding; +import javafx.beans.binding.ObjectBinding; +import javafx.beans.binding.ObjectExpression; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; +import javafx.event.EventHandler; +import javafx.scene.paint.Color; +import javafx.stage.Stage; +import javafx.stage.WindowEvent; +import org.glavo.monetfx.Brightness; +import org.glavo.monetfx.ColorScheme; +import org.glavo.monetfx.Contrast; +import org.glavo.monetfx.beans.property.ColorSchemeProperty; +import org.glavo.monetfx.beans.property.ReadOnlyColorSchemeProperty; +import org.glavo.monetfx.beans.property.SimpleColorSchemeProperty; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.WindowsNativeUtils; +import org.jackhuang.hmcl.util.platform.NativeUtils; +import org.jackhuang.hmcl.util.platform.OSVersion; +import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jackhuang.hmcl.util.platform.SystemUtils; +import org.jackhuang.hmcl.util.platform.windows.Dwmapi; +import org.jackhuang.hmcl.util.platform.windows.WinConstants; +import org.jackhuang.hmcl.util.platform.windows.WinReg; +import org.jackhuang.hmcl.util.platform.windows.WinTypes; + +import java.util.*; + +import static org.jackhuang.hmcl.setting.ConfigHolder.config; + +/// @author Glavo +public final class Themes { + + private static final ObjectExpression theme = new ObjectBinding<>() { + { + List observables = new ArrayList<>(); + + observables.add(config().themeBrightnessProperty()); + observables.add(config().themeColorProperty()); + if (FXUtils.DARK_MODE != null) { + observables.add(FXUtils.DARK_MODE); + } + bind(observables.toArray(new Observable[0])); + } + + private Brightness getBrightness() { + String themeBrightness = config().getThemeBrightness(); + if (themeBrightness == null) + return Brightness.DEFAULT; + + return switch (themeBrightness.toLowerCase(Locale.ROOT).trim()) { + case "auto" -> { + if (FXUtils.DARK_MODE != null) { + yield FXUtils.DARK_MODE.get() ? Brightness.DARK : Brightness.LIGHT; + } else { + yield getDefaultBrightness(); + } + } + case "dark" -> Brightness.DARK; + case "light" -> Brightness.LIGHT; + default -> Brightness.DEFAULT; + }; + } + + @Override + protected Theme computeValue() { + ThemeColor themeColor = Objects.requireNonNullElse(config().getThemeColor(), ThemeColor.DEFAULT); + + return new Theme(themeColor, getBrightness(), Theme.DEFAULT.colorStyle(), Contrast.DEFAULT); + } + }; + private static final ColorSchemeProperty colorScheme = new SimpleColorSchemeProperty(); + private static final BooleanBinding darkMode = Bindings.createBooleanBinding( + () -> colorScheme.get().getBrightness() == Brightness.DARK, + colorScheme + ); + + static { + ChangeListener listener = (observable, oldValue, newValue) -> { + if (!Objects.equals(oldValue, newValue)) { + colorScheme.set(newValue != null ? newValue.toColorScheme() : Theme.DEFAULT.toColorScheme()); + } + }; + listener.changed(theme, null, theme.get()); + theme.addListener(listener); + } + + private static Brightness defaultBrightness; + + private static Brightness getDefaultBrightness() { + if (defaultBrightness != null) + return defaultBrightness; + + Brightness brightness = Brightness.DEFAULT; + if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { + WinReg reg = WinReg.INSTANCE; + if (reg != null) { + Object appsUseLightTheme = reg.queryValue(WinReg.HKEY.HKEY_CURRENT_USER, "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", "AppsUseLightTheme"); + if (appsUseLightTheme instanceof Integer value) { + brightness = value == 0 ? Brightness.DARK : Brightness.LIGHT; + } + } + } else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { + try { + String result = SystemUtils.run("/usr/bin/defaults", "read", "-g", "AppleInterfaceStyle").trim(); + brightness = "Dark".equalsIgnoreCase(result) ? Brightness.DARK : Brightness.LIGHT; + } catch (Exception e) { + // If the key does not exist, it means Light mode is used + brightness = Brightness.LIGHT; + } + } + + return defaultBrightness = brightness; + } + + public static ObjectExpression themeProperty() { + return theme; + } + + public static Theme getTheme() { + return themeProperty().get(); + } + + public static ReadOnlyColorSchemeProperty colorSchemeProperty() { + return colorScheme; + } + + public static ColorScheme getColorScheme() { + return colorScheme.get(); + } + + private static final ObjectBinding titleFill = Bindings.createObjectBinding( + () -> config().isTitleTransparent() + ? getColorScheme().getOnSurface() + : getColorScheme().getOnPrimaryContainer(), + colorSchemeProperty(), + config().titleTransparentProperty() + ); + + public static ObservableValue titleFillProperty() { + return titleFill; + } + + public static BooleanBinding darkModeProperty() { + return darkMode; + } + + public static void applyNativeDarkMode(Stage stage) { + if (OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_11) && NativeUtils.USE_JNA && Dwmapi.INSTANCE != null) { + ChangeListener listener = FXUtils.onWeakChange(Themes.darkModeProperty(), darkMode -> { + if (stage.isShowing()) { + WindowsNativeUtils.getWindowHandle(stage).ifPresent(handle -> { + if (handle == WinTypes.HANDLE.INVALID_VALUE) + return; + + Dwmapi.INSTANCE.DwmSetWindowAttribute( + new WinTypes.HANDLE(Pointer.createConstant(handle)), + WinConstants.DWMWA_USE_IMMERSIVE_DARK_MODE, + new WinTypes.BOOLByReference(new WinTypes.BOOL(darkMode)), + WinTypes.BOOL.SIZE + ); + }); + } + }); + stage.getProperties().put("Themes.applyNativeDarkMode.listener", listener); + + if (stage.isShowing()) { + listener.changed(null, false, Themes.darkModeProperty().get()); + } else { + stage.addEventFilter(WindowEvent.WINDOW_SHOWN, new EventHandler<>() { + @Override + public void handle(WindowEvent event) { + stage.removeEventFilter(WindowEvent.WINDOW_SHOWN, this); + listener.changed(null, false, Themes.darkModeProperty().get()); + } + }); + } + } + } + + private Themes() { + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java index a832401e7f..4993bb7019 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java @@ -19,6 +19,7 @@ import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXDialogLayout; +import com.jfoenix.validation.base.ValidatorBase; import javafx.animation.KeyFrame; import javafx.animation.KeyValue; import javafx.animation.Timeline; @@ -72,9 +73,10 @@ import java.util.List; import java.util.concurrent.CompletableFuture; -import static org.jackhuang.hmcl.setting.ConfigHolder.*; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; +import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; public final class Controllers { public static final String JAVA_VERSION_TIP = "javaVersion"; @@ -505,8 +507,8 @@ public static CompletableFuture prompt(String title, FutureCallback prompt(String title, FutureCallback onResult, String initialValue) { - InputDialogPane pane = new InputDialogPane(title, initialValue, onResult); + public static CompletableFuture prompt(String title, FutureCallback onResult, String initialValue, ValidatorBase... validators) { + InputDialogPane pane = new InputDialogPane(title, initialValue, onResult, validators); dialog(pane); return pane.getCompletableFuture(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/DialogUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/DialogUtils.java new file mode 100644 index 0000000000..faae25cbdf --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/DialogUtils.java @@ -0,0 +1,155 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui; + +import com.jfoenix.controls.JFXDialog; +import javafx.application.Platform; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; +import javafx.event.EventHandler; +import javafx.scene.Node; +import javafx.scene.layout.StackPane; +import org.jackhuang.hmcl.ui.animation.AnimationUtils; +import org.jackhuang.hmcl.ui.construct.DialogAware; +import org.jackhuang.hmcl.ui.construct.DialogCloseEvent; +import org.jackhuang.hmcl.ui.construct.JFXDialogPane; +import org.jackhuang.hmcl.ui.decorator.Decorator; +import org.jetbrains.annotations.Nullable; + +import java.util.Optional; +import java.util.function.Consumer; + +public final class DialogUtils { + private DialogUtils() { + } + + public static final String PROPERTY_DIALOG_INSTANCE = DialogUtils.class.getName() + ".dialog.instance"; + public static final String PROPERTY_DIALOG_PANE_INSTANCE = DialogUtils.class.getName() + ".dialog.pane.instance"; + public static final String PROPERTY_DIALOG_CLOSE_HANDLER = DialogUtils.class.getName() + ".dialog.closeListener"; + + public static final String PROPERTY_PARENT_PANE_REF = DialogUtils.class.getName() + ".dialog.parentPaneRef"; + public static final String PROPERTY_PARENT_DIALOG_REF = DialogUtils.class.getName() + ".dialog.parentDialogRef"; + + public static void show(Decorator decorator, Node content) { + if (decorator.getDrawerWrapper() == null) { + Platform.runLater(() -> show(decorator, content)); + return; + } + + show(decorator.getDrawerWrapper(), content, (dialog) -> { + JFXDialogPane pane = (JFXDialogPane) dialog.getContent(); + decorator.capableDraggingWindow(dialog); + decorator.forbidDraggingWindow(pane); + dialog.setDialogContainer(decorator.getDrawerWrapper()); + }); + } + + public static void show(StackPane container, Node content) { + show(container, content, null); + } + + public static void show(StackPane container, Node content, @Nullable Consumer onDialogCreated) { + FXUtils.checkFxUserThread(); + + JFXDialog dialog = (JFXDialog) container.getProperties().get(PROPERTY_DIALOG_INSTANCE); + JFXDialogPane dialogPane = (JFXDialogPane) container.getProperties().get(PROPERTY_DIALOG_PANE_INSTANCE); + + if (dialog == null) { + dialog = new JFXDialog(AnimationUtils.isAnimationEnabled() + ? JFXDialog.DialogTransition.CENTER + : JFXDialog.DialogTransition.NONE); + dialogPane = new JFXDialogPane(); + + dialog.setContent(dialogPane); + dialog.setDialogContainer(container); + dialog.setOverlayClose(false); + + container.getProperties().put(PROPERTY_DIALOG_INSTANCE, dialog); + container.getProperties().put(PROPERTY_DIALOG_PANE_INSTANCE, dialogPane); + + if (onDialogCreated != null) { + onDialogCreated.accept(dialog); + } + + dialog.show(); + } + + content.getProperties().put(PROPERTY_PARENT_PANE_REF, dialogPane); + content.getProperties().put(PROPERTY_PARENT_DIALOG_REF, dialog); + + dialogPane.push(content); + + EventHandler handler = event -> close(content); + content.getProperties().put(PROPERTY_DIALOG_CLOSE_HANDLER, handler); + content.addEventHandler(DialogCloseEvent.CLOSE, handler); + + handleDialogShown(dialog, content); + } + + private static void handleDialogShown(JFXDialog dialog, Node node) { + if (dialog.isVisible()) { + dialog.requestFocus(); + if (node instanceof DialogAware dialogAware) + dialogAware.onDialogShown(); + } else { + dialog.visibleProperty().addListener(new ChangeListener<>() { + @Override + public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { + if (newValue) { + dialog.requestFocus(); + if (node instanceof DialogAware dialogAware) + dialogAware.onDialogShown(); + observable.removeListener(this); + } + } + }); + } + } + + @SuppressWarnings("unchecked") + public static void close(Node content) { + FXUtils.checkFxUserThread(); + + Optional.ofNullable(content.getProperties().get(PROPERTY_DIALOG_CLOSE_HANDLER)) + .ifPresent(handler -> content.removeEventHandler(DialogCloseEvent.CLOSE, (EventHandler) handler)); + + JFXDialogPane pane = (JFXDialogPane) content.getProperties().get(PROPERTY_PARENT_PANE_REF); + JFXDialog dialog = (JFXDialog) content.getProperties().get(PROPERTY_PARENT_DIALOG_REF); + + if (dialog != null && pane != null) { + if (pane.size() == 1 && pane.peek().orElse(null) == content) { + dialog.setOnDialogClosed(e -> pane.pop(content)); + dialog.close(); + + StackPane container = dialog.getDialogContainer(); + if (container != null) { + container.getProperties().remove(PROPERTY_DIALOG_INSTANCE); + container.getProperties().remove(PROPERTY_DIALOG_PANE_INSTANCE); + container.getProperties().remove(PROPERTY_PARENT_DIALOG_REF); + container.getProperties().remove(PROPERTY_PARENT_PANE_REF); + } + } else { + pane.pop(content); + } + + if (content instanceof DialogAware dialogAware) { + dialogAware.onDialogClosed(); + } + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java index c8a072b06f..0a1ade2f22 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java @@ -18,7 +18,8 @@ package org.jackhuang.hmcl.ui; import com.jfoenix.controls.*; -import javafx.animation.*; +import javafx.animation.Animation; +import javafx.animation.Interpolator; import javafx.application.Platform; import javafx.beans.InvalidationListener; import javafx.beans.Observable; @@ -26,7 +27,10 @@ import javafx.beans.WeakListener; import javafx.beans.binding.Bindings; import javafx.beans.property.*; -import javafx.beans.value.*; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableBooleanValue; +import javafx.beans.value.ObservableValue; +import javafx.beans.value.WeakChangeListener; import javafx.collections.ObservableMap; import javafx.event.Event; import javafx.event.EventDispatcher; @@ -39,32 +43,38 @@ import javafx.scene.Node; import javafx.scene.Parent; import javafx.scene.Scene; -import javafx.scene.control.Label; -import javafx.scene.control.ScrollPane; import javafx.scene.control.*; import javafx.scene.control.skin.VirtualFlow; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.input.*; -import javafx.scene.layout.*; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.paint.Paint; import javafx.scene.shape.Rectangle; import javafx.scene.text.Text; import javafx.scene.text.TextFlow; -import javafx.stage.*; +import javafx.stage.FileChooser; +import javafx.stage.Screen; +import javafx.stage.Stage; import javafx.util.Callback; import javafx.util.Duration; import javafx.util.StringConverter; import org.jackhuang.hmcl.setting.StyleSheets; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.task.CacheFileTask; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.animation.AnimationUtils; +import org.jackhuang.hmcl.ui.construct.IconedMenuItem; +import org.jackhuang.hmcl.ui.construct.MenuSeparator; +import org.jackhuang.hmcl.ui.construct.PopupMenu; import org.jackhuang.hmcl.ui.image.ImageLoader; import org.jackhuang.hmcl.ui.image.ImageUtils; -import org.jackhuang.hmcl.util.*; +import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.ResourceNotFoundError; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.javafx.ExtendedProperties; @@ -89,12 +99,14 @@ import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; import java.lang.ref.WeakReference; -import java.net.*; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLConnection; import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.PathMatcher; -import java.util.List; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.function.*; @@ -103,8 +115,8 @@ import static org.jackhuang.hmcl.util.Lang.thread; import static org.jackhuang.hmcl.util.Lang.tryCast; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; public final class FXUtils { private FXUtils() { @@ -135,8 +147,10 @@ private FXUtils() { public static final @Nullable ObservableMap PREFERENCES; public static final @Nullable ObservableBooleanValue DARK_MODE; public static final @Nullable Boolean REDUCED_MOTION; + public static final @Nullable ReadOnlyObjectProperty ACCENT_COLOR; public static final @Nullable MethodHandle TEXT_TRUNCATED_PROPERTY; + public static final @Nullable MethodHandle FOCUS_VISIBLE_PROPERTY; static { String jfxVersion = System.getProperty("javafx.version"); @@ -151,6 +165,7 @@ private FXUtils() { ObservableMap preferences = null; ObservableBooleanValue darkMode = null; + ReadOnlyObjectProperty accentColorProperty = null; Boolean reducedMotion = null; if (JAVAFX_MAJOR_VERSION >= 22) { try { @@ -162,14 +177,19 @@ private FXUtils() { preferences = preferences0; @SuppressWarnings("unchecked") - var colorSchemeProperty = - (ReadOnlyObjectProperty>) - lookup.findVirtual(preferencesClass, "colorSchemeProperty", MethodType.methodType(ReadOnlyObjectProperty.class)) - .invoke(preferences); + var colorSchemeProperty = (ReadOnlyObjectProperty>) + lookup.findVirtual(preferencesClass, "colorSchemeProperty", MethodType.methodType(ReadOnlyObjectProperty.class)) + .invoke(preferences); darkMode = Bindings.createBooleanBinding(() -> "DARK".equals(colorSchemeProperty.get().name()), colorSchemeProperty); + @SuppressWarnings("unchecked") + var accentColorProperty0 = (ReadOnlyObjectProperty) + lookup.findVirtual(preferencesClass, "accentColorProperty", MethodType.methodType(ReadOnlyObjectProperty.class)) + .invoke(preferences); + accentColorProperty = accentColorProperty0; + if (JAVAFX_MAJOR_VERSION >= 24) { reducedMotion = (boolean) lookup.findVirtual(preferencesClass, "isReducedMotion", MethodType.methodType(boolean.class)) @@ -182,6 +202,7 @@ private FXUtils() { PREFERENCES = preferences; DARK_MODE = darkMode; REDUCED_MOTION = reducedMotion; + ACCENT_COLOR = accentColorProperty; MethodHandle textTruncatedProperty = null; if (JAVAFX_MAJOR_VERSION >= 23) { @@ -196,6 +217,20 @@ private FXUtils() { } } TEXT_TRUNCATED_PROPERTY = textTruncatedProperty; + + MethodHandle focusVisibleProperty = null; + if (JAVAFX_MAJOR_VERSION >= 19) { + try { + focusVisibleProperty = MethodHandles.publicLookup().findVirtual( + Node.class, + "focusVisibleProperty", + MethodType.methodType(ReadOnlyBooleanProperty.class) + ); + } catch (Throwable e) { + LOG.warning("Failed to lookup focusVisibleProperty", e); + } + } + FOCUS_VISIBLE_PROPERTY = focusVisibleProperty; } public static final String DEFAULT_MONOSPACE_FONT = OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "Consolas" : "Monospace"; @@ -277,6 +312,14 @@ public static void limitSize(ImageView imageView, double maxWidth, double maxHei }); } + public static Node wrap(Node node) { + return limitingSize(node, 30, 20); + } + + public static Node wrap(SVG svg) { + return wrap(svg.createIcon(20)); + } + private static class ListenerPair { private final ObservableValue value; private final ChangeListener listener; @@ -416,6 +459,20 @@ public static void smoothScrolling(VirtualFlow virtualFlow) { } } + public static @Nullable ReadOnlyBooleanProperty focusVisibleProperty(Node node) { + if (FOCUS_VISIBLE_PROPERTY != null) { + try { + return (ReadOnlyBooleanProperty) FOCUS_VISIBLE_PROPERTY.invokeExact(node); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable e) { + throw new RuntimeException(e); + } + } else { + return null; + } + } + private static final Duration TOOLTIP_FAST_SHOW_DELAY = Duration.millis(50); private static final Duration TOOLTIP_SLOW_SHOW_DELAY = Duration.millis(500); private static final Duration TOOLTIP_SHOW_DURATION = Duration.millis(5000); @@ -1181,7 +1238,16 @@ public static Image newBuiltinImage(String url, double requestedWidth, double re public static Task getRemoteImageTask(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) { return new CacheFileTask(url) - .thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth)); + .setSignificance(Task.TaskSignificance.MINOR) + .thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth)) + .setSignificance(Task.TaskSignificance.MINOR); + } + + public static Task getRemoteImageTask(URI uri, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) { + return new CacheFileTask(uri) + .setSignificance(Task.TaskSignificance.MINOR) + .thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth)) + .setSignificance(Task.TaskSignificance.MINOR); } public static ObservableValue newRemoteImage(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) { @@ -1194,6 +1260,7 @@ public static ObservableValue newRemoteImage(String url, int requestedWid LOG.warning("An exception encountered while loading remote image: " + url, exception); } }) + .setSignificance(Task.TaskSignificance.MINOR) .start(); return image; } @@ -1214,7 +1281,7 @@ public static JFXButton newBorderButton(String text) { public static JFXButton newToggleButton4(SVG icon) { JFXButton button = new JFXButton(); button.getStyleClass().add("toggle-icon4"); - button.setGraphic(icon.createIcon(Theme.blackFill(), -1)); + button.setGraphic(icon.createIcon()); return button; } @@ -1295,17 +1362,11 @@ public T fromString(String string) { } public static Callback, ListCell> jfxListCellFactory(Function graphicBuilder) { - Holder lastCell = new Holder<>(); return view -> new JFXListCell() { @Override public void updateItem(T item, boolean empty) { super.updateItem(item, empty); - // https://mail.openjdk.org/pipermail/openjfx-dev/2022-July/034764.html - if (this == lastCell.value && !isVisible()) - return; - lastCell.value = this; - if (!empty) { setContentDisplay(ContentDisplay.GRAPHIC_ONLY); setGraphic(graphicBuilder.apply(item)); @@ -1350,7 +1411,16 @@ public static void onEscPressed(Node node, Runnable action) { public static void onClicked(Node node, Runnable action) { node.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> { - if (e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 1) { + if (e.getButton() == MouseButton.PRIMARY) { + action.run(); + e.consume(); + } + }); + } + + public static void onSecondaryButtonClicked(Node node, Runnable action) { + node.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> { + if (e.getButton() == MouseButton.SECONDARY) { action.run(); e.consume(); } @@ -1396,16 +1466,6 @@ public static void onScroll(Node node, List list, }); } - public static void clearFocus(Node node) { - Scene scene = node.getScene(); - if (scene != null) { - Parent root = scene.getRoot(); - if (root != null) { - root.requestFocus(); - } - } - } - public static void copyOnDoubleClick(Labeled label) { label.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> { if (e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 2) { @@ -1447,11 +1507,11 @@ public static List parseSegment(String segment, Consumer hyperlink for (int i = 0; i < children.getLength(); i++) { org.w3c.dom.Node node = children.item(i); - if (node instanceof Element) { - Element element = (Element) node; + if (node instanceof Element element) { if ("a".equals(element.getTagName())) { String href = element.getAttribute("href"); Text text = new Text(element.getTextContent()); + text.getStyleClass().add("hyperlink"); onClicked(text, () -> { String link = href; try { @@ -1461,7 +1521,6 @@ public static List parseSegment(String segment, Consumer hyperlink hyperlinkAction.accept(link); }); text.setCursor(Cursor.HAND); - text.setFill(Color.web("#0070E0")); text.setUnderline(true); texts.add(text); } else if ("b".equals(element.getTagName())) { @@ -1563,4 +1622,39 @@ public static JFXPopup.PopupVPosition determineOptimalPopupPosition(Node root, J ? JFXPopup.PopupVPosition.BOTTOM // Show menu below the button, expanding downward : JFXPopup.PopupVPosition.TOP; // Show menu above the button, expanding upward } + + public static void useJFXContextMenu(TextInputControl control) { + control.setContextMenu(null); + + PopupMenu menu = new PopupMenu(); + JFXPopup popup = new JFXPopup(menu); + popup.setAutoHide(true); + + control.setOnContextMenuRequested(e -> { + boolean hasNoSelection = control.getSelectedText().isEmpty(); + + IconedMenuItem undo = new IconedMenuItem(SVG.UNDO, i18n("menu.undo"), control::undo, popup); + IconedMenuItem redo = new IconedMenuItem(SVG.REDO, i18n("menu.redo"), control::redo, popup); + IconedMenuItem cut = new IconedMenuItem(SVG.CONTENT_CUT, i18n("menu.cut"), control::cut, popup); + IconedMenuItem copy = new IconedMenuItem(SVG.CONTENT_COPY, i18n("menu.copy"), control::copy, popup); + IconedMenuItem paste = new IconedMenuItem(SVG.CONTENT_PASTE, i18n("menu.paste"), control::paste, popup); + IconedMenuItem delete = new IconedMenuItem(SVG.DELETE, i18n("menu.deleteselection"), () -> control.replaceSelection(""), popup); + IconedMenuItem selectall = new IconedMenuItem(SVG.SELECT_ALL, i18n("menu.selectall"), control::selectAll, popup); + + menu.getContent().setAll(undo, redo, new MenuSeparator(), cut, copy, paste, delete, new MenuSeparator(), selectall); + + undo.setDisable(!control.isUndoable()); + redo.setDisable(!control.isRedoable()); + cut.setDisable(hasNoSelection); + delete.setDisable(hasNoSelection); + copy.setDisable(hasNoSelection); + paste.setDisable(!Clipboard.getSystemClipboard().hasString()); + selectall.setDisable(control.getText() == null || control.getText().isEmpty()); + + JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(control, popup); + popup.show(control, vPosition, JFXPopup.PopupHPosition.LEFT, e.getX(), vPosition == JFXPopup.PopupVPosition.TOP ? e.getY() : e.getY() - control.getHeight()); + + e.consume(); + }); + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java index 86d966256d..6cc322ec94 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java @@ -24,11 +24,11 @@ import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.Scene; -import javafx.scene.control.Alert; import javafx.scene.control.Label; import javafx.scene.control.ScrollPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; +import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import javafx.scene.text.Text; import javafx.scene.text.TextFlow; @@ -40,6 +40,8 @@ import org.jackhuang.hmcl.setting.StyleSheets; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.theme.Themes; +import org.jackhuang.hmcl.ui.construct.MessageDialogPane; import org.jackhuang.hmcl.ui.construct.TwoLineListItem; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.Log4jLevel; @@ -50,9 +52,12 @@ import org.jackhuang.hmcl.util.platform.*; import java.io.IOException; +import java.lang.management.ManagementFactory; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.attribute.FileTime; +import java.time.Instant; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; @@ -81,10 +86,13 @@ public class GameCrashWindow extends Stage { private final ProcessListener.ExitType exitType; private final LaunchOptions launchOptions; private final View view; + private final StackPane stackPane; private final List logs; public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType exitType, DefaultGameRepository repository, Version version, LaunchOptions launchOptions, List logs) { + Themes.applyNativeDarkMode(this); + this.managedProcess = managedProcess; this.exitType = exitType; this.repository = repository; @@ -93,7 +101,7 @@ public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType e this.logs = logs; this.analyzer = LibraryAnalyzer.analyze(version, repository.getGameVersion(version).orElse(null)); - memory = Optional.ofNullable(launchOptions.getMaxMemory()).map(i -> i + " MB").orElse("-"); + memory = Optional.ofNullable(launchOptions.getMaxMemory()).map(i -> i + " " + i18n("settings.memory.unit.mib")).orElse("-"); total_memory = MEGABYTES.formatBytes(SystemInfo.getTotalMemorySize()); @@ -103,9 +111,10 @@ public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType e this.view = new View(); + this.stackPane = new StackPane(view); this.feedbackTextFlow.getChildren().addAll(FXUtils.parseSegment(i18n("game.crash.feedback"), Controllers::onHyperlinkAction)); - setScene(new Scene(view, 800, 480)); + setScene(new Scene(stackPane, 800, 480)); StyleSheets.init(getScene()); setTitle(i18n("game.crash.title")); FXUtils.setIcon(this); @@ -269,22 +278,41 @@ private void exportGameCrashInfo() { CompletableFuture.supplyAsync(() -> logs.stream().map(Log::getLog).collect(Collectors.joining("\n"))) - .thenComposeAsync(logs -> - LogExporter.exportLogs(logFile, repository, launchOptions.getVersionName(), logs, new CommandBuilder().addAll(managedProcess.getCommands()).toString())) + .thenComposeAsync(logs -> { + long processStartTime = managedProcess.getProcess().info() + .startInstant() + .map(Instant::toEpochMilli).orElseGet(() -> { + try { + return ManagementFactory.getRuntimeMXBean().getStartTime(); + } catch (Throwable e) { + LOG.warning("Failed to get process start time", e); + return 0L; + } + }); + + return LogExporter.exportLogs(logFile, repository, launchOptions.getVersionName(), logs, + new CommandBuilder().addAll(managedProcess.getCommands()).toString(), + path -> { + try { + FileTime lastModifiedTime = Files.getLastModifiedTime(path); + return lastModifiedTime.toMillis() >= processStartTime; + } catch (Throwable e) { + LOG.warning("Failed to read file attributes", e); + return false; + } + }); + }) .handleAsync((result, exception) -> { - Alert alert; - if (exception == null) { FXUtils.showFileInExplorer(logFile); - alert = new Alert(Alert.AlertType.INFORMATION, i18n("settings.launcher.launcher_log.export.success", logFile)); + var dialog = new MessageDialogPane.Builder(i18n("settings.launcher.launcher_log.export.success", logFile), i18n("message.success"), MessageDialogPane.MessageType.SUCCESS).ok(null).build(); + DialogUtils.show(stackPane, dialog); } else { LOG.warning("Failed to export game crash info", exception); - alert = new Alert(Alert.AlertType.WARNING, i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(exception)); + var dialog = new MessageDialogPane.Builder(i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(exception), i18n("message.error"), MessageDialogPane.MessageType.ERROR).ok(null).build(); + DialogUtils.show(stackPane, dialog); } - alert.setTitle(i18n("settings.launcher.launcher_log.export")); - alert.showAndWait(); - return null; }, Schedulers.javafx()); } @@ -292,7 +320,7 @@ private void exportGameCrashInfo() { private final class View extends VBox { View() { - setStyle("-fx-background-color: white"); + this.getStyleClass().add("game-crash-window"); HBox titlePane = new HBox(); { @@ -395,10 +423,13 @@ private final class View extends VBox { reasonTitle.getStyleClass().add("two-line-item-second-large-title"); ScrollPane reasonPane = new ScrollPane(reasonTextFlow); + reasonTextFlow.getStyleClass().add("crash-reason-text-flow"); reasonPane.setFitToWidth(true); reasonPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); reasonPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + feedbackTextFlow.getStyleClass().add("crash-reason-text-flow"); + gameDirPane.setPadding(new Insets(8)); VBox.setVgrow(gameDirPane, Priority.ALWAYS); FXUtils.onChangeAndOperate(feedbackTextFlow.visibleProperty(), visible -> { @@ -411,6 +442,7 @@ private final class View extends VBox { } HBox toolBar = new HBox(); + VBox.setMargin(toolBar, new Insets(0, 0, 4, 0)); { JFXButton exportGameCrashInfoButton = FXUtils.newRaisedButton(i18n("logwindow.export_game_crash_logs")); exportGameCrashInfoButton.setOnAction(e -> exportGameCrashInfo()); @@ -422,7 +454,6 @@ private final class View extends VBox { helpButton.setOnAction(e -> FXUtils.openLink(Metadata.CONTACT_URL)); FXUtils.installFastTooltip(helpButton, i18n("logwindow.help")); - toolBar.setPadding(new Insets(8)); toolBar.setSpacing(8); toolBar.getStyleClass().add("jfx-tool-bar"); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java index 1925bbeb71..b72dee96e3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.ui; import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXRippler; import javafx.beans.Observable; import javafx.beans.binding.Bindings; import javafx.beans.property.ObjectProperty; @@ -36,7 +37,6 @@ import javafx.scene.input.MouseButton; import javafx.scene.layout.*; import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.setting.VersionIconType; import org.jackhuang.hmcl.ui.construct.RipplerContainer; import org.jackhuang.hmcl.util.i18n.I18n; @@ -63,7 +63,7 @@ public class InstallerItem extends Control { private final ObjectProperty onInstall = new SimpleObjectProperty<>(this, "onInstall"); private final ObjectProperty onRemove = new SimpleObjectProperty<>(this, "onRemove"); - public interface State { + public sealed interface State { } public static final class InstallableState implements State { @@ -73,46 +73,10 @@ private InstallableState() { } } - public static final class IncompatibleState implements State { - private final String incompatibleItemName; - private final String incompatibleItemVersion; - - public IncompatibleState(String incompatibleItemName, String incompatibleItemVersion) { - this.incompatibleItemName = incompatibleItemName; - this.incompatibleItemVersion = incompatibleItemVersion; - } - - public String getIncompatibleItemName() { - return incompatibleItemName; - } - - public String getIncompatibleItemVersion() { - return incompatibleItemVersion; - } + public record IncompatibleState(String incompatibleItemName, String incompatibleItemVersion) implements State { } - public static final class InstalledState implements State { - private final String version; - private final boolean external; - private final boolean incompatibleWithGame; - - public InstalledState(String version, boolean external, boolean incompatibleWithGame) { - this.version = version; - this.external = external; - this.incompatibleWithGame = incompatibleWithGame; - } - - public String getVersion() { - return version; - } - - public boolean isExternal() { - return external; - } - - public boolean isIncompatibleWithGame() { - return incompatibleWithGame; - } + public record InstalledState(String version, boolean external, boolean incompatibleWithGame) implements State { } public enum Style { @@ -128,37 +92,18 @@ public InstallerItem(String id, Style style) { this.id = id; this.style = style; - switch (id) { - case "game": - iconType = VersionIconType.GRASS; - break; - case "fabric": - case "fabric-api": - iconType = VersionIconType.FABRIC; - break; - case "forge": - iconType = VersionIconType.FORGE; - break; - case "cleanroom": - iconType = VersionIconType.CLEANROOM; - break; - case "liteloader": - iconType = VersionIconType.CHICKEN; - break; - case "optifine": - iconType = VersionIconType.OPTIFINE; - break; - case "quilt": - case "quilt-api": - iconType = VersionIconType.QUILT; - break; - case "neoforge": - iconType = VersionIconType.NEO_FORGE; - break; - default: - iconType = null; - break; - } + iconType = switch (id) { + case "game" -> VersionIconType.GRASS; + case "fabric", "fabric-api" -> VersionIconType.FABRIC; + case "legacyfabric", "legacyfabric-api" -> VersionIconType.LEGACY_FABRIC; + case "forge" -> VersionIconType.FORGE; + case "cleanroom" -> VersionIconType.CLEANROOM; + case "liteloader" -> VersionIconType.CHICKEN; + case "optifine" -> VersionIconType.OPTIFINE; + case "quilt", "quilt-api" -> VersionIconType.QUILT; + case "neoforge" -> VersionIconType.NEO_FORGE; + default -> null; + }; } public String getLibraryId() { @@ -237,6 +182,8 @@ public InstallerItemGroup(String gameVersion, Style style) { InstallerItem fabricApi = new InstallerItem(FABRIC_API, style); InstallerItem forge = new InstallerItem(FORGE, style); InstallerItem cleanroom = new InstallerItem(CLEANROOM, style); + InstallerItem legacyfabric = new InstallerItem(LEGACY_FABRIC, style); + InstallerItem legacyfabricApi = new InstallerItem(LEGACY_FABRIC_API, style); InstallerItem neoForge = new InstallerItem(NEO_FORGE, style); InstallerItem liteLoader = new InstallerItem(LITELOADER, style); InstallerItem optiFine = new InstallerItem(OPTIFINE, style); @@ -244,11 +191,11 @@ public InstallerItemGroup(String gameVersion, Style style) { InstallerItem quiltApi = new InstallerItem(QUILT_API, style); Map> incompatibleMap = new HashMap<>(); - mutualIncompatible(incompatibleMap, forge, fabric, quilt, neoForge, cleanroom); - addIncompatibles(incompatibleMap, liteLoader, fabric, quilt, neoForge, cleanroom); - addIncompatibles(incompatibleMap, optiFine, fabric, quilt, neoForge, cleanroom); - addIncompatibles(incompatibleMap, fabricApi, forge, quiltApi, neoForge, liteLoader, optiFine, cleanroom); - addIncompatibles(incompatibleMap, quiltApi, forge, fabric, fabricApi, neoForge, liteLoader, optiFine, cleanroom); + mutualIncompatible(incompatibleMap, forge, fabric, quilt, neoForge, cleanroom, legacyfabric); + addIncompatibles(incompatibleMap, liteLoader, fabric, quilt, neoForge, cleanroom, legacyfabric); + addIncompatibles(incompatibleMap, optiFine, fabric, quilt, neoForge, cleanroom, liteLoader, legacyfabric); + addIncompatibles(incompatibleMap, fabricApi, forge, quiltApi, neoForge, liteLoader, optiFine, cleanroom, legacyfabricApi, legacyfabricApi); + addIncompatibles(incompatibleMap, quiltApi, forge, fabric, fabricApi, neoForge, liteLoader, optiFine, cleanroom, legacyfabric, legacyfabricApi); for (Map.Entry> entry : incompatibleMap.entrySet()) { InstallerItem item = entry.getKey(); @@ -282,7 +229,7 @@ public InstallerItemGroup(String gameVersion, Style style) { game.versionProperty.set(new InstalledState(gameVersion, false, false)); } - InstallerItem[] all = {game, forge, neoForge, liteLoader, optiFine, fabric, fabricApi, quilt, quiltApi, cleanroom}; + InstallerItem[] all = {game, forge, neoForge, liteLoader, optiFine, fabric, fabricApi, quilt, quiltApi, legacyfabric, legacyfabricApi, cleanroom}; for (InstallerItem item : all) { if (!item.resolvedStateProperty.isBound()) { @@ -299,9 +246,9 @@ public InstallerItemGroup(String gameVersion, Style style) { if (gameVersion == null) { this.libraries = all; } else if (gameVersion.equals("1.12.2")) { - this.libraries = new InstallerItem[]{game, forge, cleanroom, liteLoader, optiFine}; - } else if (GameVersionNumber.compare(gameVersion, "1.13") < 0) { - this.libraries = new InstallerItem[]{game, forge, liteLoader, optiFine}; + this.libraries = new InstallerItem[]{game, forge, cleanroom, liteLoader, legacyfabric, legacyfabricApi, optiFine}; + } else if (GameVersionNumber.compare(gameVersion, "1.13.2") <= 0) { + this.libraries = new InstallerItem[]{game, forge, liteLoader, optiFine, legacyfabric, legacyfabricApi}; } else { this.libraries = new InstallerItem[]{game, forge, neoForge, optiFine, fabric, fabricApi, quilt, quiltApi}; } @@ -336,10 +283,15 @@ private static final class InstallerItemSkin extends SkinBase { } pane.getStyleClass().add("installer-item"); RipplerContainer container = new RipplerContainer(pane); - getChildren().setAll(container); + container.setPosition(JFXRippler.RipplerPos.BACK); + StackPane paneWrapper = new StackPane(); + paneWrapper.getStyleClass().add("installer-item-wrapper"); + paneWrapper.getChildren().setAll(container); + getChildren().setAll(paneWrapper); pane.pseudoClassStateChanged(LIST_ITEM, control.style == Style.LIST_ITEM); pane.pseudoClassStateChanged(CARD, control.style == Style.CARD); + paneWrapper.pseudoClassStateChanged(CARD, control.style == Style.CARD); if (control.iconType != null) { ImageView view = new ImageView(control.iconType.getIcon()); @@ -368,21 +320,20 @@ private static final class InstallerItemSkin extends SkinBase { statusLabel.textProperty().bind(Bindings.createStringBinding(() -> { State state = control.resolvedStateProperty.get(); - if (state instanceof InstalledState) { - InstalledState s = (InstalledState) state; - if (s.incompatibleWithGame) { - return i18n("install.installer.change_version", s.version); + if (state instanceof InstalledState installedState) { + if (installedState.incompatibleWithGame) { + return i18n("install.installer.change_version", installedState.version); } - if (s.external) { - return i18n("install.installer.external_version", s.version); + if (installedState.external) { + return i18n("install.installer.external_version", installedState.version); } - return i18n("install.installer.version", s.version); + return i18n("install.installer.version", installedState.version); } else if (state instanceof InstallableState) { return control.style == Style.CARD ? i18n("install.installer.do_not_install") : i18n("install.installer.not_installed"); - } else if (state instanceof IncompatibleState) { - return i18n("install.installer.incompatible", i18n("install.installer." + ((IncompatibleState) state).incompatibleItemName)); + } else if (state instanceof IncompatibleState incompatibleState) { + return i18n("install.installer.incompatible", i18n("install.installer." + incompatibleState.incompatibleItemName)); } else { throw new AssertionError("Unknown state type: " + state.getClass()); } @@ -396,14 +347,14 @@ private static final class InstallerItemSkin extends SkinBase { pane.getChildren().add(buttonsContainer); JFXButton removeButton = new JFXButton(); - removeButton.setGraphic(SVG.CLOSE.createIcon(Theme.blackFill(), -1)); + removeButton.setGraphic(SVG.CLOSE.createIcon()); removeButton.getStyleClass().add("toggle-icon4"); if (control.id.equals(MINECRAFT.getPatchId())) { removeButton.setVisible(false); } else { removeButton.visibleProperty().bind(Bindings.createBooleanBinding(() -> { State state = control.resolvedStateProperty.get(); - return state instanceof InstalledState && !((InstalledState) state).external; + return state instanceof InstalledState installedState && !installedState.external; }, control.resolvedStateProperty)); } removeButton.managedProperty().bind(removeButton.visibleProperty()); @@ -417,8 +368,8 @@ private static final class InstallerItemSkin extends SkinBase { JFXButton installButton = new JFXButton(); installButton.graphicProperty().bind(Bindings.createObjectBinding(() -> control.resolvedStateProperty.get() instanceof InstallableState ? - SVG.ARROW_FORWARD.createIcon(Theme.blackFill(), -1) : - SVG.UPDATE.createIcon(Theme.blackFill(), -1), + SVG.ARROW_FORWARD.createIcon() : + SVG.UPDATE.createIcon(), control.resolvedStateProperty )); installButton.getStyleClass().add("toggle-icon4"); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPage.java deleted file mode 100644 index 4025271e02..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPage.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.ui; - -import javafx.beans.property.BooleanProperty; -import javafx.beans.property.SimpleBooleanProperty; -import javafx.scene.Node; -import javafx.scene.control.Skin; - -public abstract class ListPage extends ListPageBase { - private final BooleanProperty refreshable = new SimpleBooleanProperty(this, "refreshable", false); - - public abstract void add(); - - public void refresh() { - } - - @Override - protected Skin createDefaultSkin() { - return new ListPageSkin(this); - } - - public boolean isRefreshable() { - return refreshable.get(); - } - - public BooleanProperty refreshableProperty() { - return refreshable; - } - - public void setRefreshable(boolean refreshable) { - this.refreshable.set(refreshable); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPageBase.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPageBase.java index 38911bbddf..b8c603fb8d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPageBase.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPageBase.java @@ -23,10 +23,11 @@ import javafx.event.Event; import javafx.event.EventHandler; import javafx.scene.control.Control; +import org.jackhuang.hmcl.ui.animation.TransitionPane; import static org.jackhuang.hmcl.ui.construct.SpinnerPane.FAILED_ACTION; -public class ListPageBase extends Control { +public class ListPageBase extends Control implements TransitionPane.Cacheable { private final ListProperty items = new SimpleListProperty<>(this, "items", FXCollections.observableArrayList()); private final BooleanProperty loading = new SimpleBooleanProperty(this, "loading", false); private final StringProperty failedReason = new SimpleStringProperty(this, "failed"); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPageSkin.java deleted file mode 100644 index 1927b31726..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPageSkin.java +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.ui; - -import com.jfoenix.controls.JFXButton; -import javafx.beans.binding.Bindings; -import javafx.geometry.Insets; -import javafx.geometry.Pos; -import javafx.scene.control.ScrollPane; -import javafx.scene.control.SkinBase; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.Pane; -import javafx.scene.layout.StackPane; -import javafx.scene.layout.VBox; -import org.jackhuang.hmcl.setting.Theme; -import org.jackhuang.hmcl.ui.construct.SpinnerPane; - -public class ListPageSkin extends SkinBase> { - - public ListPageSkin(ListPage skinnable) { - super(skinnable); - - SpinnerPane spinnerPane = new SpinnerPane(); - spinnerPane.getStyleClass().add("large-spinner-pane"); - Pane placeholder = new Pane(); - VBox list = new VBox(); - - StackPane contentPane = new StackPane(); - { - ScrollPane scrollPane = new ScrollPane(); - { - scrollPane.setFitToWidth(true); - - list.maxWidthProperty().bind(scrollPane.widthProperty()); - list.setSpacing(10); - - VBox content = new VBox(); - content.getChildren().setAll(list, placeholder); - - Bindings.bindContent(list.getChildren(), skinnable.itemsProperty()); - - scrollPane.setContent(content); - FXUtils.smoothScrolling(scrollPane); - } - - VBox vBox = new VBox(); - { - vBox.getStyleClass().add("card-list"); - vBox.setAlignment(Pos.BOTTOM_RIGHT); - vBox.setPickOnBounds(false); - - JFXButton btnAdd = FXUtils.newRaisedButton(""); - FXUtils.setLimitWidth(btnAdd, 40); - FXUtils.setLimitHeight(btnAdd, 40); - btnAdd.setGraphic(SVG.ADD.createIcon(Theme.whiteFill(), -1)); - btnAdd.setOnAction(e -> skinnable.add()); - - JFXButton btnRefresh = new JFXButton(); - FXUtils.setLimitWidth(btnRefresh, 40); - FXUtils.setLimitHeight(btnRefresh, 40); - btnRefresh.getStyleClass().add("jfx-button-raised-round"); - btnRefresh.setButtonType(JFXButton.ButtonType.RAISED); - btnRefresh.setGraphic(SVG.REFRESH.createIcon(Theme.whiteFill(), -1)); - btnRefresh.setOnAction(e -> skinnable.refresh()); - - vBox.getChildren().setAll(btnAdd); - - FXUtils.onChangeAndOperate(skinnable.refreshableProperty(), - refreshable -> { - if (refreshable) { - list.setPadding(new Insets(10, 10, 15 + 40 + 15 + 40 + 15, 10)); - vBox.getChildren().setAll(btnRefresh, btnAdd); - } else { - list.setPadding(new Insets(10, 10, 15 + 40 + 15, 10)); - vBox.getChildren().setAll(btnAdd); - } - }); - } - - // Keep a blank space to prevent buttons from blocking up mod items. - BorderPane group = new BorderPane(); - group.setPickOnBounds(false); - group.setBottom(vBox); - placeholder.minHeightProperty().bind(vBox.heightProperty()); - - contentPane.getChildren().setAll(scrollPane, group); - } - - spinnerPane.loadingProperty().bind(skinnable.loadingProperty()); - spinnerPane.setContent(contentPane); - - getChildren().setAll(spinnerPane); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java index dd0c6c896a..c049c76b51 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java @@ -31,7 +31,6 @@ import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Scene; -import javafx.scene.control.Label; import javafx.scene.control.*; import javafx.scene.input.KeyCode; import javafx.scene.input.MouseButton; @@ -40,14 +39,12 @@ import org.jackhuang.hmcl.game.GameDumpGenerator; import org.jackhuang.hmcl.game.Log; import org.jackhuang.hmcl.setting.StyleSheets; +import org.jackhuang.hmcl.theme.Themes; +import org.jackhuang.hmcl.ui.construct.MessageDialogPane; import org.jackhuang.hmcl.ui.construct.NoneMultipleSelectionModel; import org.jackhuang.hmcl.ui.construct.SpinnerPane; -import org.jackhuang.hmcl.util.Holder; -import org.jackhuang.hmcl.util.CircularArrayList; -import org.jackhuang.hmcl.util.Lang; -import org.jackhuang.hmcl.util.Log4jLevel; -import org.jackhuang.hmcl.util.platform.ManagedProcess; -import org.jackhuang.hmcl.util.platform.SystemUtils; +import org.jackhuang.hmcl.util.*; +import org.jackhuang.hmcl.util.platform.*; import java.io.IOException; import java.nio.file.Files; @@ -60,8 +57,8 @@ import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.util.Lang.thread; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** * @author huangyuhui @@ -89,6 +86,8 @@ public LogWindow(ManagedProcess gameProcess) { } public LogWindow(ManagedProcess gameProcess, CircularArrayList logs) { + Themes.applyNativeDarkMode(this); + this.logs = logs; this.impl = new LogWindowImpl(); setScene(new Scene(impl, 800, 480)); @@ -164,10 +163,12 @@ private final class LogWindowImpl extends Control { private final StringProperty[] buttonText = new StringProperty[LEVELS.length]; private final BooleanProperty[] showLevel = new BooleanProperty[LEVELS.length]; private final JFXComboBox cboLines = new JFXComboBox<>(); + private final StackPane stackPane = new StackPane(); LogWindowImpl() { getStyleClass().add("log-window"); + listView.getProperties().put("no-smooth-scrolling", true); listView.setItems(FXCollections.observableList(new CircularArrayList<>(logs.size()))); for (int i = 0; i < LEVELS.length; i++) { @@ -205,9 +206,8 @@ private void onExportLogs() { } Platform.runLater(() -> { - Alert alert = new Alert(Alert.AlertType.INFORMATION, i18n("settings.launcher.launcher_log.export.success", logFile)); - alert.setTitle(i18n("settings.launcher.launcher_log.export")); - alert.showAndWait(); + var dialog = new MessageDialogPane.Builder(i18n("settings.launcher.launcher_log.export.success", logFile), i18n("message.success"), MessageDialogPane.MessageType.SUCCESS).ok(null).build(); + DialogUtils.show(stackPane, dialog); }); FXUtils.showFileInExplorer(logFile); @@ -231,9 +231,8 @@ private void onExportDump(SpinnerPane pane) { LOG.warning("Failed to create minecraft jstack dump", e); Platform.runLater(() -> { - Alert alert = new Alert(Alert.AlertType.ERROR, i18n("logwindow.export_dump")); - alert.setTitle(i18n("message.error")); - alert.showAndWait(); + var dialog = new MessageDialogPane.Builder(i18n("logwindow.export_dump") + "\n" + StringUtils.getStackTrace(e), i18n("message.error"), MessageDialogPane.MessageType.ERROR).ok(null).build(); + DialogUtils.show(stackPane, dialog); }); } @@ -264,8 +263,9 @@ private static final class LogWindowSkin extends SkinBase { VBox vbox = new VBox(3); vbox.setPadding(new Insets(3, 0, 3, 0)); - vbox.setStyle("-fx-background-color: white"); - getChildren().setAll(vbox); + getSkinnable().stackPane.getChildren().setAll(vbox); + getChildren().setAll(getSkinnable().stackPane); + { BorderPane borderPane = new BorderPane(); @@ -286,8 +286,7 @@ private static final class LogWindowSkin extends SkinBase { HBox hBox = new HBox(3); for (int i = 0; i < LEVELS.length; i++) { ToggleButton button = new ToggleButton(); - button.setStyle("-fx-background-color: " + FXUtils.toWeb(LEVELS[i].getColor()) + ";"); - button.getStyleClass().add("log-toggle"); + button.getStyleClass().addAll("log-toggle", LEVELS[i].name().toLowerCase(Locale.ROOT)); button.textProperty().bind(control.buttonText[i]); button.setSelected(true); control.showLevel[i].bind(button.selectedProperty()); @@ -309,8 +308,7 @@ private static final class LogWindowSkin extends SkinBase { listView.setStyle("-fx-font-family: \"" + Lang.requireNonNullElse(config().getFontFamily(), FXUtils.DEFAULT_MONOSPACE_FONT) + "\"; -fx-font-size: " + config().getFontSize() + "px;"); - Holder lastCell = new Holder<>(); - listView.setCellFactory(x -> new ListCell() { + listView.setCellFactory(x -> new ListCell<>() { { x.setSelectionModel(new NoneMultipleSelectionModel<>()); getStyleClass().add("log-window-list-cell"); @@ -354,11 +352,6 @@ private static final class LogWindowSkin extends SkinBase { protected void updateItem(Log item, boolean empty) { super.updateItem(item, empty); - // https://mail.openjdk.org/pipermail/openjfx-dev/2022-July/034764.html - if (this == lastCell.value && !isVisible()) - return; - lastCell.value = this; - pseudoClassStateChanged(EMPTY, empty); pseudoClassStateChanged(FATAL, !empty && item.getLevel() == Log4jLevel.FATAL); pseudoClassStateChanged(ERROR, !empty && item.getLevel() == Log4jLevel.ERROR); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java index 48c968fd78..47cac09fc1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java @@ -50,7 +50,9 @@ public enum SVG { CHECKROOM("M3 20Q2.575 20 2.2875 19.7125T2 19Q2 18.75 2.1 18.5375T2.4 18.2L11 11.75V10Q11 9.575 11.3 9.2875T12.025 9Q12.65 9 13.075 8.55T13.5 7.475Q13.5 6.85 13.0625 6.425T12 6Q11.375 6 10.9375 6.4375T10.5 7.5H8.5Q8.5 6.05 9.525 5.025T12 4Q13.45 4 14.475 5.0125T15.5 7.475Q15.5 8.65 14.8125 9.575T13 10.85V11.75L21.6 18.2Q21.8 18.325 21.9 18.5375T22 19Q22 19.425 21.7125 19.7125T21 20H3ZM6 18H18L12 13.5 6 18Z"), CHECK_CIRCLE("M10.6 16.6 17.65 9.55 16.25 8.15 10.6 13.8 7.75 10.95 6.35 12.35 10.6 16.6ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"), CLOSE("M6.4 19 5 17.6 10.6 12 5 6.4 6.4 5 12 10.6 17.6 5 19 6.4 13.4 12 19 17.6 17.6 19 12 13.4 6.4 19Z"), + CONTENT_CUT("m12 14l-2.35 2.35q.2.375.275.8T10 18q0 1.65-1.175 2.825T6 22t-2.825-1.175T2 18t1.175-2.825T6 14q.425 0 .85.075t.8.275L10 12L7.65 9.65q-.375.2-.8.275T6 10q-1.65 0-2.825-1.175T2 6t1.175-2.825T6 2t2.825 1.175T10 6q0 .425-.075.85t-.275.8L20.6 18.6q.675.675.3 1.538T19.575 21q-.275 0-.537-.112t-.463-.313zm3-3l-2-2l5.575-5.575q.2-.2.463-.312T19.574 3q.95 0 1.313.875t-.313 1.55zM6 8q.825 0 1.413-.587T8 6t-.587-1.412T6 4t-1.412.588T4 6t.588 1.413T6 8m6 4.5q.2 0 .35-.15t.15-.35t-.15-.35t-.35-.15t-.35.15t-.15.35t.15.35t.35.15M6 20q.825 0 1.413-.587T8 18t-.587-1.412T6 16t-1.412.588T4 18t.588 1.413T6 20"), CONTENT_COPY("M9 18Q8.175 18 7.5875 17.4125T7 16V4Q7 3.175 7.5875 2.5875T9 2H18Q18.825 2 19.4125 2.5875T20 4V16Q20 16.825 19.4125 17.4125T18 18H9ZM9 16H18V4H9V16ZM5 22Q4.175 22 3.5875 21.4125T3 20V6H5V20H16V22H5ZM9 16V4 16Z"), + CONTENT_PASTE("M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h4.175q.275-.875 1.075-1.437T12 1q1 0 1.788.563T14.85 3H19q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm0-2h14V5h-2v2q0 .425-.288.713T16 8H8q-.425 0-.712-.288T7 7V5H5zm7-14q.425 0 .713-.288T13 4t-.288-.712T12 3t-.712.288T11 4t.288.713T12 5"), CREATE_NEW_FOLDER("M14 16h2V14h2V12H16V10H14v2H12v2h2v2ZM4 20q-.825 0-1.4125-.5875T2 18V6q0-.825.5875-1.4125T4 4h6l2 2h8q.825 0 1.4125.5875T22 8V18q0 .825-.5875 1.4125T20 20H4Zm0-2H20V8H11.175l-2-2H4V18ZV6 18Z"), DELETE("M7 21Q6.175 21 5.5875 20.4125T5 19V6H4V4H9V3H15V4H20V6H19V19Q19 19.825 18.4125 20.4125T17 21H7ZM17 6H7V19H17V6ZM9 17H11V8H9V17ZM13 17H15V8H13V17ZM7 6V19 6Z"), DELETE_FOREVER("M9.4 16.5 12 13.9 14.6 16.5 16 15.1 13.4 12.5 16 9.9 14.6 8.5 12 11.1 9.4 8.5 8 9.9 10.6 12.5 8 15.1 9.4 16.5ZM7 21Q6.175 21 5.5875 20.4125T5 19V6H4V4H9V3H15V4H20V6H19V19Q19 19.825 18.4125 20.4125T17 21H7ZM17 6H7V19H17V6ZM7 6V19 6Z"), @@ -102,6 +104,7 @@ public enum SVG { PERSON("M12 12Q10.35 12 9.175 10.825T8 8Q8 6.35 9.175 5.175T12 4Q13.65 4 14.825 5.175T16 8Q16 9.65 14.825 10.825T12 12ZM4 20V17.2Q4 16.35 4.4375 15.6375T5.6 14.55Q7.15 13.775 8.75 13.3875T12 13Q13.65 13 15.25 13.3875T18.4 14.55Q19.125 14.925 19.5625 15.6375T20 17.2V20H4ZM6 18H18V17.2Q18 16.925 17.8625 16.7T17.5 16.35Q16.15 15.675 14.775 15.3375T12 15Q10.6 15 9.225 15.3375T6.5 16.35Q6.275 16.475 6.1375 16.7T6 17.2V18ZM12 10Q12.825 10 13.4125 9.4125T14 8Q14 7.175 13.4125 6.5875T12 6Q11.175 6 10.5875 6.5875T10 8Q10 8.825 10.5875 9.4125T12 10ZM12 8ZM12 18Z"), PUBLIC("M12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM11 19.95V18Q10.175 18 9.5875 17.4125T9 16V15L4.2 10.2Q4.125 10.65 4.0625 11.1T4 12Q4 15.025 5.9875 17.3T11 19.95ZM17.9 17.4Q18.925 16.275 19.4625 14.8875T20 12Q20 9.55 18.6375 7.525T15 4.6V5Q15 5.825 14.4125 6.4125T13 7H11V9Q11 9.425 10.7125 9.7125T10 10H8V12H14Q14.425 12 14.7125 12.2875T15 13V16H16Q16.65 16 17.175 16.3875T17.9 17.4Z"), REFRESH("M12 20Q8.65 20 6.325 17.675T4 12Q4 8.65 6.325 6.325T12 4Q13.725 4 15.3 4.7125T18 6.75V4H20V11H13V9H17.2Q16.4 7.6 15.0125 6.8T12 6Q9.5 6 7.75 7.75T6 12Q6 14.5 7.75 16.25T12 18Q13.925 18 15.475 16.9T17.65 14H19.75Q19.05 16.65 16.9 18.325T12 20Z"), + REDO("M16.2 10H9.9q-1.575 0-2.738 1T6 13.5T7.163 16T9.9 17H16q.425 0 .713.288T17 18t-.288.713T16 19H9.9q-2.425 0-4.163-1.575T4 13.5t1.738-3.925T9.9 8h6.3l-1.9-1.9q-.275-.275-.275-.7t.275-.7t.7-.275t.7.275l3.6 3.6q.15.15.213.325t.062.375t-.062.375t-.213.325l-3.6 3.6q-.275.275-.7.275t-.7-.275t-.275-.7t.275-.7z"), RELEASE_CIRCLE("M9,7H13A2,2 0 0,1 15,9V11C15,11.84 14.5,12.55 13.76,12.85L15,17H13L11.8,13H11V17H9V7M11,9V11H13V9H11M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12C4,16.41 7.58,20 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4Z"), // Not Material RESTORE("M12 21Q8.55 21 5.9875 18.7125T3.05 13H5.1Q5.45 15.6 7.4125 17.3T12 19Q14.925 19 16.9625 16.9625T19 12Q19 9.075 16.9625 7.0375T12 5Q10.275 5 8.775 5.8T6.25 8H9V10H3V4H5V6.35Q6.275 4.75 8.1125 3.875T12 3Q13.875 3 15.5125 3.7125T18.3625 5.6375Q19.575 6.85 20.2875 8.4875T21 12Q21 13.875 20.2875 15.5125T18.3625 18.3625Q17.15 19.575 15.5125 20.2875T12 21Z"), // Not Material ROCKET_LAUNCH("M5.65 10.025 7.6 10.85Q7.95 10.15 8.325 9.5T9.15 8.2L7.75 7.925 5.65 10.025ZM9.2 12.1 12.05 14.925Q13.1 14.525 14.3 13.7T16.55 11.825Q18.3 10.075 19.2875 7.9375T20.15 4Q18.35 3.875 16.2 4.8625T12.3 7.6Q11.25 8.65 10.425 9.85T9.2 12.1ZM13.65 10.475Q13.075 9.9 13.075 9.0625T13.65 7.65Q14.225 7.075 15.075 7.075T16.5 7.65Q17.075 8.225 17.075 9.0625T16.5 10.475Q15.925 11.05 15.075 11.05T13.65 10.475ZM14.125 18.5 16.225 16.4 15.95 15Q15.3 15.45 14.65 15.8125T13.3 16.525L14.125 18.5ZM21.95 2.175Q22.425 5.2 21.3625 8.0625T17.7 13.525L18.2 16Q18.3 16.5 18.15 16.975T17.65 17.8L13.45 22 11.35 17.075 7.075 12.8 2.15 10.7 6.325 6.5Q6.675 6.15 7.1625 6T8.15 5.95L10.625 6.45Q13.225 3.85 16.075 2.775T21.95 2.175ZM3.925 15.975Q4.8 15.1 6.0625 15.0875T8.2 15.95Q9.075 16.825 9.0625 18.0875T8.175 20.225Q7.55 20.85 6.0875 21.3T2.05 22.1Q2.4 19.525 2.85 18.0625T3.925 15.975ZM5.35 17.375Q5.1 17.625 4.85 18.2875T4.5 19.625Q5.175 19.525 5.8375 19.2875T6.75 18.8Q7.05 18.5 7.075 18.075T6.8 17.35Q6.5 17.05 6.075 17.0625T5.35 17.375Z"), @@ -121,6 +124,7 @@ public enum SVG { TRIP("M4 21Q3.175 21 2.5875 20.4125T2 19V8Q2 7.175 2.5875 6.5875T4 6H8V4Q8 3.175 8.5875 2.5875T10 2H14Q14.825 2 15.4125 2.5875T16 4V6H20Q20.825 6 21.4125 6.5875T22 8V19Q22 19.825 21.4125 20.4125T20 21H4ZM10 6H14V4H10V6ZM6 8H4V19H6V8ZM16 19V8H8V19H16ZM18 8V19H20V8H18ZM12 13.5Z"), TUNE("M11 21V15H13V17H21V19H13V21H11ZM3 19V17H9V19H3ZM7 15V13H3V11H7V9H9V15H7ZM11 13V11H21V13H11ZM15 9V3H17V5H21V7H17V9H15ZM3 7V5H13V7H3Z"), UPDATE("M12 21Q10.125 21 8.4875 20.2875T5.6375 18.3625Q4.425 17.15 3.7125 15.5125T3 12Q3 10.125 3.7125 8.4875T5.6375 5.6375Q6.85 4.425 8.4875 3.7125T12 3Q14.05 3 15.8875 3.875T19 6.35V4H21V10H15V8H17.75Q16.725 6.6 15.225 5.8T12 5Q9.075 5 7.0375 7.0375T5 12Q5 14.925 7.0375 16.9625T12 19Q14.625 19 16.5875 17.3T18.9 13H20.95Q20.575 16.425 18.0125 18.7125T12 21ZM14.8 16.2 11 12.4V7H13V11.6L16.2 14.8 14.8 16.2Z"), + UNDO("M8 19q-.425 0-.712-.288T7 18t.288-.712T8 17h6.1q1.575 0 2.738-1T18 13.5T16.838 11T14.1 10H7.8l1.9 1.9q.275.275.275.7t-.275.7t-.7.275t-.7-.275L4.7 9.7q-.15-.15-.213-.325T4.426 9t.063-.375T4.7 8.3l3.6-3.6q.275-.275.7-.275t.7.275t.275.7t-.275.7L7.8 8h6.3q2.425 0 4.163 1.575T20 13.5t-1.737 3.925T14.1 19z"), VISIBILITY("M12 16q1.875 0 3.1875-1.3125T16.5 11.5 15.1875 8.3125 12 7 8.8125 8.3125 7.5 11.5t1.3125 3.1875T12 16Zm0-1.8q-1.125 0-1.9125-.7875T9.3 11.5t.7875-1.9125T12 8.8q1.125 0 1.9125.7875T14.7 11.5q0 1.125-.7875 1.9125T12 14.2ZM12 19q-3.65 0-6.65-2.0375T1 11.5Q2.35 8.075 5.35 6.0375T12 4q3.65 0 6.65 2.0375T23 11.5q-1.35 3.425-4.35 5.4625T12 19Zm0-7.5ZM12 17q2.825 0 5.1875-1.4875T20.8 11.5q-1.25-2.525-3.6125-4.0125T12 6 6.8125 7.4875 3.2 11.5q1.25 2.525 3.6125 4.0125T12 17Z"), VISIBILITY_OFF("M16.1 13.3l-1.45-1.45q.225-1.175-.675-2.2t-2.325-.8L10.2 7.4q.425-.2.8625-.3T12 7q1.875 0 3.1875 1.3125T16.5 11.5q0 .5-.1.9375t-.3.8625Zm3.2 3.15-1.45-1.4q.95-.725 1.6875-1.5875T20.8 11.5q-1.25-2.525-3.5875-4.0125T12 6q-.725 0-1.425.1T9.2 6.4L7.65 4.85q1.025-.425 2.1-.6375T12 4q3.775 0 6.725 2.0875T23 11.5q-.575 1.475-1.5125 2.7375T19.3 16.45Zm.5 6.15-4.2-4.15q-.875.275-1.7625.4125T12 19q-3.775 0-6.725-2.0875T1 11.5q.525-1.325 1.325-2.4625T4.15 7L1.4 4.2 2.8 2.8 21.2 21.2l-1.4 1.4ZM5.55 8.4q-.725.65-1.325 1.425T3.2 11.5q1.25 2.525 3.5875 4.0125T12 17q.5 0 .975-.0625T13.95 16.8l-.9-.95q-.275.075-.525.1125T12 16q-1.875 0-3.1875-1.3125T7.5 11.5q0-.275.0375-.525T7.65 10.45L5.55 8.4Zm7.975 2.325ZM9.75 12.6Z"), WARNING("M1 21 12 2 23 21H1ZM4.45 19H19.55L12 6 4.45 19ZM12 18Q12.425 18 12.7125 17.7125T13 17Q13 16.575 12.7125 16.2875T12 16Q11.575 16 11.2875 16.2875T11 17Q11 17.425 11.2875 17.7125T12 18ZM11 15H13V10H11V15ZM12 12.5Z"), @@ -153,24 +157,25 @@ private static Node createIcon(SVGPath path, double size) { return new Group(path); } - public Node createIcon(ObservableValue fill, double size) { + public Node createIcon(double size) { SVGPath p = new SVGPath(); - p.getStyleClass().add("svg"); p.setContent(path); - if (fill != null) - p.fillProperty().bind(fill); - + p.getStyleClass().add("svg"); return createIcon(p, size); } - public Node createIcon(Paint fill, double size) { + public Node createIcon() { SVGPath p = new SVGPath(); - p.getStyleClass().add("svg"); p.setContent(path); - if (fill != null) - p.fillProperty().set(fill); - - return createIcon(p, size); + p.getStyleClass().add("svg"); + return createIcon(p, -1); } + public Node createIcon(ObservableValue color) { + SVGPath p = new SVGPath(); + p.setContent(path); + p.getStyleClass().add("svg"); + p.fillProperty().bind(color); + return createIcon(p, -1); + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ScrollUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ScrollUtils.java index 72b2076dd2..2f52011b1b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ScrollUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ScrollUtils.java @@ -25,7 +25,6 @@ import javafx.animation.KeyFrame; import javafx.animation.Timeline; import javafx.event.EventHandler; -import javafx.scene.control.IndexedCell; import javafx.scene.control.ScrollPane; import javafx.scene.control.skin.VirtualFlow; import javafx.scene.input.MouseEvent; @@ -52,8 +51,10 @@ public int intDirection() { } } - private ScrollUtils() { - } + private static final double DEFAULT_SPEED = 1.0; + private static final double DEFAULT_TRACK_PAD_ADJUSTMENT = 7.0; + + private static final double CUTOFF_DELTA = 0.01; /** * Determines if the given ScrollEvent comes from a trackpad. @@ -68,16 +69,10 @@ private ScrollUtils() { * @see ScrollEvent#getDeltaY() */ public static boolean isTrackPad(ScrollEvent event, ScrollDirection scrollDirection) { - switch (scrollDirection) { - case UP: - case DOWN: - return Math.abs(event.getDeltaY()) < 10; - case LEFT: - case RIGHT: - return Math.abs(event.getDeltaX()) < 10; - default: - return false; - } + return switch (scrollDirection) { + case UP, DOWN -> Math.abs(event.getDeltaY()) < 10; + case LEFT, RIGHT -> Math.abs(event.getDeltaX()) < 10; + }; } /** @@ -117,7 +112,7 @@ public static ScrollDirection determineScrollDirection(ScrollEvent event) { * default speed value of 1. */ public static void addSmoothScrolling(ScrollPane scrollPane) { - addSmoothScrolling(scrollPane, 1); + addSmoothScrolling(scrollPane, DEFAULT_SPEED); } /** @@ -126,7 +121,7 @@ public static void addSmoothScrolling(ScrollPane scrollPane) { * with a default trackPadAdjustment of 7. */ public static void addSmoothScrolling(ScrollPane scrollPane, double speed) { - addSmoothScrolling(scrollPane, speed, 7); + addSmoothScrolling(scrollPane, speed, DEFAULT_TRACK_PAD_ADJUSTMENT); } /** @@ -143,12 +138,12 @@ public static void addSmoothScrolling(ScrollPane scrollPane, double speed, doubl /// @author Glavo public static void addSmoothScrolling(VirtualFlow virtualFlow) { - addSmoothScrolling(virtualFlow, 1); + addSmoothScrolling(virtualFlow, DEFAULT_SPEED); } /// @author Glavo public static void addSmoothScrolling(VirtualFlow virtualFlow, double speed) { - addSmoothScrolling(virtualFlow, speed, 7); + addSmoothScrolling(virtualFlow, speed, DEFAULT_TRACK_PAD_ADJUSTMENT); } /// @author Glavo @@ -180,16 +175,16 @@ private static void smoothScroll(ScrollPane scrollPane, double speed, double tra } }; if (scrollPane.getContent().getParent() != null) { - scrollPane.getContent().getParent().addEventHandler(MouseEvent.MOUSE_PRESSED, mouseHandler); + scrollPane.getContent().getParent().addEventFilter(MouseEvent.MOUSE_PRESSED, mouseHandler); scrollPane.getContent().getParent().addEventHandler(ScrollEvent.ANY, scrollHandler); } scrollPane.getContent().parentProperty().addListener((observable, oldValue, newValue) -> { if (oldValue != null) { - oldValue.removeEventHandler(MouseEvent.MOUSE_PRESSED, mouseHandler); + oldValue.removeEventFilter(MouseEvent.MOUSE_PRESSED, mouseHandler); oldValue.removeEventHandler(ScrollEvent.ANY, scrollHandler); } if (newValue != null) { - newValue.addEventHandler(MouseEvent.MOUSE_PRESSED, mouseHandler); + newValue.addEventFilter(MouseEvent.MOUSE_PRESSED, mouseHandler); newValue.addEventHandler(ScrollEvent.ANY, scrollHandler); } }); @@ -217,7 +212,7 @@ private static void smoothScroll(ScrollPane scrollPane, double speed, double tra break; } - if (Math.abs(dy) < 0.001) { + if (Math.abs(dy) < CUTOFF_DELTA) { timeline.stop(); } })); @@ -232,7 +227,6 @@ private static void smoothScroll(VirtualFlow virtualFlow, double speed, doubl final double[] derivatives = new double[FRICTIONS.length]; Timeline timeline = new Timeline(); - Holder scrollDirectionHolder = new Holder<>(); final EventHandler mouseHandler = event -> timeline.stop(); final EventHandler scrollHandler = event -> { if (event.getEventType() == ScrollEvent.SCROLL) { @@ -240,7 +234,6 @@ private static void smoothScroll(VirtualFlow virtualFlow, double speed, doubl if (scrollDirection == ScrollDirection.LEFT || scrollDirection == ScrollDirection.RIGHT) { return; } - scrollDirectionHolder.value = scrollDirection; double currentSpeed = isTrackPad(event, scrollDirection) ? speed / trackPadAdjustment : speed; derivatives[0] += scrollDirection.intDirection * currentSpeed; @@ -250,7 +243,7 @@ private static void smoothScroll(VirtualFlow virtualFlow, double speed, doubl event.consume(); } }; - virtualFlow.addEventHandler(MouseEvent.MOUSE_PRESSED, mouseHandler); + virtualFlow.addEventFilter(MouseEvent.MOUSE_PRESSED, mouseHandler); virtualFlow.addEventFilter(ScrollEvent.ANY, scrollHandler); timeline.getKeyFrames().add(new KeyFrame(DURATION, event -> { @@ -262,20 +255,15 @@ private static void smoothScroll(VirtualFlow virtualFlow, double speed, doubl } double dy = derivatives[derivatives.length - 1]; + virtualFlow.scrollPixels(dy); - int cellCount = virtualFlow.getCellCount(); - IndexedCell firstVisibleCell = virtualFlow.getFirstVisibleCell(); - double height = firstVisibleCell != null ? firstVisibleCell.getHeight() * cellCount : 0.0; - - double delta = height > 0.0 - ? dy / height - : (scrollDirectionHolder.value == ScrollDirection.DOWN ? 0.001 : -0.001); - virtualFlow.setPosition(Math.min(Math.max(virtualFlow.getPosition() + delta, 0), 1)); - - if (Math.abs(dy) < 0.001) { + if (Math.abs(dy) < CUTOFF_DELTA) { timeline.stop(); } })); timeline.setCycleCount(Animation.INDEFINITE); } + + private ScrollUtils() { + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ToolbarListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ToolbarListPageSkin.java index d2a36b7370..b96c99adec 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ToolbarListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ToolbarListPageSkin.java @@ -18,25 +18,26 @@ package org.jackhuang.hmcl.ui; import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXListView; import javafx.beans.binding.Bindings; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; -import javafx.scene.control.ScrollPane; +import javafx.scene.control.ListCell; import javafx.scene.control.SkinBase; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.StackPane; -import javafx.scene.layout.VBox; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.ui.construct.ComponentList; import org.jackhuang.hmcl.ui.construct.SpinnerPane; import java.util.List; -public abstract class ToolbarListPageSkin> extends SkinBase { +public abstract class ToolbarListPageSkin> extends SkinBase

{ - public ToolbarListPageSkin(T skinnable) { + protected final JFXListView listView; + + public ToolbarListPageSkin(P skinnable) { super(skinnable); SpinnerPane spinnerPane = new SpinnerPane(); @@ -59,18 +60,12 @@ public ToolbarListPageSkin(T skinnable) { } { - ScrollPane scrollPane = new ScrollPane(); - ComponentList.setVgrow(scrollPane, Priority.ALWAYS); - scrollPane.setFitToWidth(true); - - VBox content = new VBox(); - - Bindings.bindContent(content.getChildren(), skinnable.itemsProperty()); - - scrollPane.setContent(content); - FXUtils.smoothScrolling(scrollPane); - - root.getContent().add(scrollPane); + this.listView = new JFXListView<>(); + this.listView.setPadding(Insets.EMPTY); + this.listView.setCellFactory(listView -> createListCell((JFXListView) listView)); + ComponentList.setVgrow(listView, Priority.ALWAYS); + Bindings.bindContent(this.listView.getItems(), skinnable.itemsProperty()); + root.getContent().add(listView); } spinnerPane.setContent(root); @@ -85,37 +80,39 @@ public static Node wrap(Node node) { return stackPane; } - public static JFXButton createToolbarButton(String text, SVG svg, Runnable onClick) { - JFXButton ret = new JFXButton(); - ret.getStyleClass().add("jfx-tool-bar-button"); - ret.textFillProperty().bind(Theme.foregroundFillBinding()); - ret.setGraphic(wrap(svg.createIcon(Theme.foregroundFillBinding(), -1))); - ret.setText(text); - ret.setOnAction(e -> onClick.run()); - return ret; - } - public static JFXButton createToolbarButton2(String text, SVG svg, Runnable onClick) { JFXButton ret = new JFXButton(); ret.getStyleClass().add("jfx-tool-bar-button"); - ret.setGraphic(wrap(svg.createIcon(Theme.blackFill(), -1))); + ret.setGraphic(wrap(svg.createIcon())); ret.setText(text); - ret.setOnAction(e -> { - onClick.run(); - FXUtils.clearFocus(ret); - }); + ret.setOnAction(e -> onClick.run()); return ret; } public static JFXButton createDecoratorButton(String tooltip, SVG svg, Runnable onClick) { JFXButton ret = new JFXButton(); ret.getStyleClass().add("jfx-decorator-button"); - ret.textFillProperty().bind(Theme.foregroundFillBinding()); - ret.setGraphic(wrap(svg.createIcon(Theme.foregroundFillBinding(), -1))); + ret.setGraphic(wrap(svg.createIcon())); FXUtils.installFastTooltip(ret, tooltip); ret.setOnAction(e -> onClick.run()); return ret; } - protected abstract List initializeToolbar(T skinnable); + protected abstract List initializeToolbar(P skinnable); + + protected ListCell createListCell(JFXListView listView) { + return new ListCell<>() { + @Override + protected void updateItem(E item, boolean empty) { + super.updateItem(item, empty); + if (!empty && item instanceof Node node) { + setGraphic(node); + setText(null); + } else { + setGraphic(null); + setText(null); + } + } + }; + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/UpgradeDialog.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/UpgradeDialog.java index 09845c8c30..74c3b79e8b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/UpgradeDialog.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/UpgradeDialog.java @@ -19,8 +19,8 @@ import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXDialogLayout; +import com.jfoenix.controls.JFXSpinner; import javafx.scene.control.Label; -import javafx.scene.control.ProgressIndicator; import javafx.scene.control.ScrollPane; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.task.Schedulers; @@ -48,7 +48,7 @@ public UpgradeDialog(RemoteVersion remoteVersion, Runnable updateRunnable) { maxHeightProperty().bind(Controllers.getScene().heightProperty().multiply(0.7)); setHeading(new Label(i18n("update.changelog"))); - setBody(new ProgressIndicator()); + setBody(new JFXSpinner()); String url = CHANGELOG_URL + remoteVersion.getChannel().channelName + ".html"; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/WeakListenerHolder.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/WeakListenerHolder.java index 4a7032d38b..c045160743 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/WeakListenerHolder.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/WeakListenerHolder.java @@ -55,4 +55,8 @@ public void add(Object obj) { public boolean remove(Object obj) { return refs.remove(obj); } + + public void clear() { + refs.clear(); + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/WindowsNativeUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/WindowsNativeUtils.java new file mode 100644 index 0000000000..3de245ec52 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/WindowsNativeUtils.java @@ -0,0 +1,60 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui; + +import javafx.stage.Stage; +import javafx.stage.Window; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.util.OptionalLong; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// @author Glavo +public final class WindowsNativeUtils { + + public static OptionalLong getWindowHandle(Stage stage) { + try { + Class windowStageClass = Class.forName("com.sun.javafx.tk.quantum.WindowStage"); + Class glassWindowClass = Class.forName("com.sun.glass.ui.Window"); + Class tkStageClass = Class.forName("com.sun.javafx.tk.TKStage"); + + Object tkStage = MethodHandles.privateLookupIn(Window.class, MethodHandles.lookup()) + .findVirtual(Window.class, "getPeer", MethodType.methodType(tkStageClass)) + .invoke(stage); + + MethodHandles.Lookup windowStageLookup = MethodHandles.privateLookupIn(windowStageClass, MethodHandles.lookup()); + MethodHandle getPlatformWindow = windowStageLookup.findVirtual(windowStageClass, "getPlatformWindow", MethodType.methodType(glassWindowClass)); + Object platformWindow = getPlatformWindow.invoke(tkStage); + + long handle = (long) MethodHandles.privateLookupIn(glassWindowClass, MethodHandles.lookup()) + .findVirtual(glassWindowClass, "getNativeWindow", MethodType.methodType(long.class)) + .invoke(platformWindow); + + return OptionalLong.of(handle); + } catch (Throwable ex) { + LOG.warning("Failed to get window handle", ex); + return OptionalLong.empty(); + } + } + + private WindowsNativeUtils() { + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountAdvancedListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountAdvancedListItem.java index 8c9da5ec13..49b7fcf18e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountAdvancedListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountAdvancedListItem.java @@ -68,6 +68,10 @@ protected void invalidated() { }; public AccountAdvancedListItem() { + this(null); + } + + public AccountAdvancedListItem(Account account) { tooltip = new Tooltip(); FXUtils.installFastTooltip(this, tooltip); @@ -76,9 +80,13 @@ public AccountAdvancedListItem() { setActionButtonVisible(false); - FXUtils.onScroll(this, Accounts.getAccounts(), - accounts -> accounts.indexOf(account.get()), - Accounts::setSelectedAccount); + if (account != null) { + this.accountProperty().set(account); + } else { + FXUtils.onScroll(this, Accounts.getAccounts(), + accounts -> accounts.indexOf(accountProperty().get()), + Accounts::setSelectedAccount); + } } public ObjectProperty accountProperty() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItemSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItemSkin.java index 1ce5cd280f..5e07b2b22d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItemSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItemSkin.java @@ -36,7 +36,6 @@ import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount; import org.jackhuang.hmcl.game.TexturesLoader; import org.jackhuang.hmcl.setting.Accounts; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.Controllers; @@ -117,10 +116,10 @@ public AccountListItemSkin(AccountListItem skinnable) { }); btnMove.getStyleClass().add("toggle-icon4"); if (skinnable.getAccount().isPortable()) { - btnMove.setGraphic(SVG.PUBLIC.createIcon(Theme.blackFill(), -1)); + btnMove.setGraphic(SVG.PUBLIC.createIcon()); FXUtils.installFastTooltip(btnMove, i18n("account.move_to_global")); } else { - btnMove.setGraphic(SVG.OUTPUT.createIcon(Theme.blackFill(), -1)); + btnMove.setGraphic(SVG.OUTPUT.createIcon()); FXUtils.installFastTooltip(btnMove, i18n("account.move_to_portable")); } spinnerMove.setContent(btnMove); @@ -131,7 +130,7 @@ public AccountListItemSkin(AccountListItem skinnable) { spinnerRefresh.getStyleClass().setAll("small-spinner-pane"); if (skinnable.getAccount() instanceof MicrosoftAccount && Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) { btnRefresh.setDisable(true); - FXUtils.installFastTooltip(spinnerRefresh, i18n("account.methods.microsoft.snapshot")); + FXUtils.installFastTooltip(spinnerRefresh, i18n("account.methods.microsoft.snapshot.tooltip")); } btnRefresh.setOnAction(e -> { spinnerRefresh.showSpinner(); @@ -146,7 +145,7 @@ public AccountListItemSkin(AccountListItem skinnable) { .start(); }); btnRefresh.getStyleClass().add("toggle-icon4"); - btnRefresh.setGraphic(SVG.REFRESH.createIcon(Theme.blackFill(), -1)); + btnRefresh.setGraphic(SVG.REFRESH.createIcon()); FXUtils.installFastTooltip(btnRefresh, i18n("button.refresh")); spinnerRefresh.setContent(btnRefresh); right.getChildren().add(spinnerRefresh); @@ -163,7 +162,7 @@ public AccountListItemSkin(AccountListItem skinnable) { } }); btnUpload.getStyleClass().add("toggle-icon4"); - btnUpload.setGraphic(SVG.CHECKROOM.createIcon(Theme.blackFill(), -1)); + btnUpload.setGraphic(SVG.CHECKROOM.createIcon()); FXUtils.installFastTooltip(btnUpload, i18n("account.skin.upload")); btnUpload.disableProperty().bind(Bindings.not(skinnable.canUploadSkin())); spinnerUpload.setContent(btnUpload); @@ -175,7 +174,7 @@ public AccountListItemSkin(AccountListItem skinnable) { spinnerCopyUUID.getStyleClass().add("small-spinner-pane"); btnUpload.getStyleClass().add("toggle-icon4"); btnCopyUUID.setOnAction(e -> FXUtils.copyText(skinnable.getAccount().getUUID().toString())); - btnCopyUUID.setGraphic(SVG.CONTENT_COPY.createIcon(Theme.blackFill(), -1)); + btnCopyUUID.setGraphic(SVG.CONTENT_COPY.createIcon()); FXUtils.installFastTooltip(btnCopyUUID, i18n("account.copy_uuid")); spinnerCopyUUID.setContent(btnCopyUUID); right.getChildren().add(spinnerCopyUUID); @@ -184,7 +183,7 @@ public AccountListItemSkin(AccountListItem skinnable) { btnRemove.setOnAction(e -> Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), skinnable::remove, null)); btnRemove.getStyleClass().add("toggle-icon4"); BorderPane.setAlignment(btnRemove, Pos.CENTER); - btnRemove.setGraphic(SVG.DELETE.createIcon(Theme.blackFill(), -1)); + btnRemove.setGraphic(SVG.DELETE.createIcon()); FXUtils.installFastTooltip(btnRemove, i18n("button.delete")); right.getChildren().add(btnRemove); root.setRight(right); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java index c66eb26094..bb0210a37f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java @@ -39,7 +39,6 @@ import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.setting.Accounts; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; @@ -55,7 +54,7 @@ import java.util.Locale; import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig; -import static org.jackhuang.hmcl.ui.versions.VersionPage.wrap; +import static org.jackhuang.hmcl.ui.FXUtils.wrap; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.createSelectedItemPropertyFor; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -158,7 +157,7 @@ public AccountListPageSkin(AccountListPage skinnable) { e.consume(); }); btnRemove.getStyleClass().add("toggle-icon4"); - btnRemove.setGraphic(SVG.CLOSE.createIcon(Theme.blackFill(), 14)); + btnRemove.setGraphic(SVG.CLOSE.createIcon(14)); item.setRightGraphic(btnRemove); ObservableValue title = BindingMapping.of(server, AuthlibInjectorServer::getName); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AddAuthlibInjectorServerPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AddAuthlibInjectorServerPane.java index 84f94d5407..b2359fd871 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AddAuthlibInjectorServerPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AddAuthlibInjectorServerPane.java @@ -25,11 +25,10 @@ import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.animation.ContainerAnimations; import org.jackhuang.hmcl.ui.animation.TransitionPane; -import org.jackhuang.hmcl.ui.construct.DialogAware; -import org.jackhuang.hmcl.ui.construct.DialogCloseEvent; -import org.jackhuang.hmcl.ui.construct.SpinnerPane; +import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.util.Lang; import javax.net.ssl.SSLException; @@ -88,6 +87,10 @@ public AddAuthlibInjectorServerPane() { addServerPane.setBody(txtServerUrl); addServerPane.setActions(lblCreationWarning, actions); + + txtServerUrl.getValidators().addAll(new RequiredValidator(), new URLValidator()); + FXUtils.setValidateWhileTextChanged(txtServerUrl, true); + btnAddNext.disableProperty().bind(txtServerUrl.activeValidatorProperty().isNotNull()); } confirmServerPane = new JFXDialogLayout(); @@ -149,7 +152,6 @@ public AddAuthlibInjectorServerPane() { this.setContent(addServerPane, ContainerAnimations.NONE); lblCreationWarning.maxWidthProperty().bind(((FlowPane) lblCreationWarning.getParent()).widthProperty()); - btnAddNext.disableProperty().bind(txtServerUrl.textProperty().isEmpty()); nextPane.hideSpinner(); onEscPressed(this, this::onAddCancel); @@ -162,6 +164,12 @@ public void onDialogShown() { private String resolveFetchExceptionMessage(Throwable exception) { if (exception instanceof SSLException) { + if (exception.getMessage() != null && exception.getMessage().contains("Remote host terminated")) { + return i18n("account.failed.connect_injector_server"); + } + if (exception.getMessage() != null && (exception.getMessage().contains("No name matching") || exception.getMessage().contains("No subject alternative DNS name matching"))) { + return i18n("account.failed.dns"); + } return i18n("account.failed.ssl"); } else if (exception instanceof IOException) { return i18n("account.failed.connect_injector_server"); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java index e2c00c369e..98ddaf3981 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java @@ -35,8 +35,6 @@ import javafx.scene.control.Label; import javafx.scene.control.TextInputControl; import javafx.scene.layout.*; - -import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.auth.AccountFactory; import org.jackhuang.hmcl.auth.CharacterSelector; import org.jackhuang.hmcl.auth.NoSelectedCharacterException; @@ -50,7 +48,6 @@ import org.jackhuang.hmcl.game.OAuthServer; import org.jackhuang.hmcl.game.TexturesLoader; import org.jackhuang.hmcl.setting.Accounts; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.task.TaskExecutor; @@ -65,11 +62,7 @@ import org.jackhuang.hmcl.util.javafx.BindingMapping; import org.jetbrains.annotations.Nullable; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.UUID; +import java.util.*; import java.util.concurrent.CountDownLatch; import java.util.regex.Pattern; @@ -287,72 +280,95 @@ private void initDetailsPane() { btnAccept.disableProperty().unbind(); detailsContainer.getChildren().remove(detailsPane); lblErrorMessage.setText(""); + lblErrorMessage.setVisible(true); } + if (factory == Accounts.FACTORY_MICROSOFT) { VBox vbox = new VBox(8); - if (!Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) { - HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO); - FXUtils.onChangeAndOperate(deviceCode, deviceCode -> { - if (deviceCode != null) { - FXUtils.copyText(deviceCode.getUserCode()); - hintPane.setSegment(i18n("account.methods.microsoft.manual", deviceCode.getUserCode(), deviceCode.getVerificationUri())); - } else { - hintPane.setSegment(i18n("account.methods.microsoft.hint")); - } - }); - FXUtils.onClicked(hintPane, () -> { - if (deviceCode.get() != null) { - FXUtils.copyText(deviceCode.get().getUserCode()); - } - }); - - holder.add(Accounts.OAUTH_CALLBACK.onGrantDeviceCode.registerWeak(value -> { - runInFX(() -> deviceCode.set(value)); - })); - FlowPane box = new FlowPane(); - box.setHgap(8); - JFXHyperlink birthLink = new JFXHyperlink(i18n("account.methods.microsoft.birth")); - birthLink.setExternalLink("https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a"); - JFXHyperlink profileLink = new JFXHyperlink(i18n("account.methods.microsoft.profile")); - profileLink.setExternalLink("https://account.live.com/editprof.aspx"); - JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.microsoft.purchase")); - purchaseLink.setExternalLink(YggdrasilService.PURCHASE_URL); - JFXHyperlink deauthorizeLink = new JFXHyperlink(i18n("account.methods.microsoft.deauthorize")); - deauthorizeLink.setExternalLink("https://account.live.com/consent/Edit?client_id=000000004C794E0A"); - JFXHyperlink forgotpasswordLink = new JFXHyperlink(i18n("account.methods.forgot_password")); - forgotpasswordLink.setExternalLink("https://account.live.com/ResetPassword.aspx"); - JFXHyperlink createProfileLink = new JFXHyperlink(i18n("account.methods.microsoft.makegameidsettings")); - createProfileLink.setExternalLink("https://www.minecraft.net/msaprofile/mygames/editprofile"); - JFXHyperlink bannedQueryLink = new JFXHyperlink(i18n("account.methods.ban_query")); - bannedQueryLink.setExternalLink("https://enforcement.xbox.com/enforcement/showenforcementhistory"); - box.getChildren().setAll(profileLink, birthLink, purchaseLink, deauthorizeLink, forgotpasswordLink, createProfileLink, bannedQueryLink); - GridPane.setColumnSpan(box, 2); - - if (!IntegrityChecker.isOfficial()) { - HintPane unofficialHint = new HintPane(MessageDialogPane.MessageType.WARNING); - unofficialHint.setText(i18n("unofficial.hint")); - vbox.getChildren().add(unofficialHint); - } - - vbox.getChildren().addAll(hintPane, box); + detailsPane = vbox; - btnAccept.setDisable(false); - } else { + if (Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) { HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING); hintPane.setSegment(i18n("account.methods.microsoft.snapshot")); + vbox.getChildren().add(hintPane); + return; + } - JFXHyperlink officialWebsite = new JFXHyperlink(i18n("account.methods.microsoft.snapshot.website")); - officialWebsite.setExternalLink(Metadata.PUBLISH_URL); - - vbox.getChildren().setAll(hintPane, officialWebsite); - btnAccept.setDisable(true); + if (!IntegrityChecker.isOfficial()) { + HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING); + hintPane.setSegment(i18n("unofficial.hint")); + vbox.getChildren().add(hintPane); } - detailsPane = vbox; + VBox codeBox = new VBox(8); + Label hint = new Label(i18n("account.methods.microsoft.code")); + Label code = new Label(); + code.setMouseTransparent(true); + code.setStyle("-fx-font-size: 24"); + codeBox.getChildren().addAll(hint, code); + codeBox.setAlignment(Pos.CENTER); + vbox.getChildren().add(codeBox); + + HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO); + HintPane errHintPane = new HintPane(MessageDialogPane.MessageType.ERROR); + errHintPane.setVisible(false); + errHintPane.setManaged(false); + + codeBox.setVisible(false); + codeBox.setManaged(false); + + FXUtils.onChangeAndOperate(deviceCode, deviceCode -> { + if (deviceCode != null) { + FXUtils.copyText(deviceCode.getUserCode()); + code.setText(deviceCode.getUserCode()); + hintPane.setSegment(i18n("account.methods.microsoft.manual", deviceCode.getVerificationUri())); + codeBox.setVisible(true); + codeBox.setManaged(true); + } else { + hintPane.setSegment(i18n("account.methods.microsoft.hint")); + codeBox.setVisible(false); + codeBox.setManaged(false); + } + }); + + lblErrorMessage.setVisible(false); + lblErrorMessage.textProperty().addListener((obs, oldVal, newVal) -> { + boolean hasError = !newVal.isEmpty(); + errHintPane.setSegment(newVal); + errHintPane.setVisible(hasError); + errHintPane.setManaged(hasError); + hintPane.setVisible(!hasError); + hintPane.setManaged(!hasError); + codeBox.setVisible(!hasError && deviceCode.get() != null); + codeBox.setManaged(!hasError && deviceCode.get() != null); + }); + + FXUtils.onClicked(codeBox, () -> { + if (deviceCode.get() != null) FXUtils.copyText(deviceCode.get().getUserCode()); + }); + + holder.add(Accounts.OAUTH_CALLBACK.onGrantDeviceCode.registerWeak(value -> + runInFX(() -> deviceCode.set(value)) + )); + + HBox linkBox = new HBox(); + JFXHyperlink profileLink = new JFXHyperlink(i18n("account.methods.microsoft.profile")); + profileLink.setExternalLink("https://account.live.com/editprof.aspx"); + JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.microsoft.purchase")); + purchaseLink.setExternalLink(YggdrasilService.PURCHASE_URL); + JFXHyperlink deauthorizeLink = new JFXHyperlink(i18n("account.methods.microsoft.deauthorize")); + deauthorizeLink.setExternalLink("https://account.live.com/consent/Edit?client_id=000000004C794E0A"); + JFXHyperlink forgotpasswordLink = new JFXHyperlink(i18n("account.methods.forgot_password")); + forgotpasswordLink.setExternalLink("https://account.live.com/ResetPassword.aspx"); + linkBox.getChildren().setAll(profileLink, purchaseLink, deauthorizeLink, forgotpasswordLink); + + vbox.getChildren().addAll(hintPane, errHintPane, linkBox); + btnAccept.setDisable(false); } else { detailsPane = new AccountDetailsInputPane(factory, btnAccept::fire); btnAccept.disableProperty().bind(((AccountDetailsInputPane) detailsPane).validProperty().not()); } + detailsContainer.getChildren().add(detailsPane); } @@ -461,11 +477,14 @@ public AccountDetailsInputPane(AccountFactory factory, Runnable onAction) { onChangeAndOperate(cboServers.valueProperty(), server -> { this.server = server; linksContainer.getChildren().setAll(createHyperlinks(server)); + + if (txtUsername != null) + txtUsername.validate(); }); linksContainer.setMinWidth(USE_PREF_SIZE); JFXButton btnAddServer = new JFXButton(); - btnAddServer.setGraphic(SVG.ADD.createIcon(Theme.blackFill(), 20)); + btnAddServer.setGraphic(SVG.ADD.createIcon(20)); btnAddServer.getStyleClass().add("toggle-icon4"); btnAddServer.setOnAction(e -> { Controllers.dialog(new AddAuthlibInjectorServerPane()); @@ -591,6 +610,9 @@ private boolean requiresEmailAsUsername() { if ((factory instanceof AuthlibInjectorAccountFactory) && this.server != null) { return !server.isNonEmailLogin(); } + if (factory instanceof BoundAuthlibInjectorAccountFactory bound) { + return !bound.getServer().isNonEmailLogin(); + } return false; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OAuthAccountLoginDialog.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OAuthAccountLoginDialog.java index 36780c3d47..667d063853 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OAuthAccountLoginDialog.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OAuthAccountLoginDialog.java @@ -66,11 +66,11 @@ public OAuthAccountLoginDialog(OAuthAccount account, Consumer success, HBox box = new HBox(8); JFXHyperlink birthLink = new JFXHyperlink(i18n("account.methods.microsoft.birth")); - birthLink.setOnAction(e -> FXUtils.openLink("https://support.microsoft.com/account-billing/how-to-change-a-birth-date-on-a-microsoft-account-837badbc-999e-54d2-2617-d19206b9540a")); + birthLink.setExternalLink("https://support.microsoft.com/account-billing/how-to-change-a-birth-date-on-a-microsoft-account-837badbc-999e-54d2-2617-d19206b9540a"); JFXHyperlink profileLink = new JFXHyperlink(i18n("account.methods.microsoft.profile")); - profileLink.setOnAction(e -> FXUtils.openLink("https://account.live.com/editprof.aspx")); + profileLink.setExternalLink("https://account.live.com/editprof.aspx"); JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.microsoft.purchase")); - purchaseLink.setOnAction(e -> FXUtils.openLink(YggdrasilService.PURCHASE_URL)); + purchaseLink.setExternalLink(YggdrasilService.PURCHASE_URL); box.getChildren().setAll(profileLink, birthLink, purchaseLink); GridPane.setColumnSpan(box, 2); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OfflineAccountSkinPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OfflineAccountSkinPane.java index ce6bc7ce33..5317b3c422 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OfflineAccountSkinPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OfflineAccountSkinPane.java @@ -24,6 +24,7 @@ import javafx.application.Platform; import javafx.beans.InvalidationListener; import javafx.geometry.Insets; +import javafx.geometry.VPos; import javafx.scene.control.Label; import javafx.scene.input.DragEvent; import javafx.scene.input.TransferMode; @@ -159,7 +160,9 @@ public OfflineAccountSkinPane(OfflineAccount account) { FXUtils.onChangeAndOperate(skinItem.selectedDataProperty(), selectedData -> { GridPane gridPane = new GridPane(); - gridPane.setPadding(new Insets(0, 0, 0, 10)); + // Increase bottom padding to prevent the prompt from overlapping with the dialog action area + + gridPane.setPadding(new Insets(0, 0, 45, 10)); gridPane.setHgap(16); gridPane.setVgap(8); gridPane.getColumnConstraints().setAll(new ColumnConstraints(), FXUtils.getColumnHgrowing()); @@ -172,9 +175,23 @@ public OfflineAccountSkinPane(OfflineAccount account) { case LITTLE_SKIN: HintPane hint = new HintPane(MessageDialogPane.MessageType.INFO); hint.setText(i18n("account.skin.type.little_skin.hint")); + + // Spanning two columns and expanding horizontally + GridPane.setColumnSpan(hint, 2); + GridPane.setHgrow(hint, Priority.ALWAYS); + hint.setMaxWidth(Double.MAX_VALUE); + + // Force top alignment within cells (to avoid vertical offset caused by the baseline) + GridPane.setValignment(hint, VPos.TOP); + + // Set a fixed height as the preferred height to prevent the GridPane from stretching or leaving empty space. + hint.setMaxHeight(Region.USE_PREF_SIZE); + hint.setMinHeight(Region.USE_PREF_SIZE); + gridPane.addRow(0, hint); break; case LOCAL_FILE: + gridPane.setPadding(new Insets(0, 0, 0, 10)); gridPane.addRow(0, new Label(i18n("account.skin.model")), modelCombobox); gridPane.addRow(1, new Label(i18n("account.skin")), skinSelector); gridPane.addRow(2, new Label(i18n("account.cape")), capeSelector); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/TransitionPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/TransitionPane.java index 2cda33e6ac..10dae217f3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/TransitionPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/TransitionPane.java @@ -82,7 +82,9 @@ public void setContent(Node newView, AnimationProducer transition, duration, interpolator); newAnimation.setOnFinished(e -> { setMouseTransparent(false); - getChildren().remove(previousNode); + if (previousNode != currentNode) { + getChildren().remove(previousNode); + } if (cacheHint != null) { newView.setCache(false); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListBox.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListBox.java index cd48a22a0b..3a8249bc4d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListBox.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListBox.java @@ -26,12 +26,10 @@ import javafx.scene.layout.Pane; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; -import javafx.scene.paint.Paint; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.animation.ContainerAnimations; import org.jackhuang.hmcl.ui.animation.TransitionPane; -import org.jackhuang.hmcl.ui.versions.VersionPage; import java.util.function.Consumer; @@ -76,7 +74,7 @@ private AdvancedListItem createNavigationDrawerItem(String title, SVG leftGraphi item.setActionButtonVisible(false); item.setTitle(title); if (leftGraphic != null) { - item.setLeftGraphic(VersionPage.wrap(leftGraphic)); + item.setLeftGraphic(FXUtils.wrap(leftGraphic)); } return item; } @@ -109,8 +107,8 @@ public AdvancedListBox addNavigationDrawerTab(TabHeader tabHeader, TabControl.Ta item.activeProperty().bind(tabHeader.getSelectionModel().selectedItemProperty().isEqualTo(tab)); item.setOnAction(e -> tabHeader.select(tab)); - Node unselectedIcon = unselectedGraphic.createIcon((Paint) null, 20); - Node selectedIcon = selectedGraphic.createIcon((Paint) null, 20); + Node unselectedIcon = unselectedGraphic.createIcon(20); + Node selectedIcon = selectedGraphic.createIcon(20); TransitionPane leftGraphic = new TransitionPane(); leftGraphic.setAlignment(Pos.CENTER); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ClassTitle.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ClassTitle.java index c4e9bb3f81..7499e11933 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ClassTitle.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ClassTitle.java @@ -21,7 +21,6 @@ import javafx.scene.layout.BorderPane; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; -import javafx.scene.paint.Color; import javafx.scene.shape.Rectangle; import javafx.scene.text.Text; import org.jackhuang.hmcl.util.Lang; @@ -44,7 +43,6 @@ public ClassTitle(Node content) { Rectangle rectangle = new Rectangle(); rectangle.widthProperty().bind(vbox.widthProperty()); rectangle.setHeight(1.0); - rectangle.setFill(Color.GRAY); vbox.getChildren().add(rectangle); getChildren().setAll(vbox); getStyleClass().add("class-title"); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentListCell.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentListCell.java index bb8ce317f0..20503523cc 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentListCell.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentListCell.java @@ -19,8 +19,6 @@ import javafx.animation.*; import javafx.application.Platform; -import javafx.beans.property.BooleanProperty; -import javafx.beans.property.SimpleBooleanProperty; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; @@ -28,9 +26,8 @@ import javafx.scene.input.MouseButton; import javafx.scene.input.MouseEvent; import javafx.scene.layout.*; -import javafx.scene.shape.Rectangle; import javafx.util.Duration; -import org.jackhuang.hmcl.setting.Theme; +import org.jackhuang.hmcl.theme.Themes; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.animation.AnimationUtils; @@ -42,8 +39,7 @@ final class ComponentListCell extends StackPane { private final Node content; private Animation expandAnimation; - private Rectangle clipRect; - private final BooleanProperty expanded = new SimpleBooleanProperty(this, "expanded", false); + private boolean expanded = false; ComponentListCell(Node content) { this.content = content; @@ -51,25 +47,6 @@ final class ComponentListCell extends StackPane { updateLayout(); } - private void updateClip(double newHeight) { - if (clipRect != null) - clipRect.setHeight(newHeight); - } - - @Override - protected void layoutChildren() { - super.layoutChildren(); - - if (clipRect == null) - clipRect = new Rectangle(0, 0, getWidth(), getHeight()); - else { - clipRect.setX(0); - clipRect.setY(0); - clipRect.setHeight(getHeight()); - clipRect.setWidth(getWidth()); - } - } - private void updateLayout() { if (content instanceof ComponentList list) { content.getStyleClass().remove("options-list"); @@ -79,7 +56,7 @@ private void updateLayout() { VBox groupNode = new VBox(); - Node expandIcon = SVG.KEYBOARD_ARROW_DOWN.createIcon(Theme.blackFill(), 20); + Node expandIcon = SVG.KEYBOARD_ARROW_DOWN.createIcon(20); expandIcon.setMouseTransparent(true); HBox.setMargin(expandIcon, new Insets(0, 8, 0, 8)); @@ -99,12 +76,14 @@ private void updateLayout() { if (!overrideHeaderLeft) { Label label = new Label(); label.textProperty().bind(list.titleProperty()); + label.getStyleClass().add("title-label"); labelVBox.getChildren().add(label); if (list.isHasSubtitle()) { Label subtitleLabel = new Label(); subtitleLabel.textProperty().bind(list.subtitleProperty()); subtitleLabel.getStyleClass().add("subtitle-label"); + subtitleLabel.textFillProperty().bind(Themes.colorSchemeProperty().getOnSurfaceVariant()); labelVBox.getChildren().add(subtitleLabel); } } @@ -145,8 +124,8 @@ private void updateLayout() { expandAnimation.stop(); } - boolean expanded = !isExpanded(); - setExpanded(expanded); + boolean expanded = !this.expanded; + this.expanded = expanded; if (expanded) { list.doLazyInit(); list.layout(); @@ -155,14 +134,9 @@ private void updateLayout() { Platform.runLater(() -> { // FIXME: ComponentSubList without padding must have a 4 pixel padding for displaying a border radius. double newAnimatedHeight = (list.prefHeight(list.getWidth()) + (hasPadding ? 8 + 10 : 4)) * (expanded ? 1 : -1); - double newHeight = expanded ? getHeight() + newAnimatedHeight : prefHeight(list.getWidth()); double contentHeight = expanded ? newAnimatedHeight : 0; double targetRotate = expanded ? -180 : 0; - if (expanded) { - updateClip(newHeight); - } - if (AnimationUtils.isAnimationEnabled()) { double currentRotate = expandIcon.getRotate(); Duration duration = Motion.LONG2.multiply(Math.abs(currentRotate - targetRotate) / 180.0); @@ -175,19 +149,11 @@ private void updateLayout() { new KeyValue(expandIcon.rotateProperty(), targetRotate, interpolator)) ); - if (!expanded) { - expandAnimation.setOnFinished(e2 -> updateClip(newHeight)); - } - expandAnimation.play(); } else { container.setMinHeight(contentHeight); container.setMaxHeight(contentHeight); expandIcon.setRotate(targetRotate); - - if (!expanded) { - updateClip(newHeight); - } } }); }); @@ -198,16 +164,4 @@ private void updateLayout() { getChildren().setAll(content); } } - - public boolean isExpanded() { - return expanded.get(); - } - - public BooleanProperty expandedProperty() { - return expanded; - } - - public void setExpanded(boolean expanded) { - this.expanded.set(expanded); - } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileItem.java index ccf722f3b0..5cd9e74ea5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileItem.java @@ -23,12 +23,10 @@ import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.scene.control.Label; -import javafx.scene.control.Tooltip; import javafx.scene.layout.BorderPane; import javafx.scene.layout.VBox; import javafx.stage.DirectoryChooser; import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; @@ -46,7 +44,6 @@ public class FileItem extends BorderPane { private final SimpleStringProperty name = new SimpleStringProperty(this, "name"); private final SimpleStringProperty title = new SimpleStringProperty(this, "title"); - private final SimpleStringProperty tooltip = new SimpleStringProperty(this, "tooltip"); private final SimpleStringProperty path = new SimpleStringProperty(this, "path"); private final SimpleBooleanProperty convertToRelativePath = new SimpleBooleanProperty(this, "convertToRelativePath"); @@ -60,16 +57,12 @@ public FileItem() { setLeft(left); JFXButton right = new JFXButton(); - right.setGraphic(SVG.EDIT.createIcon(Theme.blackFill(), 16)); + right.setGraphic(SVG.EDIT.createIcon(16)); right.getStyleClass().add("toggle-icon4"); right.setOnAction(e -> onExplore()); FXUtils.installFastTooltip(right, i18n("button.edit")); setRight(right); - Tooltip tip = new Tooltip(); - tip.textProperty().bind(tooltipProperty()); - Tooltip.install(this, tip); - convertToRelativePath.addListener(onInvalidating(() -> path.set(processPath(path.get())))); } @@ -143,18 +136,6 @@ public void setTitle(String title) { this.title.set(title); } - public String getTooltip() { - return tooltip.get(); - } - - public StringProperty tooltipProperty() { - return tooltip; - } - - public void setTooltip(String tooltip) { - this.tooltip.set(tooltip); - } - public String getPath() { return path.get(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileSelector.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileSelector.java index 96cffb9a0f..a0dd9d53e4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileSelector.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileSelector.java @@ -27,7 +27,6 @@ import javafx.scene.layout.HBox; import javafx.stage.DirectoryChooser; import javafx.stage.FileChooser; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; @@ -82,7 +81,7 @@ public FileSelector() { FXUtils.bindString(customField, valueProperty()); JFXButton selectButton = new JFXButton(); - selectButton.setGraphic(SVG.FOLDER_OPEN.createIcon(Theme.blackFill(), 15)); + selectButton.setGraphic(SVG.FOLDER_OPEN.createIcon(15)); selectButton.setOnAction(e -> { if (directory) { DirectoryChooser chooser = new DirectoryChooser(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FloatListCell.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FloatListCell.java deleted file mode 100644 index dc4faf0626..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FloatListCell.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.ui.construct; - -import com.jfoenix.controls.JFXListView; -import com.jfoenix.effects.JFXDepthManager; -import javafx.css.PseudoClass; -import javafx.geometry.Insets; -import javafx.scene.Cursor; -import javafx.scene.control.ListCell; -import javafx.scene.layout.Region; -import javafx.scene.layout.StackPane; -import org.jackhuang.hmcl.ui.FXUtils; - -public abstract class FloatListCell extends ListCell { - private final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected"); - - protected final StackPane pane = new StackPane(); - - public FloatListCell(JFXListView listView) { - setText(null); - setGraphic(null); - - pane.getStyleClass().add("card"); - pane.setCursor(Cursor.HAND); - setPadding(new Insets(9, 9, 0, 9)); - JFXDepthManager.setDepth(pane, 1); - - FXUtils.onChangeAndOperate(selectedProperty(), selected -> { - pane.pseudoClassStateChanged(SELECTED, selected); - }); - - Region clippedContainer = (Region) listView.lookup(".clipped-container"); - setPrefWidth(0); - if (clippedContainer != null) { - maxWidthProperty().bind(clippedContainer.widthProperty()); - prefWidthProperty().bind(clippedContainer.widthProperty()); - minWidthProperty().bind(clippedContainer.widthProperty()); - } - } - - @Override - protected void updateItem(T item, boolean empty) { - super.updateItem(item, empty); - updateControl(item, empty); - if (empty) { - setGraphic(null); - } else { - setGraphic(pane); - } - } - - protected abstract void updateControl(T dataItem, boolean empty); -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FloatScrollBarSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FloatScrollBarSkin.java index c14b73c032..0cfe681d64 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FloatScrollBarSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FloatScrollBarSkin.java @@ -28,6 +28,8 @@ import javafx.scene.shape.Rectangle; import org.jackhuang.hmcl.util.Lang; +// Referenced in root.css +@SuppressWarnings("unused") public class FloatScrollBarSkin implements Skin { private ScrollBar scrollBar; private Region group; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/HintPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/HintPane.java index aa95e093f3..cfa318a3fa 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/HintPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/HintPane.java @@ -26,10 +26,8 @@ import javafx.scene.layout.VBox; import javafx.scene.text.Text; import javafx.scene.text.TextFlow; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.SVG; import java.util.Locale; @@ -46,29 +44,9 @@ public HintPane(MessageDialogPane.MessageType type) { setFillWidth(true); getStyleClass().addAll("hint", type.name().toLowerCase(Locale.ROOT)); - SVG svg; - switch (type) { - case INFO: - svg = SVG.INFO; - break; - case ERROR: - svg = SVG.ERROR; - break; - case SUCCESS: - svg = SVG.CHECK_CIRCLE; - break; - case WARNING: - svg = SVG.WARNING; - break; - case QUESTION: - svg = SVG.HELP; - break; - default: - throw new IllegalArgumentException("Unrecognized message box message type " + type); - } - - HBox hbox = new HBox(svg.createIcon(Theme.blackFill(), 16), new Text(type.getDisplayName())); + HBox hbox = new HBox(type.getIcon().createIcon(16), new Text(type.getDisplayName())); hbox.setAlignment(Pos.CENTER_LEFT); + hbox.setSpacing(2); flow.getChildren().setAll(label); getChildren().setAll(hbox, flow); label.textProperty().bind(text); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedMenuItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedMenuItem.java index 076e095e0b..bf2ac0a313 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedMenuItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedMenuItem.java @@ -18,14 +18,13 @@ package org.jackhuang.hmcl.ui.construct; import com.jfoenix.controls.JFXPopup; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; -public class IconedMenuItem extends IconedItem { +public final class IconedMenuItem extends IconedItem { public IconedMenuItem(SVG icon, String text, Runnable action, JFXPopup popup) { - super(icon != null ? FXUtils.limitingSize(icon.createIcon(Theme.blackFill(), 14), 14, 14) : null, text); + super(icon != null ? FXUtils.limitingSize(icon.createIcon(14), 14, 14) : null, text); getStyleClass().setAll("iconed-menu-item"); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedTwoLineListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedTwoLineListItem.java index 3687dfea7a..9cdcae0db9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedTwoLineListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedTwoLineListItem.java @@ -15,7 +15,6 @@ import javafx.scene.image.ImageView; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.util.StringUtils; @@ -111,7 +110,7 @@ public JFXButton getExternalLinkButton() { if (externalLinkButton == null) { externalLinkButton = new JFXButton(); externalLinkButton.getStyleClass().add("toggle-icon4"); - externalLinkButton.setGraphic(SVG.OPEN_IN_NEW.createIcon(Theme.blackFill(), -1)); + externalLinkButton.setGraphic(SVG.OPEN_IN_NEW.createIcon()); externalLinkButton.setOnAction(e -> FXUtils.openLink(externalLink.get())); } return externalLinkButton; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ImagePickerItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ImagePickerItem.java index c05764740d..203e6fc188 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ImagePickerItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ImagePickerItem.java @@ -32,7 +32,6 @@ import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; @@ -54,16 +53,17 @@ public ImagePickerItem() { imageView.setPreserveRatio(true); JFXButton selectButton = new JFXButton(); - selectButton.setGraphic(SVG.EDIT.createIcon(Theme.blackFill(), 20)); + selectButton.setGraphic(SVG.EDIT.createIcon(20)); selectButton.onActionProperty().bind(onSelectButtonClicked); selectButton.getStyleClass().add("toggle-icon4"); JFXButton deleteButton = new JFXButton(); - deleteButton.setGraphic(SVG.CLOSE.createIcon(Theme.blackFill(), 20)); + deleteButton.setGraphic(SVG.RESTORE.createIcon(20)); deleteButton.onActionProperty().bind(onDeleteButtonClicked); deleteButton.getStyleClass().add("toggle-icon4"); FXUtils.installFastTooltip(selectButton, i18n("button.edit")); + FXUtils.installFastTooltip(deleteButton, i18n("button.reset")); HBox hBox = new HBox(); hBox.getChildren().setAll(imageView, selectButton, deleteButton); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/InputDialogPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/InputDialogPane.java index d2c06b5d90..6926970136 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/InputDialogPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/InputDialogPane.java @@ -20,9 +20,11 @@ import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXDialogLayout; import com.jfoenix.controls.JFXTextField; +import com.jfoenix.validation.base.ValidatorBase; import javafx.scene.control.Label; import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; +import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.FutureCallback; import java.util.concurrent.CompletableFuture; @@ -30,12 +32,22 @@ import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; -public class InputDialogPane extends JFXDialogLayout { +public class InputDialogPane extends JFXDialogLayout implements DialogAware { private final CompletableFuture future = new CompletableFuture<>(); private final JFXTextField textField; private final Label lblCreationWarning; private final SpinnerPane acceptPane; + private final JFXButton acceptButton; + + public InputDialogPane(String text, String initialValue, FutureCallback onResult, ValidatorBase... validators) { + this(text, initialValue, onResult); + if (validators != null && validators.length > 0) { + textField.getValidators().addAll(validators); + FXUtils.setValidateWhileTextChanged(textField, true); + acceptButton.disableProperty().bind(textField.activeValidatorProperty().isNotNull()); + } + } public InputDialogPane(String text, String initialValue, FutureCallback onResult) { textField = new JFXTextField(initialValue); @@ -47,7 +59,7 @@ public InputDialogPane(String text, String initialValue, FutureCallback acceptPane = new SpinnerPane(); acceptPane.getStyleClass().add("small-spinner-pane"); - JFXButton acceptButton = new JFXButton(i18n("button.ok")); + acceptButton = new JFXButton(i18n("button.ok")); acceptButton.getStyleClass().add("dialog-accept"); acceptPane.setContent(acceptButton); @@ -60,19 +72,30 @@ public InputDialogPane(String text, String initialValue, FutureCallback acceptButton.setOnAction(e -> { acceptPane.showSpinner(); - onResult.call(textField.getText(), () -> { - acceptPane.hideSpinner(); - future.complete(textField.getText()); - fireEvent(new DialogCloseEvent()); - }, msg -> { - acceptPane.hideSpinner(); - lblCreationWarning.setText(msg); + onResult.call(textField.getText(), new FutureCallback.ResultHandler() { + @Override + public void resolve() { + acceptPane.hideSpinner(); + future.complete(textField.getText()); + fireEvent(new DialogCloseEvent()); + } + + @Override + public void reject(String reason) { + acceptPane.hideSpinner(); + lblCreationWarning.setText(reason); + } }); }); - + textField.setOnAction(event -> acceptButton.fire()); onEscPressed(this, cancelButton::fire); } + @Override + public void onDialogShown() { + textField.requestFocus(); + } + public CompletableFuture getCompletableFuture() { return future; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/JFXCheckBoxTableCell.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/JFXCheckBoxTableCell.java new file mode 100644 index 0000000000..8473c6e7e3 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/JFXCheckBoxTableCell.java @@ -0,0 +1,68 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.construct; + +import com.jfoenix.controls.JFXCheckBox; +import javafx.beans.binding.Bindings; +import javafx.beans.property.BooleanProperty; +import javafx.scene.control.TableCell; +import javafx.scene.control.TableColumn; +import javafx.util.Callback; + +/// @author Glavo +public final class JFXCheckBoxTableCell extends TableCell { + public static Callback, TableCell> forTableColumn( + final TableColumn column) { + return list -> new JFXCheckBoxTableCell<>(); + } + + private final JFXCheckBox checkBox = new JFXCheckBox(); + private BooleanProperty booleanProperty; + + public JFXCheckBoxTableCell() { + this.getStyleClass().add("jfx-checkbox-table-cell"); + } + + @Override + protected void updateItem(T item, boolean empty) { + super.updateItem(item, empty); + + if (empty) { + setText(null); + setGraphic(null); + checkBox.disableProperty().unbind(); + } else { + setGraphic(checkBox); + + if (booleanProperty != null) { + checkBox.selectedProperty().unbindBidirectional(booleanProperty); + } + if (getTableColumn().getCellObservableValue(getIndex()) instanceof BooleanProperty obsValue) { + booleanProperty = obsValue; + checkBox.selectedProperty().bindBidirectional(booleanProperty); + } + + checkBox.disableProperty().bind(Bindings.not( + getTableView().editableProperty().and( + getTableColumn().editableProperty()).and( + editableProperty()) + )); + } + } + +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/JFXHyperlink.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/JFXHyperlink.java index 0fefaff464..d67b4422e7 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/JFXHyperlink.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/JFXHyperlink.java @@ -18,7 +18,6 @@ package org.jackhuang.hmcl.ui.construct; import javafx.scene.control.Hyperlink; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; @@ -27,7 +26,7 @@ public final class JFXHyperlink extends Hyperlink { public JFXHyperlink(String text) { super(text); - setGraphic(SVG.OPEN_IN_NEW.createIcon(Theme.blackFill(), 16)); + setGraphic(SVG.OPEN_IN_NEW.createIcon(16)); } public void setExternalLink(String externalLink) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MDListCell.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MDListCell.java index 156e62d12b..62fbf277a3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MDListCell.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MDListCell.java @@ -24,17 +24,14 @@ import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.util.Holder; public abstract class MDListCell extends ListCell { private final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected"); private final StackPane container = new StackPane(); private final StackPane root = new StackPane(); - private final Holder lastCell; - public MDListCell(JFXListView listView, Holder lastCell) { - this.lastCell = lastCell; + public MDListCell(JFXListView listView) { setText(null); setGraphic(null); @@ -58,13 +55,6 @@ public MDListCell(JFXListView listView, Holder lastCell) { protected void updateItem(T item, boolean empty) { super.updateItem(item, empty); - // https://mail.openjdk.org/pipermail/openjfx-dev/2022-July/034764.html - if (lastCell != null) { - if (this == lastCell.value && !isVisible()) - return; - lastCell.value = this; - } - updateControl(item, empty); if (empty) { setGraphic(null); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MenuUpDownButton.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MenuUpDownButton.java index f603769e31..f6bbcc5d25 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MenuUpDownButton.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MenuUpDownButton.java @@ -29,7 +29,6 @@ import javafx.scene.control.Skin; import javafx.scene.control.SkinBase; import javafx.scene.layout.HBox; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; @@ -39,6 +38,7 @@ public class MenuUpDownButton extends Control { private final StringProperty text = new SimpleStringProperty(this, "text"); public MenuUpDownButton() { + this.getStyleClass().add("menu-up-down-button"); } @Override @@ -78,11 +78,10 @@ protected MenuUpDownButtonSkin(MenuUpDownButton control) { HBox content = new HBox(8); content.setAlignment(Pos.CENTER); Label label = new Label(); - label.setStyle("-fx-text-fill: black;"); label.textProperty().bind(control.text); - Node up = SVG.ARROW_DROP_UP.createIcon(Theme.blackFill(), 16); - Node down = SVG.ARROW_DROP_DOWN.createIcon(Theme.blackFill(), 16); + Node up = SVG.ARROW_DROP_UP.createIcon(16); + Node down = SVG.ARROW_DROP_DOWN.createIcon(16); JFXButton button = new JFXButton(); button.setGraphic(content); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MessageDialogPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MessageDialogPane.java index fe20460db0..3cf2f71351 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MessageDialogPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MessageDialogPane.java @@ -28,7 +28,6 @@ import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import javafx.scene.text.TextFlow; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; @@ -47,11 +46,21 @@ public final class MessageDialogPane extends HBox { public enum MessageType { - ERROR, - INFO, - WARNING, - QUESTION, - SUCCESS; + ERROR(SVG.ERROR), + INFO(SVG.INFO), + WARNING(SVG.WARNING), + QUESTION(SVG.HELP), + SUCCESS(SVG.CHECK_CIRCLE); + + private final SVG icon; + + MessageType(SVG icon) { + this.icon = icon; + } + + public SVG getIcon() { + return icon; + } public String getDisplayName() { return i18n("message." + name().toLowerCase(Locale.ROOT)); @@ -71,34 +80,14 @@ public MessageDialogPane(@NotNull String text, @Nullable String title, @NotNull graphic.setTranslateY(10); graphic.setMinSize(40, 40); graphic.setMaxSize(40, 40); - SVG svg; - switch (type) { - case INFO: - svg = SVG.INFO; - break; - case ERROR: - svg = SVG.ERROR; - break; - case SUCCESS: - svg = SVG.CHECK_CIRCLE; - break; - case WARNING: - svg = SVG.WARNING; - break; - case QUESTION: - svg = SVG.HELP; - break; - default: - throw new IllegalArgumentException("Unrecognized message box message type " + type); - } - graphic.setGraphic(svg.createIcon(Theme.blackFill(), 40)); + graphic.setGraphic(type.getIcon().createIcon(40)); VBox vbox = new VBox(); HBox.setHgrow(vbox, Priority.ALWAYS); { StackPane titlePane = new StackPane(); titlePane.getStyleClass().addAll("jfx-layout-heading", "title"); - titlePane.getChildren().setAll(new Label(title != null ? title : i18n("message.info"))); + titlePane.getChildren().setAll(new Label(title != null ? title : type.getDisplayName())); StackPane content = new StackPane(); content.getStyleClass().add("jfx-layout-body"); @@ -201,6 +190,7 @@ public Builder addCancel(@Nullable Runnable cancel) { public Builder addCancel(String cancelText, @Nullable Runnable cancel) { JFXButton btnCancel = new JFXButton(cancelText); + btnCancel.setButtonType(JFXButton.ButtonType.FLAT); btnCancel.getStyleClass().add("dialog-cancel"); if (cancel != null) { btnCancel.setOnAction(e -> cancel.run()); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MultiFileItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MultiFileItem.java index d205027274..5f89da798b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MultiFileItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MultiFileItem.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.ui.construct; +import com.jfoenix.controls.JFXColorPicker; import com.jfoenix.controls.JFXRadioButton; import com.jfoenix.controls.JFXTextField; import com.jfoenix.validation.base.ValidatorBase; @@ -30,12 +31,15 @@ import javafx.scene.control.ToggleGroup; import javafx.scene.layout.BorderPane; import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; import javafx.scene.paint.Paint; import javafx.stage.FileChooser; +import org.jackhuang.hmcl.theme.ThemeColor; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.StringUtils; import java.util.Collection; +import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.function.Consumer; @@ -177,6 +181,7 @@ protected Node createItem(ToggleGroup group) { center.setWrapText(true); center.getStyleClass().add("subtitle-label"); center.setStyle("-fx-font-size: 10;"); + center.setPadding(new Insets(0, 0, 0, 15)); pane.setCenter(center); } @@ -301,17 +306,27 @@ protected Node createItem(ToggleGroup group) { } public static final class PaintOption extends Option { - private final ColorPicker colorPicker = new ColorPicker(); + private final ColorPicker colorPicker = new JFXColorPicker(); public PaintOption(String title, T data) { super(title, data); } + public PaintOption setCustomColors(List colors) { + colorPicker.getCustomColors().setAll(colors); + return this; + } + public PaintOption bindBidirectional(Property property) { FXUtils.bindPaint(colorPicker, property); return this; } + public PaintOption bindThemeColorBidirectional(Property property) { + ThemeColor.bindBidirectional(colorPicker, property); + return this; + } + @Override protected Node createItem(ToggleGroup group) { BorderPane pane = new BorderPane(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/OptionToggleButton.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/OptionToggleButton.java index 66ccb26042..af46845345 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/OptionToggleButton.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/OptionToggleButton.java @@ -25,8 +25,6 @@ import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.Label; -import javafx.scene.input.MouseButton; -import javafx.scene.input.MouseEvent; import javafx.scene.layout.BorderPane; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; @@ -51,24 +49,22 @@ public OptionToggleButton() { Label titleLabel = new Label(); titleLabel.textProperty().bind(title); Label subtitleLabel = new Label(); - subtitleLabel.setMouseTransparent(true); subtitleLabel.setWrapText(true); + subtitleLabel.setMouseTransparent(true); + subtitleLabel.getStyleClass().add("subtitle"); subtitleLabel.textProperty().bind(subtitle); pane.setCenter(left); left.setAlignment(Pos.CENTER_LEFT); JFXToggleButton toggleButton = new JFXToggleButton(); - pane.setRight(toggleButton); + StackPane right = new StackPane(toggleButton); + right.setAlignment(Pos.CENTER); + pane.setRight(right); toggleButton.selectedProperty().bindBidirectional(selected); toggleButton.setSize(8); FXUtils.setLimitHeight(toggleButton, 30); - container.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> { - if (e.getButton() == MouseButton.PRIMARY) { - toggleButton.setSelected(!toggleButton.isSelected()); - e.consume(); - } - }); + FXUtils.onClicked(container, () -> toggleButton.setSelected(!toggleButton.isSelected())); FXUtils.onChangeAndOperate(subtitleProperty(), subtitle -> { if (StringUtils.isNotBlank(subtitle)) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PopupMenu.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PopupMenu.java index 027ffa7ef1..bf8e9dfce4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PopupMenu.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PopupMenu.java @@ -78,6 +78,8 @@ protected PopupMenuSkin() { ScrollPane scrollPane = new ScrollPane(); scrollPane.setFitToHeight(true); scrollPane.setFitToWidth(true); + scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); scrollPane.vbarPolicyProperty().bind(new When(alwaysShowingVBar) .then(ScrollPane.ScrollBarPolicy.ALWAYS) .otherwise(ScrollPane.ScrollBarPolicy.AS_NEEDED)); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PromptDialogPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PromptDialogPane.java index b239163212..25c700e668 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PromptDialogPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PromptDialogPane.java @@ -117,11 +117,17 @@ public PromptDialogPane(Builder builder) { protected void onAccept() { setLoading(); - builder.callback.call(builder.questions, () -> { - future.complete(builder.questions); - runInFX(this::onSuccess); - }, msg -> { - runInFX(() -> onFailure(msg)); + builder.callback.call(builder.questions, new FutureCallback.ResultHandler() { + @Override + public void resolve() { + future.complete(builder.questions); + runInFX(() -> onSuccess()); + } + + @Override + public void reject(String reason) { + runInFX(() -> onFailure(reason)); + } }); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/RipplerContainer.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/RipplerContainer.java index fb93b0801f..90016dc59b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/RipplerContainer.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/RipplerContainer.java @@ -38,6 +38,7 @@ import javafx.scene.paint.Paint; import javafx.scene.shape.Rectangle; import javafx.util.Duration; +import org.jackhuang.hmcl.theme.Themes; import org.jackhuang.hmcl.ui.animation.AnimationUtils; import org.jackhuang.hmcl.ui.animation.Motion; import org.jackhuang.hmcl.util.Lang; @@ -50,7 +51,7 @@ public class RipplerContainer extends StackPane { private static final Duration DURATION = Duration.millis(200); private final ObjectProperty container = new SimpleObjectProperty<>(this, "container", null); - private final StyleableObjectProperty ripplerFill = new SimpleStyleableObjectProperty<>(StyleableProperties.RIPPLER_FILL,this, "ripplerFill", null); + private final StyleableObjectProperty ripplerFill = new SimpleStyleableObjectProperty<>(StyleableProperties.RIPPLER_FILL, this, "ripplerFill", null); private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected", false); private final StackPane buttonContainer = new StackPane(); @@ -136,16 +137,27 @@ protected void interpolate(double frac) { } private void interpolateBackground(double frac) { - setBackground(new Background(new BackgroundFill(Color.rgb(0, 0, 0, frac * 0.04), CornerRadii.EMPTY, Insets.EMPTY))); + Color onSurface = Themes.getColorScheme().getOnSurface(); + setBackground(new Background(new BackgroundFill( + Color.color(onSurface.getRed(), onSurface.getGreen(), onSurface.getBlue(), frac * 0.04), + CornerRadii.EMPTY, Insets.EMPTY))); } protected void updateChildren() { - getChildren().addAll(buttonContainer, getContainer()); + if (buttonRippler.getPosition() == JFXRippler.RipplerPos.BACK) + getChildren().setAll(buttonContainer, getContainer()); + else + getChildren().setAll(getContainer(), buttonContainer); for (int i = 1; i < getChildren().size(); ++i) getChildren().get(i).setPickOnBounds(false); } + public void setPosition(JFXRippler.RipplerPos pos) { + buttonRippler.setPosition(pos); + updateChildren(); + } + public Node getContainer() { return container.get(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TaskExecutorDialogPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TaskExecutorDialogPane.java index b3031f80d8..cf4bd06c7b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TaskExecutorDialogPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TaskExecutorDialogPane.java @@ -50,6 +50,8 @@ public class TaskExecutorDialogPane extends BorderPane { private final TaskListPane taskListPane; public TaskExecutorDialogPane(@NotNull TaskCancellationAction cancel) { + this.getStyleClass().add("task-executor-dialog-layout"); + FXUtils.setLimitWidth(this, 500); FXUtils.setLimitHeight(this, 300); @@ -74,6 +76,7 @@ public TaskExecutorDialogPane(@NotNull TaskCancellationAction cancel) { bottom.setLeft(lblProgress); btnCancel = new JFXButton(i18n("button.cancel")); + btnCancel.getStyleClass().add("dialog-cancel"); bottom.setRight(btnCancel); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TaskListPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TaskListPane.java index 6a0d930acf..0eb62cbed8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TaskListPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TaskListPane.java @@ -41,6 +41,7 @@ import org.jackhuang.hmcl.download.game.GameAssetDownloadTask; import org.jackhuang.hmcl.download.game.GameInstallTask; import org.jackhuang.hmcl.download.java.mojang.MojangJavaDownloadTask; +import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricInstallTask; import org.jackhuang.hmcl.download.liteloader.LiteLoaderInstallTask; import org.jackhuang.hmcl.download.neoforge.NeoForgeInstallTask; import org.jackhuang.hmcl.download.neoforge.NeoForgeOldInstallTask; @@ -64,7 +65,6 @@ import org.jackhuang.hmcl.mod.server.ServerModpackCompletionTask; import org.jackhuang.hmcl.mod.server.ServerModpackExportTask; import org.jackhuang.hmcl.mod.server.ServerModpackLocalInstallTask; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.task.TaskExecutor; import org.jackhuang.hmcl.task.TaskListener; @@ -93,8 +93,6 @@ public final class TaskListPane extends StackPane { private final ObjectProperty progressNodePadding = new SimpleObjectProperty<>(Insets.EMPTY); private final DoubleProperty cellWidth = new SimpleDoubleProperty(); - private Cell lastCell; - public TaskListPane() { listView.setPadding(new Insets(12, 0, 0, 0)); listView.setCellFactory(l -> new Cell()); @@ -167,6 +165,8 @@ public void onRunning(Task task) { task.setName(i18n("install.installer.install", i18n("install.installer.game"))); } else if (task instanceof CleanroomInstallTask) { task.setName(i18n("install.installer.install", i18n("install.installer.cleanroom"))); + } else if (task instanceof LegacyFabricInstallTask) { + task.setName(i18n("install.installer.install", i18n("install.installer.legacyfabric"))); } else if (task instanceof ForgeNewInstallTask || task instanceof ForgeOldInstallTask) { task.setName(i18n("install.installer.install", i18n("install.installer.forge"))); } else if (task instanceof NeoForgeInstallTask || task instanceof NeoForgeOldInstallTask) { @@ -310,18 +310,13 @@ private Cell() { } private void updateLeftIcon(StageNode.Status status) { - left.getChildren().setAll(status.svg.createIcon(Theme.blackFill(), STATUS_ICON_SIZE)); + left.getChildren().setAll(status.svg.createIcon(STATUS_ICON_SIZE)); } @Override protected void updateItem(Node item, boolean empty) { super.updateItem(item, empty); - // https://mail.openjdk.org/pipermail/openjfx-dev/2022-July/034764.html - if (this == lastCell && !isVisible()) - return; - lastCell = this; - pane.paddingProperty().unbind(); title.textProperty().unbind(); message.textProperty().unbind(); @@ -435,23 +430,25 @@ private StageNode(String stage) { // CHECKSTYLE:OFF // @formatter:off - switch (stageKey) { - case "hmcl.modpack": message = i18n("install.modpack"); break; - case "hmcl.modpack.download": message = i18n("launch.state.modpack"); break; - case "hmcl.install.assets": message = i18n("assets.download"); break; - case "hmcl.install.libraries": message = i18n("libraries.download"); break; - case "hmcl.install.game": message = i18n("install.installer.install", i18n("install.installer.game") + " " + stageValue); break; - case "hmcl.install.forge": message = i18n("install.installer.install", i18n("install.installer.forge") + " " + stageValue); break; - case "hmcl.install.cleanroom": message = i18n("install.installer.install", i18n("install.installer.cleanroom") + " " + stageValue); break; - case "hmcl.install.neoforge": message = i18n("install.installer.install", i18n("install.installer.neoforge") + " " + stageValue); break; - case "hmcl.install.liteloader": message = i18n("install.installer.install", i18n("install.installer.liteloader") + " " + stageValue); break; - case "hmcl.install.optifine": message = i18n("install.installer.install", i18n("install.installer.optifine") + " " + stageValue); break; - case "hmcl.install.fabric": message = i18n("install.installer.install", i18n("install.installer.fabric") + " " + stageValue); break; - case "hmcl.install.fabric-api": message = i18n("install.installer.install", i18n("install.installer.fabric-api") + " " + stageValue); break; - case "hmcl.install.quilt": message = i18n("install.installer.install", i18n("install.installer.quilt") + " " + stageValue); break; - case "hmcl.install.quilt-api": message = i18n("install.installer.install", i18n("install.installer.quilt-api") + " " + stageValue); break; - default: message = i18n(stageKey); break; - } + message = switch (stageKey) { + case "hmcl.modpack" -> i18n("install.modpack"); + case "hmcl.modpack.download" -> i18n("launch.state.modpack"); + case "hmcl.install.assets" -> i18n("assets.download"); + case "hmcl.install.libraries" -> i18n("libraries.download"); + case "hmcl.install.game" -> i18n("install.installer.install", i18n("install.installer.game") + " " + stageValue); + case "hmcl.install.forge" -> i18n("install.installer.install", i18n("install.installer.forge") + " " + stageValue); + case "hmcl.install.cleanroom" -> i18n("install.installer.install", i18n("install.installer.cleanroom") + " " + stageValue); + case "hmcl.install.neoforge" -> i18n("install.installer.install", i18n("install.installer.neoforge") + " " + stageValue); + case "hmcl.install.liteloader" -> i18n("install.installer.install", i18n("install.installer.liteloader") + " " + stageValue); + case "hmcl.install.optifine" -> i18n("install.installer.install", i18n("install.installer.optifine") + " " + stageValue); + case "hmcl.install.fabric" -> i18n("install.installer.install", i18n("install.installer.fabric") + " " + stageValue); + case "hmcl.install.fabric-api" -> i18n("install.installer.install", i18n("install.installer.fabric-api") + " " + stageValue); + case "hmcl.install.legacyfabric" -> i18n("install.installer.install", i18n("install.installer.legacyfabric") + " " + stageValue); + case "hmcl.install.legacyfabric-api" -> i18n("install.installer.install", i18n("install.installer.legacyfabric-api") + " " + stageValue); + case "hmcl.install.quilt" -> i18n("install.installer.install", i18n("install.installer.quilt") + " " + stageValue); + case "hmcl.install.quilt-api" -> i18n("install.installer.install", i18n("install.installer.quilt-api") + " " + stageValue); + default -> i18n(stageKey); + }; // @formatter:on // CHECKSTYLE:ON diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorController.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorController.java index 95833a79b0..34ee72b71b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorController.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorController.java @@ -17,18 +17,13 @@ */ package org.jackhuang.hmcl.ui.decorator; -import com.jfoenix.controls.JFXDialog; import com.jfoenix.controls.JFXSnackbar; import javafx.animation.Interpolator; import javafx.animation.KeyFrame; import javafx.animation.KeyValue; import javafx.animation.Timeline; -import javafx.application.Platform; import javafx.beans.InvalidationListener; import javafx.beans.WeakInvalidationListener; -import javafx.beans.value.ChangeListener; -import javafx.beans.value.ObservableValue; -import javafx.event.EventHandler; import javafx.geometry.Insets; import javafx.scene.Node; import javafx.scene.image.Image; @@ -48,14 +43,15 @@ import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.DialogUtils; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.account.AddAuthlibInjectorServerPane; -import org.jackhuang.hmcl.ui.animation.*; +import org.jackhuang.hmcl.ui.animation.AnimationUtils; +import org.jackhuang.hmcl.ui.animation.ContainerAnimations; +import org.jackhuang.hmcl.ui.animation.Motion; import org.jackhuang.hmcl.ui.animation.TransitionPane.AnimationProducer; -import org.jackhuang.hmcl.ui.construct.DialogAware; -import org.jackhuang.hmcl.ui.construct.DialogCloseEvent; -import org.jackhuang.hmcl.ui.construct.Navigator; import org.jackhuang.hmcl.ui.construct.JFXDialogPane; +import org.jackhuang.hmcl.ui.construct.Navigator; import org.jackhuang.hmcl.ui.wizard.Refreshable; import org.jackhuang.hmcl.ui.wizard.WizardProvider; import org.jackhuang.hmcl.util.Lang; @@ -66,7 +62,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.*; +import java.util.List; +import java.util.Locale; +import java.util.Random; import java.util.stream.Stream; import static java.util.stream.Collectors.toList; @@ -77,14 +75,9 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; public class DecoratorController { - private static final String PROPERTY_DIALOG_CLOSE_HANDLER = DecoratorController.class.getName() + ".dialog.closeListener"; - private final Decorator decorator; private final Navigator navigator; - private JFXDialog dialog; - private JFXDialogPane dialogPane; - public DecoratorController(Stage stage, Node mainPage) { decorator = new Decorator(stage); decorator.setOnCloseButtonAction(() -> { @@ -134,19 +127,24 @@ public DecoratorController(Stage stage, Node mainPage) { // pass key events to current dialog / current page decorator.addEventFilter(KeyEvent.ANY, e -> { - if (!(e.getTarget() instanceof Node)) { - return; // event source can't be determined + if (!(e.getTarget() instanceof Node t)) { + return; } Node newTarget; - if (dialogPane != null && dialogPane.peek().isPresent()) { - newTarget = dialogPane.peek().get(); // current dialog - } else { - newTarget = navigator.getCurrentPage(); // current page + + JFXDialogPane currentDialogPane = null; + if (decorator.getDrawerWrapper() != null) { + currentDialogPane = (JFXDialogPane) decorator.getDrawerWrapper().getProperties().get(DialogUtils.PROPERTY_DIALOG_PANE_INSTANCE); } + if (currentDialogPane != null && currentDialogPane.peek().isPresent()) { + newTarget = currentDialogPane.peek().get(); + } else { + newTarget = navigator.getCurrentPage(); + } boolean needsRedirect = true; - Node t = (Node) e.getTarget(); + while (t != null) { if (t == newTarget) { // current event target is in newTarget @@ -266,7 +264,7 @@ private Background getBackground() { private Background createBackgroundWithOpacity(Image image, int opacity) { if (opacity <= 0) { return new Background(new BackgroundFill(new Color(1, 1, 1, 0), CornerRadii.EMPTY, Insets.EMPTY)); - } else if (opacity >= 100) { + } else if (opacity >= 100 || image.getPixelReader() == null) { return new Background(new BackgroundImage( image, BackgroundRepeat.NO_REPEAT, @@ -373,9 +371,8 @@ private void close() { if (navigator.getCurrentPage() instanceof DecoratorPage) { DecoratorPage page = (DecoratorPage) navigator.getCurrentPage(); - // FIXME: Get WorldPage working first, and revisit this later - page.closePage(); if (page.isPageCloseable()) { + page.closePage(); return; } } @@ -446,81 +443,12 @@ private void onNavigated(Navigator.NavigationEvent event) { } // ==== Dialog ==== - public void showDialog(Node node) { - FXUtils.checkFxUserThread(); - - if (dialog == null) { - if (decorator.getDrawerWrapper() == null) { - // Sometimes showDialog will be invoked before decorator was initialized. - // Keep trying again. - Platform.runLater(() -> showDialog(node)); - return; - } - dialog = new JFXDialog(AnimationUtils.isAnimationEnabled() - ? JFXDialog.DialogTransition.CENTER - : JFXDialog.DialogTransition.NONE); - dialogPane = new JFXDialogPane(); - - dialog.setContent(dialogPane); - decorator.capableDraggingWindow(dialog); - decorator.forbidDraggingWindow(dialogPane); - dialog.setDialogContainer(decorator.getDrawerWrapper()); - dialog.setOverlayClose(false); - dialog.show(); - - navigator.setDisable(true); - } - dialogPane.push(node); - - EventHandler handler = event -> closeDialog(node); - node.getProperties().put(PROPERTY_DIALOG_CLOSE_HANDLER, handler); - node.addEventHandler(DialogCloseEvent.CLOSE, handler); - - if (dialog.isVisible()) { - dialog.requestFocus(); - if (node instanceof DialogAware) - ((DialogAware) node).onDialogShown(); - } else { - dialog.visibleProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - if (newValue) { - dialog.requestFocus(); - if (node instanceof DialogAware) - ((DialogAware) node).onDialogShown(); - observable.removeListener(this); - } - } - }); - } + DialogUtils.show(decorator, node); } - @SuppressWarnings("unchecked") private void closeDialog(Node node) { - FXUtils.checkFxUserThread(); - - Optional.ofNullable(node.getProperties().get(PROPERTY_DIALOG_CLOSE_HANDLER)) - .ifPresent(handler -> node.removeEventHandler(DialogCloseEvent.CLOSE, (EventHandler) handler)); - - if (dialog != null) { - JFXDialogPane pane = dialogPane; - - if (pane.size() == 1 && pane.peek().orElse(null) == node) { - dialog.setOnDialogClosed(e -> pane.pop(node)); - dialog.close(); - dialog = null; - dialogPane = null; - - navigator.setDisable(false); - } else { - pane.pop(node); - } - - if (node instanceof DialogAware) { - ((DialogAware) node).onDialogClosed(); - } - } + DialogUtils.close(node); } // ==== Toast ==== diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorSkin.java index 14f28853ed..6c6a824b89 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorSkin.java @@ -45,7 +45,7 @@ import javafx.util.Duration; import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.setting.Theme; +import org.jackhuang.hmcl.theme.Themes; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.animation.ContainerAnimations; @@ -233,19 +233,19 @@ public DecoratorSkin(Decorator control) { { JFXButton btnHelp = new JFXButton(); btnHelp.setFocusTraversable(false); - btnHelp.setGraphic(SVG.HELP.createIcon(Theme.foregroundFillBinding(), -1)); + btnHelp.setGraphic(SVG.HELP.createIcon(Themes.titleFillProperty())); btnHelp.getStyleClass().add("jfx-decorator-button"); btnHelp.setOnAction(e -> FXUtils.openLink(Metadata.CONTACT_URL)); JFXButton btnMin = new JFXButton(); btnMin.setFocusTraversable(false); - btnMin.setGraphic(SVG.MINIMIZE.createIcon(Theme.foregroundFillBinding(), -1)); + btnMin.setGraphic(SVG.MINIMIZE.createIcon(Themes.titleFillProperty())); btnMin.getStyleClass().add("jfx-decorator-button"); btnMin.setOnAction(e -> skinnable.minimize()); JFXButton btnClose = new JFXButton(); btnClose.setFocusTraversable(false); - btnClose.setGraphic(SVG.CLOSE.createIcon(Theme.foregroundFillBinding(), -1)); + btnClose.setGraphic(SVG.CLOSE.createIcon(Themes.titleFillProperty())); btnClose.getStyleClass().add("jfx-decorator-button"); btnClose.setOnAction(e -> skinnable.close()); @@ -265,6 +265,8 @@ public DecoratorSkin(Decorator control) { private Node createNavBar(Decorator skinnable, double leftPaneWidth, boolean canBack, boolean canClose, boolean showCloseAsHome, boolean canRefresh, String title, Node titleNode) { BorderPane navBar = new BorderPane(); + navBar.getStyleClass().add("navigation-bar"); + { HBox navLeft = new HBox(); navLeft.setAlignment(Pos.CENTER_LEFT); @@ -273,9 +275,8 @@ private Node createNavBar(Decorator skinnable, double leftPaneWidth, boolean can if (canBack) { JFXButton backNavButton = new JFXButton(); backNavButton.setFocusTraversable(false); - backNavButton.setGraphic(SVG.ARROW_BACK.createIcon(Theme.foregroundFillBinding(), -1)); + backNavButton.setGraphic(SVG.ARROW_BACK.createIcon(Themes.titleFillProperty())); backNavButton.getStyleClass().add("jfx-decorator-button"); - backNavButton.ripplerFillProperty().set(Theme.whiteFill()); backNavButton.onActionProperty().bind(skinnable.onBackNavButtonActionProperty()); backNavButton.visibleProperty().set(canBack); @@ -285,14 +286,13 @@ private Node createNavBar(Decorator skinnable, double leftPaneWidth, boolean can if (canClose) { JFXButton closeNavButton = new JFXButton(); closeNavButton.setFocusTraversable(false); - closeNavButton.setGraphic(SVG.CLOSE.createIcon(Theme.foregroundFillBinding(), -1)); + closeNavButton.setGraphic(SVG.CLOSE.createIcon(Themes.titleFillProperty())); closeNavButton.getStyleClass().add("jfx-decorator-button"); - closeNavButton.ripplerFillProperty().set(Theme.whiteFill()); closeNavButton.onActionProperty().bind(skinnable.onCloseNavButtonActionProperty()); if (showCloseAsHome) - closeNavButton.setGraphic(SVG.HOME.createIcon(Theme.foregroundFillBinding(), -1)); + closeNavButton.setGraphic(SVG.HOME.createIcon(Themes.titleFillProperty())); else - closeNavButton.setGraphic(SVG.CLOSE.createIcon(Theme.foregroundFillBinding(), -1)); + closeNavButton.setGraphic(SVG.CLOSE.createIcon(Themes.titleFillProperty())); navLeft.getChildren().add(closeNavButton); } @@ -304,6 +304,7 @@ private Node createNavBar(Decorator skinnable, double leftPaneWidth, boolean can BorderPane center = new BorderPane(); if (title != null) { Label titleLabel = new Label(); + titleLabel.textFillProperty().bind(Themes.titleFillProperty()); BorderPane.setAlignment(titleLabel, Pos.CENTER_LEFT); titleLabel.getStyleClass().add("jfx-decorator-title"); if (titleNode == null) { @@ -327,15 +328,26 @@ private Node createNavBar(Decorator skinnable, double leftPaneWidth, boolean can } if (onTitleBarDoubleClick != null) center.setOnMouseClicked(onTitleBarDoubleClick); + center.setOnMouseDragged(mouseEvent -> { + if (!getSkinnable().isDragging() && primaryStage.isMaximized()) { + getSkinnable().setDragging(true); + mouseInitX = mouseEvent.getScreenX(); + mouseInitY = mouseEvent.getScreenY(); + primaryStage.setMaximized(false); + stageInitWidth = primaryStage.getWidth(); + stageInitHeight = primaryStage.getHeight(); + primaryStage.setY(stageInitY = 0); + primaryStage.setX(stageInitX = mouseInitX - stageInitWidth / 2); + } + }); navBar.setCenter(center); if (canRefresh) { HBox navRight = new HBox(); navRight.setAlignment(Pos.CENTER_RIGHT); JFXButton refreshNavButton = new JFXButton(); - refreshNavButton.setGraphic(SVG.REFRESH.createIcon(Theme.foregroundFillBinding(), -1)); + refreshNavButton.setGraphic(SVG.REFRESH.createIcon(Themes.titleFillProperty())); refreshNavButton.getStyleClass().add("jfx-decorator-button"); - refreshNavButton.ripplerFillProperty().set(Theme.whiteFill()); refreshNavButton.onActionProperty().bind(skinnable.onRefreshNavButtonActionProperty()); Rectangle separator = new Rectangle(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java index d187876734..4c1d8919b4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java @@ -24,12 +24,12 @@ import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.*; +import javafx.scene.layout.BorderPane; import javafx.scene.layout.FlowPane; import javafx.scene.layout.HBox; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.ui.Controllers; -import javafx.scene.layout.BorderPane; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.InstallerItem; import org.jackhuang.hmcl.ui.construct.MessageDialogPane; @@ -38,9 +38,11 @@ import org.jackhuang.hmcl.ui.wizard.WizardPage; import org.jackhuang.hmcl.util.SettingsMap; +import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public abstract class AbstractInstallersPage extends Control implements WizardPage { + public static final String FABRIC_QUILT_API_TIP = "fabricQuiltApi"; protected final WizardController controller; protected InstallerItem.InstallerItemGroup group; @@ -56,8 +58,15 @@ public AbstractInstallersPage(WizardController controller, String gameVersion, D String libraryId = library.getLibraryId(); if (libraryId.equals(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId())) continue; library.setOnInstall(() -> { - if (LibraryAnalyzer.LibraryType.FABRIC_API.getPatchId().equals(libraryId)) { - Controllers.dialog(i18n("install.installer.fabric-api.warning"), i18n("message.warning"), MessageDialogPane.MessageType.WARNING); + if (!Boolean.TRUE.equals(config().getShownTips().get(FABRIC_QUILT_API_TIP)) + && (LibraryAnalyzer.LibraryType.FABRIC_API.getPatchId().equals(libraryId) + || LibraryAnalyzer.LibraryType.QUILT_API.getPatchId().equals(libraryId) + || LibraryAnalyzer.LibraryType.LEGACY_FABRIC_API.getPatchId().equals(libraryId))) { + Controllers.dialog(new MessageDialogPane.Builder( + i18n("install.installer.fabric-quilt-api.warning", i18n("install.installer." + libraryId)), + i18n("message.warning"), + MessageDialogPane.MessageType.WARNING + ).ok(null).addCancel(i18n("button.do_not_show_again"), () -> config().getShownTips().put(FABRIC_QUILT_API_TIP, true)).build()); } if (!(library.resolvedStateProperty().get() instanceof InstallerItem.IncompatibleState)) @@ -68,7 +77,7 @@ public AbstractInstallersPage(WizardController controller, String gameVersion, D gameVersion, downloadProvider, libraryId, - () -> controller.onPrev(false, Navigation.NavigationDirection.NEXT) + () -> controller.onPrev(false, Navigation.NavigationDirection.PREVIOUS) ), Navigation.NavigationDirection.NEXT ); }); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java index fc66f761d6..ff3f237f80 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java @@ -25,7 +25,6 @@ import org.jackhuang.hmcl.download.game.GameRemoteVersion; import org.jackhuang.hmcl.mod.RemoteMod; import org.jackhuang.hmcl.mod.curse.CurseForgeRemoteModRepository; -import org.jackhuang.hmcl.mod.modrinth.ModrinthRemoteModRepository; import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.setting.Profiles; @@ -40,6 +39,7 @@ import org.jackhuang.hmcl.ui.construct.AdvancedListBox; import org.jackhuang.hmcl.ui.construct.MessageDialogPane; import org.jackhuang.hmcl.ui.construct.TabHeader; +import org.jackhuang.hmcl.ui.construct.Validator; import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; import org.jackhuang.hmcl.ui.versions.DownloadListPage; @@ -84,8 +84,8 @@ public DownloadPage(String uploadVersion) { newGameTab.setNodeSupplier(loadVersionFor(() -> new VersionsPage(versionPageNavigator, i18n("install.installer.choose", i18n("install.installer.game")), "", DownloadProviders.getDownloadProvider(), "game", versionPageNavigator::onGameSelected))); modpackTab.setNodeSupplier(loadVersionFor(() -> { - DownloadListPage page = HMCLLocalizedDownloadListPage.ofModPack((profile, __, file) -> { - Versions.downloadModpackImpl(profile, uploadVersion, file); + DownloadListPage page = HMCLLocalizedDownloadListPage.ofModPack((profile, __, mod, file) -> { + Versions.downloadModpackImpl(profile, uploadVersion, mod, file); }, false); JFXButton installLocalModpackButton = FXUtils.newRaisedButton(i18n("install.modpack")); @@ -94,9 +94,9 @@ public DownloadPage(String uploadVersion) { page.getActions().add(installLocalModpackButton); return page; })); - modTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofMod((profile, version, file) -> download(profile, version, file, "mods"), true))); - resourcePackTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofResourcePack((profile, version, file) -> download(profile, version, file, "resourcepacks"), true))); - shaderTab.setNodeSupplier(loadVersionFor(() -> new DownloadListPage(ModrinthRemoteModRepository.SHADER_PACKS, (profile, version, file) -> download(profile, version, file, "shaderpacks"), true))); + modTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofMod((profile, version, mod, file) -> download(profile, version, file, "mods"), true))); + resourcePackTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofResourcePack((profile, version, mod, file) -> download(profile, version, file, "resourcepacks"), true))); + shaderTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofShaderPack((profile, version, mod, file) -> download(profile, version, file, "shaderpacks"), true))); worldTab.setNodeSupplier(loadVersionFor(() -> new DownloadListPage(CurseForgeRemoteModRepository.WORLDS))); tab = new TabHeader(transitionPane, newGameTab, modpackTab, modTab, resourcePackTab, shaderTab, worldTab); @@ -134,11 +134,7 @@ public static void download(Profile profile, @Nullable String version, RemoteMod Path runDirectory = profile.getRepository().hasVersion(version) ? profile.getRepository().getRunDirectory(version) : profile.getRepository().getBaseDirectory(); - Controllers.prompt(i18n("archive.file.name"), (result, resolve, reject) -> { - if (!FileUtils.isNameValid(result)) { - reject.accept(i18n("install.new_game.malformed")); - return; - } + Controllers.prompt(i18n("archive.file.name"), (result, handler) -> { Path dest = runDirectory.resolve(subdirectoryName).resolve(result); Controllers.taskDialog(Task.composeAsync(() -> { @@ -156,9 +152,8 @@ public static void download(Profile profile, @Nullable String version, RemoteMod Controllers.showToast(i18n("install.success")); } }), i18n("message.downloading"), TaskCancellationAction.NORMAL); - - resolve.run(); - }, file.getFile().getFilename()); + handler.resolve(); + }, file.getFile().getFilename(), new Validator(i18n("install.new_game.malformed"), FileUtils::isNameValid)); } @@ -200,6 +195,10 @@ public void showModpackDownloads() { tab.select(modpackTab, false); } + public void showResourcepackDownloads() { + tab.select(resourcePackTab, false); + } + public DownloadListPage showModDownloads() { tab.select(modTab, false); return modTab.getNode(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java index 346c4a0dfa..d0c4d7b361 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java @@ -50,7 +50,7 @@ public InstallersPage(WizardController controller, HMCLGameRepository repository @Override public String getTitle() { - return group.getGame().versionProperty().get().getVersion(); + return group.getGame().versionProperty().get().version(); } private String getVersion(String id) { @@ -111,7 +111,7 @@ protected void onInstall() { } private void setTxtNameWithLoaders() { - StringBuilder nameBuilder = new StringBuilder(group.getGame().versionProperty().get().getVersion()); + StringBuilder nameBuilder = new StringBuilder(group.getGame().versionProperty().get().version()); for (InstallerItem library : group.getLibraries()) { String libraryId = library.getLibraryId().replace(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId(), ""); @@ -121,32 +121,20 @@ private void setTxtNameWithLoaders() { LibraryAnalyzer.LibraryType libraryType = LibraryAnalyzer.LibraryType.fromPatchId(libraryId); if (libraryType != null) { - String loaderName; - switch (libraryType) { - case FORGE: - loaderName = i18n("install.installer.forge"); - break; - case NEO_FORGE: - loaderName = i18n("install.installer.neoforge"); - break; - case CLEANROOM: - loaderName = i18n("install.installer.cleanroom"); - break; - case FABRIC: - loaderName = i18n("install.installer.fabric"); - break; - case LITELOADER: - loaderName = i18n("install.installer.liteloader"); - break; - case QUILT: - loaderName = i18n("install.installer.quilt"); - break; - case OPTIFINE: - loaderName = i18n("install.installer.optifine"); - break; - default: - continue; - } + String loaderName = switch (libraryType) { + case FORGE -> i18n("install.installer.forge"); + case NEO_FORGE -> i18n("install.installer.neoforge"); + case CLEANROOM -> i18n("install.installer.cleanroom"); + case LEGACY_FABRIC -> i18n("install.installer.legacyfabric").replace(" ", "_"); + case FABRIC -> i18n("install.installer.fabric"); + case LITELOADER -> i18n("install.installer.liteloader"); + case QUILT -> i18n("install.installer.quilt"); + case OPTIFINE -> i18n("install.installer.optifine"); + default -> null; + }; + + if (loaderName == null) + continue; nameBuilder.append("-").append(loaderName); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/LocalModpackPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/LocalModpackPage.java index ad99b108e4..41f302b9f6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/LocalModpackPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/LocalModpackPage.java @@ -180,4 +180,5 @@ protected void onDescribe() { public static final SettingsMap.Key MODPACK_MANIFEST = new SettingsMap.Key<>("MODPACK_MANIFEST"); public static final SettingsMap.Key MODPACK_CHARSET = new SettingsMap.Key<>("MODPACK_CHARSET"); public static final SettingsMap.Key MODPACK_MANUALLY_CREATED = new SettingsMap.Key<>("MODPACK_MANUALLY_CREATED"); + public static final SettingsMap.Key MODPACK_ICON_URL = new SettingsMap.Key<>("MODPACK_ICON_URL"); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java index c9790bc7a0..bd6a40012d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java @@ -33,6 +33,7 @@ import org.jackhuang.hmcl.ui.wizard.WizardController; import org.jackhuang.hmcl.ui.wizard.WizardProvider; import org.jackhuang.hmcl.util.SettingsMap; +import org.jackhuang.hmcl.util.StringUtils; import java.io.FileNotFoundException; import java.io.IOException; @@ -45,6 +46,7 @@ public final class ModpackInstallWizardProvider implements WizardProvider { private final Profile profile; private final Path file; private final String updateVersion; + private String iconUrl; public ModpackInstallWizardProvider(Profile profile) { this(profile, null, null); @@ -64,12 +66,18 @@ public ModpackInstallWizardProvider(Profile profile, Path modpackFile, String up this.updateVersion = updateVersion; } + public void setIconUrl(String iconUrl) { + this.iconUrl = iconUrl; + } + @Override public void start(SettingsMap settings) { if (file != null) settings.put(LocalModpackPage.MODPACK_FILE, file); if (updateVersion != null) settings.put(LocalModpackPage.MODPACK_NAME, updateVersion); + if (StringUtils.isNotBlank(iconUrl)) + settings.put(LocalModpackPage.MODPACK_ICON_URL, iconUrl); settings.put(ModpackPage.PROFILE, profile); } @@ -78,6 +86,7 @@ private Task finishModpackInstallingAsync(SettingsMap settings) { ServerModpackManifest serverModpackManifest = settings.get(RemoteModpackPage.MODPACK_SERVER_MANIFEST); Modpack modpack = settings.get(LocalModpackPage.MODPACK_MANIFEST); String name = settings.get(LocalModpackPage.MODPACK_NAME); + String iconUrl = settings.get(LocalModpackPage.MODPACK_ICON_URL); Charset charset = settings.get(LocalModpackPage.MODPACK_CHARSET); boolean isManuallyCreated = settings.getOrDefault(LocalModpackPage.MODPACK_MANUALLY_CREATED, false); @@ -111,7 +120,7 @@ private Task finishModpackInstallingAsync(SettingsMap settings) { return ModpackHelper.getInstallTask(profile, serverModpackManifest, name, modpack) .thenRunAsync(Schedulers.javafx(), () -> profile.setSelectedVersion(name)); } else { - return ModpackHelper.getInstallTask(profile, selected, name, modpack) + return ModpackHelper.getInstallTask(profile, selected, name, modpack, iconUrl) .thenRunAsync(Schedulers.javafx(), () -> profile.setSelectedVersion(name)); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackSelectionPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackSelectionPage.java index 5a9d22a504..50090a1935 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackSelectionPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackSelectionPage.java @@ -29,11 +29,14 @@ import javafx.stage.FileChooser; import org.jackhuang.hmcl.game.ModpackHelper; import org.jackhuang.hmcl.mod.server.ServerModpackManifest; -import org.jackhuang.hmcl.task.*; +import org.jackhuang.hmcl.task.FileDownloadTask; +import org.jackhuang.hmcl.task.GetTask; +import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.construct.TwoLineListItem; +import org.jackhuang.hmcl.ui.construct.URLValidator; import org.jackhuang.hmcl.ui.wizard.WizardController; import org.jackhuang.hmcl.ui.wizard.WizardPage; import org.jackhuang.hmcl.util.SettingsMap; @@ -121,37 +124,37 @@ private void onChooseLocalFile() { } private void onChooseRemoteFile() { - Controllers.prompt(i18n("modpack.choose.remote.tooltip"), (url, resolve, reject) -> { + Controllers.prompt(i18n("modpack.choose.remote.tooltip"), (url, handler) -> { try { if (url.endsWith("server-manifest.json")) { // if urlString ends with .json, we assume that the url is server-manifest.json Controllers.taskDialog(new GetTask(url).whenComplete(Schedulers.javafx(), (result, e) -> { ServerModpackManifest manifest = JsonUtils.fromMaybeMalformedJson(result, ServerModpackManifest.class); if (manifest == null) { - reject.accept(i18n("modpack.type.server.malformed")); + handler.reject(i18n("modpack.type.server.malformed")); } else if (e == null) { - resolve.run(); + handler.resolve(); controller.getSettings().put(MODPACK_SERVER_MANIFEST, manifest); controller.onNext(); } else { - reject.accept(e.getMessage()); + handler.reject(e.getMessage()); } }).executor(true), i18n("message.downloading"), TaskCancellationAction.NORMAL); } else { // otherwise we still consider the file as modpack zip file // since casually the url may not ends with ".zip" Path modpack = Files.createTempFile("modpack", ".zip"); - resolve.run(); + handler.resolve(); Controllers.taskDialog( new FileDownloadTask(url, modpack) .whenComplete(Schedulers.javafx(), e -> { if (e == null) { - resolve.run(); + handler.resolve(); controller.getSettings().put(MODPACK_FILE, modpack); controller.onNext(); } else { - reject.accept(e.getMessage()); + handler.reject(e.getMessage()); } }).executor(true), i18n("message.downloading"), @@ -159,9 +162,9 @@ private void onChooseRemoteFile() { ); } } catch (IOException e) { - reject.accept(e.getMessage()); + handler.reject(e.getMessage()); } - }); + }, "", new URLValidator()); } public void onChooseRepository() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/VersionsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/VersionsPage.java index 837e3edfbd..7950950892 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/VersionsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/VersionsPage.java @@ -38,6 +38,8 @@ import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion; import org.jackhuang.hmcl.download.forge.ForgeRemoteVersion; import org.jackhuang.hmcl.download.game.GameRemoteVersion; +import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricAPIRemoteVersion; +import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricRemoteVersion; import org.jackhuang.hmcl.download.liteloader.LiteLoaderRemoteVersion; import org.jackhuang.hmcl.download.neoforge.NeoForgeRemoteVersion; import org.jackhuang.hmcl.download.optifine.OptiFineRemoteVersion; @@ -57,7 +59,6 @@ import org.jackhuang.hmcl.ui.wizard.Navigation; import org.jackhuang.hmcl.ui.wizard.Refreshable; import org.jackhuang.hmcl.ui.wizard.WizardPage; -import org.jackhuang.hmcl.util.Holder; import org.jackhuang.hmcl.util.NativePatcher; import org.jackhuang.hmcl.util.SettingsMap; import org.jackhuang.hmcl.util.StringUtils; @@ -164,10 +165,7 @@ private static class RemoteVersionListCell extends ListCell { private final ImageView imageView = new ImageView(); private final StackPane pane = new StackPane(); - private final Holder lastCell; - - RemoteVersionListCell(Holder lastCell, VersionsPage control) { - this.lastCell = lastCell; + RemoteVersionListCell(VersionsPage control) { this.control = control; HBox hbox = new HBox(16); @@ -179,10 +177,7 @@ private static class RemoteVersionListCell extends ListCell { { if ("game".equals(control.libraryId)) { JFXButton wikiButton = newToggleButton4(SVG.GLOBE_BOOK); - wikiButton.setOnAction(event -> { - onOpenWiki(); - FXUtils.clearFocus(wikiButton); - }); + wikiButton.setOnAction(event -> onOpenWiki()); FXUtils.installFastTooltip(wikiButton, i18n("wiki.tooltip")); actions.getChildren().add(wikiButton); } @@ -222,18 +217,13 @@ private void onOpenWiki() { public void updateItem(RemoteVersion remoteVersion, boolean empty) { super.updateItem(remoteVersion, empty); - // https://mail.openjdk.org/pipermail/openjfx-dev/2022-July/034764.html - if (this == lastCell.value && !isVisible()) - return; - lastCell.value = this; - if (empty) { setGraphic(null); return; } setGraphic(pane); - twoLineListItem.setTitle(I18n.getDisplaySelfVersion(remoteVersion)); + twoLineListItem.setTitle(I18n.getDisplayVersion(remoteVersion)); if (remoteVersion.getReleaseDate() != null) { twoLineListItem.setSubtitle(I18n.formatDateTime(remoteVersion.getReleaseDate())); } else { @@ -282,6 +272,8 @@ else if (remoteVersion instanceof CleanroomRemoteVersion) iconType = VersionIconType.CLEANROOM; else if (remoteVersion instanceof NeoForgeRemoteVersion) iconType = VersionIconType.NEO_FORGE; + else if (remoteVersion instanceof LegacyFabricRemoteVersion || remoteVersion instanceof LegacyFabricAPIRemoteVersion) + iconType = VersionIconType.LEGACY_FABRIC; else if (remoteVersion instanceof FabricRemoteVersion || remoteVersion instanceof FabricAPIRemoteVersion) iconType = VersionIconType.FABRIC; else if (remoteVersion instanceof QuiltRemoteVersion || remoteVersion instanceof QuiltAPIRemoteVersion) @@ -398,8 +390,7 @@ private static final class VersionsPageSkin extends SkinBase { control.versions.addListener((InvalidationListener) o -> updateList()); - Holder lastCell = new Holder<>(); - list.setCellFactory(listView -> new RemoteVersionListCell(lastCell, control)); + list.setCellFactory(listView -> new RemoteVersionListCell(control)); ComponentList.setVgrow(list, Priority.ALWAYS); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java index cb7407ee9a..54bcadbb11 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java @@ -22,10 +22,10 @@ import org.jackhuang.hmcl.mod.ModAdviser; import org.jackhuang.hmcl.mod.ModpackExportInfo; import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackExportTask; +import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackExportTask; import org.jackhuang.hmcl.mod.multimc.MultiMCInstanceConfiguration; import org.jackhuang.hmcl.mod.multimc.MultiMCModpackExportTask; import org.jackhuang.hmcl.mod.server.ServerModpackExportTask; -import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackExportTask; import org.jackhuang.hmcl.setting.Config; import org.jackhuang.hmcl.setting.FontManager; import org.jackhuang.hmcl.setting.Profile; @@ -41,7 +41,9 @@ import java.nio.file.Files; import java.nio.file.Path; -import java.util.*; +import java.util.Collection; +import java.util.Collections; +import java.util.List; import static org.jackhuang.hmcl.setting.ConfigHolder.config; @@ -126,13 +128,15 @@ public void execute() throws Exception { exported.setBackgroundImageType(config().getBackgroundImageType()); exported.setBackgroundImage(config().getBackgroundImage()); - exported.setTheme(config().getTheme()); + exported.setThemeColor(config().getThemeColor()); exported.setDownloadType(config().getDownloadType()); exported.setPreferredLoginType(config().getPreferredLoginType()); exported.getAuthlibInjectorServers().setAll(config().getAuthlibInjectorServers()); zip.putTextFile(exported.toJson(), ".hmcl/hmcl.json"); - zip.putFile(tempModpack, "modpack.zip"); + zip.putFile(tempModpack, ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH.equals(modpackType) + ? "modpack.mrpack" + : "modpack.zip"); Path bg = Metadata.HMCL_CURRENT_DIRECTORY.resolve("background"); if (!Files.isDirectory(bg)) @@ -263,10 +267,10 @@ private Task exportAsModrinth(ModpackExportInfo exportInfo, Path modpackFile) @Override public void execute() { dependency = new ModrinthModpackExportTask( - profile.getRepository(), - version, - exportInfo, - modpackFile + profile.getRepository(), + version, + exportInfo, + modpackFile ); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackFileSelectionPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackFileSelectionPage.java index 17d6392208..ed7664a9da 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackFileSelectionPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackFileSelectionPage.java @@ -18,10 +18,10 @@ package org.jackhuang.hmcl.ui.export; import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXCheckBox; import com.jfoenix.controls.JFXTreeView; import javafx.geometry.Insets; import javafx.geometry.Pos; -import javafx.scene.control.CheckBox; import javafx.scene.control.CheckBoxTreeItem; import javafx.scene.control.Label; import javafx.scene.control.TreeItem; @@ -43,8 +43,8 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Objects; +import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; 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; @@ -68,6 +68,8 @@ public ModpackFileSelectionPage(WizardController controller, Profile profile, St rootNode = getTreeItem(profile.getRepository().getRunDirectory(version), "minecraft"); treeView.setRoot(rootNode); treeView.setSelectionModel(new NoneMultipleSelectionModel<>()); + onEscPressed(treeView, () -> controller.onPrev(true)); + setMargin(treeView, new Insets(10, 10, 5, 10)); this.setCenter(treeView); HBox nextPane = new HBox(); @@ -93,9 +95,22 @@ private CheckBoxTreeItem getTreeItem(Path file, String basePath) { ModAdviser.ModSuggestion state = ModAdviser.ModSuggestion.SUGGESTED; if (basePath.length() > "minecraft/".length()) { state = adviser.advise(StringUtils.substringAfter(basePath, "minecraft/") + (isDirectory ? "/" : ""), isDirectory); - if (!isDirectory && Objects.equals(FileUtils.getNameWithoutExtension(file), version)) - state = ModAdviser.ModSuggestion.HIDDEN; - if (isDirectory && Objects.equals(FileUtils.getName(file), version + "-natives")) // Ignore -natives + + String fileName = FileUtils.getName(file); + + if (!isDirectory) { + switch (fileName) { + case ".DS_Store", // macOS system file + "desktop.ini", "Thumbs.db" // Windows system files + -> state = ModAdviser.ModSuggestion.HIDDEN; + } + if (fileName.startsWith("._")) // macOS system file + state = ModAdviser.ModSuggestion.HIDDEN; + if (FileUtils.getNameWithoutExtension(file).equals(version)) + state = ModAdviser.ModSuggestion.HIDDEN; + } + + if (isDirectory && fileName.equals(version + "-natives")) // Ignore -natives state = ModAdviser.ModSuggestion.HIDDEN; if (state == ModAdviser.ModSuggestion.HIDDEN) return null; @@ -130,15 +145,14 @@ private CheckBoxTreeItem getTreeItem(Path file, String basePath) { } HBox graphic = new HBox(); - CheckBox checkBox = new CheckBox(); + JFXCheckBox checkBox = new JFXCheckBox(); checkBox.selectedProperty().bindBidirectional(node.selectedProperty()); checkBox.indeterminateProperty().bindBidirectional(node.indeterminateProperty()); graphic.getChildren().add(checkBox); if (TRANSLATION.containsKey(basePath)) { - Label comment = new Label(); - comment.setText(TRANSLATION.get(basePath)); - comment.setStyle("-fx-text-fill: gray;"); + Label comment = new Label(TRANSLATION.get(basePath)); + comment.setStyle("-fx-text-fill: -monet-on-surface-variant;"); comment.setMouseTransparent(true); graphic.getChildren().add(comment); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java index 2cdaa1f53a..222f6bd2fe 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java @@ -45,6 +45,7 @@ import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.JarUtils; +import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jackhuang.hmcl.util.platform.SystemInfo; import java.nio.file.Path; @@ -108,9 +109,9 @@ public ModpackInfoPage(WizardController controller, HMCLGameRepository gameRepos private void onNext() { FileChooser fileChooser = new FileChooser(); fileChooser.setTitle(i18n("modpack.wizard.step.initialization.save")); - if (controller.getSettings().get(MODPACK_TYPE) == ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH) { + if (!packWithLauncher.get() && controller.getSettings().get(MODPACK_TYPE) == MODPACK_TYPE_MODRINTH) { fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.mrpack")); - fileChooser.setInitialFileName(name.get() + ".mrpack"); + fileChooser.setInitialFileName(name.get() + (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS ? "" : ".mrpack")); } else { fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.zip")); fileChooser.setInitialFileName(name.get() + ".zip"); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackTypeSelectionPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackTypeSelectionPage.java index 62da3f0fce..9f0f7bb410 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackTypeSelectionPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackTypeSelectionPage.java @@ -20,10 +20,10 @@ import com.jfoenix.controls.JFXButton; import javafx.geometry.Insets; import javafx.geometry.Pos; +import javafx.scene.Node; import javafx.scene.control.Label; import javafx.scene.layout.BorderPane; import javafx.scene.layout.VBox; -import javafx.scene.shape.SVGPath; import org.jackhuang.hmcl.mod.ModpackExportInfo; import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackExportTask; import org.jackhuang.hmcl.mod.multimc.MultiMCModpackExportTask; @@ -77,8 +77,7 @@ private JFXButton createButton(String type, ModpackExportInfo.Options option) { graphic.setMouseTransparent(true); graphic.setLeft(new TwoLineListItem(i18n("modpack.type." + type), i18n("modpack.type." + type + ".export"))); - SVGPath arrow = new SVGPath(); - arrow.setContent(SVG.ARROW_FORWARD.getPath()); + Node arrow = SVG.ARROW_FORWARD.createIcon(); BorderPane.setAlignment(arrow, Pos.CENTER); graphic.setRight(arrow); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/AboutPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/AboutPage.java index 153ad7a55d..2410938073 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/AboutPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/AboutPage.java @@ -21,12 +21,14 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; +import javafx.beans.binding.Bindings; import javafx.geometry.Insets; import javafx.scene.control.ScrollPane; import javafx.scene.image.Image; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.theme.Themes; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.construct.ComponentList; import org.jackhuang.hmcl.ui.construct.IconedTwoLineListItem; @@ -105,6 +107,12 @@ public AboutPage() { getChildren().setAll(scrollPane); } + private static Image loadImage(String url) { + return url.startsWith("/") + ? FXUtils.newBuiltinImage(url) + : new Image(url); + } + private static ComponentList loadIconedTwoLineList(String path) { ComponentList componentList = new ComponentList(); @@ -122,10 +130,14 @@ private static ComponentList loadIconedTwoLineList(String path) { IconedTwoLineListItem item = new IconedTwoLineListItem(); if (obj.has("image")) { - String image = obj.get("image").getAsString(); - item.setImage(image.startsWith("/") - ? FXUtils.newBuiltinImage(image) - : new Image(image)); + JsonElement image = obj.get("image"); + if (image.isJsonPrimitive()) { + item.setImage(loadImage(image.getAsString())); + } else if (image.isJsonObject()) { + item.imageProperty().bind(Bindings.when(Themes.darkModeProperty()) + .then(loadImage(image.getAsJsonObject().get("dark").getAsString())) + .otherwise(loadImage(image.getAsJsonObject().get("light").getAsString()))); + } } if (obj.has("title")) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/DownloadSettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/DownloadSettingsPage.java index 859c7c2557..0e7d535d8d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/DownloadSettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/DownloadSettingsPage.java @@ -78,7 +78,7 @@ public DownloadSettingsPage() { versionListSourcePane.setRight(cboVersionListSource); FXUtils.setLimitWidth(cboVersionListSource, 400); - cboVersionListSource.getItems().setAll(DownloadProviders.providersById.keySet()); + cboVersionListSource.getItems().setAll(DownloadProviders.AUTO_PROVIDERS.keySet()); selectedItemPropertyFor(cboVersionListSource).bindBidirectional(config().versionListSourceProperty()); } @@ -95,7 +95,7 @@ public DownloadSettingsPage() { downloadSourcePane.setRight(cboDownloadSource); FXUtils.setLimitWidth(cboDownloadSource, 420); - cboDownloadSource.getItems().setAll(DownloadProviders.rawProviders.keySet()); + cboDownloadSource.getItems().setAll(DownloadProviders.DIRECT_PROVIDERS.keySet()); selectedItemPropertyFor(cboDownloadSource).bindBidirectional(config().downloadTypeProperty()); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/FeedbackPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/FeedbackPage.java index 4b39d6197e..354a582d0f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/FeedbackPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/FeedbackPage.java @@ -17,9 +17,11 @@ */ package org.jackhuang.hmcl.ui.main; +import javafx.beans.binding.Bindings; import javafx.geometry.Insets; import javafx.scene.control.ScrollPane; import javafx.scene.layout.VBox; +import org.jackhuang.hmcl.theme.Themes; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.construct.ComponentList; import org.jackhuang.hmcl.ui.construct.IconedTwoLineListItem; @@ -41,32 +43,41 @@ public FeedbackPage() { FXUtils.smoothScrolling(scrollPane); setContent(scrollPane); - ComponentList community = new ComponentList(); + ComponentList groups = new ComponentList(); { IconedTwoLineListItem users = new IconedTwoLineListItem(); users.setImage(FXUtils.newBuiltinImage("/assets/img/icon.png")); - users.setTitle(i18n("feedback.qq_group")); - users.setSubtitle(i18n("feedback.qq_group.statement")); + users.setTitle(i18n("contact.chat.qq_group")); + users.setSubtitle(i18n("contact.chat.qq_group.statement")); users.setExternalLink(Metadata.GROUPS_URL); - IconedTwoLineListItem github = new IconedTwoLineListItem(); - github.setImage(FXUtils.newBuiltinImage("/assets/img/github.png")); - github.setTitle(i18n("feedback.github")); - github.setSubtitle(i18n("feedback.github.statement")); - github.setExternalLink("https://github.com/HMCL-dev/HMCL/issues/new/choose"); - IconedTwoLineListItem discord = new IconedTwoLineListItem(); discord.setImage(FXUtils.newBuiltinImage("/assets/img/discord.png")); - discord.setTitle(i18n("feedback.discord")); - discord.setSubtitle(i18n("feedback.discord.statement")); + discord.setTitle(i18n("contact.chat.discord")); + discord.setSubtitle(i18n("contact.chat.discord.statement")); discord.setExternalLink("https://discord.gg/jVvC7HfM6U"); - community.getContent().setAll(users, github, discord); + groups.getContent().setAll(users, discord); + } + + ComponentList feedback = new ComponentList(); + { + IconedTwoLineListItem github = new IconedTwoLineListItem(); + github.imageProperty().bind(Bindings.when(Themes.darkModeProperty()) + .then(FXUtils.newBuiltinImage("/assets/img/github-white.png")) + .otherwise(FXUtils.newBuiltinImage("/assets/img/github.png"))); + github.setTitle(i18n("contact.feedback.github")); + github.setSubtitle(i18n("contact.feedback.github.statement")); + github.setExternalLink("https://github.com/HMCL-dev/HMCL/issues/new/choose"); + + feedback.getContent().setAll(github); } content.getChildren().addAll( - ComponentList.createComponentListTitle(i18n("feedback.channel")), - community + ComponentList.createComponentListTitle(i18n("contact.chat")), + groups, + ComponentList.createComponentListTitle(i18n("contact.feedback")), + feedback ); this.setContent(content); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/JavaDownloadDialog.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/JavaDownloadDialog.java index 94faea763c..7d6dd5fc66 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/JavaDownloadDialog.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/JavaDownloadDialog.java @@ -38,7 +38,10 @@ import org.jackhuang.hmcl.java.JavaInfo; import org.jackhuang.hmcl.java.JavaManager; import org.jackhuang.hmcl.setting.DownloadProviders; -import org.jackhuang.hmcl.task.*; +import org.jackhuang.hmcl.task.FileDownloadTask; +import org.jackhuang.hmcl.task.GetTask; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.construct.DialogCloseEvent; @@ -116,7 +119,7 @@ private final class DownloadMojangJava extends DialogPane { vbox.getChildren().add(prompt); for (GameJavaVersion version : supportedGameJavaVersions) { - JFXRadioButton button = new JFXRadioButton("Java " + version.getMajorVersion()); + JFXRadioButton button = new JFXRadioButton("Java " + version.majorVersion()); button.setUserData(version); vbox.getChildren().add(button); toggleGroup.getToggles().add(button); @@ -248,7 +251,7 @@ private final class DownloadDiscoJava extends JFXDialogLayout { for (int i = 0; i < versions.size(); i++) { DiscoJavaRemoteVersion version = versions.get(i); - if (version.getJdkVersion() == GameJavaVersion.LATEST.getMajorVersion()) { + if (version.getJdkVersion() == GameJavaVersion.LATEST.majorVersion()) { remoteVersionBox.getSelectionModel().select(i); return; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/JavaManagementPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/JavaManagementPage.java index 0f5bec1a3e..1465c9bab4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/JavaManagementPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/JavaManagementPage.java @@ -18,24 +18,25 @@ package org.jackhuang.hmcl.ui.main; import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXListView; import javafx.beans.binding.Bindings; import javafx.beans.value.ChangeListener; 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.ListCell; import javafx.scene.control.Skin; -import javafx.scene.control.SkinBase; +import javafx.scene.control.Tooltip; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; +import javafx.scene.layout.StackPane; import javafx.stage.FileChooser; import org.jackhuang.hmcl.java.JavaInfo; import org.jackhuang.hmcl.java.JavaManager; import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.setting.ConfigHolder; import org.jackhuang.hmcl.setting.DownloadProviders; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.*; @@ -43,6 +44,7 @@ import org.jackhuang.hmcl.ui.construct.RipplerContainer; import org.jackhuang.hmcl.ui.construct.TwoLineListItem; import org.jackhuang.hmcl.ui.wizard.SinglePageWizardProvider; +import org.jackhuang.hmcl.util.FXThread; import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.TaskCancellationAction; import org.jackhuang.hmcl.util.io.FileUtils; @@ -63,7 +65,7 @@ /** * @author Glavo */ -public final class JavaManagementPage extends ListPageBase { +public final class JavaManagementPage extends ListPageBase { @SuppressWarnings("FieldCanBeLocal") private final ChangeListener> listener; @@ -173,74 +175,54 @@ private void onInstallArchive(Path file) { }).start(); } - // FXThread + @FXThread private void loadJava(Collection javaRuntimes) { if (javaRuntimes != null) { - List items = new ArrayList<>(); - for (JavaRuntime java : javaRuntimes) { - items.add(new JavaItem(java)); - } - this.setItems(FXCollections.observableList(items)); + this.setItems(FXCollections.observableArrayList(javaRuntimes)); this.setLoading(false); - } else + } else { this.setLoading(true); + } } - static final class JavaItem extends Control { - private final JavaRuntime java; + private static final class JavaPageSkin extends ToolbarListPageSkin { - public JavaItem(JavaRuntime java) { - this.java = java; + JavaPageSkin(JavaManagementPage skinnable) { + super(skinnable); } - public JavaRuntime getJava() { - return java; - } + @Override + protected List initializeToolbar(JavaManagementPage skinnable) { + ArrayList res = new ArrayList<>(4); - public void onReveal() { - Path target; - Path parent = java.getBinary().getParent(); - if (parent != null - && parent.getParent() != null - && parent.getFileName() != null - && parent.getFileName().toString().equals("bin") - && Files.exists(parent.getParent().resolve("release"))) { - target = parent.getParent(); - } else { - target = java.getBinary(); + res.add(createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, JavaManager::refresh)); + if (skinnable.onInstallJava != null) { + res.add(createToolbarButton2(i18n("java.download"), SVG.DOWNLOAD, skinnable.onInstallJava)); } + res.add(createToolbarButton2(i18n("java.add"), SVG.ADD, skinnable::onAddJava)); - FXUtils.showFileInExplorer(target); - } + JFXButton disableJava = createToolbarButton2(i18n("java.disabled.management"), SVG.FORMAT_LIST_BULLETED, skinnable::onShowRestoreJavaPage); + disableJava.disableProperty().bind(Bindings.isEmpty(ConfigHolder.globalConfig().getDisabledJava())); + res.add(disableJava); - public void onRemove() { - if (java.isManaged()) { - Controllers.taskDialog(JavaManager.getUninstallJavaTask(java), i18n("java.uninstall"), TaskCancellationAction.NORMAL); - } else { - String path = java.getBinary().toString(); - ConfigHolder.globalConfig().getUserJava().remove(path); - ConfigHolder.globalConfig().getDisabledJava().add(path); - try { - JavaManager.removeJava(java); - } catch (InterruptedException ignored) { - } - } + return res; } @Override - protected Skin createDefaultSkin() { - return new JavaRuntimeItemSkin(this); + protected ListCell createListCell(JFXListView listView) { + return new JavaItemCell(); } - } - private static final class JavaRuntimeItemSkin extends SkinBase { + private static final class JavaItemCell extends ListCell { + private final Node graphic; + private final TwoLineListItem content; - JavaRuntimeItemSkin(JavaItem control) { - super(control); - JavaRuntime java = control.getJava(); - String vendor = JavaInfo.normalizeVendor(java.getVendor()); + private SVG removeIcon; + private final StackPane removeIconPane; + private final Tooltip removeTooltip = new Tooltip(); + JavaItemCell() { BorderPane root = new BorderPane(); HBox center = new HBox(); @@ -248,42 +230,39 @@ private static final class JavaRuntimeItemSkin extends SkinBase { center.setSpacing(8); center.setAlignment(Pos.CENTER_LEFT); - TwoLineListItem item = new TwoLineListItem(); - item.setTitle((java.isJDK() ? "JDK" : "JRE") + " " + java.getVersion()); - item.setSubtitle(java.getBinary().toString()); - item.addTag(i18n("java.info.architecture") + ": " + java.getArchitecture().getDisplayName()); - if (vendor != null) - item.addTag(i18n("java.info.vendor") + ": " + vendor); - BorderPane.setAlignment(item, Pos.CENTER); - center.getChildren().setAll(item); + this.content = new TwoLineListItem(); + + BorderPane.setAlignment(content, Pos.CENTER); + center.getChildren().setAll(content); root.setCenter(center); HBox right = new HBox(); right.setAlignment(Pos.CENTER_RIGHT); { JFXButton revealButton = new JFXButton(); + revealButton.setGraphic(FXUtils.limitingSize(SVG.FOLDER_OPEN.createIcon(24), 24, 24)); revealButton.getStyleClass().add("toggle-icon4"); - revealButton.setGraphic(FXUtils.limitingSize(SVG.FOLDER_OPEN.createIcon(Theme.blackFill(), 24), 24, 24)); - revealButton.setOnAction(e -> control.onReveal()); + revealButton.setOnAction(e -> { + JavaRuntime java = getItem(); + if (java != null) + onReveal(java); + }); FXUtils.installFastTooltip(revealButton, i18n("reveal.in_file_manager")); JFXButton removeButton = new JFXButton(); removeButton.getStyleClass().add("toggle-icon4"); - removeButton.setOnAction(e -> Controllers.confirm( - java.isManaged() ? i18n("java.uninstall.confirm") : i18n("java.disable.confirm"), - i18n("message.warning"), - control::onRemove, - null - )); - if (java.isManaged()) { - removeButton.setGraphic(FXUtils.limitingSize(SVG.DELETE_FOREVER.createIcon(Theme.blackFill(), 24), 24, 24)); - FXUtils.installFastTooltip(removeButton, i18n("java.uninstall")); - if (JavaRuntime.CURRENT_JAVA != null && java.getBinary().equals(JavaRuntime.CURRENT_JAVA.getBinary())) - removeButton.setDisable(true); - } else { - removeButton.setGraphic(FXUtils.limitingSize(SVG.DELETE.createIcon(Theme.blackFill(), 24), 24, 24)); - FXUtils.installFastTooltip(removeButton, i18n("java.disable")); - } + removeButton.setOnAction(e -> { + JavaRuntime java = getItem(); + if (java != null) + onRemove(java); + }); + FXUtils.installFastTooltip(removeButton, removeTooltip); + + this.removeIconPane = new StackPane(); + removeIconPane.setAlignment(Pos.CENTER); + FXUtils.setLimitWidth(removeIconPane, 24); + FXUtils.setLimitHeight(removeIconPane, 24); + removeButton.setGraphic(removeIconPane); right.getChildren().setAll(revealButton, removeButton); } @@ -292,31 +271,75 @@ private static final class JavaRuntimeItemSkin extends SkinBase { root.getStyleClass().add("md-list-cell"); root.setPadding(new Insets(8)); - getChildren().setAll(new RipplerContainer(root)); + this.graphic = new RipplerContainer(root); } - } - private static final class JavaPageSkin extends ToolbarListPageSkin { + @Override + protected void updateItem(JavaRuntime item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setGraphic(null); + } else { + content.setTitle((item.isJDK() ? "JDK" : "JRE") + " " + item.getVersion()); + content.setSubtitle(item.getBinary().toString()); + + content.getTags().clear(); + content.addTag(i18n("java.info.architecture") + ": " + item.getArchitecture().getDisplayName()); + String vendor = JavaInfo.normalizeVendor(item.getVendor()); + if (vendor != null) + content.addTag(i18n("java.info.vendor") + ": " + vendor); + + SVG newRemoveIcon = item.isManaged() ? SVG.DELETE_FOREVER : SVG.DELETE; + if (removeIcon != newRemoveIcon) { + removeIcon = newRemoveIcon; + removeIconPane.getChildren().setAll(removeIcon.createIcon(24)); + removeTooltip.setText(item.isManaged() ? i18n("java.uninstall") : i18n("java.disable")); + } - JavaPageSkin(JavaManagementPage skinnable) { - super(skinnable); + setGraphic(graphic); + } } - @Override - protected List initializeToolbar(JavaManagementPage skinnable) { - ArrayList res = new ArrayList<>(4); - - res.add(createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, JavaManager::refresh)); - if (skinnable.onInstallJava != null) { - res.add(createToolbarButton2(i18n("java.download"), SVG.DOWNLOAD, skinnable.onInstallJava)); + private void onReveal(JavaRuntime java) { + Path target; + Path parent = java.getBinary().getParent(); + if (parent != null + && parent.getParent() != null + && parent.getFileName() != null + && parent.getFileName().toString().equals("bin") + && Files.exists(parent.getParent().resolve("release"))) { + target = parent.getParent(); + } else { + target = java.getBinary(); } - res.add(createToolbarButton2(i18n("java.add"), SVG.ADD, skinnable::onAddJava)); - JFXButton disableJava = createToolbarButton2(i18n("java.disabled.management"), SVG.FORMAT_LIST_BULLETED, skinnable::onShowRestoreJavaPage); - disableJava.disableProperty().bind(Bindings.isEmpty(ConfigHolder.globalConfig().getDisabledJava())); - res.add(disableJava); + FXUtils.showFileInExplorer(target); + } - return res; + private void onRemove(JavaRuntime java) { + if (java.isManaged()) { + Controllers.confirm( + i18n("java.uninstall.confirm"), + i18n("message.warning"), + () -> Controllers.taskDialog(JavaManager.getUninstallJavaTask(java), i18n("java.uninstall"), TaskCancellationAction.NORMAL), + null + ); + } else { + Controllers.confirm( + i18n("java.disable.confirm"), + i18n("message.warning"), + () -> { + String path = java.getBinary().toString(); + ConfigHolder.globalConfig().getUserJava().remove(path); + ConfigHolder.globalConfig().getDisabledJava().add(path); + try { + JavaManager.removeJava(java); + } catch (InterruptedException ignored) { + } + }, + null + ); + } } } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/JavaRestorePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/JavaRestorePage.java index b81c2e6cf7..1b3b4ebaa9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/JavaRestorePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/JavaRestorePage.java @@ -32,7 +32,6 @@ import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import org.jackhuang.hmcl.java.JavaManager; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.ui.*; import org.jackhuang.hmcl.ui.construct.MessageDialogPane; @@ -160,7 +159,7 @@ private static final class DisabledJavaItemSkin extends SkinBase skinnable.onReveal()); FXUtils.installFastTooltip(revealButton, i18n("reveal.in_file_manager")); @@ -169,7 +168,7 @@ private static final class DisabledJavaItemSkin extends SkinBase skinnable.onRemove()); FXUtils.installFastTooltip(removeButton, i18n("java.disabled.management.remove")); @@ -177,7 +176,7 @@ private static final class DisabledJavaItemSkin extends SkinBase skinnable.onRestore()); FXUtils.installFastTooltip(restoreButton, i18n("java.disabled.management.restore")); @@ -193,7 +192,7 @@ private static final class DisabledJavaItemSkin extends SkinBase { + private static final class JavaRestorePageSkin extends ToolbarListPageSkin { JavaRestorePageSkin(JavaRestorePage skinnable) { super(skinnable); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java index 07753b9345..de82ee32be 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java @@ -69,7 +69,7 @@ public LauncherSettingsPage() { .addNavigationDrawerTab(tab, downloadTab, i18n("download"), SVG.DOWNLOAD) .startCategory(i18n("help").toUpperCase(Locale.ROOT)) .addNavigationDrawerTab(tab, helpTab, i18n("help"), SVG.HELP, SVG.HELP_FILL) - .addNavigationDrawerTab(tab, feedbackTab, i18n("feedback"), SVG.FEEDBACK, SVG.FEEDBACK_FILL) + .addNavigationDrawerTab(tab, feedbackTab, i18n("contact"), SVG.FEEDBACK, SVG.FEEDBACK_FILL) .addNavigationDrawerTab(tab, aboutTab, i18n("about"), SVG.INFO, SVG.INFO_FILL); FXUtils.setLimitWidth(sideBar, 200); setLeft(sideBar); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java index 67f33d9043..8485178683 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java @@ -22,14 +22,12 @@ import javafx.animation.KeyFrame; import javafx.animation.KeyValue; import javafx.animation.Timeline; -import javafx.beans.binding.Bindings; import javafx.beans.property.*; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.event.EventHandler; import javafx.geometry.Insets; import javafx.geometry.Pos; -import javafx.scene.Node; import javafx.scene.control.Label; import javafx.scene.control.Tooltip; import javafx.scene.image.ImageView; @@ -39,7 +37,6 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; -import javafx.scene.shape.Rectangle; import javafx.scene.text.TextFlow; import javafx.util.Duration; import org.jackhuang.hmcl.Metadata; @@ -50,9 +47,9 @@ import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.setting.Profiles; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.theme.Themes; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; @@ -60,10 +57,9 @@ import org.jackhuang.hmcl.ui.animation.ContainerAnimations; import org.jackhuang.hmcl.ui.animation.TransitionPane; import org.jackhuang.hmcl.ui.construct.MessageDialogPane; -import org.jackhuang.hmcl.ui.construct.PopupMenu; import org.jackhuang.hmcl.ui.construct.TwoLineListItem; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; -import org.jackhuang.hmcl.ui.versions.GameItem; +import org.jackhuang.hmcl.ui.versions.GameListPopupMenu; import org.jackhuang.hmcl.ui.versions.Versions; import org.jackhuang.hmcl.upgrade.RemoteVersion; import org.jackhuang.hmcl.upgrade.UpdateChecker; @@ -74,7 +70,6 @@ import org.jackhuang.hmcl.util.TaskCancellationAction; import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.javafx.BindingMapping; -import org.jackhuang.hmcl.util.javafx.MappedObservableList; import java.io.IOException; import java.util.List; @@ -93,16 +88,10 @@ public final class MainPage extends StackPane implements DecoratorPage { private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(); - private final PopupMenu menu = new PopupMenu(); - - private final StackPane popupWrapper = new StackPane(menu); - private final JFXPopup popup = new JFXPopup(popupWrapper); - private final StringProperty currentGame = new SimpleStringProperty(this, "currentGame"); private final BooleanProperty showUpdate = new SimpleBooleanProperty(this, "showUpdate"); private final ObjectProperty latestVersion = new SimpleObjectProperty<>(this, "latestVersion"); private final ObservableList versions = FXCollections.observableArrayList(); - private final ObservableList versionNodes; private Profile profile; private TransitionPane announcementPane; @@ -121,6 +110,7 @@ public final class MainPage extends StackPane implements DecoratorPage { titleLabel.setRotate(180); } titleLabel.getStyleClass().add("jfx-decorator-title"); + titleLabel.textFillProperty().bind(Themes.titleFillProperty()); titleNode.getChildren().setAll(titleIcon, titleLabel); state.setValue(new State(null, titleNode, false, false, true)); @@ -152,7 +142,7 @@ public final class MainPage extends StackPane implements DecoratorPage { } }); btnHide.getStyleClass().add("announcement-close-button"); - btnHide.setGraphic(SVG.CLOSE.createIcon(Theme.blackFill(), 20)); + btnHide.setGraphic(SVG.CLOSE.createIcon(20)); titleBar.setRight(btnHide); TextFlow body = FXUtils.segmentToTextFlow(content, Controllers::onHyperlinkAction); @@ -163,11 +153,13 @@ public final class MainPage extends StackPane implements DecoratorPage { announcementCard.getStyleClass().addAll("card", "announcement"); VBox announcementBox = new VBox(16); + announcementBox.setPadding(new Insets(15)); announcementBox.getChildren().add(announcementCard); announcementPane = new TransitionPane(); announcementPane.setContent(announcementBox, ContainerAnimations.NONE); + StackPane.setMargin(announcementPane, new Insets(-15)); getChildren().add(announcementPane); } @@ -187,20 +179,17 @@ public final class MainPage extends StackPane implements DecoratorPage { StackPane.setAlignment(hBox, Pos.CENTER_LEFT); StackPane.setMargin(hBox, new Insets(9, 12, 9, 16)); { - Label lblIcon = new Label(); - lblIcon.setGraphic(SVG.UPDATE.createIcon(Theme.whiteFill(), 20)); - TwoLineListItem prompt = new TwoLineListItem(); prompt.setSubtitle(i18n("update.bubble.subtitle")); prompt.setPickOnBounds(false); prompt.titleProperty().bind(BindingMapping.of(latestVersionProperty()).map(latestVersion -> latestVersion == null ? "" : i18n("update.bubble.title", latestVersion.getVersion()))); - hBox.getChildren().setAll(lblIcon, prompt); + hBox.getChildren().setAll(SVG.UPDATE.createIcon(20), prompt); } JFXButton closeUpdateButton = new JFXButton(); - closeUpdateButton.setGraphic(SVG.CLOSE.createIcon(Theme.whiteFill(), 10)); + closeUpdateButton.setGraphic(SVG.CLOSE.createIcon(10)); StackPane.setAlignment(closeUpdateButton, Pos.TOP_RIGHT); closeUpdateButton.getStyleClass().add("toggle-icon-tiny"); StackPane.setMargin(closeUpdateButton, new Insets(5)); @@ -209,10 +198,8 @@ public final class MainPage extends StackPane implements DecoratorPage { updatePane.getChildren().setAll(hBox, closeUpdateButton); } - StackPane launchPane = new StackPane(); + HBox launchPane = new HBox(); launchPane.getStyleClass().add("launch-pane"); - launchPane.setMaxWidth(230); - launchPane.setMaxHeight(55); FXUtils.onScroll(launchPane, versions, list -> { String currentId = getCurrentGame(); return Lang.indexWhere(list, instance -> instance.getId().equals(currentId)); @@ -221,16 +208,11 @@ public final class MainPage extends StackPane implements DecoratorPage { StackPane.setAlignment(launchPane, Pos.BOTTOM_RIGHT); { JFXButton launchButton = new JFXButton(); - launchButton.setPrefWidth(230); - launchButton.setPrefHeight(55); - //launchButton.setButtonType(JFXButton.ButtonType.RAISED); + launchButton.getStyleClass().add("launch-button"); launchButton.setDefaultButton(true); - launchButton.setClip(new Rectangle(-100, -100, 310, 200)); { VBox graphic = new VBox(); graphic.setAlignment(Pos.CENTER); - graphic.setTranslateX(-7); - graphic.setMaxWidth(200); Label launchLabel = new Label(); launchLabel.setStyle("-fx-font-size: 16px;"); Label currentLabel = new Label(); @@ -263,26 +245,18 @@ public void accept(String currentGame) { launchButton.setGraphic(graphic); } - Rectangle separator = new Rectangle(); - separator.setWidth(1); - separator.setHeight(57); - separator.setTranslateX(95); - separator.setMouseTransparent(true); - menuButton = new JFXButton(); - menuButton.setPrefHeight(55); - menuButton.setPrefWidth(230); - //menuButton.setButtonType(JFXButton.ButtonType.RAISED); - menuButton.setStyle("-fx-font-size: 15px;"); - menuButton.setOnAction(e -> onMenu()); - menuButton.setClip(new Rectangle(211, -100, 100, 200)); - StackPane graphic = new StackPane(); - Node svg = SVG.ARROW_DROP_UP.createIcon(Theme.foregroundFillBinding(), 30); - StackPane.setAlignment(svg, Pos.CENTER_RIGHT); - graphic.getChildren().setAll(svg); - graphic.setTranslateX(6); + menuButton.getStyleClass().add("menu-button"); + menuButton.setOnAction(e -> GameListPopupMenu.show( + menuButton, + JFXPopup.PopupVPosition.BOTTOM, + JFXPopup.PopupHPosition.RIGHT, + 0, + -menuButton.getHeight(), + profile, versions + )); FXUtils.installFastTooltip(menuButton, i18n("version.switch")); - menuButton.setGraphic(graphic); + menuButton.setGraphic(SVG.ARROW_DROP_UP.createIcon(30)); EventHandler secondaryClickHandle = event -> { if (event.getButton() == MouseButton.SECONDARY && event.getClickCount() == 1) { @@ -293,24 +267,11 @@ public void accept(String currentGame) { launchButton.addEventHandler(MouseEvent.MOUSE_CLICKED, secondaryClickHandle); menuButton.addEventHandler(MouseEvent.MOUSE_CLICKED, secondaryClickHandle); - launchPane.getChildren().setAll(launchButton, separator, menuButton); + launchPane.getChildren().setAll(launchButton, menuButton); } getChildren().addAll(updatePane, launchPane); - menu.setMaxHeight(365); - menu.setMaxWidth(545); - menu.setAlwaysShowingVBar(true); - FXUtils.onClicked(menu, popup::hide); - versionNodes = MappedObservableList.create(versions, version -> { - Node node = PopupMenu.wrapPopupMenuItem(new GameItem(profile, version.getId())); - FXUtils.onClicked(node, () -> { - profile.setSelectedVersion(version.getId()); - popup.hide(); - }); - return node; - }); - Bindings.bindContent(menu.getContent(), versionNodes); } private void showUpdate(boolean show) { @@ -348,7 +309,7 @@ private void doAnimation(boolean show) { private void launch() { Profile profile = Profiles.getSelectedProfile(); - Versions.launch(profile, profile.getSelectedVersion(), null); + Versions.launch(profile, profile.getSelectedVersion()); } private void launchNoGame() { @@ -389,30 +350,6 @@ private void launchNoGame() { Controllers.taskDialog(task, i18n("version.launch.empty.installing"), TaskCancellationAction.NORMAL); } - private void onMenu() { - Node contentNode; - if (menu.getContent().isEmpty()) { - Label placeholder = new Label(i18n("version.empty")); - placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: gray; -fx-font-style: italic;"); - contentNode = placeholder; - } else { - contentNode = menu; - } - - popupWrapper.getChildren().setAll(contentNode); - - if (popup.isShowing()) { - popup.hide(); - } - popup.show( - menuButton, - JFXPopup.PopupVPosition.BOTTOM, - JFXPopup.PopupHPosition.RIGHT, - 0, - -menuButton.getHeight() - ); - } - private void onUpgrade() { RemoteVersion target = UpdateChecker.getLatestVersion(); if (target == null) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java index 67a2678b06..d0f1e3bc8b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java @@ -17,10 +17,7 @@ */ package org.jackhuang.hmcl.ui.main; -import com.jfoenix.controls.JFXButton; -import com.jfoenix.controls.JFXComboBox; -import com.jfoenix.controls.JFXSlider; -import com.jfoenix.controls.JFXTextField; +import com.jfoenix.controls.*; import com.jfoenix.effects.JFXDepthManager; import javafx.application.Platform; import javafx.beans.binding.Bindings; @@ -35,12 +32,11 @@ import javafx.scene.control.Label; import javafx.scene.control.ScrollPane; import javafx.scene.layout.*; -import javafx.scene.paint.Color; import javafx.scene.text.Font; import javafx.scene.text.FontSmoothingType; import org.jackhuang.hmcl.setting.EnumBackgroundImage; import org.jackhuang.hmcl.setting.FontManager; -import org.jackhuang.hmcl.setting.Theme; +import org.jackhuang.hmcl.theme.ThemeColor; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.construct.*; @@ -81,6 +77,22 @@ public PersonalizationPage() { getChildren().setAll(scrollPane); ComponentList themeList = new ComponentList(); + { + BorderPane brightnessPane = new BorderPane(); + themeList.getContent().add(brightnessPane); + + Label left = new Label(i18n("settings.launcher.brightness")); + BorderPane.setAlignment(left, Pos.CENTER_LEFT); + + brightnessPane.setLeft(left); + + JFXComboBox cboBrightness = new JFXComboBox<>(); + cboBrightness.getItems().setAll("auto", "light", "dark"); + cboBrightness.setConverter(FXUtils.stringConverter(name -> i18n("settings.launcher.brightness." + name))); + cboBrightness.valueProperty().bindBidirectional(config().themeBrightnessProperty()); + brightnessPane.setRight(cboBrightness); + } + { BorderPane themePane = new BorderPane(); themeList.getContent().add(themePane); @@ -93,10 +105,9 @@ public PersonalizationPage() { themeColorPickerContainer.setMinHeight(30); themePane.setRight(themeColorPickerContainer); - ColorPicker picker = new ColorPicker(Color.web(Theme.getTheme().getColor())); - picker.getCustomColors().setAll(Theme.SUGGESTED_COLORS); - picker.setOnAction(e -> - config().setTheme(Theme.custom(Theme.getColorDisplayName(picker.getValue())))); + ColorPicker picker = new JFXColorPicker(); + picker.getCustomColors().setAll(ThemeColor.STANDARD_COLORS.stream().map(ThemeColor::color).toList()); + ThemeColor.bindBidirectional(picker, config().themeColorProperty()); themeColorPickerContainer.getChildren().setAll(picker); Platform.runLater(() -> JFXDepthManager.setDepth(picker, 0)); } @@ -111,6 +122,7 @@ public PersonalizationPage() { themeList.getContent().add(animationButton); animationButton.selectedProperty().bindBidirectional(config().animationDisabledProperty()); animationButton.setTitle(i18n("settings.launcher.turn_off_animations")); + animationButton.setSubtitle(i18n("settings.take_effect_after_restart")); } content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.appearance")), themeList); @@ -157,6 +169,7 @@ public PersonalizationPage() { slider.setMinorTickCount(1); slider.setBlockIncrement(5); slider.setSnapToTicks(true); + slider.setPadding(new Insets(9, 0, 0, 0)); HBox.setHgrow(slider, Priority.ALWAYS); if (config().getBackgroundImageType() == EnumBackgroundImage.TRANSLUCENT) { @@ -221,7 +234,14 @@ public void changed(ObservableValue observable, E .fallbackTo(12.0) .asPredicate(Validator.addTo(txtLogFontSize))); - hBox.getChildren().setAll(cboLogFont, txtLogFontSize); + JFXButton clearButton = new JFXButton(); + clearButton.getStyleClass().add("toggle-icon4"); + clearButton.setGraphic(SVG.RESTORE.createIcon()); + clearButton.setOnAction(e -> cboLogFont.setValue(null)); + + FXUtils.installFastTooltip(clearButton, i18n("button.reset")); + + hBox.getChildren().setAll(cboLogFont, txtLogFontSize, clearButton); borderPane.setRight(hBox); } @@ -267,9 +287,11 @@ public void changed(ObservableValue observable, E JFXButton clearButton = new JFXButton(); clearButton.getStyleClass().add("toggle-icon4"); - clearButton.setGraphic(SVG.RESTORE.createIcon(Theme.blackFill(), -1)); + clearButton.setGraphic(SVG.RESTORE.createIcon()); clearButton.setOnAction(e -> cboFont.setValue(null)); + FXUtils.installFastTooltip(clearButton, i18n("button.reset")); + hBox.getChildren().setAll(cboFont, clearButton); borderPane.setRight(hBox); @@ -284,8 +306,12 @@ public void changed(ObservableValue observable, E { BorderPane fontAntiAliasingPane = new BorderPane(); { - Label left = new Label(i18n("settings.launcher.font.anti_aliasing")); - BorderPane.setAlignment(left, Pos.CENTER_LEFT); + VBox left = new VBox(); + Label title = new Label(i18n("settings.launcher.font.anti_aliasing")); + title.getStyleClass().add("title"); + Label subtitle = new Label(i18n("settings.take_effect_after_restart")); + subtitle.getStyleClass().add("subtitle"); + left.getChildren().setAll(title, subtitle); fontAntiAliasingPane.setLeft(left); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java index 169bea4292..38a7122914 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java @@ -17,9 +17,11 @@ */ package org.jackhuang.hmcl.ui.main; +import com.jfoenix.controls.JFXPopup; import javafx.beans.property.ReadOnlyObjectProperty; - +import javafx.scene.control.Label; import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.event.EventBus; import org.jackhuang.hmcl.event.RefreshedVersionsEvent; import org.jackhuang.hmcl.game.HMCLGameRepository; @@ -39,12 +41,14 @@ import org.jackhuang.hmcl.ui.construct.AdvancedListBox; import org.jackhuang.hmcl.ui.construct.AdvancedListItem; import org.jackhuang.hmcl.ui.construct.MessageDialogPane; +import org.jackhuang.hmcl.ui.construct.PopupMenu; 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.nbt.NBTEditorPage; import org.jackhuang.hmcl.ui.nbt.NBTFileType; import org.jackhuang.hmcl.ui.versions.GameAdvancedListItem; +import org.jackhuang.hmcl.ui.versions.GameListPopupMenu; import org.jackhuang.hmcl.ui.versions.Versions; import org.jackhuang.hmcl.upgrade.UpdateChecker; import org.jackhuang.hmcl.util.Lang; @@ -63,9 +67,9 @@ import java.util.stream.Collectors; import static org.jackhuang.hmcl.ui.FXUtils.runInFX; -import static org.jackhuang.hmcl.ui.versions.VersionPage.wrap; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; +import static org.jackhuang.hmcl.ui.FXUtils.wrap; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; public class RootPage extends DecoratorAnimatedPage implements DecoratorPage { private MainPage mainPage = null; @@ -144,6 +148,7 @@ protected Skin(RootPage control) { // first item in left sidebar AccountAdvancedListItem accountListItem = new AccountAdvancedListItem(); accountListItem.setOnAction(e -> Controllers.navigate(Controllers.getAccountListPage())); + FXUtils.onSecondaryButtonClicked(accountListItem, () -> showAccountListPopupMenu(accountListItem)); accountListItem.accountProperty().bind(Accounts.selectedAccountProperty()); // second item in left sidebar @@ -164,6 +169,7 @@ protected Skin(RootPage control) { if (AnimationUtils.isAnimationEnabled()) { FXUtils.prepareOnMouseEnter(gameListItem, Controllers::prepareVersionPage); } + FXUtils.onSecondaryButtonClicked(gameListItem, () -> showGameListPopupMenu(gameListItem)); // third item in left sidebar AdvancedListItem gameItem = new AdvancedListItem(); @@ -171,6 +177,7 @@ protected Skin(RootPage control) { gameItem.setActionButtonVisible(false); gameItem.setTitle(i18n("version.manage")); gameItem.setOnAction(e -> Controllers.navigate(Controllers.getGameListPage())); + FXUtils.onSecondaryButtonClicked(gameItem, () -> showGameListPopupMenu(gameItem)); // forth item in left sidebar AdvancedListItem downloadItem = new AdvancedListItem(); @@ -234,13 +241,55 @@ else if (Platform.SYSTEM_PLATFORM.equals(OperatingSystem.LINUX, Architecture.LOO .startCategory(i18n("settings.launcher.general").toUpperCase(Locale.ROOT)) .add(launcherSettingsItem) .add(terracottaItem) - .addNavigationDrawerItem(i18n("chat"), SVG.CHAT, () -> FXUtils.openLink(Metadata.GROUPS_URL)); + .addNavigationDrawerItem(i18n("contact.chat"), SVG.CHAT, () -> { + Controllers.getSettingsPage().showFeedback(); + Controllers.navigate(Controllers.getSettingsPage()); + }); // the root page, with the sidebar in left, navigator in center. setLeft(sideBar); setCenter(getSkinnable().getMainPage()); } + public void showAccountListPopupMenu( + AccountAdvancedListItem accountListItem + ) { + PopupMenu popupMenu = new PopupMenu(); + JFXPopup popup = new JFXPopup(popupMenu); + AdvancedListBox scrollPane = new AdvancedListBox(); + scrollPane.getStyleClass().add("no-padding"); + scrollPane.setPrefWidth(220); + scrollPane.setPrefHeight(-1); + scrollPane.setMaxHeight(260); + + if (Accounts.getAccounts().isEmpty()) { + Label placeholder = new Label(i18n("account.empty")); + placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: -monet-on-surface-variant; -fx-font-style: italic;"); + scrollPane.add(placeholder); + } else { + for (Account account : Accounts.getAccounts()) { + AccountAdvancedListItem item = new AccountAdvancedListItem(account); + item.setOnAction(e -> { + Accounts.setSelectedAccount(account); + popup.hide(); + }); + scrollPane.add(item); + } + } + + popupMenu.getContent().add(scrollPane); + popup.show(accountListItem, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.LEFT, accountListItem.getWidth(), 0); + } + + public void showGameListPopupMenu(AdvancedListItem gameListItem) { + GameListPopupMenu.show(gameListItem, + JFXPopup.PopupVPosition.TOP, + JFXPopup.PopupHPosition.LEFT, + gameListItem.getWidth(), + 0, + getSkinnable().getMainPage().getProfile(), + getSkinnable().getMainPage().getVersions()); + } } private boolean checkedModpack = false; @@ -251,13 +300,23 @@ private void onRefreshedVersions(HMCLGameRepository repository) { checkedModpack = true; if (repository.getVersionCount() == 0) { - Path modpackFile = Metadata.CURRENT_DIRECTORY.resolve("modpack.zip"); - if (Files.exists(modpackFile)) { + Path zipModpack = Metadata.CURRENT_DIRECTORY.resolve("modpack.zip"); + Path mrpackModpack = Metadata.CURRENT_DIRECTORY.resolve("modpack.mrpack"); + + Path modpackFile; + if (Files.exists(zipModpack)) { + modpackFile = zipModpack; + } else if (Files.exists(mrpackModpack)) { + modpackFile = mrpackModpack; + } else { + modpackFile = null; + } + + if (modpackFile != null) { Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(modpackFile)) - .thenApplyAsync( - encoding -> ModpackHelper.readModpackManifest(modpackFile, encoding)) + .thenApplyAsync(encoding -> ModpackHelper.readModpackManifest(modpackFile, encoding)) .thenApplyAsync(modpack -> ModpackHelper - .getInstallTask(repository.getProfile(), modpackFile, modpack.getName(), modpack) + .getInstallTask(repository.getProfile(), modpackFile, modpack.getName(), modpack, null) .executor()) .thenAcceptAsync(Schedulers.javafx(), executor -> { Controllers.taskDialog(executor, i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java index ddc700a719..1ad646e666 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java @@ -17,22 +17,40 @@ */ package org.jackhuang.hmcl.ui.main; +import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXComboBox; +import com.jfoenix.controls.JFXRadioButton; import javafx.application.Platform; import javafx.beans.InvalidationListener; import javafx.beans.WeakInvalidationListener; import javafx.beans.binding.Bindings; import javafx.beans.property.ObjectProperty; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.geometry.VPos; +import javafx.scene.Cursor; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; import javafx.scene.control.ToggleGroup; +import javafx.scene.layout.*; +import javafx.scene.text.TextAlignment; import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.setting.EnumCommonDirectory; import org.jackhuang.hmcl.setting.Settings; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.ui.construct.ComponentList; +import org.jackhuang.hmcl.ui.construct.ComponentSublist; import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType; +import org.jackhuang.hmcl.ui.construct.MultiFileItem; +import org.jackhuang.hmcl.ui.construct.OptionToggleButton; import org.jackhuang.hmcl.upgrade.RemoteVersion; import org.jackhuang.hmcl.upgrade.UpdateChannel; import org.jackhuang.hmcl.upgrade.UpdateChecker; import org.jackhuang.hmcl.upgrade.UpdateHandler; import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.i18n.SupportedLocale; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.IOUtils; @@ -45,86 +63,264 @@ import java.nio.file.Path; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; -import java.util.List; -import java.util.Optional; +import java.util.*; import java.util.zip.GZIPInputStream; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.ui.FXUtils.stringConverter; import static org.jackhuang.hmcl.util.Lang.thread; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.selectedItemPropertyFor; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -public final class SettingsPage extends SettingsView { +public final class SettingsPage extends ScrollPane { - private InvalidationListener updateListener; + @SuppressWarnings("FieldCanBeLocal") + private final ToggleGroup updateChannelGroup; + @SuppressWarnings("FieldCanBeLocal") + private final InvalidationListener updateListener; public SettingsPage() { - FXUtils.smoothScrolling(scroll); - - // ==== Languages ==== - cboLanguage.getItems().setAll(SupportedLocale.getSupportedLocales()); - selectedItemPropertyFor(cboLanguage).bindBidirectional(config().localizationProperty()); - - disableAutoGameOptionsPane.selectedProperty().bindBidirectional(config().disableAutoGameOptionsProperty()); - // ==== - - fileCommonLocation.selectedDataProperty().bindBidirectional(config().commonDirTypeProperty()); - fileCommonLocationSublist.subtitleProperty().bind( - Bindings.createObjectBinding(() -> Optional.ofNullable(Settings.instance().getCommonDirectory()) - .orElse(i18n("launcher.cache_directory.disabled")), - config().commonDirectoryProperty(), config().commonDirTypeProperty())); - - // ==== Update ==== - FXUtils.installFastTooltip(btnUpdate, i18n("update.tooltip")); - updateListener = any -> { - btnUpdate.setVisible(UpdateChecker.isOutdated()); - - if (UpdateChecker.isOutdated()) { - lblUpdateSub.setText(i18n("update.newest_version", UpdateChecker.getLatestVersion().getVersion())); - lblUpdateSub.getStyleClass().setAll("update-label"); - - lblUpdate.setText(i18n("update.found")); - lblUpdate.getStyleClass().setAll("update-label"); - } else if (UpdateChecker.isCheckingUpdate()) { - lblUpdateSub.setText(i18n("update.checking")); - lblUpdateSub.getStyleClass().setAll("subtitle-label"); - - lblUpdate.setText(i18n("update")); - lblUpdate.getStyleClass().setAll(); - } else { - lblUpdateSub.setText(i18n("update.latest")); - lblUpdateSub.getStyleClass().setAll("subtitle-label"); - - lblUpdate.setText(i18n("update")); - lblUpdate.getStyleClass().setAll(); + this.setFitToWidth(true); + + VBox rootPane = new VBox(); + rootPane.setPadding(new Insets(10)); + this.setContent(rootPane); + FXUtils.smoothScrolling(this); + + ComponentList settingsPane = new ComponentList(); + { + { + StackPane sponsorPane = new StackPane(); + sponsorPane.setCursor(Cursor.HAND); + FXUtils.onClicked(sponsorPane, this::onSponsor); + sponsorPane.setPadding(new Insets(8, 0, 8, 0)); + + GridPane gridPane = new GridPane(); + + ColumnConstraints col = new ColumnConstraints(); + col.setHgrow(Priority.SOMETIMES); + col.setMaxWidth(Double.POSITIVE_INFINITY); + + gridPane.getColumnConstraints().setAll(col); + + RowConstraints row = new RowConstraints(); + row.setMinHeight(Double.NEGATIVE_INFINITY); + row.setValignment(VPos.TOP); + row.setVgrow(Priority.SOMETIMES); + gridPane.getRowConstraints().setAll(row); + + { + Label label = new Label(i18n("sponsor.hmcl")); + label.setWrapText(true); + label.setTextAlignment(TextAlignment.JUSTIFY); + GridPane.setRowIndex(label, 0); + GridPane.setColumnIndex(label, 0); + gridPane.getChildren().add(label); + } + + sponsorPane.getChildren().setAll(gridPane); + settingsPane.getContent().add(sponsorPane); + } + + { + ComponentSublist updatePane = new ComponentSublist(); + updatePane.setTitle(i18n("update")); + updatePane.setHasSubtitle(true); + + final Label lblUpdate; + final Label lblUpdateSub; + { + VBox headerLeft = new VBox(); + + lblUpdate = new Label(i18n("update")); + lblUpdate.getStyleClass().add("title-label"); + lblUpdateSub = new Label(); + lblUpdateSub.getStyleClass().add("subtitle-label"); + + headerLeft.getChildren().setAll(lblUpdate, lblUpdateSub); + updatePane.setHeaderLeft(headerLeft); + } + + { + JFXButton btnUpdate = new JFXButton(); + btnUpdate.setOnAction(e -> onUpdate()); + btnUpdate.getStyleClass().add("toggle-icon4"); + btnUpdate.setGraphic(SVG.UPDATE.createIcon(20)); + FXUtils.installFastTooltip(btnUpdate, i18n("update.tooltip")); + + updateListener = any -> { + btnUpdate.setVisible(UpdateChecker.isOutdated()); + + if (UpdateChecker.isOutdated()) { + lblUpdateSub.setText(i18n("update.newest_version", UpdateChecker.getLatestVersion().getVersion())); + lblUpdateSub.getStyleClass().setAll("update-label"); + + lblUpdate.setText(i18n("update.found")); + lblUpdate.getStyleClass().setAll("update-label"); + } else if (UpdateChecker.isCheckingUpdate()) { + lblUpdateSub.setText(i18n("update.checking")); + lblUpdateSub.getStyleClass().setAll("subtitle-label"); + + lblUpdate.setText(i18n("update")); + lblUpdate.getStyleClass().setAll("title-label"); + } else { + lblUpdateSub.setText(i18n("update.latest")); + lblUpdateSub.getStyleClass().setAll("subtitle-label"); + + lblUpdate.setText(i18n("update")); + lblUpdate.getStyleClass().setAll("title-label"); + } + }; + UpdateChecker.latestVersionProperty().addListener(new WeakInvalidationListener(updateListener)); + UpdateChecker.outdatedProperty().addListener(new WeakInvalidationListener(updateListener)); + UpdateChecker.checkingUpdateProperty().addListener(new WeakInvalidationListener(updateListener)); + updateListener.invalidated(null); + + updatePane.setHeaderRight(btnUpdate); + } + + { + VBox content = new VBox(); + content.setSpacing(8); + + JFXRadioButton chkUpdateStable = new JFXRadioButton(i18n("update.channel.stable")); + JFXRadioButton chkUpdateDev = new JFXRadioButton(i18n("update.channel.dev")); + + updateChannelGroup = new ToggleGroup(); + chkUpdateDev.setToggleGroup(updateChannelGroup); + chkUpdateDev.setUserData(UpdateChannel.DEVELOPMENT); + chkUpdateStable.setToggleGroup(updateChannelGroup); + chkUpdateStable.setUserData(UpdateChannel.STABLE); + + Label noteWrapper = new Label(i18n("update.note")); + VBox.setMargin(noteWrapper, new Insets(10, 0, 0, 0)); + + content.getChildren().setAll(chkUpdateStable, chkUpdateDev, noteWrapper); + + updatePane.getContent().add(content); + } + settingsPane.getContent().add(updatePane); + } + + { + OptionToggleButton previewPane = new OptionToggleButton(); + previewPane.setTitle(i18n("update.preview")); + previewPane.setSubtitle(i18n("update.preview.subtitle")); + previewPane.selectedProperty().bindBidirectional(config().acceptPreviewUpdateProperty()); + + ObjectProperty updateChannel = selectedItemPropertyFor(updateChannelGroup, UpdateChannel.class); + updateChannel.set(UpdateChannel.getChannel()); + InvalidationListener checkUpdateListener = e -> { + UpdateChecker.requestCheckUpdate(updateChannel.get(), previewPane.isSelected()); + }; + updateChannel.addListener(checkUpdateListener); + previewPane.selectedProperty().addListener(checkUpdateListener); + + settingsPane.getContent().add(previewPane); + } + + { + MultiFileItem fileCommonLocation = new MultiFileItem<>(); + fileCommonLocation.loadChildren(Arrays.asList( + new MultiFileItem.Option<>(i18n("launcher.cache_directory.default"), EnumCommonDirectory.DEFAULT), + new MultiFileItem.FileOption<>(i18n("settings.custom"), EnumCommonDirectory.CUSTOM) + .setChooserTitle(i18n("launcher.cache_directory.choose")) + .setDirectory(true) + .bindBidirectional(config().commonDirectoryProperty()) + )); + fileCommonLocation.selectedDataProperty().bindBidirectional(config().commonDirTypeProperty()); + + ComponentSublist fileCommonLocationSublist = new ComponentSublist(); + fileCommonLocationSublist.getContent().add(fileCommonLocation); + fileCommonLocationSublist.setTitle(i18n("launcher.cache_directory")); + fileCommonLocationSublist.setHasSubtitle(true); + fileCommonLocationSublist.subtitleProperty().bind( + Bindings.createObjectBinding(() -> Optional.ofNullable(Settings.instance().getCommonDirectory()) + .orElse(i18n("launcher.cache_directory.disabled")), + config().commonDirectoryProperty(), config().commonDirTypeProperty())); + + JFXButton cleanButton = FXUtils.newBorderButton(i18n("launcher.cache_directory.clean")); + cleanButton.setOnAction(e -> clearCacheDirectory()); + fileCommonLocationSublist.setHeaderRight(cleanButton); + + settingsPane.getContent().add(fileCommonLocationSublist); } - }; - UpdateChecker.latestVersionProperty().addListener(new WeakInvalidationListener(updateListener)); - UpdateChecker.outdatedProperty().addListener(new WeakInvalidationListener(updateListener)); - UpdateChecker.checkingUpdateProperty().addListener(new WeakInvalidationListener(updateListener)); - updateListener.invalidated(null); - - ToggleGroup updateChannelGroup = new ToggleGroup(); - chkUpdateDev.setToggleGroup(updateChannelGroup); - chkUpdateDev.setUserData(UpdateChannel.DEVELOPMENT); - chkUpdateStable.setToggleGroup(updateChannelGroup); - chkUpdateStable.setUserData(UpdateChannel.STABLE); - ObjectProperty updateChannel = selectedItemPropertyFor(updateChannelGroup, UpdateChannel.class); - updateChannel.set(UpdateChannel.getChannel()); - - InvalidationListener checkUpdateListener = e -> { - UpdateChecker.requestCheckUpdate(updateChannel.get(), previewPane.isSelected()); - }; - updateChannel.addListener(checkUpdateListener); - previewPane.selectedProperty().addListener(checkUpdateListener); - // ==== + + { + BorderPane languagePane = new BorderPane(); + + VBox left = new VBox(); + Label title = new Label(i18n("settings.launcher.language")); + title.getStyleClass().add("title"); + Label subtitle = new Label(i18n("settings.take_effect_after_restart")); + subtitle.getStyleClass().add("subtitle"); + left.getChildren().setAll(title, subtitle); + languagePane.setLeft(left); + + SupportedLocale currentLocale = I18n.getLocale(); + JFXComboBox cboLanguage = new JFXComboBox<>(); + cboLanguage.setConverter(stringConverter(locale -> { + if (locale.isDefault()) + return locale.getDisplayName(currentLocale); + else if (locale.isSameLanguage(currentLocale)) + return locale.getDisplayName(locale); + else + return locale.getDisplayName(currentLocale) + " - " + locale.getDisplayName(locale); + })); + cboLanguage.getItems().setAll(SupportedLocale.getSupportedLocales()); + selectedItemPropertyFor(cboLanguage).bindBidirectional(config().localizationProperty()); + + FXUtils.setLimitWidth(cboLanguage, 300); + languagePane.setRight(cboLanguage); + + settingsPane.getContent().add(languagePane); + } + + { + OptionToggleButton disableAutoGameOptionsPane = new OptionToggleButton(); + disableAutoGameOptionsPane.setTitle(i18n("settings.launcher.disable_auto_game_options")); + disableAutoGameOptionsPane.selectedProperty().bindBidirectional(config().disableAutoGameOptionsProperty()); + + settingsPane.getContent().add(disableAutoGameOptionsPane); + } + + { + BorderPane debugPane = new BorderPane(); + + Label left = new Label(i18n("settings.launcher.debug")); + BorderPane.setAlignment(left, Pos.CENTER_LEFT); + debugPane.setLeft(left); + + JFXButton openLogFolderButton = new JFXButton(i18n("settings.launcher.launcher_log.reveal")); + openLogFolderButton.setOnAction(e -> openLogFolder()); + openLogFolderButton.getStyleClass().add("jfx-button-border"); + if (LOG.getLogFile() == null) + openLogFolderButton.setDisable(true); + + JFXButton logButton = FXUtils.newBorderButton(i18n("settings.launcher.launcher_log.export")); + logButton.setOnAction(e -> onExportLogs()); + + HBox buttonBox = new HBox(); + buttonBox.setSpacing(10); + buttonBox.getChildren().addAll(openLogFolderButton, logButton); + BorderPane.setAlignment(buttonBox, Pos.CENTER_RIGHT); + debugPane.setRight(buttonBox); + + settingsPane.getContent().add(debugPane); + } + + rootPane.getChildren().add(settingsPane); + } + } + + private void openLogFolder() { + FXUtils.openFolder(LOG.getLogFile().getParent()); } - @Override - protected void onUpdate() { + private void onUpdate() { RemoteVersion target = UpdateChecker.getLatestVersion(); if (target == null) { return; @@ -132,6 +328,19 @@ protected void onUpdate() { UpdateHandler.updateFrom(target); } + private static String getEntryName(Set entryNames, String name) { + if (entryNames.add(name)) { + return name; + } + + for (long i = 1; ; i++) { + String newName = name + "." + i; + if (entryNames.add(newName)) { + return newName; + } + } + } + /// This method guarantees to close both `input` and the current zip entry. /// /// If no exception occurs, this method returns `true`; @@ -168,8 +377,7 @@ private static boolean exportLogFile(ZipOutputStream output, } } - @Override - protected void onExportLogs() { + private void onExportLogs() { thread(() -> { String nameBase = "hmcl-exported-logs-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")); List recentLogFiles = LOG.findRecentLogFiles(5); @@ -192,6 +400,8 @@ protected void onExportLogs() { try (var os = Files.newOutputStream(outputFile); var zos = new ZipOutputStream(os)) { + Set entryNames = new HashSet<>(); + for (Path path : recentLogFiles) { String fileName = FileUtils.getName(path); String extension = StringUtils.substringAfterLast(fileName, '.'); @@ -213,7 +423,7 @@ protected void onExportLogs() { input = null; } - String entryName = StringUtils.substringBeforeLast(fileName, "."); + String entryName = getEntryName(entryNames, StringUtils.substringBeforeLast(fileName, ".")); if (input != null && exportLogFile(zos, path, entryName, input, buffer)) continue; } @@ -230,10 +440,10 @@ protected void onExportLogs() { continue; } - exportLogFile(zos, path, fileName, input, buffer); + exportLogFile(zos, path, getEntryName(entryNames, fileName), input, buffer); } - zos.putNextEntry(new ZipEntry("hmcl-latest.log")); + zos.putNextEntry(new ZipEntry(getEntryName(entryNames, "hmcl-latest.log"))); LOG.exportLogs(zos); zos.closeEntry(); } @@ -249,13 +459,11 @@ protected void onExportLogs() { }); } - @Override - protected void onSponsor() { + private void onSponsor() { FXUtils.openLink("https://github.com/HMCL-dev/HMCL"); } - @Override - protected void clearCacheDirectory() { + private void clearCacheDirectory() { String commonDirectory = Settings.instance().getCommonDirectory(); if (commonDirectory != null) { FileUtils.cleanDirectoryQuietly(Path.of(commonDirectory, "cache")); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsView.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsView.java deleted file mode 100644 index 6b90221640..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsView.java +++ /dev/null @@ -1,257 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.ui.main; - -import com.jfoenix.controls.JFXButton; -import com.jfoenix.controls.JFXComboBox; -import com.jfoenix.controls.JFXRadioButton; -import javafx.geometry.Insets; -import javafx.geometry.Pos; -import javafx.geometry.VPos; -import javafx.scene.Cursor; -import javafx.scene.control.Label; -import javafx.scene.control.ScrollPane; -import javafx.scene.layout.*; -import javafx.scene.text.Text; -import javafx.scene.text.TextAlignment; -import javafx.scene.text.TextFlow; -import org.jackhuang.hmcl.setting.EnumCommonDirectory; -import org.jackhuang.hmcl.setting.Theme; -import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.SVG; -import org.jackhuang.hmcl.ui.construct.ComponentList; -import org.jackhuang.hmcl.ui.construct.ComponentSublist; -import org.jackhuang.hmcl.ui.construct.MultiFileItem; -import org.jackhuang.hmcl.ui.construct.OptionToggleButton; -import org.jackhuang.hmcl.util.i18n.I18n; -import org.jackhuang.hmcl.util.i18n.SupportedLocale; - -import java.util.Arrays; - -import static org.jackhuang.hmcl.setting.ConfigHolder.config; -import static org.jackhuang.hmcl.ui.FXUtils.stringConverter; -import static org.jackhuang.hmcl.util.i18n.I18n.i18n; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; - -public abstract class SettingsView extends StackPane { - protected final JFXComboBox cboLanguage; - protected final OptionToggleButton disableAutoGameOptionsPane; - protected final MultiFileItem fileCommonLocation; - protected final ComponentSublist fileCommonLocationSublist; - protected final Label lblUpdate; - protected final Label lblUpdateSub; - protected final JFXRadioButton chkUpdateStable; - protected final JFXRadioButton chkUpdateDev; - protected final JFXButton btnUpdate; - protected final OptionToggleButton previewPane; - protected final ScrollPane scroll; - - public SettingsView() { - scroll = new ScrollPane(); - getChildren().setAll(scroll); - scroll.setStyle("-fx-font-size: 14;"); - scroll.setFitToWidth(true); - - { - VBox rootPane = new VBox(); - rootPane.setPadding(new Insets(32, 10, 32, 10)); - { - ComponentList settingsPane = new ComponentList(); - { - { - StackPane sponsorPane = new StackPane(); - sponsorPane.setCursor(Cursor.HAND); - FXUtils.onClicked(sponsorPane, this::onSponsor); - sponsorPane.setPadding(new Insets(8, 0, 8, 0)); - - GridPane gridPane = new GridPane(); - - ColumnConstraints col = new ColumnConstraints(); - col.setHgrow(Priority.SOMETIMES); - col.setMaxWidth(Double.POSITIVE_INFINITY); - - gridPane.getColumnConstraints().setAll(col); - - RowConstraints row = new RowConstraints(); - row.setMinHeight(Double.NEGATIVE_INFINITY); - row.setValignment(VPos.TOP); - row.setVgrow(Priority.SOMETIMES); - gridPane.getRowConstraints().setAll(row); - - { - Label label = new Label(i18n("sponsor.hmcl")); - label.setWrapText(true); - label.setTextAlignment(TextAlignment.JUSTIFY); - GridPane.setRowIndex(label, 0); - GridPane.setColumnIndex(label, 0); - gridPane.getChildren().add(label); - } - - sponsorPane.getChildren().setAll(gridPane); - settingsPane.getContent().add(sponsorPane); - } - } - - { - ComponentSublist updatePane = new ComponentSublist(); - updatePane.setTitle(i18n("update")); - updatePane.setHasSubtitle(true); - { - VBox headerLeft = new VBox(); - - lblUpdate = new Label(i18n("update")); - lblUpdateSub = new Label(); - lblUpdateSub.getStyleClass().add("subtitle-label"); - - headerLeft.getChildren().setAll(lblUpdate, lblUpdateSub); - updatePane.setHeaderLeft(headerLeft); - } - - { - btnUpdate = new JFXButton(); - btnUpdate.setOnAction(e -> onUpdate()); - btnUpdate.getStyleClass().add("toggle-icon4"); - btnUpdate.setGraphic(SVG.UPDATE.createIcon(Theme.blackFill(), 20)); - - updatePane.setHeaderRight(btnUpdate); - } - - { - VBox content = new VBox(); - content.setSpacing(8); - - chkUpdateStable = new JFXRadioButton(i18n("update.channel.stable")); - chkUpdateDev = new JFXRadioButton(i18n("update.channel.dev")); - - TextFlow noteWrapper = new TextFlow(new Text(i18n("update.note"))); - VBox.setMargin(noteWrapper, new Insets(10, 0, 0, 0)); - - content.getChildren().setAll(chkUpdateStable, chkUpdateDev, noteWrapper); - - updatePane.getContent().add(content); - } - settingsPane.getContent().add(updatePane); - } - - { - previewPane = new OptionToggleButton(); - previewPane.setTitle(i18n("update.preview")); - previewPane.selectedProperty().bindBidirectional(config().acceptPreviewUpdateProperty()); - FXUtils.installFastTooltip(previewPane, i18n("update.preview.tooltip")); - - settingsPane.getContent().add(previewPane); - } - - { - fileCommonLocation = new MultiFileItem<>(); - fileCommonLocationSublist = new ComponentSublist(); - fileCommonLocationSublist.getContent().add(fileCommonLocation); - fileCommonLocationSublist.setTitle(i18n("launcher.cache_directory")); - fileCommonLocationSublist.setHasSubtitle(true); - fileCommonLocation.loadChildren(Arrays.asList( - new MultiFileItem.Option<>(i18n("launcher.cache_directory.default"), EnumCommonDirectory.DEFAULT), - new MultiFileItem.FileOption<>(i18n("settings.custom"), EnumCommonDirectory.CUSTOM) - .setChooserTitle(i18n("launcher.cache_directory.choose")) - .setDirectory(true) - .bindBidirectional(config().commonDirectoryProperty()) - )); - - { - JFXButton cleanButton = FXUtils.newBorderButton(i18n("launcher.cache_directory.clean")); - cleanButton.setOnAction(e -> clearCacheDirectory()); - - fileCommonLocationSublist.setHeaderRight(cleanButton); - } - - settingsPane.getContent().add(fileCommonLocationSublist); - } - - { - BorderPane languagePane = new BorderPane(); - - Label left = new Label(i18n("settings.launcher.language")); - BorderPane.setAlignment(left, Pos.CENTER_LEFT); - languagePane.setLeft(left); - - SupportedLocale currentLocale = I18n.getLocale(); - cboLanguage = new JFXComboBox<>(); - cboLanguage.setConverter(stringConverter(locale -> { - if (locale.isDefault()) - return locale.getDisplayName(currentLocale); - else if (locale.isSameLanguage(currentLocale)) - return locale.getDisplayName(locale); - else - return locale.getDisplayName(currentLocale) + " - " + locale.getDisplayName(locale); - })); - - FXUtils.setLimitWidth(cboLanguage, 300); - languagePane.setRight(cboLanguage); - - settingsPane.getContent().add(languagePane); - } - - { - disableAutoGameOptionsPane = new OptionToggleButton(); - disableAutoGameOptionsPane.setTitle(i18n("settings.launcher.disable_auto_game_options")); - - settingsPane.getContent().add(disableAutoGameOptionsPane); - } - - { - BorderPane debugPane = new BorderPane(); - - Label left = new Label(i18n("settings.launcher.debug")); - BorderPane.setAlignment(left, Pos.CENTER_LEFT); - debugPane.setLeft(left); - - JFXButton openLogFolderButton = new JFXButton(i18n("settings.launcher.launcher_log.reveal")); - openLogFolderButton.setOnAction(e -> openLogFolder()); - openLogFolderButton.getStyleClass().add("jfx-button-border"); - if (LOG.getLogFile() == null) - openLogFolderButton.setDisable(true); - - JFXButton logButton = FXUtils.newBorderButton(i18n("settings.launcher.launcher_log.export")); - logButton.setOnAction(e -> onExportLogs()); - - HBox buttonBox = new HBox(); - buttonBox.setSpacing(10); - buttonBox.getChildren().addAll(openLogFolderButton, logButton); - BorderPane.setAlignment(buttonBox, Pos.CENTER_RIGHT); - debugPane.setRight(buttonBox); - - settingsPane.getContent().add(debugPane); - } - - rootPane.getChildren().add(settingsPane); - } - scroll.setContent(rootPane); - } - } - - public void openLogFolder() { - FXUtils.openFolder(LOG.getLogFile().getParent()); - } - - protected abstract void onUpdate(); - - protected abstract void onExportLogs(); - - protected abstract void onSponsor(); - - protected abstract void clearCacheDirectory(); -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/nbt/NBTEditorPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/nbt/NBTEditorPage.java index 3632aa7412..fca15907bd 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/nbt/NBTEditorPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/nbt/NBTEditorPage.java @@ -89,7 +89,10 @@ public NBTEditorPage(Path file) throws IOException { .whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception == null) { setLoading(false); - root.setCenter(new NBTTreeView(result)); + NBTTreeView view = new NBTTreeView(result); + BorderPane.setMargin(view, new Insets(10)); + onEscPressed(view, cancelButton::fire); + root.setCenter(view); } else { LOG.warning("Fail to open nbt file", exception); Controllers.dialog(i18n("nbt.open.failed") + "\n\n" + StringUtils.getStackTrace(exception), null, MessageDialogPane.MessageType.WARNING, cancelButton::fire); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/nbt/NBTTreeView.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/nbt/NBTTreeView.java index 86a5594c5e..1448855a9b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/nbt/NBTTreeView.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/nbt/NBTTreeView.java @@ -20,11 +20,13 @@ import com.github.steveice10.opennbt.tag.builtin.CompoundTag; import com.github.steveice10.opennbt.tag.builtin.ListTag; import com.github.steveice10.opennbt.tag.builtin.Tag; -import javafx.scene.control.*; +import com.jfoenix.controls.JFXTreeView; +import javafx.scene.control.TreeCell; +import javafx.scene.control.TreeItem; +import javafx.scene.control.TreeView; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.util.Callback; -import org.jackhuang.hmcl.util.Holder; import java.lang.reflect.Array; import java.util.EnumMap; @@ -34,7 +36,7 @@ /** * @author Glavo */ -public final class NBTTreeView extends TreeView { +public final class NBTTreeView extends JFXTreeView { public NBTTreeView(NBTTreeView.Item tree) { this.setRoot(tree); @@ -42,10 +44,9 @@ public NBTTreeView(NBTTreeView.Item tree) { } private static Callback, TreeCell> cellFactory() { - Holder lastCell = new Holder<>(); EnumMap icons = new EnumMap<>(NBTTagType.class); - return view -> new TreeCell() { + return view -> new TreeCell<>() { private void setTagText(String text) { String name = ((Item) getTreeItem()).getName(); @@ -66,11 +67,6 @@ private void setTagText(int nEntries) { public void updateItem(Tag item, boolean empty) { super.updateItem(item, empty); - // https://mail.openjdk.org/pipermail/openjfx-dev/2022-July/034764.html - if (this == lastCell.value && !isVisible()) - return; - lastCell.value = this; - ImageView imageView = (ImageView) this.getGraphic(); if (imageView == null) { imageView = new ImageView(); @@ -85,6 +81,8 @@ public void updateItem(Tag item, boolean empty) { NBTTagType tagType = NBTTagType.typeOf(item); imageView.setImage(icons.computeIfAbsent(tagType, type -> new Image(type.getIconUrl()))); + imageView.setFitHeight(16); + imageView.setFitWidth(16); if (((Item) getTreeItem()).getText() != null) { setText(((Item) getTreeItem()).getText()); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/profile/ProfileListItemSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/profile/ProfileListItemSkin.java index 77c15b170a..de09d619cd 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/profile/ProfileListItemSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/profile/ProfileListItemSkin.java @@ -24,12 +24,10 @@ import javafx.scene.control.SkinBase; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.construct.RipplerContainer; import org.jackhuang.hmcl.ui.construct.TwoLineListItem; -import org.jackhuang.hmcl.ui.versions.VersionPage; public class ProfileListItemSkin extends SkinBase { private final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected"); @@ -48,7 +46,7 @@ public ProfileListItemSkin(ProfileListItem skinnable) { FXUtils.onClicked(getSkinnable(), () -> getSkinnable().setSelected(true)); - Node left = VersionPage.wrap(SVG.FOLDER); + Node left = FXUtils.wrap(SVG.FOLDER); root.setLeft(left); BorderPane.setAlignment(left, Pos.CENTER_LEFT); @@ -64,7 +62,7 @@ public ProfileListItemSkin(ProfileListItem skinnable) { btnRemove.setOnAction(e -> skinnable.remove()); btnRemove.getStyleClass().add("toggle-icon4"); BorderPane.setAlignment(btnRemove, Pos.CENTER); - btnRemove.setGraphic(SVG.CLOSE.createIcon(Theme.blackFill(), 14)); + btnRemove.setGraphic(SVG.CLOSE.createIcon(14)); right.getChildren().add(btnRemove); root.setRight(right); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/profile/ProfilePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/profile/ProfilePage.java index 91ea0b5ef2..899fe7b700 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/profile/ProfilePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/profile/ProfilePage.java @@ -22,10 +22,9 @@ import com.jfoenix.validation.RequiredFieldValidator; import com.jfoenix.validation.base.ValidatorBase; import javafx.beans.binding.Bindings; -import javafx.beans.property.ReadOnlyObjectProperty; -import javafx.beans.property.ReadOnlyObjectWrapper; -import javafx.beans.property.SimpleStringProperty; -import javafx.beans.property.StringProperty; +import javafx.beans.property.*; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.Label; @@ -44,6 +43,7 @@ import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.FileUtils; +import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.util.Optional; @@ -53,7 +53,6 @@ public final class ProfilePage extends BorderPane implements DecoratorPage { private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(); private final StringProperty location; private final Profile profile; - private final JFXTextField txtProfileName; private final FileItem gameDir; private final OptionToggleButton toggleUseRelativePath; @@ -147,6 +146,38 @@ protected void eval() { () -> !txtProfileName.validate() || StringUtils.isBlank(getLocation()), txtProfileName.textProperty(), location)); } + + ChangeListener locationChangeListener = (observable, oldValue, newValue) -> { + Path newPath; + try { + newPath = FileUtils.toAbsolute(Path.of(newValue)); + } catch (InvalidPathException ignored) { + return; + } + + if (!".minecraft".equals(FileUtils.getName(newPath))) + return; + + Path parent = newPath.getParent(); + if (parent == null) + return; + + String suggestedName = FileUtils.getName(parent); + if (!suggestedName.isBlank()) { + txtProfileName.setText(suggestedName); + } + }; + locationProperty().addListener(locationChangeListener); + + txtProfileName.textProperty().addListener(new ChangeListener<>() { + @Override + public void changed(ObservableValue observable, String oldValue, String newValue) { + if (txtProfileName.isFocused()) { + txtProfileName.textProperty().removeListener(this); + locationProperty().removeListener(locationChangeListener); + } + } + }); } private void onSave() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaControllerPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaControllerPage.java index e4b5ca2d20..ce24167ed9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaControllerPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaControllerPage.java @@ -40,13 +40,11 @@ import javafx.scene.layout.Priority; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; -import javafx.scene.paint.Color; import javafx.scene.text.Text; import javafx.scene.text.TextFlow; import org.jackhuang.hmcl.game.LauncherHelper; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.setting.Profiles; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.terracotta.TerracottaManager; @@ -61,12 +59,14 @@ import org.jackhuang.hmcl.ui.animation.TransitionPane; import org.jackhuang.hmcl.ui.construct.ComponentList; import org.jackhuang.hmcl.ui.construct.ComponentSublist; +import org.jackhuang.hmcl.ui.construct.HintPane; import org.jackhuang.hmcl.ui.construct.MessageDialogPane; import org.jackhuang.hmcl.ui.construct.RipplerContainer; import org.jackhuang.hmcl.ui.construct.SpinnerPane; import org.jackhuang.hmcl.ui.construct.TwoLineListItem; import org.jackhuang.hmcl.ui.versions.Versions; import org.jackhuang.hmcl.util.i18n.I18n; +import org.jackhuang.hmcl.util.i18n.LocaleUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.Zipper; import org.jackhuang.hmcl.util.logging.Logger; @@ -102,6 +102,11 @@ public class TerracottaControllerPage extends StackPane { /* FIXME: It's sucked to have such a long logic, containing UI for all states defined in TerracottaState, with unclear control flows. Consider moving UI into multiple files for each state respectively. */ public TerracottaControllerPage() { + holder.add(FXUtils.observeWeak(() -> { + // Run daemon process only if HMCL is focused and is displaying current node. + TerracottaManager.switchDaemon(getScene() != null && Controllers.getStage().isFocused()); + }, this.sceneProperty(), Controllers.getStage().focusedProperty())); + TransitionPane transition = new TransitionPane(); ObjectProperty statusProperty = new SimpleObjectProperty<>(); @@ -113,7 +118,7 @@ public TerracottaControllerPage() { if (state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence() || - state instanceof TerracottaState.Fatal fatal && fatal.getType() == TerracottaState.Fatal.Type.NETWORK + state instanceof TerracottaState.Fatal fatal && fatal.isRecoverable() ) { return Files.isReadable(path) && FileUtils.getName(path).toLowerCase(Locale.ROOT).endsWith(".tar.gz"); } else { @@ -122,7 +127,7 @@ public TerracottaControllerPage() { }, files -> { Path path = files.get(0); - if (!TerracottaManager.validate(path)) { + if (TerracottaManager.isInvalidBundle(path)) { Controllers.dialog( i18n("terracotta.from_local.file_name_mismatch", TerracottaMetadata.PACKAGE_NAME, FileUtils.getName(path)), i18n("message.error"), @@ -131,25 +136,7 @@ public TerracottaControllerPage() { return; } - TerracottaState state = UI_STATE.get(), next; - if (state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence()) { - if (state instanceof TerracottaState.Uninitialized uninitialized && !uninitialized.hasLegacy()) { - Controllers.confirmWithCountdown(i18n("terracotta.confirm.desc"), i18n("terracotta.confirm.title"), 5, - MessageDialogPane.MessageType.INFO, () -> { - TerracottaState.Preparing s = TerracottaManager.install(path); - if (s != null) { - UI_STATE.set(s); - } - }, null); - return; - } - - next = TerracottaManager.install(path); - } else if (state instanceof TerracottaState.Fatal fatal && fatal.getType() == TerracottaState.Fatal.Type.NETWORK) { - next = TerracottaManager.recover(path); - } else { - return; - } + TerracottaState.Preparing next = TerracottaManager.install(path); if (next != null) { UI_STATE.set(next); } @@ -173,6 +160,7 @@ public TerracottaControllerPage() { progressProperty.set(0); TextFlow body = FXUtils.segmentToTextFlow(i18n("terracotta.confirm.desc"), Controllers::onHyperlinkAction); + body.getStyleClass().add("terracotta-hint"); body.setLineSpacing(4); LineButton download = LineButton.of(); @@ -181,7 +169,7 @@ public TerracottaControllerPage() { download.setSubtitle(i18n("terracotta.status.uninitialized.desc")); download.setRightIcon(SVG.ARROW_FORWARD); FXUtils.onClicked(download, () -> { - TerracottaState.Preparing s = TerracottaManager.install(null); + TerracottaState.Preparing s = TerracottaManager.download(); if (s != null) { UI_STATE.set(s); } @@ -216,6 +204,7 @@ public TerracottaControllerPage() { progressProperty.set(1); TextFlow flow = FXUtils.segmentToTextFlow(i18n("terracotta.confirm.desc"), Controllers::onHyperlinkAction); + flow.getStyleClass().add("terracotta-hint"); flow.setLineSpacing(4); LineButton host = LineButton.of(); @@ -236,7 +225,10 @@ public TerracottaControllerPage() { MessageDialogPane.MessageType.QUESTION ).addAction(i18n("version.launch"), () -> { Profile profile = Profiles.getSelectedProfile(); - Versions.launch(profile, profile.getSelectedVersion(), LauncherHelper::setKeep); + Versions.launch(profile, profile.getSelectedVersion(), launcherHelper -> { + launcherHelper.setKeep(); + launcherHelper.setDisableOfflineSkin(); + }); }).addCancel(i18n("terracotta.status.waiting.host.launch.skip"), () -> { TerracottaState.HostScanning s1 = TerracottaManager.setScanning(); if (s1 != null) { @@ -253,19 +245,19 @@ public TerracottaControllerPage() { guest.setSubtitle(i18n("terracotta.status.waiting.guest.desc")); guest.setRightIcon(SVG.ARROW_FORWARD); FXUtils.onClicked(guest, () -> { - Controllers.prompt(i18n("terracotta.status.waiting.guest.prompt.title"), (code, resolve, reject) -> { - Task task = TerracottaManager.setGuesting(code); + Controllers.prompt(i18n("terracotta.status.waiting.guest.prompt.title"), (code, handler) -> { + Task task = TerracottaManager.setGuesting(code); if (task != null) { task.whenComplete(Schedulers.javafx(), (s, e) -> { if (e != null) { - reject.accept(i18n("terracotta.status.waiting.guest.prompt.invalid")); + handler.reject(i18n("terracotta.status.waiting.guest.prompt.invalid")); } else { - resolve.run(); + handler.resolve(); UI_STATE.set(s); } }).setSignificance(Task.TaskSignificance.MINOR).start(); } else { - resolve.run(); + handler.resolve(); } }); }); @@ -287,6 +279,7 @@ public TerracottaControllerPage() { progressProperty.set(-1); TextFlow body = FXUtils.segmentToTextFlow(i18n("terracotta.status.scanning.desc"), Controllers::onHyperlinkAction); + body.getStyleClass().add("terracotta-hint"); body.setLineSpacing(4); LineButton room = LineButton.of(); @@ -372,7 +365,7 @@ public TerracottaControllerPage() { nodesProperty.setAll(code, copy, back, new PlayerProfileUI(hostOK.getProfiles())); } } - } else if (state instanceof TerracottaState.GuestStarting) { + } else if (state instanceof TerracottaState.GuestConnecting || state instanceof TerracottaState.GuestStarting) { statusProperty.set(i18n("terracotta.status.guest_starting")); progressProperty.set(-1); @@ -387,7 +380,26 @@ public TerracottaControllerPage() { } }); - nodesProperty.setAll(room); + nodesProperty.clear(); + if (state instanceof TerracottaState.GuestStarting) { + TerracottaState.GuestStarting.Difficulty difficulty = ((TerracottaState.GuestStarting) state).getDifficulty(); + if (difficulty != null && difficulty != TerracottaState.GuestStarting.Difficulty.UNKNOWN) { + LineButton info = LineButton.of(); + info.setLeftIcon(switch (difficulty) { + case UNKNOWN -> throw new AssertionError(); + case EASIEST, SIMPLE -> SVG.INFO; + case MEDIUM, TOUGH -> SVG.WARNING; + }); + + String difficultyID = difficulty.name().toLowerCase(Locale.ROOT); + info.setTitle(i18n(String.format("terracotta.difficulty.%s", difficultyID))); + info.setSubtitle(i18n("terracotta.difficulty.estimate_only")); + + nodesProperty.add(info); + } + } + + nodesProperty.add(room); } else if (state instanceof TerracottaState.GuestOK guestOK) { if (guestOK.isForkOf(legacyState)) { if (nodesProperty.get(nodesProperty.size() - 1) instanceof PlayerProfileUI profileUI) { @@ -486,7 +498,7 @@ public TerracottaControllerPage() { retry.setTitle(i18n("terracotta.status.fatal.retry")); retry.setSubtitle(message); FXUtils.onClicked(retry, () -> { - TerracottaState s = TerracottaManager.recover(null); + TerracottaState s = TerracottaManager.recover(); if (s != null) { UI_STATE.set(s); } @@ -530,7 +542,13 @@ public TerracottaControllerPage() { UI_STATE.addListener(new WeakChangeListener<>(listener)); VBox content = new VBox(10); - content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("terracotta.status")), transition); + content.getChildren().add(ComponentList.createComponentListTitle(i18n("terracotta.status"))); + if (!LocaleUtils.IS_CHINA_MAINLAND) { + HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING); + hintPane.setText(i18n("terracotta.unsupported.region")); + content.getChildren().add(hintPane); + } + content.getChildren().add(transition); content.setPadding(new Insets(10)); content.setFillWidth(true); @@ -559,7 +577,7 @@ private ComponentList getThirdPartyDownloadNodes() { Label description = new Label(link.description().getText(I18n.getLocale().getCandidateLocales())); HBox placeholder = new HBox(); HBox.setHgrow(placeholder, Priority.ALWAYS); - Node icon = SVG.OPEN_IN_NEW.createIcon(Theme.blackFill(), 16); + Node icon = SVG.OPEN_IN_NEW.createIcon(16); node.getChildren().setAll(description, placeholder, icon); String url = link.link(); @@ -625,9 +643,9 @@ public static LineButton of(boolean padding) { } HBox secondLine = new HBox(); + secondLine.getStyleClass().add("second-line"); { Text text = new Text(button.subTitle.get()); - text.setFill(new Color(0, 0, 0, 0.5)); TextFlow lblSubtitle = new TextFlow(text); lblSubtitle.getStyleClass().add("subtitle"); @@ -670,11 +688,11 @@ public void setLeftImage(Image left) { } public void setLeftIcon(SVG left) { - this.left.set(left.createIcon(Theme.blackFill(), 28)); + this.left.set(left.createIcon(28)); } public void setRightIcon(SVG right) { - this.right.set(right.createIcon(Theme.blackFill(), 28)); + this.right.set(right.createIcon(28)); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java index ff8f2af4bb..d70e05eed2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.ui.terracotta; +import com.jfoenix.controls.JFXPopup; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.ReadOnlyObjectWrapper; import javafx.beans.value.ChangeListener; @@ -24,7 +25,6 @@ import javafx.scene.layout.BorderPane; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; -import org.jackhuang.hmcl.game.LauncherHelper; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.setting.Profiles; import org.jackhuang.hmcl.terracotta.TerracottaMetadata; @@ -36,6 +36,7 @@ import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; import org.jackhuang.hmcl.ui.main.MainPage; +import org.jackhuang.hmcl.ui.versions.GameListPopupMenu; import org.jackhuang.hmcl.ui.versions.Versions; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.StringUtils; @@ -71,7 +72,10 @@ public TerracottaPage() { AdvancedListBox toolbar = new AdvancedListBox() .addNavigationDrawerItem(i18n("version.launch"), SVG.ROCKET_LAUNCH, () -> { Profile profile = Profiles.getSelectedProfile(); - Versions.launch(profile, profile.getSelectedVersion(), LauncherHelper::setKeep); + Versions.launch(profile, profile.getSelectedVersion(), launcherHelper -> { + launcherHelper.setKeep(); + launcherHelper.setDisableOfflineSkin(); + }); }, item -> { instanceChangeListenerHolder = FXUtils.onWeakChangeAndOperate(Profiles.selectedVersionProperty(), instanceName -> item.setSubtitle(StringUtils.isNotBlank(instanceName) ? instanceName : i18n("version.empty")) @@ -82,9 +86,15 @@ public TerracottaPage() { String currentId = mainPage.getCurrentGame(); return Lang.indexWhere(list, instance -> instance.getId().equals(currentId)); }, it -> mainPage.getProfile().setSelectedVersion(it.getId())); + + FXUtils.onSecondaryButtonClicked(item, () -> GameListPopupMenu.show(item, + JFXPopup.PopupVPosition.BOTTOM, + JFXPopup.PopupHPosition.LEFT, + item.getWidth(), + 0, + mainPage.getProfile(), mainPage.getVersions())); }) - .addNavigationDrawerItem(i18n("terracotta.feedback.title"), SVG.FEEDBACK, () -> FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK)) - .addNavigationDrawerItem(i18n("terracotta.easytier"), SVG.HOST, () -> FXUtils.openLink("https://easytier.cn/")); + .addNavigationDrawerItem(i18n("terracotta.feedback.title"), SVG.FEEDBACK, () -> FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK)); BorderPane.setMargin(toolbar, new Insets(0, 0, 12, 0)); left.setBottom(toolbar); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/AdvancedVersionSettingPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/AdvancedVersionSettingPage.java index dbb3d6e16b..478c05558e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/AdvancedVersionSettingPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/AdvancedVersionSettingPage.java @@ -73,6 +73,7 @@ public AdvancedVersionSettingPage(Profile profile, @Nullable String versionId, V getChildren().setAll(scrollPane); VBox rootPane = new VBox(); + rootPane.setFocusTraversable(true); rootPane.setFillWidth(true); scrollPane.setContent(rootPane); FXUtils.smoothScrolling(scrollPane); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java index 6788648038..49198822c2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java @@ -48,14 +48,12 @@ public final class DatapackListPage extends ListPageBase Objects.equals("zip", FileUtils.getExtension(it)), mods -> mods.forEach(this::installSingleDatapack), this::refresh); + + refresh(); } private void installSingleDatapack(Path datapack) { @@ -74,9 +72,7 @@ protected Skin createDefaultSkin() { public void refresh() { setLoading(true); Task.runAsync(datapack::loadFromDir) - .withRunAsync(Schedulers.javafx(), () -> { - setLoading(false); - }) + .withRunAsync(Schedulers.javafx(), () -> setLoading(false)) .start(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java index 81394bf70c..a6698bc2e6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java @@ -54,7 +54,6 @@ import org.jackhuang.hmcl.ui.construct.MDListCell; import org.jackhuang.hmcl.ui.construct.SpinnerPane; import org.jackhuang.hmcl.ui.construct.TwoLineListItem; -import org.jackhuang.hmcl.util.Holder; import org.jackhuang.hmcl.util.io.CompressingUtils; import org.jetbrains.annotations.Nullable; @@ -180,8 +179,7 @@ final class DatapackListPageSkin extends SkinBase { center.getStyleClass().add("large-spinner-pane"); center.loadingProperty().bind(skinnable.loadingProperty()); - Holder lastCell = new Holder<>(); - listView.setCellFactory(x -> new DatapackInfoListCell(listView, lastCell)); + listView.setCellFactory(x -> new DatapackInfoListCell(listView)); listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); this.listView.setItems(filteredList); @@ -304,8 +302,8 @@ private final class DatapackInfoListCell extends MDListCell final TwoLineListItem content = new TwoLineListItem(); BooleanProperty booleanProperty; - DatapackInfoListCell(JFXListView listView, Holder lastCell) { - super(listView, lastCell); + DatapackInfoListCell(JFXListView listView) { + super(listView); HBox container = new HBox(8); container.setPickOnBounds(false); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadListPage.java index 4a679ed301..171fac3209 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadListPage.java @@ -17,10 +17,8 @@ */ package org.jackhuang.hmcl.ui.versions; -import com.jfoenix.controls.JFXButton; -import com.jfoenix.controls.JFXComboBox; -import com.jfoenix.controls.JFXListView; -import com.jfoenix.controls.JFXTextField; +import com.jfoenix.controls.*; +import com.jfoenix.effects.JFXDepthManager; import javafx.beans.binding.Bindings; import javafx.beans.binding.ObjectBinding; import javafx.beans.property.*; @@ -30,11 +28,10 @@ import javafx.event.EventHandler; import javafx.geometry.Insets; import javafx.geometry.Pos; +import javafx.scene.Cursor; 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.control.*; +import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; @@ -51,18 +48,17 @@ import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.WeakListenerHolder; -import org.jackhuang.hmcl.ui.construct.FloatListCell; +import org.jackhuang.hmcl.ui.construct.RipplerContainer; import org.jackhuang.hmcl.ui.construct.SpinnerPane; import org.jackhuang.hmcl.ui.construct.TwoLineListItem; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; -import org.jackhuang.hmcl.util.AggregatedObservableList; -import org.jackhuang.hmcl.util.Holder; -import org.jackhuang.hmcl.util.Lang; -import org.jackhuang.hmcl.util.StringUtils; +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 java.net.URI; import java.util.*; import java.util.stream.Collectors; @@ -199,6 +195,10 @@ protected String getLocalizedCategory(String 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.getCategory() == null @@ -234,6 +234,12 @@ protected Skin createDefaultSkin() { private static class ModDownloadListPageSkin extends SkinBase { private final JFXListView listView = new JFXListView<>(); + private final RemoteImageLoader iconLoader = new RemoteImageLoader() { + @Override + protected @NotNull Task createLoadTask(@NotNull URI uri) { + return FXUtils.getRemoteImageTask(uri, 80, 80, true, true); + } + }; protected ModDownloadListPageSkin(DownloadListPage control) { super(control); @@ -364,6 +370,7 @@ protected ModDownloadListPageSkin(DownloadListPage control) { IntegerProperty filterID = new SimpleIntegerProperty(this, "Filter ID", 0); IntegerProperty currentFilterID = new SimpleIntegerProperty(this, "Current Filter ID", -1); EventHandler searchAction = e -> { + iconLoader.clearInvalidCache(); if (currentFilterID.get() != -1 && currentFilterID.get() != filterID.get()) { control.pageOffset.set(0); } @@ -514,43 +521,66 @@ protected ModDownloadListPageSkin(DownloadListPage control) { spinnerPane.setContent(listView); Bindings.bindContent(listView.getItems(), getSkinnable().items); - FXUtils.onClicked(listView, () -> { - if (listView.getSelectionModel().getSelectedIndex() < 0) - return; - RemoteMod selectedItem = listView.getSelectionModel().getSelectedItem(); - Controllers.navigate(new DownloadPage(getSkinnable(), selectedItem, getSkinnable().getProfileVersion(), getSkinnable().callback)); - }); - + listView.setSelectionModel(null); // 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 FloatListCell<>(listView) { + + listView.setCellFactory(x -> new ListCell<>() { + private static final Insets PADDING = new Insets(9, 9, 0, 9); + + private final RipplerContainer graphic; + private final TwoLineListItem content = new TwoLineListItem(); private final ImageView imageView = new ImageView(); { + setPadding(PADDING); + HBox container = new HBox(8); + container.getStyleClass().add("card"); + container.setCursor(Cursor.HAND); container.setAlignment(Pos.CENTER_LEFT); - pane.getChildren().add(container); + JFXDepthManager.setDepth(container, 1); imageView.setFitWidth(40); imageView.setFitHeight(40); container.getChildren().setAll(FXUtils.limitingSize(imageView, 40, 40), content); HBox.setHgrow(content, Priority.ALWAYS); + + this.graphic = new RipplerContainer(container); + graphic.setPosition(JFXRippler.RipplerPos.FRONT); + FXUtils.onClicked(graphic, () -> { + RemoteMod item = getItem(); + if (item != null) + Controllers.navigate(new DownloadPage(getSkinnable(), item, getSkinnable().getProfileVersion(), getSkinnable().callback)); + }); + + setPrefWidth(0); + + if (listView.lookup(".clipped-container") instanceof Region clippedContainer) { + maxWidthProperty().bind(clippedContainer.widthProperty()); + prefWidthProperty().bind(clippedContainer.widthProperty()); + minWidthProperty().bind(clippedContainer.widthProperty()); + } } @Override - protected void updateControl(RemoteMod dataItem, boolean empty) { - if (empty) return; - ModTranslations.Mod mod = ModTranslations.getTranslationsByRepositoryType(getSkinnable().repository.getType()).getModByCurseForgeId(dataItem.getSlug()); - content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : dataItem.getTitle()); - content.setSubtitle(dataItem.getDescription()); - content.getTags().clear(); - dataItem.getCategories().stream() - .map(category -> getSkinnable().getLocalizedCategory(category)) - .forEach(content::addTag); - if (StringUtils.isNotBlank(dataItem.getIconUrl())) { - imageView.imageProperty().bind(FXUtils.newRemoteImage(dataItem.getIconUrl(), 80, 80, true, true)); + protected void updateItem(RemoteMod item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setGraphic(null); + } else { + ModTranslations.Mod mod = ModTranslations.getTranslationsByRepositoryType(getSkinnable().repository.getType()).getModByCurseForgeId(item.getSlug()); + content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : item.getTitle()); + content.setSubtitle(item.getDescription()); + content.getTags().clear(); + for (String category : item.getCategories()) { + if (getSkinnable().shouldDisplayCategory(category)) + content.addTag(getSkinnable().getLocalizedCategory(category)); + } + iconLoader.load(imageView.imageProperty(), item.getIconUrl()); + setGraphic(graphic); } } }); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java index c62da41ee1..7b9e3ba364 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java @@ -38,7 +38,6 @@ import org.jackhuang.hmcl.mod.RemoteMod; import org.jackhuang.hmcl.mod.RemoteModRepository; import org.jackhuang.hmcl.setting.Profile; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -158,15 +157,15 @@ public void setFailed(boolean failed) { this.failed.set(failed); } - public void download(RemoteMod.Version file) { + public void download(RemoteMod mod, RemoteMod.Version file) { if (this.callback == null) { - saveAs(file); + saveAs(mod, file); } else { - this.callback.download(version.getProfile(), version.getVersion(), file); + this.callback.download(version.getProfile(), version.getVersion(), mod, file); } } - public void saveAs(RemoteMod.Version file) { + public void saveAs(RemoteMod mod, RemoteMod.Version file) { String extension = StringUtils.substringAfterLast(file.getFile().getFilename(), '.'); FileChooser fileChooser = new FileChooser(); @@ -288,7 +287,7 @@ protected ModDownloadPageSkin(DownloadPage control) { if (targetLoaders.contains(loader)) { list.getContent().addAll( ComponentList.createComponentListTitle(i18n("mods.download.recommend", gameVersion)), - new ModItem(modVersion, control) + new ModItem(control.addon, modVersion, control) ); break resolve; } @@ -309,7 +308,7 @@ protected ModDownloadPageSkin(DownloadPage control) { ComponentList sublist = new ComponentList(() -> { ArrayList items = new ArrayList<>(versions.size()); for (RemoteMod.Version v : versions) { - items.add(new ModItem(v, control)); + items.add(new ModItem(control.addon, v, control)); } return items; }); @@ -358,9 +357,10 @@ private static final class DependencyModItem extends StackPane { ModTranslations.Mod mod = ModTranslations.getTranslationsByRepositoryType(page.repository.getType()).getModByCurseForgeId(addon.getSlug()); content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : addon.getTitle()); content.setSubtitle(addon.getDescription()); - addon.getCategories().stream() - .map(page::getLocalizedCategory) - .forEach(content::addTag); + for (String category : addon.getCategories()) { + if (page.shouldDisplayCategory(category)) + content.addTag(page.getLocalizedCategory(category)); + } if (StringUtils.isNotBlank(addon.getIconUrl())) { imageView.imageProperty().bind(FXUtils.newRemoteImage(addon.getIconUrl(), 80, 80, true, true)); } @@ -374,7 +374,7 @@ private static final class DependencyModItem extends StackPane { private static final class ModItem extends StackPane { - ModItem(RemoteMod.Version dataItem, DownloadPage selfPage) { + ModItem(RemoteMod mod, RemoteMod.Version dataItem, DownloadPage selfPage) { VBox pane = new VBox(8); pane.setPadding(new Insets(8, 0, 8, 0)); @@ -394,15 +394,15 @@ private static final class ModItem extends StackPane { switch (dataItem.getVersionType()) { case Alpha: content.addTag(i18n("mods.channel.alpha")); - graphicPane.getChildren().setAll(SVG.ALPHA_CIRCLE.createIcon(Theme.blackFill(), 24)); + graphicPane.getChildren().setAll(SVG.ALPHA_CIRCLE.createIcon(24)); break; case Beta: content.addTag(i18n("mods.channel.beta")); - graphicPane.getChildren().setAll(SVG.BETA_CIRCLE.createIcon(Theme.blackFill(), 24)); + graphicPane.getChildren().setAll(SVG.BETA_CIRCLE.createIcon(24)); break; case Release: content.addTag(i18n("mods.channel.release")); - graphicPane.getChildren().setAll(SVG.RELEASE_CIRCLE.createIcon(Theme.blackFill(), 24)); + graphicPane.getChildren().setAll(SVG.RELEASE_CIRCLE.createIcon(24)); break; } @@ -436,7 +436,7 @@ private static final class ModItem extends StackPane { } RipplerContainer container = new RipplerContainer(pane); - FXUtils.onClicked(container, () -> Controllers.dialog(new ModVersion(dataItem, selfPage))); + FXUtils.onClicked(container, () -> Controllers.dialog(new ModVersion(mod, dataItem, selfPage))); getChildren().setAll(container); // Workaround for https://github.com/HMCL-dev/HMCL/issues/2129 @@ -445,30 +445,21 @@ private static final class ModItem extends StackPane { } private static final class ModVersion extends JFXDialogLayout { - public ModVersion(RemoteMod.Version version, DownloadPage selfPage) { + public ModVersion(RemoteMod mod, RemoteMod.Version version, DownloadPage selfPage) { RemoteModRepository.Type type = selfPage.repository.getType(); - String title; - switch (type) { - case WORLD: - title = "world.download.title"; - break; - case MODPACK: - title = "modpack.download.title"; - break; - case RESOURCE_PACK: - title = "resourcepack.download.title"; - break; - case MOD: - default: - title = "mods.download.title"; - break; - } + String title = switch (type) { + case WORLD -> "world.download.title"; + case MODPACK -> "modpack.download.title"; + 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.getName())))); VBox box = new VBox(8); box.setPadding(new Insets(8)); - ModItem modItem = new ModItem(version, selfPage); + ModItem modItem = new ModItem(mod, version, selfPage); modItem.setMouseTransparent(true); // Item is displayed for info, clicking shouldn't open the dialog again box.getChildren().setAll(modItem); SpinnerPane spinnerPane = new SpinnerPane(); @@ -494,7 +485,7 @@ public ModVersion(RemoteMod.Version version, DownloadPage selfPage) { if (type == RemoteModRepository.Type.MODPACK || !spinnerPane.isLoading() && spinnerPane.getFailedReason() == null) { fireEvent(new DialogCloseEvent()); } - selfPage.download(version); + selfPage.download(mod, version); }); } @@ -504,7 +495,7 @@ public ModVersion(RemoteMod.Version version, DownloadPage selfPage) { if (!spinnerPane.isLoading() && spinnerPane.getFailedReason() == null) { fireEvent(new DialogCloseEvent()); } - selfPage.saveAs(version); + selfPage.saveAs(mod, version); }); JFXButton cancelButton = new JFXButton(i18n("button.cancel")); @@ -558,6 +549,6 @@ private void loadDependencies(RemoteMod.Version version, DownloadPage selfPage, } public interface DownloadCallback { - void download(Profile profile, @Nullable String version, RemoteMod.Version file); + void download(Profile profile, @Nullable String version, RemoteMod mod, RemoteMod.Version file); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameItem.java index c34d1ce020..ea9f48c7b9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameItem.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors + * Copyright (C) 2026 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,107 +17,122 @@ */ package org.jackhuang.hmcl.ui.versions; -import com.google.gson.JsonParseException; -import javafx.application.Platform; -import javafx.beans.property.ObjectProperty; -import javafx.beans.property.SimpleObjectProperty; -import javafx.beans.property.SimpleStringProperty; -import javafx.beans.property.StringProperty; -import javafx.scene.control.Control; -import javafx.scene.control.Skin; +import javafx.beans.property.*; import javafx.scene.image.Image; import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.mod.ModpackConfiguration; import org.jackhuang.hmcl.setting.Profile; +import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.util.i18n.I18n; +import org.jetbrains.annotations.Nullable; import java.io.IOException; +import java.util.Objects; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.MINECRAFT; -import static org.jackhuang.hmcl.util.Lang.handleUncaught; import static org.jackhuang.hmcl.util.Lang.threadPool; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; -public class GameItem extends Control { +public class GameItem { + private static final ThreadPoolExecutor POOL_VERSION_RESOLVE = threadPool("VersionResolve", true, 1, 10, TimeUnit.SECONDS); - private static final ThreadPoolExecutor POOL_VERSION_RESOLVE = threadPool("VersionResolve", true, 1, 1, TimeUnit.SECONDS); + protected final Profile profile; + protected final String id; - private final Profile profile; - private final String version; - private final StringProperty title = new SimpleStringProperty(); - private final StringProperty tag = new SimpleStringProperty(); - private final StringProperty subtitle = new SimpleStringProperty(); - private final ObjectProperty image = new SimpleObjectProperty<>(); + private boolean initialized = false; + private StringProperty title; + private StringProperty tag; + private StringProperty subtitle; + private ObjectProperty image; public GameItem(Profile profile, String id) { this.profile = profile; - this.version = id; - - // GameVersion.minecraftVersion() is a time-costing job (up to ~200 ms) - CompletableFuture.supplyAsync(() -> profile.getRepository().getGameVersion(id), POOL_VERSION_RESOLVE) - .thenAcceptAsync(game -> { - StringBuilder libraries = new StringBuilder(game.orElse(i18n("message.unknown"))); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(profile.getRepository().getResolvedPreservingPatchesVersion(id), game.orElse(null)); - for (LibraryAnalyzer.LibraryMark mark : analyzer) { - String libraryId = mark.getLibraryId(); - String libraryVersion = mark.getLibraryVersion(); - if (libraryId.equals(MINECRAFT.getPatchId())) continue; - if (I18n.hasKey("install.installer." + libraryId)) { - libraries.append(", ").append(i18n("install.installer." + libraryId)); - if (libraryVersion != null) - libraries.append(": ").append(libraryVersion.replaceAll("(?i)" + libraryId, "")); - } - } - - subtitle.set(libraries.toString()); - }, Platform::runLater) - .exceptionally(handleUncaught); - - CompletableFuture.runAsync(() -> { - try { - ModpackConfiguration config = profile.getRepository().readModpackConfiguration(version); - if (config == null) return; - tag.set(config.getVersion()); - } catch (IOException | JsonParseException e) { - LOG.warning("Failed to read modpack configuration from " + version, e); - } - }, Platform::runLater) - .exceptionally(handleUncaught); - - title.set(id); - image.set(profile.getRepository().getVersionIconImage(version)); - } - - @Override - protected Skin createDefaultSkin() { - return new GameItemSkin(this); + this.id = id; } public Profile getProfile() { return profile; } - public String getVersion() { - return version; + public String getId() { + return id; + } + + 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(() -> { + // GameVersion.minecraftVersion() is a time-costing job (up to ~200 ms) + Optional gameVersion = profile.getRepository().getGameVersion(id); + String modPackVersion = null; + try { + ModpackConfiguration config = profile.getRepository().readModpackConfiguration(id); + modPackVersion = config != null ? config.getVersion() : null; + } catch (IOException e) { + LOG.warning("Failed to read modpack configuration from " + id, e); + } + return new Result(gameVersion.orElse(null), 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"))); + LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(profile.getRepository().getResolvedPreservingPatchesVersion(id), result.gameVersion); + for (LibraryAnalyzer.LibraryMark mark : analyzer) { + String libraryId = mark.getLibraryId(); + String libraryVersion = mark.getLibraryVersion(); + if (libraryId.equals(MINECRAFT.getPatchId())) continue; + if (I18n.hasKey("install.installer." + libraryId)) { + libraries.append(", ").append(i18n("install.installer." + libraryId)); + if (libraryVersion != null) + libraries.append(": ").append(libraryVersion.replaceAll("(?i)" + libraryId, "")); + } + } + + subtitle.set(libraries.toString()); + } else { + LOG.warning("Failed to read version info from " + id, exception); + } + }, Schedulers.javafx()); + + title.set(id); + image.set(profile.getRepository().getVersionIconImage(id)); } - public StringProperty titleProperty() { + public ReadOnlyStringProperty titleProperty() { + init(); return title; } - public StringProperty tagProperty() { + public ReadOnlyStringProperty tagProperty() { + init(); return tag; } - public StringProperty subtitleProperty() { + public ReadOnlyStringProperty subtitleProperty() { + init(); return subtitle; } - public ObjectProperty imageProperty() { + public ReadOnlyObjectProperty imageProperty() { + init(); return image; } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameItemSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameItemSkin.java deleted file mode 100644 index b7b79f6213..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameItemSkin.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.ui.versions; - -import javafx.geometry.Pos; -import javafx.scene.control.SkinBase; -import javafx.scene.image.ImageView; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.HBox; -import javafx.scene.layout.StackPane; -import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.construct.TwoLineListItem; -import org.jackhuang.hmcl.util.StringUtils; - -public class GameItemSkin extends SkinBase { - public GameItemSkin(GameItem skinnable) { - super(skinnable); - - HBox center = new HBox(); - center.setSpacing(8); - center.setAlignment(Pos.CENTER_LEFT); - - StackPane imageViewContainer = new StackPane(); - FXUtils.setLimitWidth(imageViewContainer, 32); - FXUtils.setLimitHeight(imageViewContainer, 32); - - ImageView imageView = new ImageView(); - FXUtils.limitSize(imageView, 32, 32); - imageView.imageProperty().bind(skinnable.imageProperty()); - imageViewContainer.getChildren().setAll(imageView); - - TwoLineListItem item = new TwoLineListItem(); - item.titleProperty().bind(skinnable.titleProperty()); - FXUtils.onChangeAndOperate(skinnable.tagProperty(), tag -> { - item.getTags().clear(); - if (StringUtils.isNotBlank(tag)) - item.addTag(tag); - }); - item.subtitleProperty().bind(skinnable.subtitleProperty()); - BorderPane.setAlignment(item, Pos.CENTER); - center.getChildren().setAll(imageView, item); - getChildren().setAll(center); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListCell.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListCell.java new file mode 100644 index 0000000000..7817092e12 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListCell.java @@ -0,0 +1,214 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.versions; + +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.image.ImageView; +import javafx.scene.input.MouseButton; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Region; +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 static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +public final class GameListCell extends ListCell { + + private final Region graphic; + + private final ImageView imageView; + private final TwoLineListItem content; + + private final JFXRadioButton chkSelected; + private final JFXButton btnUpgrade; + private final JFXButton btnLaunch; + private final JFXButton btnManage; + + private final HBox right; + + private final StringProperty tag = new SimpleStringProperty(); + + public GameListCell() { + BorderPane root = new BorderPane(); + 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() { + @Override + public void fire() { + if (!isDisable() && !isSelected()) { + fireEvent(new ActionEvent()); + GameListItem item = GameListCell.this.getItem(); + if (item != null) { + item.getProfile().setSelectedVersion(item.getId()); + } + } + } + }; + root.setLeft(chkSelected); + BorderPane.setAlignment(chkSelected, Pos.CENTER); + } + + { + HBox center = new HBox(); + center.setMouseTransparent(true); + root.setCenter(center); + center.setPrefWidth(Region.USE_PREF_SIZE); + center.setSpacing(8); + center.setAlignment(Pos.CENTER_LEFT); + + this.imageView = new ImageView(); + FXUtils.limitSize(imageView, 32, 32); + + this.content = new TwoLineListItem(); + BorderPane.setAlignment(content, Pos.CENTER); + + FXUtils.onChangeAndOperate(tag, tag -> { + content.getTags().clear(); + if (StringUtils.isNotBlank(tag)) + content.addTag(tag); + }); + + center.getChildren().setAll(imageView, content); + } + + { + this.right = new HBox(); + root.setRight(right); + + right.setAlignment(Pos.CENTER_RIGHT); + + this.btnUpgrade = new JFXButton(); + btnUpgrade.setOnAction(e -> { + GameListItem item = this.getItem(); + if (item != null) + item.update(); + }); + btnUpgrade.getStyleClass().add("toggle-icon4"); + btnUpgrade.setGraphic(FXUtils.limitingSize(SVG.UPDATE.createIcon(24), 24, 24)); + FXUtils.installFastTooltip(btnUpgrade, i18n("version.update")); + right.getChildren().add(btnUpgrade); + + this.btnLaunch = new JFXButton(); + btnLaunch.setOnAction(e -> { + GameListItem item = this.getItem(); + if (item != null) + item.testGame(); + }); + btnLaunch.getStyleClass().add("toggle-icon4"); + BorderPane.setAlignment(btnLaunch, Pos.CENTER); + btnLaunch.setGraphic(FXUtils.limitingSize(SVG.ROCKET_LAUNCH.createIcon(24), 24, 24)); + FXUtils.installFastTooltip(btnLaunch, i18n("version.launch.test")); + right.getChildren().add(btnLaunch); + + this.btnManage = new JFXButton(); + btnManage.setOnAction(e -> { + 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()); + }); + btnManage.getStyleClass().add("toggle-icon4"); + BorderPane.setAlignment(btnManage, Pos.CENTER); + btnManage.setGraphic(FXUtils.limitingSize(SVG.MORE_VERT.createIcon(24), 24, 24)); + FXUtils.installFastTooltip(btnManage, i18n("settings.game.management")); + right.getChildren().add(btnManage); + } + + root.setCursor(Cursor.HAND); + container.setOnMouseClicked(e -> { + GameListItem item = getItem(); + if (item == null) + return; + + if (e.getButton() == MouseButton.PRIMARY) { + if (e.getClickCount() == 1) { + item.modifyGameSettings(); + } + } 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) { + super.updateItem(item, empty); + + 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(btnLaunch, 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("version.launch.test"), item::testGame, popup), + new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), item::generateLaunchScript, popup), + new MenuSeparator(), + new IconedMenuItem(SVG.SETTINGS, i18n("version.manage.manage"), item::modifyGameSettings, popup), + new MenuSeparator(), + new IconedMenuItem(SVG.EDIT, i18n("version.manage.rename"), item::rename, popup), + new IconedMenuItem(SVG.FOLDER_COPY, i18n("version.manage.duplicate"), item::duplicate, popup), + new IconedMenuItem(SVG.DELETE, i18n("version.manage.remove"), item::remove, popup), + new IconedMenuItem(SVG.OUTPUT, i18n("modpack.export"), item::export, popup), + new MenuSeparator(), + new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.game"), item::browse, popup)); + return popup; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListItem.java index 79820e5c99..8151215a5a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListItem.java @@ -18,83 +18,58 @@ package org.jackhuang.hmcl.ui.versions; import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.beans.property.SimpleBooleanProperty; -import javafx.scene.control.Control; -import javafx.scene.control.Skin; -import javafx.scene.control.ToggleGroup; import org.jackhuang.hmcl.setting.Profile; -public class GameListItem extends Control { - private final Profile profile; - private final String version; +public class GameListItem extends GameItem { private final boolean isModpack; - private final ToggleGroup toggleGroup; - private final BooleanProperty selected = new SimpleBooleanProperty(); + private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected"); - public GameListItem(ToggleGroup toggleGroup, Profile profile, String id) { - this.profile = profile; - this.version = id; - this.toggleGroup = toggleGroup; + public GameListItem(Profile profile, String id) { + super(profile, id); this.isModpack = profile.getRepository().isModpack(id); - - selected.set(id.equals(profile.getSelectedVersion())); - } - - @Override - protected Skin createDefaultSkin() { - return new GameListItemSkin(this); - } - - public ToggleGroup getToggleGroup() { - return toggleGroup; - } - - public Profile getProfile() { - return profile; + selected.bind(profile.selectedVersionProperty().isEqualTo(id)); } - public String getVersion() { - return version; - } - - public BooleanProperty selectedProperty() { + public ReadOnlyBooleanProperty selectedProperty() { return selected; } - public void checkSelection() { - selected.set(version.equals(profile.getSelectedVersion())); - } - public void rename() { - Versions.renameVersion(profile, version); + Versions.renameVersion(profile, id); } public void duplicate() { - Versions.duplicateVersion(profile, version); + Versions.duplicateVersion(profile, id); } public void remove() { - Versions.deleteVersion(profile, version); + Versions.deleteVersion(profile, id); } public void export() { - Versions.exportVersion(profile, version); + Versions.exportVersion(profile, id); } public void browse() { - Versions.openFolder(profile, version); + Versions.openFolder(profile, id); + } + + public void testGame() { + Versions.testGame(profile, id); } public void launch() { - Versions.testGame(profile, version); + Versions.launch(profile, id); } public void modifyGameSettings() { - Versions.modifyGameSettings(profile, version); + Versions.modifyGameSettings(profile, id); } public void generateLaunchScript() { - Versions.generateLaunchScript(profile, version); + Versions.generateLaunchScript(profile, id); } public boolean canUpdate() { @@ -102,6 +77,6 @@ public boolean canUpdate() { } public void update() { - Versions.updateVersion(profile, version); + Versions.updateVersion(profile, id); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListItemSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListItemSkin.java deleted file mode 100644 index 6cad34694e..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListItemSkin.java +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.ui.versions; - -import com.jfoenix.controls.JFXButton; -import com.jfoenix.controls.JFXPopup; -import com.jfoenix.controls.JFXRadioButton; -import javafx.geometry.Pos; -import javafx.scene.Cursor; -import javafx.scene.control.SkinBase; -import javafx.scene.input.MouseButton; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.HBox; -import org.jackhuang.hmcl.setting.Theme; -import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.SVG; -import org.jackhuang.hmcl.ui.construct.IconedMenuItem; -import org.jackhuang.hmcl.ui.construct.MenuSeparator; -import org.jackhuang.hmcl.ui.construct.PopupMenu; -import org.jackhuang.hmcl.ui.construct.RipplerContainer; -import org.jackhuang.hmcl.util.Lazy; - -import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition; -import static org.jackhuang.hmcl.util.i18n.I18n.i18n; - -public class GameListItemSkin extends SkinBase { - private static GameListItem currentSkinnable; - private static final Lazy popup = new Lazy<>(() -> { - PopupMenu menu = new PopupMenu(); - JFXPopup popup = new JFXPopup(menu); - - menu.getContent().setAll( - new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch.test"), () -> currentSkinnable.launch(), popup), - new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), () -> currentSkinnable.generateLaunchScript(), popup), - new MenuSeparator(), - new IconedMenuItem(SVG.SETTINGS, i18n("version.manage.manage"), () -> currentSkinnable.modifyGameSettings(), popup), - new MenuSeparator(), - new IconedMenuItem(SVG.EDIT, i18n("version.manage.rename"), () -> currentSkinnable.rename(), popup), - new IconedMenuItem(SVG.FOLDER_COPY, i18n("version.manage.duplicate"), () -> currentSkinnable.duplicate(), popup), - new IconedMenuItem(SVG.DELETE, i18n("version.manage.remove"), () -> currentSkinnable.remove(), popup), - new IconedMenuItem(SVG.OUTPUT, i18n("modpack.export"), () -> currentSkinnable.export(), popup), - new MenuSeparator(), - new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.game"), () -> currentSkinnable.browse(), popup)); - return popup; - }); - - public GameListItemSkin(GameListItem skinnable) { - super(skinnable); - - BorderPane root = new BorderPane(); - - JFXRadioButton chkSelected = new JFXRadioButton(); - BorderPane.setAlignment(chkSelected, Pos.CENTER); - chkSelected.setUserData(skinnable); - chkSelected.selectedProperty().bindBidirectional(skinnable.selectedProperty()); - chkSelected.setToggleGroup(skinnable.getToggleGroup()); - root.setLeft(chkSelected); - - GameItem gameItem = new GameItem(skinnable.getProfile(), skinnable.getVersion()); - gameItem.setMouseTransparent(true); - root.setCenter(gameItem); - - HBox right = new HBox(); - right.setAlignment(Pos.CENTER_RIGHT); - if (skinnable.canUpdate()) { - JFXButton btnUpgrade = new JFXButton(); - btnUpgrade.setOnAction(e -> skinnable.update()); - btnUpgrade.getStyleClass().add("toggle-icon4"); - btnUpgrade.setGraphic(FXUtils.limitingSize(SVG.UPDATE.createIcon(Theme.blackFill(), 24), 24, 24)); - FXUtils.installFastTooltip(btnUpgrade, i18n("version.update")); - right.getChildren().add(btnUpgrade); - } - - { - JFXButton btnLaunch = new JFXButton(); - btnLaunch.setOnAction(e -> skinnable.launch()); - btnLaunch.getStyleClass().add("toggle-icon4"); - BorderPane.setAlignment(btnLaunch, Pos.CENTER); - btnLaunch.setGraphic(FXUtils.limitingSize(SVG.ROCKET_LAUNCH.createIcon(Theme.blackFill(), 24), 24, 24)); - FXUtils.installFastTooltip(btnLaunch, i18n("version.launch.test")); - right.getChildren().add(btnLaunch); - } - - { - JFXButton btnManage = new JFXButton(); - btnManage.setOnAction(e -> { - currentSkinnable = skinnable; - - JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup.get()); - popup.get().show(root, vPosition, JFXPopup.PopupHPosition.RIGHT, 0, vPosition == JFXPopup.PopupVPosition.TOP ? root.getHeight() : -root.getHeight()); - }); - btnManage.getStyleClass().add("toggle-icon4"); - BorderPane.setAlignment(btnManage, Pos.CENTER); - btnManage.setGraphic(FXUtils.limitingSize(SVG.MORE_VERT.createIcon(Theme.blackFill(), 24), 24, 24)); - FXUtils.installFastTooltip(btnManage, i18n("settings.game.management")); - right.getChildren().add(btnManage); - } - - root.setRight(right); - - root.getStyleClass().add("md-list-cell"); - root.setStyle("-fx-padding: 8 8 8 0"); - - RipplerContainer container = new RipplerContainer(root); - getChildren().setAll(container); - - root.setCursor(Cursor.HAND); - container.setOnMouseClicked(e -> { - if (e.getButton() == MouseButton.PRIMARY) { - if (e.getClickCount() == 1) { - skinnable.modifyGameSettings(); - } - } else if (e.getButton() == MouseButton.SECONDARY) { - currentSkinnable = skinnable; - - JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup.get()); - popup.get().show(root, vPosition, JFXPopup.PopupHPosition.LEFT, e.getX(), vPosition == JFXPopup.PopupVPosition.TOP ? e.getY() : e.getY() - root.getHeight()); - } - }); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListPage.java index 402fb64095..6b1673bef8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListPage.java @@ -17,16 +17,16 @@ */ package org.jackhuang.hmcl.ui.versions; +import com.jfoenix.controls.JFXListView; import javafx.beans.binding.Bindings; import javafx.beans.property.*; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.scene.Node; +import javafx.scene.control.ListCell; import javafx.scene.control.ScrollPane; -import javafx.scene.control.ToggleGroup; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; -import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.setting.Profiles; import org.jackhuang.hmcl.ui.*; @@ -36,13 +36,12 @@ import org.jackhuang.hmcl.ui.decorator.DecoratorPage; import org.jackhuang.hmcl.ui.profile.ProfileListItem; import org.jackhuang.hmcl.ui.profile.ProfilePage; +import org.jackhuang.hmcl.util.FXThread; import org.jackhuang.hmcl.util.javafx.MappedObservableList; import java.util.Collections; import java.util.List; -import java.util.stream.Collectors; -import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.createSelectedItemPropertyFor; @@ -53,8 +52,6 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage private final ObservableList profileListItems; private final ObjectProperty selectedProfile; - private ToggleGroup toggleGroup; - public GameListPage() { profileListItems = MappedObservableList.create(profilesProperty(), profile -> { ProfileListItem item = new ProfileListItem(profile); @@ -73,7 +70,7 @@ public GameListPage() { addProfileItem.getStyleClass().add("navigation-drawer-item"); addProfileItem.setTitle(i18n("profile.new")); addProfileItem.setActionButtonVisible(false); - addProfileItem.setLeftGraphic(VersionPage.wrap(SVG.ADD_CIRCLE)); + addProfileItem.setLeftGraphic(FXUtils.wrap(SVG.ADD_CIRCLE)); addProfileItem.setOnAction(e -> Controllers.navigate(new ProfilePage(null))); pane.setFitToWidth(true); @@ -123,50 +120,31 @@ public ReadOnlyObjectProperty stateProperty() { return state.getReadOnlyProperty(); } - private class GameList extends ListPageBase { - public GameList() { - super(); + private static class GameList extends ListPageBase { + private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); + public GameList() { Profiles.registerVersionsListener(this::loadVersions); setOnFailedAction(e -> Controllers.navigate(Controllers.getDownloadPage())); } + @FXThread private void loadVersions(Profile profile) { + listenerHolder.clear(); setLoading(true); setFailedReason(null); - HMCLGameRepository repository = profile.getRepository(); - toggleGroup = new ToggleGroup(); - WeakListenerHolder listenerHolder = new WeakListenerHolder(); - toggleGroup.getProperties().put("ReferenceHolder", listenerHolder); - runInFX(() -> { - if (profile == Profiles.getSelectedProfile()) { - setLoading(false); - List children = repository.getDisplayVersions() - .map(version -> new GameListItem(toggleGroup, profile, version.getId())) - .collect(Collectors.toList()); - itemsProperty().setAll(children); - children.forEach(GameListItem::checkSelection); - - if (children.isEmpty()) { - setFailedReason(i18n("version.empty.hint")); - } - - profile.selectedVersionProperty().addListener(listenerHolder.weak((a, b, newValue) -> { - FXUtils.checkFxUserThread(); - children.forEach(it -> it.selectedProperty().set(false)); - children.stream() - .filter(it -> it.getVersion().equals(newValue)) - .findFirst() - .ifPresent(it -> it.selectedProperty().set(true)); - })); - } - toggleGroup.selectedToggleProperty().addListener((o, a, toggle) -> { - if (toggle == null) return; - GameListItem model = (GameListItem) toggle.getUserData(); - model.getProfile().setSelectedVersion(model.getVersion()); - }); - }); + if (profile != Profiles.getSelectedProfile()) + return; + + ObservableList children = FXCollections.observableList(profile.getRepository().getDisplayVersions() + .map(instance -> new GameListItem(profile, instance.getId())) + .toList()); + setItems(children); + if (children.isEmpty()) { + setFailedReason(i18n("version.empty.hint")); + } + setLoading(false); } public void refreshList() { @@ -178,7 +156,7 @@ protected GameListSkin createDefaultSkin() { return new GameListSkin(); } - private class GameListSkin extends ToolbarListPageSkin { + private class GameListSkin extends ToolbarListPageSkin { public GameListSkin() { super(GameList.this); @@ -188,6 +166,11 @@ public GameListSkin() { protected List initializeToolbar(GameList skinnable) { return Collections.emptyList(); } + + @Override + protected ListCell createListCell(JFXListView listView) { + return new GameListCell(); + } } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListPopupMenu.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListPopupMenu.java new file mode 100644 index 0000000000..c26e982c2e --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListPopupMenu.java @@ -0,0 +1,159 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.versions; + +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.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; +import javafx.scene.image.ImageView; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import org.jackhuang.hmcl.game.Version; +import org.jackhuang.hmcl.setting.Profile; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.construct.RipplerContainer; +import org.jackhuang.hmcl.ui.construct.TwoLineListItem; +import org.jackhuang.hmcl.util.StringUtils; + +import java.util.List; + +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +/// @author Glavo +public final class GameListPopupMenu extends StackPane { + + public static void show(Node owner, JFXPopup.PopupVPosition vAlign, JFXPopup.PopupHPosition hAlign, + double initOffsetX, double initOffsetY, + Profile profile, List versions) { + GameListPopupMenu menu = new GameListPopupMenu(); + menu.getItems().setAll(versions.stream().map(it -> new GameItem(profile, it.getId())).toList()); + JFXPopup popup = new JFXPopup(menu); + popup.show(owner, vAlign, hAlign, initOffsetX, initOffsetY); + } + + private final JFXListView listView = new JFXListView<>(); + private final BooleanBinding isEmpty = Bindings.isEmpty(listView.getItems()); + + public GameListPopupMenu() { + this.setMaxHeight(365); + this.getStyleClass().add("popup-menu-content"); + + listView.setCellFactory(Cell::new); + listView.setFixedCellSize(60); + listView.setPrefWidth(300); + + listView.prefHeightProperty().bind(Bindings.size(getItems()).multiply(60).add(2)); + + Label placeholder = new Label(i18n("version.empty")); + placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: -monet-on-surface-variant; -fx-font-style: italic;"); + + FXUtils.onChangeAndOperate(isEmpty, empty -> { + getChildren().setAll(empty ? placeholder : listView); + }); + } + + public ObservableList getItems() { + return listView.getItems(); + } + + private static final class Cell extends ListCell { + + private final Region graphic; + + private final ImageView imageView; + private final TwoLineListItem content; + + private final StringProperty tag = new SimpleStringProperty(); + + public Cell(ListView listView) { + this.setPadding(Insets.EMPTY); + HBox root = new HBox(); + + root.setSpacing(8); + root.setAlignment(Pos.CENTER_LEFT); + + StackPane imageViewContainer = new StackPane(); + FXUtils.setLimitWidth(imageViewContainer, 32); + FXUtils.setLimitHeight(imageViewContainer, 32); + + this.imageView = new ImageView(); + FXUtils.limitSize(imageView, 32, 32); + imageViewContainer.getChildren().setAll(imageView); + + this.content = new TwoLineListItem(); + FXUtils.onChangeAndOperate(tag, tag -> { + content.getTags().clear(); + if (StringUtils.isNotBlank(tag)) { + content.addTag(tag); + } + }); + BorderPane.setAlignment(content, Pos.CENTER); + root.getChildren().setAll(imageView, content); + + StackPane pane = new StackPane(); + pane.getChildren().setAll(root); + pane.getStyleClass().add("menu-container"); + root.setMouseTransparent(true); + + RipplerContainer ripplerContainer = new RipplerContainer(pane); + FXUtils.onClicked(ripplerContainer, () -> { + GameItem item = getItem(); + if (item != null) { + item.getProfile().setSelectedVersion(item.getId()); + if (getScene().getWindow() instanceof JFXPopup popup) + popup.hide(); + } + }); + this.graphic = ripplerContainer; + ripplerContainer.maxWidthProperty().bind(listView.widthProperty().subtract(5)); + } + + @Override + protected void updateItem(GameItem item, boolean empty) { + super.updateItem(item, empty); + + this.imageView.imageProperty().unbind(); + this.content.titleProperty().unbind(); + this.content.subtitleProperty().unbind(); + this.tag.unbind(); + + if (empty || item == null) { + setGraphic(null); + } else { + setGraphic(this.graphic); + + this.imageView.imageProperty().bind(item.imageProperty()); + this.content.titleProperty().bind(item.titleProperty()); + this.content.subtitleProperty().bind(item.subtitleProperty()); + this.tag.bind(item.tagProperty()); + } + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/HMCLLocalizedDownloadListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/HMCLLocalizedDownloadListPage.java index 145878608c..1dd5247d1d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/HMCLLocalizedDownloadListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/HMCLLocalizedDownloadListPage.java @@ -25,8 +25,8 @@ import java.util.MissingResourceException; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; 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 ofMod(DownloadPage.DownloadCallback callback, boolean versionSelection) { @@ -49,6 +49,10 @@ public static DownloadListPage ofResourcePack(DownloadPage.DownloadCallback call return new HMCLLocalizedDownloadListPage(callback, versionSelection, RemoteModRepository.Type.RESOURCE_PACK, CurseForgeRemoteModRepository.RESOURCE_PACKS, ModrinthRemoteModRepository.RESOURCE_PACKS); } + public static DownloadListPage ofShaderPack(DownloadPage.DownloadCallback callback, boolean versionSelection) { + return new HMCLLocalizedDownloadListPage(callback, versionSelection, RemoteModRepository.Type.SHADER_PACK, null, ModrinthRemoteModRepository.SHADER_PACKS); + } + private HMCLLocalizedDownloadListPage(DownloadPage.DownloadCallback callback, boolean versionSelection, RemoteModRepository.Type type, CurseForgeRemoteModRepository curseForge, ModrinthRemoteModRepository modrinth) { super(null, callback, versionSelection); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/InstallerListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/InstallerListPage.java index 6897b1c287..a25ca1c77c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/InstallerListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/InstallerListPage.java @@ -29,7 +29,6 @@ import org.jackhuang.hmcl.task.TaskExecutor; import org.jackhuang.hmcl.task.TaskListener; import org.jackhuang.hmcl.ui.*; -import org.jackhuang.hmcl.ui.animation.TransitionPane; import org.jackhuang.hmcl.ui.download.UpdateInstallerWizardProvider; import org.jackhuang.hmcl.util.TaskCancellationAction; import org.jackhuang.hmcl.util.io.FileUtils; @@ -43,7 +42,7 @@ import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; -public class InstallerListPage extends ListPageBase implements VersionPage.VersionLoadable, TransitionPane.Cacheable { +public class InstallerListPage extends ListPageBase implements VersionPage.VersionLoadable { private Profile profile; private String versionId; private Version version; @@ -77,12 +76,12 @@ public void loadVersion(Profile profile, String versionId) { InstallerItem.InstallerItemGroup group = new InstallerItem.InstallerItemGroup(gameVersion, InstallerItem.Style.LIST_ITEM); - // Conventional libraries: game, fabric, forge, cleanroom, neoforge, liteloader, optifine + // Conventional libraries: game, fabric, legacyfabric, forge, cleanroom, neoforge, liteloader, optifine for (InstallerItem item : group.getLibraries()) { String libraryId = item.getLibraryId(); - // Skip fabric-api and quilt-api - if (libraryId.contains("fabric-api") || libraryId.contains("quilt-api")) { + // Skip fabric-api and quilt-api and legacyfabric-api + if (libraryId.endsWith("-api")) { continue; } @@ -166,7 +165,7 @@ public void onStop(boolean success, TaskExecutor executor) { executor.start(); } - private class InstallerListPageSkin extends ToolbarListPageSkin { + private class InstallerListPageSkin extends ToolbarListPageSkin { InstallerListPageSkin() { super(InstallerListPage.this); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModCheckUpdatesTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModCheckUpdatesTask.java index 72e5f03242..286ae0fdb1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModCheckUpdatesTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModCheckUpdatesTask.java @@ -19,34 +19,45 @@ import org.jackhuang.hmcl.mod.LocalModFile; import org.jackhuang.hmcl.mod.RemoteMod; +import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; -import java.util.*; -import java.util.stream.Collectors; +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; public class ModCheckUpdatesTask extends Task> { - private final String gameVersion; - private final Collection mods; - private final Collection>> dependents; + private final List> dependents; public ModCheckUpdatesTask(String gameVersion, Collection mods) { - this.gameVersion = gameVersion; - this.mods = mods; + dependents = mods.stream().map(mod -> + Task.supplyAsync(Schedulers.io(), () -> { + LocalModFile.ModUpdate candidate = null; + for (RemoteMod.Type type : RemoteMod.Type.values()) { + LocalModFile.ModUpdate update = null; + try { + update = mod.checkUpdates(gameVersion, type.getRemoteModRepository()); + } catch (IOException e) { + LOG.warning(String.format("Cannot check update for mod %s.", mod.getFileName()), e); + } + if (update == null) { + continue; + } + + if (candidate == null || candidate.getCandidate().getDatePublished().isBefore(update.getCandidate().getDatePublished())) { + candidate = update; + } + } - dependents = mods.stream() - .map(mod -> - Arrays.stream(RemoteMod.Type.values()) - .map(type -> - Task.supplyAsync(() -> mod.checkUpdates(gameVersion, type.getRemoteModRepository())) - .setSignificance(TaskSignificance.MAJOR) - .setName(String.format("%s (%s)", mod.getFileName(), type.name())).withCounter("mods.check_updates") - ) - .collect(Collectors.toList()) - ) - .collect(Collectors.toList()); + return candidate; + }).setName(mod.getFileName()).setSignificance(TaskSignificance.MAJOR).withCounter("update.checking") + ).toList(); - setStage("mods.check_updates"); - getProperties().put("total", dependents.size() * RemoteMod.Type.values().length); + setStage("update.checking"); + getProperties().put("total", dependents.size()); } @Override @@ -61,7 +72,7 @@ public void preExecute() { @Override public Collection> getDependents() { - return dependents.stream().flatMap(Collection::stream).collect(Collectors.toList()); + return dependents; } @Override @@ -72,14 +83,7 @@ public boolean isRelyingOnDependents() { @Override public void execute() throws Exception { setResult(dependents.stream() - .map(tasks -> tasks.stream() - .filter(task -> task.getResult() != null) - .map(Task::getResult) - .filter(modUpdate -> !modUpdate.getCandidates().isEmpty()) - .max(Comparator.comparing((LocalModFile.ModUpdate modUpdate) -> modUpdate.getCandidates().get(0).getDatePublished())) - .orElse(null) - ) - .filter(Objects::nonNull) - .collect(Collectors.toList())); + .map(Task::getResult) + .filter(Objects::nonNull).toList()); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java index 61706ddf17..6411333012 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java @@ -34,7 +34,6 @@ import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.ListPageBase; -import org.jackhuang.hmcl.ui.animation.TransitionPane; import org.jackhuang.hmcl.ui.construct.MessageDialogPane; import org.jackhuang.hmcl.ui.construct.PageAware; import org.jackhuang.hmcl.util.TaskCancellationAction; @@ -50,7 +49,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; -public final class ModListPage extends ListPageBase implements VersionPage.VersionLoadable, PageAware, TransitionPane.Cacheable { +public final class ModListPage extends ListPageBase implements VersionPage.VersionLoadable, PageAware { private final BooleanProperty modded = new SimpleBooleanProperty(this, "modded", false); private final ReentrantLock lock = new ReentrantLock(); @@ -153,6 +152,10 @@ private void updateSupportedLoaders(ModManager modManager) { supportedLoaders.add(ModLoaderType.FABRIC); } + if (analyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC)) { + supportedLoaders.add(ModLoaderType.FABRIC); + } + if (analyzer.has(LibraryAnalyzer.LibraryType.FABRIC) && modManager.hasMod("kilt", ModLoaderType.FABRIC)) { supportedLoaders.add(ModLoaderType.FORGE); supportedLoaders.add(ModLoaderType.NEO_FORGED); @@ -230,12 +233,13 @@ public void openModFolder() { FXUtils.openFolder(profile.getRepository().getRunDirectory(instanceId).resolve("mods")); } - public void checkUpdates() { + public void checkUpdates(Collection mods) { + Objects.requireNonNull(mods); Runnable action = () -> Controllers.taskDialog(Task .composeAsync(() -> { Optional gameVersion = profile.getRepository().getGameVersion(instanceId); if (gameVersion.isPresent()) { - return new ModCheckUpdatesTask(gameVersion.get(), modManager.getMods()); + return new ModCheckUpdatesTask(gameVersion.get(), mods); } return null; }) @@ -248,8 +252,8 @@ public void checkUpdates() { Controllers.navigateForward(new ModUpdatesPage(modManager, result)); } }) - .withStagesHint(Collections.singletonList("mods.check_updates")), - i18n("update.checking"), TaskCancellationAction.NORMAL); + .withStagesHint(Collections.singletonList("update.checking")), + i18n("mods.check_updates"), TaskCancellationAction.NORMAL); if (profile.getRepository().isModpack(instanceId)) { Controllers.confirm( diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java index 09fb47b002..d3fea4f1bc 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java @@ -23,7 +23,6 @@ import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; -import javafx.beans.value.ChangeListener; import javafx.collections.ListChangeListener; import javafx.css.PseudoClass; import javafx.geometry.Insets; @@ -46,7 +45,6 @@ import org.jackhuang.hmcl.mod.curse.CurseForgeRemoteModRepository; import org.jackhuang.hmcl.mod.modrinth.ModrinthRemoteModRepository; import org.jackhuang.hmcl.setting.Profile; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.setting.VersionIconType; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -94,9 +92,6 @@ final class ModListPageSkin extends SkinBase { // FXThread private boolean isSearching = false; - @SuppressWarnings({"FieldCanBeLocal", "unused"}) - private final ChangeListener holder; - ModListPageSkin(ModListPage skinnable) { super(skinnable); @@ -108,12 +103,6 @@ final class ModListPageSkin extends SkinBase { root.getStyleClass().add("no-padding"); listView = new JFXListView<>(); - this.holder = FXUtils.onWeakChange(skinnable.loadingProperty(), loading -> { - if (!loading) { - listView.scrollTo(0); - } - }); - { toolbarPane = new TransitionPane(); @@ -152,7 +141,13 @@ final class ModListPageSkin extends SkinBase { createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh), createToolbarButton2(i18n("mods.add"), SVG.ADD, skinnable::add), createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::openModFolder), - createToolbarButton2(i18n("mods.check_updates"), SVG.UPDATE, skinnable::checkUpdates), + createToolbarButton2(i18n("mods.check_updates.button"), SVG.UPDATE, () -> + skinnable.checkUpdates( + listView.getItems().stream() + .map(ModInfoObject::getModInfo) + .toList() + ) + ), createToolbarButton2(i18n("download"), SVG.DOWNLOAD, skinnable::download), createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar)) ); @@ -168,6 +163,13 @@ final class ModListPageSkin extends SkinBase { skinnable.enableSelected(listView.getSelectionModel().getSelectedItems())), createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () -> skinnable.disableSelected(listView.getSelectionModel().getSelectedItems())), + createToolbarButton2(i18n("mods.check_updates.button"), SVG.UPDATE, () -> + skinnable.checkUpdates( + listView.getSelectionModel().getSelectedItems().stream() + .map(ModInfoObject::getModInfo) + .toList() + ) + ), createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> listView.getSelectionModel().selectAll()), createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () -> @@ -200,8 +202,7 @@ final class ModListPageSkin extends SkinBase { center.getStyleClass().add("large-spinner-pane"); center.loadingProperty().bind(skinnable.loadingProperty()); - Holder lastCell = new Holder<>(); - listView.setCellFactory(x -> new ModInfoListCell(listView, lastCell)); + listView.setCellFactory(x -> new ModInfoListCell(listView)); listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); Bindings.bindContent(listView.getItems(), skinnable.getItems()); skinnable.getItems().addListener((ListChangeListener) c -> { @@ -469,29 +470,18 @@ final class ModInfoDialog extends JFXDialogLayout { RemoteMod remoteMod = repository.getModById(versionOptional.get().getModid()); FXUtils.runInFX(() -> { for (ModLoaderType modLoaderType : versionOptional.get().getLoaders()) { - String loaderName; - switch (modLoaderType) { - case FORGE: - loaderName = i18n("install.installer.forge"); - break; - case CLEANROOM: - loaderName = i18n("install.installer.cleanroom"); - break; - case NEO_FORGED: - loaderName = i18n("install.installer.neoforge"); - break; - case FABRIC: - loaderName = i18n("install.installer.fabric"); - break; - case LITE_LOADER: - loaderName = i18n("install.installer.liteloader"); - break; - case QUILT: - loaderName = i18n("install.installer.quilt"); - break; - default: - continue; - } + String loaderName = switch (modLoaderType) { + case FORGE -> i18n("install.installer.forge"); + case CLEANROOM -> i18n("install.installer.cleanroom"); + case LEGACY_FABRIC -> i18n("install.installer.legacyfabric"); + case NEO_FORGED -> i18n("install.installer.neoforge"); + case FABRIC -> i18n("install.installer.fabric"); + case LITE_LOADER -> i18n("install.installer.liteloader"); + case QUILT -> i18n("install.installer.quilt"); + default -> null; + }; + if (loaderName == null) + continue; if (title.getTags() .stream() .noneMatch(it -> it.getText().equals(loaderName))) { @@ -505,7 +495,7 @@ final class ModInfoDialog extends JFXDialogLayout { repository instanceof CurseForgeRemoteModRepository ? HMCLLocalizedDownloadListPage.ofCurseForgeMod(null, false) : HMCLLocalizedDownloadListPage.ofModrinthMod(null, false), remoteMod, new Profile.ProfileVersion(ModListPageSkin.this.getSkinnable().getProfile(), ModListPageSkin.this.getSkinnable().getInstanceId()), - (profile, version, file) -> org.jackhuang.hmcl.ui.download.DownloadPage.download(profile, version, file, "mods") + (profile, version, mod, file) -> org.jackhuang.hmcl.ui.download.DownloadPage.download(profile, version, file, "mods") )); }); button.setDisable(false); @@ -573,8 +563,8 @@ final class ModInfoListCell extends MDListCell { Tooltip warningTooltip; - ModInfoListCell(JFXListView listView, Holder lastCell) { - super(listView, lastCell); + ModInfoListCell(JFXListView listView) { + super(listView); this.getStyleClass().add("mod-info-list-cell"); @@ -591,15 +581,15 @@ final class ModInfoListCell extends MDListCell { imageView.setImage(VersionIconType.COMMAND.getIcon()); restoreButton.getStyleClass().add("toggle-icon4"); - restoreButton.setGraphic(FXUtils.limitingSize(SVG.RESTORE.createIcon(Theme.blackFill(), 24), 24, 24)); + restoreButton.setGraphic(FXUtils.limitingSize(SVG.RESTORE.createIcon(24), 24, 24)); FXUtils.installFastTooltip(restoreButton, i18n("mods.restore")); revealButton.getStyleClass().add("toggle-icon4"); - revealButton.setGraphic(FXUtils.limitingSize(SVG.FOLDER.createIcon(Theme.blackFill(), 24), 24, 24)); + revealButton.setGraphic(FXUtils.limitingSize(SVG.FOLDER.createIcon(24), 24, 24)); infoButton.getStyleClass().add("toggle-icon4"); - infoButton.setGraphic(FXUtils.limitingSize(SVG.INFO.createIcon(Theme.blackFill(), 24), 24, 24)); + infoButton.setGraphic(FXUtils.limitingSize(SVG.INFO.createIcon(24), 24, 24)); container.getChildren().setAll(checkBox, imageView, content, restoreButton, revealButton, infoButton); @@ -663,24 +653,13 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) { } else if (!ModListPageSkin.this.getSkinnable().supportedLoaders.contains(modLoaderType)) { warning.add(i18n("mods.warning.loader_mismatch")); switch (dataItem.getModInfo().getModLoaderType()) { - case FORGE: - content.addTagWarning(i18n("install.installer.forge")); - break; - case CLEANROOM: - content.addTagWarning(i18n("install.installer.cleanroom")); - break; - case NEO_FORGED: - content.addTagWarning(i18n("install.installer.neoforge")); - break; - case FABRIC: - content.addTagWarning(i18n("install.installer.fabric")); - break; - case LITE_LOADER: - content.addTagWarning(i18n("install.installer.liteloader")); - break; - case QUILT: - content.addTagWarning(i18n("install.installer.quilt")); - break; + case FORGE -> content.addTagWarning(i18n("install.installer.forge")); + case LEGACY_FABRIC -> content.addTagWarning(i18n("install.installer.legacyfabric")); + case CLEANROOM -> content.addTagWarning(i18n("install.installer.cleanroom")); + case NEO_FORGED -> content.addTagWarning(i18n("install.installer.neoforge")); + case FABRIC -> content.addTagWarning(i18n("install.installer.fabric")); + case LITE_LOADER -> content.addTagWarning(i18n("install.installer.liteloader")); + case QUILT -> content.addTagWarning(i18n("install.installer.quilt")); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModUpdatesPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModUpdatesPage.java index 8b02ca9f85..c0c0a2f46f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModUpdatesPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModUpdatesPage.java @@ -18,16 +18,15 @@ package org.jackhuang.hmcl.ui.versions; import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXCheckBox; import javafx.beans.property.*; import javafx.beans.value.ObservableValue; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.geometry.Insets; import javafx.geometry.Pos; -import javafx.scene.control.CheckBox; import javafx.scene.control.TableColumn; import javafx.scene.control.TableView; -import javafx.scene.control.cell.CheckBoxTableCell; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import org.jackhuang.hmcl.mod.LocalModFile; @@ -38,6 +37,7 @@ import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.construct.JFXCheckBoxTableCell; import org.jackhuang.hmcl.ui.construct.MessageDialogPane; import org.jackhuang.hmcl.ui.construct.PageCloseEvent; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; @@ -72,9 +72,10 @@ public ModUpdatesPage(ModManager modManager, List update getStyleClass().add("gray-background"); TableColumn enabledColumn = new TableColumn<>(); - CheckBox allEnabledBox = new CheckBox(); + var allEnabledBox = new JFXCheckBox(); + enabledColumn.setStyle("-fx-alignment: CENTER;"); enabledColumn.setGraphic(allEnabledBox); - enabledColumn.setCellFactory(CheckBoxTableCell.forTableColumn(enabledColumn)); + enabledColumn.setCellFactory(JFXCheckBoxTableCell.forTableColumn(enabledColumn)); setupCellValueFactory(enabledColumn, ModUpdateObject::enabledProperty); enabledColumn.setEditable(true); enabledColumn.setMaxWidth(40); @@ -101,6 +102,7 @@ public ModUpdatesPage(ModManager modManager, List update TableView table = new TableView<>(objects); table.setEditable(true); table.getColumns().setAll(enabledColumn, fileNameColumn, currentVersionColumn, targetVersionColumn, sourceColumn); + setMargin(table, new Insets(10, 10, 5, 10)); setCenter(table); @@ -111,12 +113,13 @@ public ModUpdatesPage(ModManager modManager, List update JFXButton exportListButton = FXUtils.newRaisedButton(i18n("button.export")); exportListButton.setOnAction(e -> exportList()); - JFXButton nextButton = FXUtils.newRaisedButton(i18n("mods.check_updates.update")); + JFXButton nextButton = FXUtils.newRaisedButton(i18n("mods.check_updates.confirm")); nextButton.setOnAction(e -> updateMods()); JFXButton cancelButton = FXUtils.newRaisedButton(i18n("button.cancel")); cancelButton.setOnAction(e -> fireEvent(new PageCloseEvent())); onEscPressed(this, cancelButton::fire); + onEscPressed(table, cancelButton::fire); actions.getChildren().setAll(exportListButton, nextButton, cancelButton); setBottom(actions); @@ -131,7 +134,7 @@ private void updateMods() { modManager, objects.stream() .filter(o -> o.enabled.get()) - .map(object -> pair(object.data.getLocalMod(), object.data.getCandidates().get(0))) + .map(object -> pair(object.data.getLocalMod(), object.data.getCandidate())) .collect(Collectors.toList())); Controllers.taskDialog( task.whenComplete(Schedulers.javafx(), exception -> { @@ -147,7 +150,7 @@ private void updateMods() { Controllers.dialog(i18n("install.success")); } }), - i18n("mods.check_updates.update"), + i18n("mods.check_updates"), TaskCancellationAction.NORMAL); } @@ -200,7 +203,7 @@ public ModUpdateObject(LocalModFile.ModUpdate data) { enabled.set(!data.getLocalMod().getModManager().isDisabled(data.getLocalMod().getFile())); fileName.set(data.getLocalMod().getFileName()); currentVersion.set(data.getCurrentVersion().getVersion()); - targetVersion.set(data.getCandidates().get(0).getVersion()); + targetVersion.set(data.getCandidate().getVersion()); switch (data.getCurrentVersion().getSelf().getType()) { case CURSEFORGE: source.set(i18n("mods.curseforge")); @@ -276,7 +279,7 @@ public static class ModUpdateTask extends Task { private final List failedMods = new ArrayList<>(); ModUpdateTask(ModManager modManager, List> mods) { - setStage("mods.check_updates.update"); + setStage("mods.check_updates.confirm"); getProperties().put("total", mods.size()); this.dependents = new ArrayList<>(); @@ -308,7 +311,7 @@ public static class ModUpdateTask extends Task { failedMods.add(local); } }) - .withCounter("mods.check_updates.update")); + .withCounter("mods.check_updates.confirm")); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ResourcepackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ResourcepackListPage.java new file mode 100644 index 0000000000..bd2b1da00f --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ResourcepackListPage.java @@ -0,0 +1,280 @@ +package org.jackhuang.hmcl.ui.versions; + +import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXListView; +import javafx.beans.binding.Bindings; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Skin; +import javafx.scene.control.SkinBase; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.StackPane; +import javafx.stage.FileChooser; +import org.jackhuang.hmcl.mod.LocalModFile; +import org.jackhuang.hmcl.resourcepack.ResourcepackFile; +import org.jackhuang.hmcl.setting.Profile; +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.construct.*; +import org.jackhuang.hmcl.util.io.FileUtils; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + +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; + +public final class ResourcepackListPage extends ListPageBase implements VersionPage.VersionLoadable { + private Path resourcepackDirectory; + + public ResourcepackListPage() { + FXUtils.applyDragListener(this, file -> file.getFileName().toString().endsWith(".zip"), this::addFiles); + } + + @Override + protected Skin createDefaultSkin() { + return new ResourcepackListPageSkin(this); + } + + @Override + public void loadVersion(Profile profile, String version) { + this.resourcepackDirectory = profile.getRepository().getResourcepacksDirectory(version); + + try { + if (!Files.exists(resourcepackDirectory)) { + Files.createDirectories(resourcepackDirectory); + } + } catch (IOException e) { + LOG.warning("Failed to create resourcepack directory" + resourcepackDirectory, e); + } + refresh(); + } + + public void refresh() { + if (resourcepackDirectory == null || !Files.isDirectory(resourcepackDirectory)) return; + setLoading(true); + Task.supplyAsync(Schedulers.io(), () -> { + try (Stream stream = Files.list(resourcepackDirectory)) { + return stream.sorted(Comparator.comparing(FileUtils::getName)) + .flatMap(item -> { + try { + return Stream.of(ResourcepackFile.parse(item)).filter(Objects::nonNull).map(ResourcepackInfoObject::new); + } catch (IOException e) { + LOG.warning("Failed to load resourcepack " + item, e); + return Stream.empty(); + } + }) + .toList(); + } + }).whenComplete(Schedulers.javafx(), ((result, exception) -> { + if (exception == null) { + getItems().setAll(result); + } else { + LOG.warning("Failed to load resourcepacks", exception); + getItems().clear(); + } + setLoading(false); + })).start(); + } + + public void addFiles(List files) { + if (resourcepackDirectory == null) return; + + try { + for (Path file : files) { + Path target = resourcepackDirectory.resolve(file.getFileName()); + if (!Files.exists(target)) { + Files.copy(file, target); + } + } + } catch (IOException e) { + LOG.warning("Failed to add resourcepacks", e); + Controllers.dialog(i18n("resourcepack.add.failed"), i18n("message.error"), MessageDialogPane.MessageType.ERROR); + } + + refresh(); + } + + public void onAddFiles() { + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle(i18n("resourcepack.add")); + fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("resourcepack"), "*.zip")); + List files = FileUtils.toPaths(fileChooser.showOpenMultipleDialog(Controllers.getStage())); + if (files != null && !files.isEmpty()) { + addFiles(files); + } + } + + private void onDownload() { + Controllers.getDownloadPage().showResourcepackDownloads(); + Controllers.navigate(Controllers.getDownloadPage()); + } + + private static final class ResourcepackListPageSkin extends SkinBase { + private final JFXListView listView; + + 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<>(); + + HBox toolbar = new HBox(); + toolbar.setAlignment(Pos.CENTER_LEFT); + toolbar.setPickOnBounds(false); + toolbar.getChildren().setAll( + createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, control::refresh), + createToolbarButton2(i18n("resourcepack.add"), SVG.ADD, control::onAddFiles), + createToolbarButton2(i18n("resourcepack.download"), SVG.DOWNLOAD, control::onDownload) + ); + root.getContent().add(toolbar); + + SpinnerPane center = new SpinnerPane(); + ComponentList.setVgrow(center, Priority.ALWAYS); + center.getStyleClass().add("large-spinner-pane"); + center.loadingProperty().bind(control.loadingProperty()); + + listView.setCellFactory(x -> new ResourcepackListCell(listView, control)); + Bindings.bindContent(listView.getItems(), control.getItems()); + + center.setContent(listView); + root.getContent().add(center); + + pane.getChildren().setAll(root); + getChildren().setAll(pane); + } + } + + public static class ResourcepackInfoObject { + private final ResourcepackFile file; + private WeakReference iconCache; + + public ResourcepackInfoObject(ResourcepackFile file) { + this.file = file; + } + + public ResourcepackFile getFile() { + return file; + } + + Image getIcon() { + Image image = null; + if (iconCache != null && (image = iconCache.get()) != null) { + return image; + } + byte[] iconData = file.getIcon(); + if (iconData != null) { + try (ByteArrayInputStream inputStream = new ByteArrayInputStream(iconData)) { + image = new Image(inputStream, 64, 64, true, true); + } catch (Exception e) { + LOG.warning("Failed to load resourcepack icon " + file.getPath(), e); + } + } + + if (image == null || image.isError() || image.getWidth() <= 0 || image.getHeight() <= 0 || + (Math.abs(image.getWidth() - image.getHeight()) >= 1)) { + image = FXUtils.newBuiltinImage("/assets/img/unknown_pack.png"); + } + iconCache = new WeakReference<>(image); + return image; + } + } + + private static final class ResourcepackListCell extends MDListCell { + private final ImageView imageView = new ImageView(); + private final TwoLineListItem content = new TwoLineListItem(); + private final JFXButton btnReveal = new JFXButton(); + private final JFXButton btnDelete = new JFXButton(); + private final ResourcepackListPage page; + + public ResourcepackListCell(JFXListView listView, ResourcepackListPage page) { + super(listView); + + this.page = page; + + BorderPane root = new BorderPane(); + root.getStyleClass().add("md-list-cell"); + root.setPadding(new Insets(8)); + + HBox left = new HBox(8); + left.setAlignment(Pos.CENTER); + FXUtils.limitSize(imageView, 32, 32); + left.getChildren().add(imageView); + left.setPadding(new Insets(0, 8, 0, 0)); + FXUtils.setLimitWidth(left, 48); + root.setLeft(left); + + HBox.setHgrow(content, Priority.ALWAYS); + root.setCenter(content); + + btnReveal.getStyleClass().add("toggle-icon4"); + btnReveal.setGraphic(SVG.FOLDER_OPEN.createIcon()); + + btnDelete.getStyleClass().add("toggle-icon4"); + btnDelete.setGraphic(SVG.DELETE_FOREVER.createIcon()); + + HBox right = new HBox(8); + right.setAlignment(Pos.CENTER_RIGHT); + right.getChildren().setAll(btnReveal, btnDelete); + root.setRight(right); + + getContainer().getChildren().add(new RipplerContainer(root)); + } + + @Override + protected void updateControl(ResourcepackListPage.ResourcepackInfoObject item, boolean empty) { + if (empty || item == null) { + return; + } + + ResourcepackFile file = item.getFile(); + imageView.setImage(item.getIcon()); + + content.setTitle(file.getName()); + LocalModFile.Description description = file.getDescription(); + content.setSubtitle(description != null ? description.toString() : ""); + + FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager")); + btnReveal.setOnAction(event -> FXUtils.showFileInExplorer(file.getPath())); + + btnDelete.setOnAction(event -> + Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), + () -> onDelete(file), null)); + } + + private void onDelete(ResourcepackFile file) { + try { + if (Files.isDirectory(file.getPath())) { + FileUtils.deleteDirectory(file.getPath()); + } else { + Files.delete(file.getPath()); + } + page.refresh(); + } catch (IOException e) { + Controllers.dialog(i18n("resourcepack.delete.failed", e.getMessage()), i18n("message.error"), MessageDialogPane.MessageType.ERROR); + LOG.warning("Failed to delete resourcepack", e); + } + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/SchematicsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/SchematicsPage.java index 3946f4a2b8..c50e4f686e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/SchematicsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/SchematicsPage.java @@ -19,8 +19,10 @@ import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXDialogLayout; +import com.jfoenix.controls.JFXListView; import javafx.geometry.Insets; import javafx.geometry.Pos; +import javafx.scene.Group; import javafx.scene.Node; import javafx.scene.control.*; import javafx.scene.image.Image; @@ -30,14 +32,13 @@ import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.StackPane; +import javafx.scene.shape.SVGPath; import javafx.stage.FileChooser; import org.jackhuang.hmcl.schematic.LitematicFile; import org.jackhuang.hmcl.setting.Profile; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.*; -import org.jackhuang.hmcl.ui.animation.TransitionPane; import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.StringUtils; @@ -61,7 +62,7 @@ /** * @author Glavo */ -public final class SchematicsPage extends ListPageBase implements VersionPage.VersionLoadable, TransitionPane.Cacheable { +public final class SchematicsPage extends ListPageBase implements VersionPage.VersionLoadable { private static String translateAuthorName(String author) { if (I18n.isUseChinese() && "hsds".equals(author)) { @@ -162,30 +163,30 @@ public void onCreateDirectory() { Controllers.dialog(new InputDialogPane( i18n("schematics.create_directory.prompt"), "", - (result, resolve, reject) -> { + (result, handler) -> { if (StringUtils.isBlank(result)) { - reject.accept(i18n("schematics.create_directory.failed.empty_name")); + handler.reject(i18n("schematics.create_directory.failed.empty_name")); return; } if (result.contains("/") || result.contains("\\") || !FileUtils.isNameValid(result)) { - reject.accept(i18n("schematics.create_directory.failed.invalid_name")); + handler.reject(i18n("schematics.create_directory.failed.invalid_name")); return; } Path targetDir = parent.resolve(result); if (Files.exists(targetDir)) { - reject.accept(i18n("schematics.create_directory.failed.already_exists")); + handler.reject(i18n("schematics.create_directory.failed.already_exists")); return; } try { Files.createDirectories(targetDir); - resolve.run(); + handler.resolve(); refresh(); } catch (IOException e) { LOG.warning("Failed to create directory: " + targetDir, e); - reject.accept(i18n("schematics.create_directory.failed", targetDir)); + handler.reject(i18n("schematics.create_directory.failed", targetDir)); } })); } @@ -223,7 +224,7 @@ private void navigateTo(DirItem item) { getItems().addAll(item.children); } - abstract class Item extends Control implements Comparable { + abstract sealed class Item implements Comparable { boolean isDirectory() { return this instanceof DirItem; @@ -241,7 +242,7 @@ Node getIcon(int size) { StackPane icon = new StackPane(); icon.setPrefSize(size, size); icon.setMaxSize(size, size); - icon.getChildren().add(getIcon().createIcon(Theme.blackFill(), size)); + icon.getChildren().add(getIcon().createIcon(size)); return icon; } @@ -260,11 +261,6 @@ public int compareTo(@NotNull SchematicsPage.Item o) { return this.getName().compareTo(o.getName()); } - - @Override - protected Skin createDefaultSkin() { - return new ItemSkin(this); - } } private final class BackItem extends Item { @@ -440,6 +436,10 @@ SVG getIcon() { return SVG.SCHEMA; } + public @Nullable Image getImage() { + return image; + } + Node getIcon(int size) { if (image == null) { return super.getIcon(size); @@ -542,66 +542,126 @@ private void updateContent(LitematicFile file) { } } - private static final class ItemSkin extends SkinBase { - public ItemSkin(Item item) { - super(item); + private static final class Cell extends ListCell { - BorderPane root = new BorderPane(); + private final RipplerContainer graphics; + private final BorderPane root; + private final StackPane left; + private final TwoLineListItem center; + private final HBox right; + + private final ImageView iconImageView; + private final SVGPath iconSVG; + private final StackPane iconSVGWrapper; + + private final Tooltip tooltip = new Tooltip(); + + public Cell() { + this.root = new BorderPane(); root.getStyleClass().add("md-list-cell"); root.setPadding(new Insets(8)); { - StackPane left = new StackPane(); - left.setMaxSize(32, 32); - left.setPrefSize(32, 32); - left.getChildren().add(item.getIcon(24)); + this.left = new StackPane(); left.setPadding(new Insets(0, 8, 0, 0)); - Path path = item.getPath(); - if (path != null) { - FXUtils.installSlowTooltip(left, path.toAbsolutePath().normalize().toString()); - } + this.iconImageView = new ImageView(); + FXUtils.limitSize(iconImageView, 32, 32); + + this.iconSVG = new SVGPath(); + iconSVG.getStyleClass().add("svg"); + iconSVG.setScaleX(32.0 / SVG.DEFAULT_SIZE); + iconSVG.setScaleY(32.0 / SVG.DEFAULT_SIZE); + + this.iconSVGWrapper = new StackPane(new Group(iconSVG)); + iconSVGWrapper.setAlignment(Pos.CENTER); + FXUtils.setLimitWidth(iconSVGWrapper, 32); + FXUtils.setLimitHeight(iconSVGWrapper, 32); BorderPane.setAlignment(left, Pos.CENTER); root.setLeft(left); } { - TwoLineListItem center = new TwoLineListItem(); - center.setTitle(item.getName()); - center.setSubtitle(item.getDescription()); - + this.center = new TwoLineListItem(); root.setCenter(center); } - if (!(item instanceof BackItem)) { - HBox right = new HBox(8); + { + this.right = new HBox(8); right.setAlignment(Pos.CENTER_RIGHT); JFXButton btnReveal = new JFXButton(); FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager")); btnReveal.getStyleClass().add("toggle-icon4"); - btnReveal.setGraphic(SVG.FOLDER_OPEN.createIcon(Theme.blackFill(), -1)); - btnReveal.setOnAction(event -> item.onReveal()); + btnReveal.setGraphic(SVG.FOLDER_OPEN.createIcon()); + btnReveal.setOnAction(event -> { + Item item = getItem(); + if (item != null && !(item instanceof BackItem)) + item.onReveal(); + }); JFXButton btnDelete = new JFXButton(); btnDelete.getStyleClass().add("toggle-icon4"); - btnDelete.setGraphic(SVG.DELETE_FOREVER.createIcon(Theme.blackFill(), -1)); - btnDelete.setOnAction(event -> + btnDelete.setGraphic(SVG.DELETE_FOREVER.createIcon()); + btnDelete.setOnAction(event -> { + Item item = getItem(); + if (item != null && !(item instanceof BackItem)) { Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), - item::onDelete, null)); + item::onDelete, null); + } + }); right.getChildren().setAll(btnReveal, btnDelete); - root.setRight(right); } - RipplerContainer container = new RipplerContainer(root); - FXUtils.onClicked(container, item::onClick); - this.getChildren().add(container); + this.graphics = new RipplerContainer(root); + FXUtils.onClicked(graphics, () -> { + Item item = getItem(); + if (item != null) + item.onClick(); + }); + } + + @Override + protected void updateItem(Item item, boolean empty) { + super.updateItem(item, empty); + + iconImageView.setImage(null); + + if (empty || item == null) { + setGraphic(null); + center.setTitle(""); + center.setSubtitle(""); + } else { + if (item instanceof LitematicFileItem fileItem && fileItem.getImage() != null) { + iconImageView.setImage(fileItem.getImage()); + left.getChildren().setAll(iconImageView); + } else { + iconSVG.setContent(item.getIcon().getPath()); + left.getChildren().setAll(iconSVGWrapper); + } + + center.setTitle(item.getName()); + center.setSubtitle(item.getDescription()); + + Path path = item.getPath(); + if (path != null) { + tooltip.setText(FileUtils.getAbsolutePath(path)); + FXUtils.installSlowTooltip(left, tooltip); + } else { + tooltip.setText(""); + Tooltip.uninstall(left, tooltip); + } + + root.setRight(item instanceof BackItem ? null : right); + + setGraphic(graphics); + } } } - private final class SchematicsPageSkin extends ToolbarListPageSkin { + private final class SchematicsPageSkin extends ToolbarListPageSkin { SchematicsPageSkin() { super(SchematicsPage.this); } @@ -614,5 +674,10 @@ protected List initializeToolbar(SchematicsPage skinnable) { createToolbarButton2(i18n("schematics.create_directory"), SVG.CREATE_NEW_FOLDER, skinnable::onCreateDirectory) ); } + + @Override + protected ListCell createListCell(JFXListView listView) { + return new Cell(); + } } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionIconDialog.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionIconDialog.java index aefb20b83a..a48fc63a09 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionIconDialog.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionIconDialog.java @@ -23,7 +23,6 @@ import javafx.stage.FileChooser; import org.jackhuang.hmcl.event.Event; import org.jackhuang.hmcl.setting.Profile; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.setting.VersionIconType; import org.jackhuang.hmcl.setting.VersionSetting; import org.jackhuang.hmcl.ui.Controllers; @@ -65,6 +64,7 @@ public VersionIconDialog(Profile profile, String versionId, Runnable onFinish) { createIcon(VersionIconType.OPTIFINE), createIcon(VersionIconType.CRAFT_TABLE), createIcon(VersionIconType.FABRIC), + createIcon(VersionIconType.LEGACY_FABRIC), createIcon(VersionIconType.FORGE), createIcon(VersionIconType.CLEANROOM), createIcon(VersionIconType.NEO_FORGE), @@ -93,7 +93,7 @@ private void exploreIcon() { } private Node createCustomIcon() { - Node shape = SVG.ADD_CIRCLE.createIcon(Theme.blackFill(), 32); + Node shape = SVG.ADD_CIRCLE.createIcon(32); shape.setMouseTransparent(true); RipplerContainer container = new RipplerContainer(shape); FXUtils.setLimitWidth(container, 36); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java index 276df89c58..7ea91bacb8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java @@ -21,13 +21,9 @@ import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.beans.property.*; -import javafx.geometry.Insets; -import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.layout.Priority; -import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; -import javafx.scene.paint.Paint; import org.jackhuang.hmcl.event.EventBus; import org.jackhuang.hmcl.event.EventPriority; import org.jackhuang.hmcl.event.RefreshedVersionsEvent; @@ -57,6 +53,7 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage private final TabHeader.Tab modListTab = new TabHeader.Tab<>("modListTab"); private final TabHeader.Tab worldListTab = new TabHeader.Tab<>("worldList"); private final TabHeader.Tab schematicsTab = new TabHeader.Tab<>("schematicsTab"); + private final TabHeader.Tab resourcePackTab = new TabHeader.Tab<>("resourcePackTab"); private final TransitionPane transitionPane = new TransitionPane(); private final BooleanProperty currentVersionUpgradable = new SimpleBooleanProperty(); private final ObjectProperty version = new SimpleObjectProperty<>(); @@ -68,10 +65,11 @@ public VersionPage() { versionSettingsTab.setNodeSupplier(loadVersionFor(() -> new VersionSettingsPage(false))); installerListTab.setNodeSupplier(loadVersionFor(InstallerListPage::new)); modListTab.setNodeSupplier(loadVersionFor(ModListPage::new)); + resourcePackTab.setNodeSupplier(loadVersionFor(ResourcepackListPage::new)); worldListTab.setNodeSupplier(loadVersionFor(WorldListPage::new)); schematicsTab.setNodeSupplier(loadVersionFor(SchematicsPage::new)); - tab = new TabHeader(transitionPane, versionSettingsTab, installerListTab, modListTab, worldListTab, schematicsTab); + tab = new TabHeader(transitionPane, versionSettingsTab, installerListTab, modListTab, resourcePackTab, worldListTab, schematicsTab); tab.select(versionSettingsTab); addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated); @@ -131,6 +129,8 @@ public void loadVersion(String version, Profile profile) { installerListTab.getNode().loadVersion(profile, version); if (modListTab.isInitialized()) modListTab.getNode().loadVersion(profile, version); + if (resourcePackTab.isInitialized()) + resourcePackTab.getNode().loadVersion(profile, version); if (worldListTab.isInitialized()) worldListTab.getNode().loadVersion(profile, version); if (schematicsTab.isInitialized()) @@ -239,6 +239,7 @@ protected Skin(VersionPage control) { .addNavigationDrawerTab(control.tab, control.versionSettingsTab, i18n("settings.game"), SVG.SETTINGS, SVG.SETTINGS_FILL) .addNavigationDrawerTab(control.tab, control.installerListTab, i18n("settings.tabs.installers"), SVG.DEPLOYED_CODE, SVG.DEPLOYED_CODE_FILL) .addNavigationDrawerTab(control.tab, control.modListTab, i18n("mods.manage"), SVG.EXTENSION, SVG.EXTENSION_FILL) + .addNavigationDrawerTab(control.tab, control.resourcePackTab, i18n("resourcepack.manage"), SVG.TEXTURE) .addNavigationDrawerTab(control.tab, control.worldListTab, i18n("world.manage"), SVG.PUBLIC) .addNavigationDrawerTab(control.tab, control.schematicsTab, i18n("schematics.manage"), SVG.SCHEMA, SVG.SCHEMA_FILL); VBox.setVgrow(sideBar, Priority.ALWAYS); @@ -248,12 +249,12 @@ protected Skin(VersionPage control) { browseList.getContent().setAll( new IconedMenuItem(SVG.STADIA_CONTROLLER, i18n("folder.game"), () -> control.onBrowse(""), browsePopup), new IconedMenuItem(SVG.EXTENSION, i18n("folder.mod"), () -> control.onBrowse("mods"), browsePopup), - new IconedMenuItem(SVG.SETTINGS, i18n("folder.config"), () -> control.onBrowse("config"), browsePopup), new IconedMenuItem(SVG.TEXTURE, i18n("folder.resourcepacks"), () -> control.onBrowse("resourcepacks"), browsePopup), - new IconedMenuItem(SVG.WB_SUNNY, i18n("folder.shaderpacks"), () -> control.onBrowse("shaderpacks"), browsePopup), + new IconedMenuItem(SVG.PUBLIC, i18n("folder.saves"), () -> control.onBrowse("saves"), browsePopup), new IconedMenuItem(SVG.SCHEMA, i18n("folder.schematics"), () -> control.onBrowse("schematics"), browsePopup), + new IconedMenuItem(SVG.WB_SUNNY, i18n("folder.shaderpacks"), () -> control.onBrowse("shaderpacks"), browsePopup), new IconedMenuItem(SVG.SCREENSHOT_MONITOR, i18n("folder.screenshots"), () -> control.onBrowse("screenshots"), browsePopup), - new IconedMenuItem(SVG.PUBLIC, i18n("folder.saves"), () -> control.onBrowse("saves"), browsePopup), + new IconedMenuItem(SVG.SETTINGS, i18n("folder.config"), () -> control.onBrowse("config"), browsePopup), new IconedMenuItem(SVG.SCRIPT, i18n("folder.logs"), () -> control.onBrowse("logs"), browsePopup) ); @@ -301,20 +302,6 @@ protected Skin(VersionPage control) { } } - public static Node wrap(Node node) { - StackPane stackPane = new StackPane(); - stackPane.setAlignment(Pos.CENTER); - FXUtils.setLimitWidth(stackPane, 30); - FXUtils.setLimitHeight(stackPane, 20); - stackPane.setPadding(new Insets(0, 0, 0, 0)); - stackPane.getChildren().setAll(node); - return stackPane; - } - - public static Node wrap(SVG svg) { - return wrap(svg.createIcon((Paint) null, 20)); - } - public interface VersionLoadable { void loadVersion(Profile profile, String version); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionSettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionSettingsPage.java index d92b6fc9da..f706c2cfc2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionSettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionSettingsPage.java @@ -42,10 +42,7 @@ import org.jackhuang.hmcl.ui.WeakListenerHolder; import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; -import org.jackhuang.hmcl.util.Lang; -import org.jackhuang.hmcl.util.Pair; -import org.jackhuang.hmcl.util.ServerAddress; -import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.*; import org.jackhuang.hmcl.util.javafx.BindingMapping; import org.jackhuang.hmcl.util.javafx.PropertyUtils; import org.jackhuang.hmcl.util.javafx.SafeStringConverter; @@ -56,34 +53,18 @@ import org.jackhuang.hmcl.util.platform.hardware.PhysicalMemoryStatus; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import java.lang.ref.WeakReference; import java.util.*; import java.util.concurrent.atomic.AtomicBoolean; import static org.jackhuang.hmcl.ui.FXUtils.stringConverter; import static org.jackhuang.hmcl.util.DataSizeUnit.GIGABYTES; import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES; -import static org.jackhuang.hmcl.util.Lang.getTimer; import static org.jackhuang.hmcl.util.Pair.pair; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public final class VersionSettingsPage extends StackPane implements DecoratorPage, VersionPage.VersionLoadable, PageAware { - private static final ObjectProperty memoryStatus = new SimpleObjectProperty<>(PhysicalMemoryStatus.INVALID); - private static TimerTask memoryStatusUpdateTask; - - private static void initMemoryStatusUpdateTask() { - FXUtils.checkFxUserThread(); - if (memoryStatusUpdateTask != null) - return; - memoryStatusUpdateTask = new TimerTask() { - @Override - public void run() { - Platform.runLater(() -> memoryStatus.set(SystemInfo.getPhysicalMemoryStatus())); - } - }; - getTimer().scheduleAtFixedRate(memoryStatusUpdateTask, 0, 1000); - } - private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(new State("", null, false, false, false)); private AdvancedVersionSettingPage advancedVersionSettingPage; @@ -111,6 +92,7 @@ public void run() { private final MultiFileItem.FileOption gameDirCustomOption; private final JFXComboBox cboProcessPriority; private final OptionToggleButton showLogsPane; + private final OptionToggleButton enableDebugLogOutputPane; private final ImagePickerItem iconPickerItem; private final ChangeListener> javaListChangeListener; @@ -126,6 +108,8 @@ public void run() { private final IntegerProperty maxMemory = new SimpleIntegerProperty(); private final BooleanProperty modpack = new SimpleBooleanProperty(); + private final ReadOnlyObjectProperty memoryStatus = UpdateMemoryStatus.memoryStatusProperty(); + public VersionSettingsPage(boolean globalSetting) { ScrollPane scrollPane = new ScrollPane(); scrollPane.setFitToHeight(true); @@ -410,6 +394,9 @@ public VersionSettingsPage(boolean globalSetting) { showLogsPane = new OptionToggleButton(); showLogsPane.setTitle(i18n("settings.show_log")); + enableDebugLogOutputPane = new OptionToggleButton(); + enableDebugLogOutputPane.setTitle(i18n("settings.enable_debug_log_output")); + BorderPane processPriorityPane = new BorderPane(); { Label label = new Label(i18n("settings.advanced.process_priority")); @@ -474,6 +461,7 @@ public VersionSettingsPage(boolean globalSetting) { launcherVisibilityPane, dimensionPane, showLogsPane, + enableDebugLogOutputPane, processPriorityPane, serverPane, showAdvancedSettingPane @@ -505,10 +493,7 @@ public VersionSettingsPage(boolean globalSetting) { cboProcessPriority.getItems().setAll(ProcessPriority.values()); cboProcessPriority.setConverter(stringConverter(e -> i18n("settings.advanced.process_priority." + e.name().toLowerCase(Locale.ROOT)))); - memoryStatus.set(SystemInfo.getPhysicalMemoryStatus()); componentList.disableProperty().bind(enableSpecificSettings.not()); - - initMemoryStatusUpdateTask(); } @Override @@ -551,6 +536,7 @@ public void loadVersion(Profile profile, String versionId) { chkAutoAllocate.selectedProperty().unbindBidirectional(lastVersionSetting.autoMemoryProperty()); chkFullscreen.selectedProperty().unbindBidirectional(lastVersionSetting.fullscreenProperty()); showLogsPane.selectedProperty().unbindBidirectional(lastVersionSetting.showLogsProperty()); + enableDebugLogOutputPane.selectedProperty().unbindBidirectional(lastVersionSetting.enableDebugLogOutputProperty()); FXUtils.unbindEnum(cboLauncherVisibility, lastVersionSetting.launcherVisibilityProperty()); FXUtils.unbindEnum(cboProcessPriority, lastVersionSetting.processPriorityProperty()); @@ -585,6 +571,7 @@ public void loadVersion(Profile profile, String versionId) { chkAutoAllocate.selectedProperty().bindBidirectional(versionSetting.autoMemoryProperty()); chkFullscreen.selectedProperty().bindBidirectional(versionSetting.fullscreenProperty()); showLogsPane.selectedProperty().bindBidirectional(versionSetting.showLogsProperty()); + enableDebugLogOutputPane.selectedProperty().bindBidirectional(versionSetting.enableDebugLogOutputProperty()); FXUtils.bindEnum(cboLauncherVisibility, versionSetting.launcherVisibilityProperty()); FXUtils.bindEnum(cboProcessPriority, versionSetting.processPriorityProperty()); @@ -802,4 +789,58 @@ private static List getSupportedResolutions() { public ReadOnlyObjectProperty stateProperty() { return state.getReadOnlyProperty(); } + + private static final class UpdateMemoryStatus extends Thread { + + @FXThread + private static WeakReference> memoryStatusPropertyCache; + + @FXThread + static ReadOnlyObjectProperty memoryStatusProperty() { + if (memoryStatusPropertyCache != null) { + var property = memoryStatusPropertyCache.get(); + if (property != null) { + return property; + } + } + + ObjectProperty property = new SimpleObjectProperty<>(PhysicalMemoryStatus.INVALID); + memoryStatusPropertyCache = new WeakReference<>(property); + new UpdateMemoryStatus(memoryStatusPropertyCache).start(); + return property; + } + + private final WeakReference> memoryStatusPropertyRef; + + UpdateMemoryStatus(WeakReference> memoryStatusPropertyRef) { + this.memoryStatusPropertyRef = memoryStatusPropertyRef; + + setName("UpdateMemoryStatus"); + setDaemon(true); + setPriority(Thread.MIN_PRIORITY); + } + + @Override + public void run() { + while (true) { + PhysicalMemoryStatus status = SystemInfo.getPhysicalMemoryStatus(); + + var memoryStatusProperty = memoryStatusPropertyRef.get(); + if (memoryStatusProperty == null) + return; + + if (Controllers.isStopped()) + return; + + Platform.runLater(() -> memoryStatusProperty.set(status)); + + try { + //noinspection BusyWait + Thread.sleep(1000); + } catch (InterruptedException e) { + return; + } + } + } + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/Versions.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/Versions.java index 50656aef27..82c3c7a8ac 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/Versions.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/Versions.java @@ -23,13 +23,13 @@ import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount; import org.jackhuang.hmcl.download.game.GameAssetDownloadTask; -import org.jackhuang.hmcl.game.GameDirectoryType; -import org.jackhuang.hmcl.game.GameRepository; -import org.jackhuang.hmcl.game.HMCLGameRepository; -import org.jackhuang.hmcl.game.LauncherHelper; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.mod.RemoteMod; import org.jackhuang.hmcl.setting.*; -import org.jackhuang.hmcl.task.*; +import org.jackhuang.hmcl.task.FileDownloadTask; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.task.TaskExecutor; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.account.CreateAccountPane; @@ -53,8 +53,8 @@ import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; public final class Versions { private Versions() { @@ -72,7 +72,7 @@ public static void importModpack() { } } - public static void downloadModpackImpl(Profile profile, String version, RemoteMod.Version file) { + public static void downloadModpackImpl(Profile profile, String version, RemoteMod mod, RemoteMod.Version file) { Path modpack; URI downloadURL; try { @@ -88,11 +88,14 @@ public static void downloadModpackImpl(Profile profile, String version, RemoteMo new FileDownloadTask(downloadURL, modpack) .whenComplete(Schedulers.javafx(), e -> { if (e == null) { - if (version != null) { - Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(profile, modpack, version)); - } else { - Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(profile, modpack)); - } + ModpackInstallWizardProvider installWizardProvider; + if (version != null) + installWizardProvider = new ModpackInstallWizardProvider(profile, modpack, version); + else + installWizardProvider = new ModpackInstallWizardProvider(profile, modpack); + if (StringUtils.isNotBlank(mod.getIconUrl())) + installWizardProvider.setIconUrl(mod.getIconUrl()); + Controllers.getDecorator().startWizard(installWizardProvider); } else if (e instanceof CancellationException) { Controllers.showToast(i18n("message.cancelled")); } else { @@ -126,13 +129,13 @@ public static void deleteVersion(Profile profile, String version) { } public static CompletableFuture renameVersion(Profile profile, String version) { - return Controllers.prompt(i18n("version.manage.rename.message"), (newName, resolve, reject) -> { - if (!HMCLGameRepository.isValidVersionId(newName)) { - reject.accept(i18n("install.new_game.malformed")); + return Controllers.prompt(i18n("version.manage.rename.message"), (newName, handler) -> { + if (newName.equals(version)) { + handler.resolve(); return; } if (profile.getRepository().renameVersion(version, newName)) { - resolve.run(); + handler.resolve(); profile.getRepository().refreshVersionsAsync() .thenRunAsync(Schedulers.javafx(), () -> { if (profile.getRepository().hasVersion(newName)) { @@ -140,9 +143,10 @@ public static CompletableFuture renameVersion(Profile profile, String ve } }).start(); } else { - reject.accept(i18n("version.manage.rename.fail")); + handler.reject(i18n("version.manage.rename.fail")); } - }, version); + }, version, new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId), + new Validator(i18n("install.new_game.already_exists"), newVersionName -> !profile.getRepository().versionIdConflicts(newVersionName) || newVersionName.equals(version))); } public static void exportVersion(Profile profile, String version) { @@ -155,20 +159,16 @@ public static void openFolder(Profile profile, String version) { public static void duplicateVersion(Profile profile, String version) { Controllers.prompt( - new PromptDialogPane.Builder(i18n("version.manage.duplicate.prompt"), (res, resolve, reject) -> { + new PromptDialogPane.Builder(i18n("version.manage.duplicate.prompt"), (res, handler) -> { String newVersionName = ((PromptDialogPane.Builder.StringQuestion) res.get(1)).getValue(); boolean copySaves = ((PromptDialogPane.Builder.BooleanQuestion) res.get(2)).getValue(); - if (!HMCLGameRepository.isValidVersionId(newVersionName)) { - reject.accept(i18n("install.new_game.malformed")); - return; - } Task.runAsync(() -> profile.getRepository().duplicateVersion(version, newVersionName, copySaves)) .thenComposeAsync(profile.getRepository().refreshVersionsAsync()) .whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception == null) { - resolve.run(); + handler.resolve(); } else { - reject.accept(StringUtils.getStackTrace(exception)); + handler.reject(StringUtils.getStackTrace(exception)); if (!profile.getRepository().versionIdConflicts(newVersionName)) { profile.getRepository().removeVersionFromDisk(newVersionName); } @@ -177,6 +177,7 @@ public static void duplicateVersion(Profile profile, String version) { }) .addQuestion(new PromptDialogPane.Builder.HintQuestion(i18n("version.manage.duplicate.confirm"))) .addQuestion(new PromptDialogPane.Builder.StringQuestion(null, version, + new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId), new Validator(i18n("install.new_game.already_exists"), newVersionName -> !profile.getRepository().versionIdConflicts(newVersionName)))) .addQuestion(new PromptDialogPane.Builder.BooleanQuestion(i18n("version.manage.duplicate.duplicate_save"), false))); } @@ -200,7 +201,8 @@ public static void cleanVersion(Profile profile, String id) { } } - public static void generateLaunchScript(Profile profile, String id) { + @SafeVarargs + public static void generateLaunchScript(Profile profile, String id, Consumer... injecters) { if (!checkVersionForLaunching(profile, id)) return; ensureSelectedAccount(account -> { @@ -209,23 +211,35 @@ public static void generateLaunchScript(Profile profile, String id) { if (Files.isDirectory(repository.getRunDirectory(id))) chooser.setInitialDirectory(repository.getRunDirectory(id).toFile()); chooser.setTitle(i18n("version.launch_script.save")); + if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { + chooser.getExtensionFilters().add( + new FileChooser.ExtensionFilter(i18n("extension.command"), "*.command") + ); + } chooser.getExtensionFilters().add(OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? new FileChooser.ExtensionFilter(i18n("extension.bat"), "*.bat") : new FileChooser.ExtensionFilter(i18n("extension.sh"), "*.sh")); chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("extension.ps1"), "*.ps1")); Path file = FileUtils.toPath(chooser.showSaveDialog(Controllers.getStage())); - if (file != null) - new LauncherHelper(profile, account, id).makeLaunchScript(file); + if (file != null) { + LauncherHelper launcherHelper = new LauncherHelper(profile, account, id); + for (Consumer injecter : injecters) { + injecter.accept(launcherHelper); + } + launcherHelper.makeLaunchScript(file); + } }); } - public static void launch(Profile profile, String id, Consumer injecter) { + @SafeVarargs + public static void launch(Profile profile, String id, Consumer... injecters) { if (!checkVersionForLaunching(profile, id)) return; ensureSelectedAccount(account -> { LauncherHelper launcherHelper = new LauncherHelper(profile, account, id); - if (injecter != null) + for (Consumer injecter : injecters) { injecter.accept(launcherHelper); + } launcherHelper.launch(); }); } @@ -234,6 +248,16 @@ public static void testGame(Profile profile, String id) { launch(profile, id, LauncherHelper::setTestMode); } + public static void launchAndEnterWorld(Profile profile, String id, String worldFolderName) { + launch(profile, id, launcherHelper -> + launcherHelper.setQuickPlayOption(new QuickPlayOption.SinglePlayer(worldFolderName))); + } + + public static void generateLaunchScriptForQuickEnterWorld(Profile profile, String id, String worldFolderName) { + generateLaunchScript(profile, id, launcherHelper -> + launcherHelper.setQuickPlayOption(new QuickPlayOption.SinglePlayer(worldFolderName))); + } + private static boolean checkVersionForLaunching(Profile profile, String id) { if (id == null || !profile.getRepository().isLoaded() || !profile.getRepository().hasVersion(id)) { JFXButton gotoDownload = new JFXButton(i18n("version.empty.launch.goto_download")); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java index 586495d1ae..02901d0687 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldBackupsPage.java @@ -31,7 +31,6 @@ import javafx.scene.layout.StackPane; import org.jackhuang.hmcl.game.World; import org.jackhuang.hmcl.game.WorldLockedException; -import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.*; @@ -40,6 +39,7 @@ import org.jackhuang.hmcl.ui.construct.TwoLineListItem; import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.i18n.I18n; import org.jetbrains.annotations.NotNull; import java.nio.file.*; @@ -155,7 +155,7 @@ void createBackup() { }), i18n("world.backup"), null); } - private final class WorldBackupsPageSkin extends ToolbarListPageSkin { + private final class WorldBackupsPageSkin extends ToolbarListPageSkin { WorldBackupsPageSkin() { super(WorldBackupsPage.this); @@ -246,7 +246,7 @@ private static final class BackupInfoSkin extends SkinBase { item.setSubtitle(formatDateTime(skinnable.getBackupTime()) + (skinnable.count == 0 ? "" : " (" + skinnable.count + ")")); if (world.getGameVersion() != null) - item.addTag(world.getGameVersion()); + item.addTag(I18n.getDisplayVersion(world.getGameVersion())); } { @@ -258,14 +258,14 @@ private static final class BackupInfoSkin extends SkinBase { right.getChildren().add(btnReveal); FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager")); btnReveal.getStyleClass().add("toggle-icon4"); - btnReveal.setGraphic(SVG.FOLDER_OPEN.createIcon(Theme.blackFill(), -1)); + btnReveal.setGraphic(SVG.FOLDER_OPEN.createIcon()); btnReveal.setOnAction(event -> skinnable.onReveal()); JFXButton btnDelete = new JFXButton(); right.getChildren().add(btnDelete); FXUtils.installFastTooltip(btnDelete, i18n("world.backup.delete")); btnDelete.getStyleClass().add("toggle-icon4"); - btnDelete.setGraphic(SVG.DELETE.createIcon(Theme.blackFill(), -1)); + btnDelete.setGraphic(SVG.DELETE.createIcon()); btnDelete.setOnAction(event -> Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), skinnable::onDelete, null)); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldExportPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldExportPage.java index 5179a303db..d7fee2c151 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldExportPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldExportPage.java @@ -23,6 +23,7 @@ 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; @@ -41,7 +42,8 @@ public WorldExportPage(World world, Path export, Runnable onFinish) { this.world = world; path.set(export.toString()); - gameVersion.set(world.getGameVersion()); + if (world.getGameVersion() != null) + gameVersion.set(I18n.getDisplayVersion(world.getGameVersion())); worldName.set(world.getWorldName()); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java index 34d109304c..30c06ee7fa 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.ui.versions; import com.github.steveice10.opennbt.tag.builtin.*; +import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXComboBox; import com.jfoenix.controls.JFXTextField; import javafx.beans.property.SimpleBooleanProperty; @@ -28,28 +29,36 @@ import javafx.scene.control.Label; import javafx.scene.control.ScrollPane; import javafx.scene.effect.BoxBlur; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; +import javafx.stage.FileChooser; +import org.glavo.png.javafx.PNGJavaFXUtils; import org.jackhuang.hmcl.game.World; -import org.jackhuang.hmcl.setting.Theme; 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.jetbrains.annotations.PropertyKey; +import java.io.File; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.Path; import java.text.DecimalFormat; import java.time.Instant; import java.util.Arrays; import java.util.Locale; +import java.util.concurrent.Callable; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; 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 @@ -59,6 +68,8 @@ public final class WorldInfoPage extends SpinnerPane { private final World world; private CompoundTag levelDat; + ImageView iconImageView = new ImageView(); + public WorldInfoPage(WorldManagePage worldManagePage) { this.worldManagePage = worldManagePage; this.world = worldManagePage.getWorld(); @@ -81,7 +92,7 @@ private CompoundTag loadWorldInfo() throws IOException { if (!Files.isDirectory(world.getFile())) throw new IOException("Not a valid world directory"); - return world.readLevelDat(); + return world.getLevelData(); } private void updateControls() { @@ -104,97 +115,132 @@ private void updateControls() { { BorderPane worldNamePane = new BorderPane(); { - Label label = new Label(i18n("world.name")); - worldNamePane.setLeft(label); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - - Label worldNameLabel = new Label(); - FXUtils.copyOnDoubleClick(worldNameLabel); - worldNameLabel.setText(world.getWorldName()); - BorderPane.setAlignment(worldNameLabel, Pos.CENTER_RIGHT); - worldNamePane.setRight(worldNameLabel); + setLeftLabel(worldNamePane, "world.name"); + JFXTextField worldNameField = new JFXTextField(); + setRightTextField(worldNamePane, worldNameField, 200); + + Tag tag = dataTag.get("LevelName"); + if (tag instanceof StringTag stringTag) { + worldNameField.setText(stringTag.getValue()); + + worldNameField.textProperty().addListener((observable, oldValue, newValue) -> { + if (newValue != null) { + try { + world.setWorldName(newValue); + } catch (Exception e) { + LOG.warning("Failed to set world name", e); + } + } + }); + } else { + worldNameField.setDisable(true); + } } BorderPane gameVersionPane = new BorderPane(); { - Label label = new Label(i18n("world.info.game_version")); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - gameVersionPane.setLeft(label); - + setLeftLabel(gameVersionPane, "world.info.game_version"); Label gameVersionLabel = new Label(); - FXUtils.copyOnDoubleClick(gameVersionLabel); - gameVersionLabel.setText(world.getGameVersion()); - BorderPane.setAlignment(gameVersionLabel, Pos.CENTER_RIGHT); - gameVersionPane.setRight(gameVersionLabel); + setRightTextLabel(gameVersionPane, gameVersionLabel, () -> world.getGameVersion() == null ? "" : world.getGameVersion().toNormalizedString()); } - BorderPane randomSeedPane = new BorderPane(); + BorderPane iconPane = new BorderPane(); { + setLeftLabel(iconPane, "world.icon"); - HBox left = new HBox(8); - BorderPane.setAlignment(left, Pos.CENTER_LEFT); - left.setAlignment(Pos.CENTER_LEFT); - randomSeedPane.setLeft(left); + Runnable onClickAction = () -> Controllers.confirm( + i18n("world.icon.change.tip"), i18n("world.icon.change"), MessageDialogPane.MessageType.INFO, + this::changeWorldIcon, + null + ); - Label label = new Label(i18n("world.info.random_seed")); + FXUtils.limitSize(iconImageView, 32, 32); + { + iconImageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon()); + } + + JFXButton editIconButton = new JFXButton(); + JFXButton resetIconButton = new JFXButton(); + { + editIconButton.setGraphic(SVG.EDIT.createIcon(20)); + editIconButton.setDisable(worldManagePage.isReadOnly()); + FXUtils.onClicked(editIconButton, onClickAction); + FXUtils.installFastTooltip(editIconButton, i18n("button.edit")); + editIconButton.getStyleClass().add("toggle-icon4"); + + resetIconButton.setGraphic(SVG.RESTORE.createIcon(20)); + resetIconButton.setDisable(worldManagePage.isReadOnly()); + FXUtils.onClicked(resetIconButton, this::clearWorldIcon); + FXUtils.installFastTooltip(resetIconButton, i18n("button.reset")); + resetIconButton.getStyleClass().add("toggle-icon4"); + } + + HBox hBox = new HBox(8); + hBox.setAlignment(Pos.CENTER_LEFT); + hBox.getChildren().addAll(iconImageView, editIconButton, resetIconButton); + + iconPane.setRight(hBox); + } + + BorderPane seedPane = new BorderPane(); + { + setLeftLabel(seedPane, "world.info.random_seed"); SimpleBooleanProperty visibility = new SimpleBooleanProperty(); StackPane visibilityButton = new StackPane(); - visibilityButton.setCursor(Cursor.HAND); - FXUtils.setLimitWidth(visibilityButton, 12); - FXUtils.setLimitHeight(visibilityButton, 12); - FXUtils.onClicked(visibilityButton, () -> visibility.set(!visibility.get())); + { + visibilityButton.setCursor(Cursor.HAND); + visibilityButton.setAlignment(Pos.BOTTOM_RIGHT); + FXUtils.setLimitWidth(visibilityButton, 12); + FXUtils.setLimitHeight(visibilityButton, 12); + FXUtils.onClicked(visibilityButton, () -> visibility.set(!visibility.get())); + } - left.getChildren().setAll(label, visibilityButton); + Label seedLabel = new Label(); + { + FXUtils.copyOnDoubleClick(seedLabel); + seedLabel.setAlignment(Pos.CENTER_RIGHT); - Label randomSeedLabel = new Label(); - FXUtils.copyOnDoubleClick(randomSeedLabel); - BorderPane.setAlignment(randomSeedLabel, Pos.CENTER_RIGHT); - randomSeedPane.setRight(randomSeedLabel); + seedLabel.setText(world.getSeed() != null ? world.getSeed().toString() : ""); - Tag tag = worldGenSettings != null ? worldGenSettings.get("seed") : dataTag.get("RandomSeed"); - if (tag instanceof LongTag) { - randomSeedLabel.setText(tag.getValue().toString()); + BoxBlur blur = new BoxBlur(); + blur.setIterations(3); + FXUtils.onChangeAndOperate(visibility, isVisibility -> { + SVG icon = isVisibility ? SVG.VISIBILITY : SVG.VISIBILITY_OFF; + visibilityButton.getChildren().setAll(icon.createIcon(12)); + seedLabel.setEffect(isVisibility ? null : blur); + }); } - BoxBlur blur = new BoxBlur(); - blur.setIterations(3); - FXUtils.onChangeAndOperate(visibility, isVisibility -> { - SVG icon = isVisibility ? SVG.VISIBILITY : SVG.VISIBILITY_OFF; - visibilityButton.getChildren().setAll(icon.createIcon(Theme.blackFill(), 12)); - randomSeedLabel.setEffect(isVisibility ? null : blur); - }); + HBox right = new HBox(8); + { + BorderPane.setAlignment(right, Pos.CENTER_RIGHT); + right.getChildren().setAll(visibilityButton, seedLabel); + seedPane.setRight(right); + } } BorderPane lastPlayedPane = new BorderPane(); { - Label label = new Label(i18n("world.info.last_played")); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - lastPlayedPane.setLeft(label); - + setLeftLabel(lastPlayedPane, "world.info.last_played"); Label lastPlayedLabel = new Label(); - FXUtils.copyOnDoubleClick(lastPlayedLabel); - lastPlayedLabel.setText(formatDateTime(Instant.ofEpochMilli(world.getLastPlayed()))); - BorderPane.setAlignment(lastPlayedLabel, Pos.CENTER_RIGHT); - lastPlayedPane.setRight(lastPlayedLabel); + setRightTextLabel(lastPlayedPane, lastPlayedLabel, () -> formatDateTime(Instant.ofEpochMilli(world.getLastPlayed()))); } BorderPane timePane = new BorderPane(); { - Label label = new Label(i18n("world.info.time")); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - timePane.setLeft(label); + setLeftLabel(timePane, "world.info.time"); Label timeLabel = new Label(); - FXUtils.copyOnDoubleClick(timeLabel); - BorderPane.setAlignment(timeLabel, Pos.CENTER_RIGHT); - timePane.setRight(timeLabel); - - Tag tag = dataTag.get("Time"); - if (tag instanceof LongTag) { - long days = ((LongTag) tag).getValue() / 24000; - timeLabel.setText(i18n("world.info.time.format", days)); - } + setRightTextLabel(timePane, timeLabel, () -> { + Tag tag = dataTag.get("Time"); + if (tag instanceof LongTag) { + long days = ((LongTag) tag).getValue() / 24000; + return i18n("world.info.time.format", days); + } else { + return ""; + } + }); } OptionToggleButton allowCheatsButton = new OptionToggleButton(); @@ -203,21 +249,7 @@ private void updateControls() { allowCheatsButton.setDisable(worldManagePage.isReadOnly()); Tag tag = dataTag.get("allowCommands"); - if (tag instanceof ByteTag) { - ByteTag byteTag = (ByteTag) tag; - byte value = byteTag.getValue(); - if (value == 0 || value == 1) { - allowCheatsButton.setSelected(value == 1); - allowCheatsButton.selectedProperty().addListener((o, oldValue, newValue) -> { - byteTag.setValue(newValue ? (byte) 1 : (byte) 0); - saveLevelDat(); - }); - } else { - allowCheatsButton.setDisable(true); - } - } else { - allowCheatsButton.setDisable(true); - } + checkTagAndSetListener(tag, allowCheatsButton); } OptionToggleButton generateFeaturesButton = new OptionToggleButton(); @@ -226,28 +258,12 @@ private void updateControls() { generateFeaturesButton.setDisable(worldManagePage.isReadOnly()); Tag tag = worldGenSettings != null ? worldGenSettings.get("generate_features") : dataTag.get("MapFeatures"); - if (tag instanceof ByteTag) { - ByteTag byteTag = (ByteTag) tag; - byte value = byteTag.getValue(); - if (value == 0 || value == 1) { - generateFeaturesButton.setSelected(value == 1); - generateFeaturesButton.selectedProperty().addListener((o, oldValue, newValue) -> { - byteTag.setValue(newValue ? (byte) 1 : (byte) 0); - saveLevelDat(); - }); - } else { - generateFeaturesButton.setDisable(true); - } - } else { - generateFeaturesButton.setDisable(true); - } + checkTagAndSetListener(tag, generateFeaturesButton); } BorderPane difficultyPane = new BorderPane(); { - Label label = new Label(i18n("world.info.difficulty")); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - difficultyPane.setLeft(label); + setLeftLabel(difficultyPane, "world.info.difficulty"); JFXComboBox difficultyBox = new JFXComboBox<>(Difficulty.items); difficultyBox.setDisable(worldManagePage.isReadOnly()); @@ -255,8 +271,7 @@ private void updateControls() { difficultyPane.setRight(difficultyBox); Tag tag = dataTag.get("Difficulty"); - if (tag instanceof ByteTag) { - ByteTag byteTag = (ByteTag) tag; + if (tag instanceof ByteTag byteTag) { Difficulty difficulty = Difficulty.of(byteTag.getValue()); if (difficulty != null) { difficultyBox.setValue(difficulty); @@ -274,86 +289,93 @@ private void updateControls() { } } + OptionToggleButton difficultyLockPane = new OptionToggleButton(); + { + difficultyLockPane.setTitle(i18n("world.info.difficulty_lock")); + difficultyLockPane.setDisable(worldManagePage.isReadOnly()); + + Tag tag = dataTag.get("DifficultyLocked"); + checkTagAndSetListener(tag, difficultyLockPane); + } + basicInfo.getContent().setAll( - worldNamePane, gameVersionPane, randomSeedPane, lastPlayedPane, timePane, - allowCheatsButton, generateFeaturesButton, difficultyPane); + worldNamePane, gameVersionPane, iconPane, seedPane, lastPlayedPane, timePane, + allowCheatsButton, generateFeaturesButton, difficultyPane, difficultyLockPane); rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("world.info.basic")), basicInfo); } Tag playerTag = dataTag.get("Player"); - if (playerTag instanceof CompoundTag) { - CompoundTag player = (CompoundTag) playerTag; + if (playerTag instanceof CompoundTag player) { ComponentList playerInfo = new ComponentList(); BorderPane locationPane = new BorderPane(); { - Label label = new Label(i18n("world.info.player.location")); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - locationPane.setLeft(label); - + setLeftLabel(locationPane, "world.info.player.location"); Label locationLabel = new Label(); - FXUtils.copyOnDoubleClick(locationLabel); - BorderPane.setAlignment(locationLabel, Pos.CENTER_RIGHT); - locationPane.setRight(locationLabel); - - Dimension dim = Dimension.of(player.get("Dimension")); - if (dim != null) { - String posString = dim.formatPosition(player.get("Pos")); - if (posString != null) - locationLabel.setText(posString); - } + setRightTextLabel(locationPane, locationLabel, () -> { + Dimension dim = Dimension.of(player.get("Dimension")); + if (dim != null) { + String posString = dim.formatPosition(player.get("Pos")); + if (posString != null) + return posString; + } + return ""; + }); } BorderPane lastDeathLocationPane = new BorderPane(); { - Label label = new Label(i18n("world.info.player.last_death_location")); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - lastDeathLocationPane.setLeft(label); - + setLeftLabel(lastDeathLocationPane, "world.info.player.last_death_location"); Label lastDeathLocationLabel = new Label(); - FXUtils.copyOnDoubleClick(lastDeathLocationLabel); - BorderPane.setAlignment(lastDeathLocationLabel, Pos.CENTER_RIGHT); - lastDeathLocationPane.setRight(lastDeathLocationLabel); - - Tag tag = player.get("LastDeathLocation"); - if (tag instanceof CompoundTag) { - Dimension dim = Dimension.of(((CompoundTag) tag).get("dimension")); - if (dim != null) { - String posString = dim.formatPosition(((CompoundTag) tag).get("pos")); - if (posString != null) - lastDeathLocationLabel.setText(posString); + setRightTextLabel(lastDeathLocationPane, lastDeathLocationLabel, () -> { + Tag tag = player.get("LastDeathLocation");// Valid after 22w14a; prior to this version, the game did not record the last death location data. + if (tag instanceof CompoundTag compoundTag) { + Dimension dim = Dimension.of(compoundTag.get("dimension")); + if (dim != null) { + String posString = dim.formatPosition(compoundTag.get("pos")); + if (posString != null) + return posString; + } } - } + return ""; + }); + } BorderPane spawnPane = new BorderPane(); { - Label label = new Label(i18n("world.info.player.spawn")); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - spawnPane.setLeft(label); - + setLeftLabel(spawnPane, "world.info.player.spawn"); Label spawnLabel = new Label(); - FXUtils.copyOnDoubleClick(spawnLabel); - BorderPane.setAlignment(spawnLabel, Pos.CENTER_RIGHT); - spawnPane.setRight(spawnLabel); - - Dimension dim = Dimension.of(player.get("SpawnDimension")); - if (dim != null) { - Tag x = player.get("SpawnX"); - Tag y = player.get("SpawnY"); - Tag z = player.get("SpawnZ"); - - if (x instanceof IntTag && y instanceof IntTag && z instanceof IntTag) - spawnLabel.setText(dim.formatPosition(((IntTag) x).getValue(), ((IntTag) y).getValue(), ((IntTag) z).getValue())); - } + setRightTextLabel(spawnPane, spawnLabel, () -> { + + Dimension dimension; + if (player.get("respawn") instanceof CompoundTag respawnTag && respawnTag.get("dimension") != null) { // Valid after 25w07a + dimension = Dimension.of(respawnTag.get("dimension")); + Tag posTag = respawnTag.get("pos"); + + if (posTag instanceof IntArrayTag intArrayTag && intArrayTag.length() >= 3) { + return dimension.formatPosition(intArrayTag.getValue(0), intArrayTag.getValue(1), intArrayTag.getValue(2)); + } + } else if (player.get("SpawnX") instanceof IntTag intX + && player.get("SpawnY") instanceof IntTag intY + && player.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. + dimension = Dimension.of(player.get("SpawnDimension") == null ? new IntTag("SpawnDimension", 0) : player.get("SpawnDimension")); + if (dimension == null) { + return ""; + } + + return dimension.formatPosition(intX.getValue(), intY.getValue(), intZ.getValue()); + } + + return ""; + }); } BorderPane playerGameTypePane = new BorderPane(); { - Label label = new Label(i18n("world.info.player.game_type")); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - playerGameTypePane.setLeft(label); + setLeftLabel(playerGameTypePane, "world.info.player.game_type"); JFXComboBox gameTypeBox = new JFXComboBox<>(GameType.items); gameTypeBox.setDisable(worldManagePage.isReadOnly()); @@ -364,8 +386,7 @@ private void updateControls() { Tag hardcoreTag = dataTag.get("hardcore"); boolean isHardcore = hardcoreTag instanceof ByteTag && ((ByteTag) hardcoreTag).getValue() == 1; - if (tag instanceof IntTag) { - IntTag intTag = (IntTag) tag; + if (tag instanceof IntTag intTag) { GameType gameType = GameType.of(intTag.getValue(), isHardcore); if (gameType != null) { gameTypeBox.setValue(gameType); @@ -395,33 +416,13 @@ private void updateControls() { BorderPane healthPane = new BorderPane(); { - Label label = new Label(i18n("world.info.player.health")); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - healthPane.setLeft(label); - + setLeftLabel(healthPane, "world.info.player.health"); JFXTextField healthField = new JFXTextField(); - healthField.setDisable(worldManagePage.isReadOnly()); - healthField.setPrefWidth(50); - healthField.setAlignment(Pos.CENTER_RIGHT); - BorderPane.setAlignment(healthField, Pos.CENTER_RIGHT); - healthPane.setRight(healthField); + setRightTextField(healthPane, healthField, 50); Tag tag = player.get("Health"); - if (tag instanceof FloatTag) { - FloatTag floatTag = (FloatTag) tag; - healthField.setText(new DecimalFormat("#").format(floatTag.getValue().floatValue())); - - healthField.textProperty().addListener((o, oldValue, newValue) -> { - if (newValue != null) { - try { - floatTag.setValue(Float.parseFloat(newValue)); - saveLevelDat(); - } catch (Throwable ignored) { - } - } - }); - FXUtils.setValidateWhileTextChanged(healthField, true); - healthField.setValidators(new DoubleValidator(i18n("input.number"), true)); + if (tag instanceof FloatTag floatTag) { + setTagAndTextField(floatTag, healthField); } else { healthField.setDisable(true); } @@ -429,33 +430,13 @@ private void updateControls() { BorderPane foodLevelPane = new BorderPane(); { - Label label = new Label(i18n("world.info.player.food_level")); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - foodLevelPane.setLeft(label); - + setLeftLabel(foodLevelPane, "world.info.player.food_level"); JFXTextField foodLevelField = new JFXTextField(); - foodLevelField.setDisable(worldManagePage.isReadOnly()); - foodLevelField.setPrefWidth(50); - foodLevelField.setAlignment(Pos.CENTER_RIGHT); - BorderPane.setAlignment(foodLevelField, Pos.CENTER_RIGHT); - foodLevelPane.setRight(foodLevelField); + setRightTextField(foodLevelPane, foodLevelField, 50); Tag tag = player.get("foodLevel"); - if (tag instanceof IntTag) { - IntTag intTag = (IntTag) tag; - foodLevelField.setText(String.valueOf(intTag.getValue())); - - foodLevelField.textProperty().addListener((o, oldValue, newValue) -> { - if (newValue != null) { - try { - intTag.setValue(Integer.parseInt(newValue)); - saveLevelDat(); - } catch (Throwable ignored) { - } - } - }); - FXUtils.setValidateWhileTextChanged(foodLevelField, true); - foodLevelField.setValidators(new NumberValidator(i18n("input.number"), true)); + if (tag instanceof IntTag intTag) { + setTagAndTextField(intTag, foodLevelField); } else { foodLevelField.setDisable(true); } @@ -463,33 +444,13 @@ private void updateControls() { BorderPane xpLevelPane = new BorderPane(); { - Label label = new Label(i18n("world.info.player.xp_level")); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - xpLevelPane.setLeft(label); - + setLeftLabel(xpLevelPane, "world.info.player.xp_level"); JFXTextField xpLevelField = new JFXTextField(); - xpLevelField.setDisable(worldManagePage.isReadOnly()); - xpLevelField.setPrefWidth(50); - xpLevelField.setAlignment(Pos.CENTER_RIGHT); - BorderPane.setAlignment(xpLevelField, Pos.CENTER_RIGHT); - xpLevelPane.setRight(xpLevelField); + setRightTextField(xpLevelPane, xpLevelField, 50); Tag tag = player.get("XpLevel"); - if (tag instanceof IntTag) { - IntTag intTag = (IntTag) tag; - xpLevelField.setText(String.valueOf(intTag.getValue())); - - xpLevelField.textProperty().addListener((o, oldValue, newValue) -> { - if (newValue != null) { - try { - intTag.setValue(Integer.parseInt(newValue)); - saveLevelDat(); - } catch (Throwable ignored) { - } - } - }); - FXUtils.setValidateWhileTextChanged(xpLevelField, true); - xpLevelField.setValidators(new NumberValidator(i18n("input.number"), true)); + if (tag instanceof IntTag intTag) { + setTagAndTextField(intTag, xpLevelField); } else { xpLevelField.setDisable(true); } @@ -504,6 +465,88 @@ private void updateControls() { } } + private void setLeftLabel(BorderPane borderPane, @PropertyKey(resourceBundle = "assets.lang.I18N") String key) { + Label label = new Label(i18n(key)); + BorderPane.setAlignment(label, Pos.CENTER_LEFT); + borderPane.setLeft(label); + } + + private void setRightTextField(BorderPane borderPane, JFXTextField textField, int perfWidth) { + textField.setDisable(worldManagePage.isReadOnly()); + textField.setPrefWidth(perfWidth); + textField.setAlignment(Pos.CENTER_RIGHT); + borderPane.setRight(textField); + } + + private void setRightTextLabel(BorderPane borderPane, Label label, Callable setNameCall) { + FXUtils.copyOnDoubleClick(label); + BorderPane.setAlignment(label, Pos.CENTER_RIGHT); + try { + label.setText(setNameCall.call()); + } catch (Exception e) { + LOG.warning("Exception happened when setting name", e); + } + borderPane.setRight(label); + } + + private void checkTagAndSetListener(Tag tag, OptionToggleButton toggleButton) { + if (tag instanceof ByteTag byteTag) { + byte value = byteTag.getValue(); + if (value == 0 || value == 1) { + toggleButton.setSelected(value == 1); + toggleButton.selectedProperty().addListener((o, oldValue, newValue) -> { + try { + byteTag.setValue((byte) (newValue ? 1 : 0)); + saveLevelDat(); + } catch (Exception e) { + toggleButton.setSelected(oldValue); + LOG.warning("Exception happened when saving level.dat", e); + } + }); + } else { + toggleButton.setDisable(true); + } + } else { + toggleButton.setDisable(true); + } + } + + private void setTagAndTextField(IntTag intTag, JFXTextField jfxTextField) { + jfxTextField.setText(String.valueOf(intTag.getValue())); + + jfxTextField.textProperty().addListener((o, oldValue, newValue) -> { + if (newValue != null) { + try { + intTag.setValue(Integer.parseInt(newValue)); + saveLevelDat(); + } catch (Exception e) { + jfxTextField.setText(oldValue); + LOG.warning("Exception happened when saving level.dat", e); + } + } + }); + FXUtils.setValidateWhileTextChanged(jfxTextField, true); + jfxTextField.setValidators(new NumberValidator(i18n("input.number"), true)); + } + + private void setTagAndTextField(FloatTag floatTag, JFXTextField jfxTextField) { + jfxTextField.setText(new DecimalFormat("#").format(floatTag.getValue().floatValue())); + + jfxTextField.textProperty().addListener((o, oldValue, newValue) -> { + if (newValue != null) { + try { + floatTag.setValue(Float.parseFloat(newValue)); + saveLevelDat(); + } catch (Exception e) { + jfxTextField.setText(oldValue); + LOG.warning("Exception happened when saving level.dat", e); + } + } + }); + FXUtils.setValidateWhileTextChanged(jfxTextField, true); + jfxTextField.setValidators(new DoubleValidator(i18n("input.number"), true)); + } + private void saveLevelDat() { LOG.info("Saving level.dat of world " + world.getWorldName()); try { @@ -513,54 +556,34 @@ private void saveLevelDat() { } } - private static final class Dimension { + 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")); - final String name; - static Dimension of(Tag tag) { - if (tag instanceof IntTag) { - switch (((IntTag) tag).getValue()) { - case 0: - return OVERWORLD; - case 1: - return THE_NETHER; - case 2: - return THE_END; - default: - return null; - } - } else if (tag instanceof StringTag) { - String id = ((StringTag) tag).getValue(); - switch (id) { - case "overworld": - case "minecraft:overworld": - return OVERWORLD; - case "the_nether": - case "minecraft:the_nether": - return THE_NETHER; - case "the_end": - case "minecraft:the_end": - return THE_END; - default: - return new Dimension(id); - } + if (tag instanceof IntTag intTag) { + return switch (intTag.getValue()) { + case 0 -> OVERWORLD; + case 1 -> THE_NETHER; + case 2 -> 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; } } - private Dimension(String name) { - this.name = name; - } - String formatPosition(Tag tag) { - if (tag instanceof ListTag) { - ListTag listTag = (ListTag) tag; - if (listTag.size() != 3) - return null; + if (tag instanceof ListTag listTag && listTag.size() == 3) { Tag x = listTag.get(0); Tag y = listTag.get(1); @@ -575,8 +598,7 @@ String formatPosition(Tag tag) { return null; } - if (tag instanceof IntArrayTag) { - IntArrayTag intArrayTag = (IntArrayTag) tag; + if (tag instanceof IntArrayTag intArrayTag) { int x = intArrayTag.getValue(0); int y = intArrayTag.getValue(1); @@ -609,7 +631,7 @@ private enum Difficulty { static final ObservableList items = FXCollections.observableList(Arrays.asList(values())); static Difficulty of(int d) { - return d >= 0 && d <= items.size() ? items.get(d) : null; + return (d >= 0 && d < items.size()) ? items.get(d) : null; } @Override @@ -633,4 +655,51 @@ 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"); + + File file = fileChooser.showOpenDialog(Controllers.getStage()); + if (file == null) return; + + Image image; + try { + image = FXUtils.loadImage(file.toPath()); + } 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"); + saveImage(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 saveImage(Image image, Path path) { + Image oldImage = iconImageView.getImage(); + try { + PNGJavaFXUtils.writeImage(image, path); + 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.getMessage()); + } + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListItem.java deleted file mode 100644 index 5927c44143..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListItem.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.ui.versions; - -import javafx.scene.control.Control; -import javafx.scene.control.Skin; -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.FXUtils; -import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType; -import org.jackhuang.hmcl.ui.wizard.SinglePageWizardProvider; -import org.jackhuang.hmcl.util.StringUtils; -import org.jackhuang.hmcl.util.io.FileUtils; - -import java.nio.file.Path; - -import static org.jackhuang.hmcl.util.i18n.I18n.i18n; - -public final class WorldListItem extends Control { - private final World world; - private final Path backupsDir; - private final WorldListPage parent; - - public WorldListItem(WorldListPage parent, World world, Path backupsDir) { - this.world = world; - this.backupsDir = backupsDir; - this.parent = parent; - } - - @Override - protected Skin createDefaultSkin() { - return new WorldListItemSkin(this); - } - - public World getWorld() { - return world; - } - - public void export() { - FileChooser fileChooser = new FileChooser(); - fileChooser.setTitle(i18n("world.export.title")); - fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("world"), "*.zip")); - fileChooser.setInitialFileName(world.getWorldName()); - Path file = FileUtils.toPath(fileChooser.showSaveDialog(Controllers.getStage())); - if (file == null) { - return; - } - - Controllers.getDecorator().startWizard(new SinglePageWizardProvider(controller -> new WorldExportPage(world, file, controller::onFinish))); - } - - public void delete() { - Controllers.confirm( - i18n("button.remove.confirm"), - i18n("world.delete"), - () -> Task.runAsync(world::delete) - .whenComplete(Schedulers.javafx(), (result, exception) -> { - if (exception == null) { - parent.remove(this); - } else if (exception instanceof WorldLockedException) { - Controllers.dialog(i18n("world.locked.failed"), null, MessageType.WARNING); - } else { - Controllers.dialog(i18n("world.delete.failed", StringUtils.getStackTrace(exception)), null, MessageType.WARNING); - } - }).start(), - null - ); - } - - public void reveal() { - FXUtils.openFolder(world.getFile()); - } - - public void showManagePage() { - Controllers.navigate(new WorldManagePage(world, backupsDir)); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListItemSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListItemSkin.java deleted file mode 100644 index 89b8006b57..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListItemSkin.java +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.ui.versions; - -import com.jfoenix.controls.JFXButton; -import com.jfoenix.controls.JFXPopup; -import javafx.geometry.Insets; -import javafx.geometry.Pos; -import javafx.scene.control.SkinBase; -import javafx.scene.image.ImageView; -import javafx.scene.input.MouseButton; -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.setting.Theme; -import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.SVG; -import org.jackhuang.hmcl.ui.construct.*; -import org.jackhuang.hmcl.util.ChunkBaseApp; -import org.jackhuang.hmcl.util.versioning.GameVersionNumber; - -import java.time.Instant; - -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; - -public final class WorldListItemSkin extends SkinBase { - - private final BorderPane root; - - public WorldListItemSkin(WorldListItem skinnable) { - super(skinnable); - - World world = skinnable.getWorld(); - - root = new BorderPane(); - root.getStyleClass().add("md-list-cell"); - root.setPadding(new Insets(8)); - - { - StackPane left = new StackPane(); - FXUtils.installSlowTooltip(left, world.getFile().toString()); - root.setLeft(left); - left.setPadding(new Insets(0, 8, 0, 0)); - - ImageView imageView = new ImageView(); - left.getChildren().add(imageView); - FXUtils.limitSize(imageView, 32, 32); - imageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon()); - } - - { - TwoLineListItem item = new TwoLineListItem(); - root.setCenter(item); - item.setMouseTransparent(true); - if (world.getWorldName() != null) - item.setTitle(parseColorEscapes(world.getWorldName())); - item.setSubtitle(i18n("world.datetime", formatDateTime(Instant.ofEpochMilli(world.getLastPlayed())), world.getGameVersion() == null ? i18n("message.unknown") : world.getGameVersion())); - - if (world.getGameVersion() != null) - item.addTag(world.getGameVersion()); - if (world.isLocked()) - item.addTag(i18n("world.locked")); - } - - { - HBox right = new HBox(8); - root.setRight(right); - right.setAlignment(Pos.CENTER_RIGHT); - - JFXButton btnMore = new JFXButton(); - right.getChildren().add(btnMore); - btnMore.getStyleClass().add("toggle-icon4"); - btnMore.setGraphic(SVG.MORE_VERT.createIcon(Theme.blackFill(), -1)); - btnMore.setOnAction(event -> showPopupMenu(JFXPopup.PopupHPosition.RIGHT, 0, root.getHeight())); - } - - RipplerContainer container = new RipplerContainer(root); - container.setOnMouseClicked(event -> { - if (event.getClickCount() != 1) - return; - - if (event.getButton() == MouseButton.PRIMARY) - skinnable.showManagePage(); - else if (event.getButton() == MouseButton.SECONDARY) - showPopupMenu(JFXPopup.PopupHPosition.LEFT, event.getX(), event.getY()); - }); - - getChildren().setAll(container); - } - - // Popup Menu - - public void showPopupMenu(JFXPopup.PopupHPosition hPosition, double initOffsetX, double initOffsetY) { - PopupMenu popupMenu = new PopupMenu(); - JFXPopup popup = new JFXPopup(popupMenu); - - WorldListItem item = getSkinnable(); - World world = item.getWorld(); - - popupMenu.getContent().addAll( - new IconedMenuItem(SVG.SETTINGS, i18n("world.manage.button"), item::showManagePage, 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 (GameVersionNumber.compare(world.getGameVersion(), "1.13") >= 0) { - popupMenu.getContent().add(new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"), - () -> ChunkBaseApp.openEndCityFinder(world), popup)); - } - } - - popupMenu.getContent().addAll( - new MenuSeparator(), - new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), item::export, popup), - new IconedMenuItem(SVG.DELETE, i18n("world.delete"), item::delete, popup), - new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.world"), item::reveal, popup)); - - JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup); - - popup.show(root, vPosition, hPosition, initOffsetX, vPosition == JFXPopup.PopupVPosition.TOP ? initOffsetY : -initOffsetY); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java index f91d11ac36..dc251c1fe6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldListPage.java @@ -17,32 +17,51 @@ */ package org.jackhuang.hmcl.ui.versions; +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.SimpleBooleanProperty; +import javafx.geometry.Insets; +import javafx.geometry.Pos; import javafx.scene.Node; +import javafx.scene.control.ListCell; +import javafx.scene.control.Skin; +import javafx.scene.control.Tooltip; +import javafx.scene.image.ImageView; +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.World; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.*; -import org.jackhuang.hmcl.ui.animation.TransitionPane; +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 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.Arrays; import java.util.List; -import java.util.stream.Collectors; import java.util.stream.Stream; +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 implements VersionPage.VersionLoadable, TransitionPane.Cacheable { +public final class WorldListPage extends ListPageBase implements VersionPage.VersionLoadable { private final BooleanProperty showAll = new SimpleBooleanProperty(this, "showAll", false); private Path savesDir; @@ -50,23 +69,19 @@ public final class WorldListPage extends ListPageBase implements private List worlds; private Profile profile; private String id; - private String gameVersion; + + private int refreshCount = 0; public WorldListPage() { FXUtils.applyDragListener(this, it -> "zip".equals(FileUtils.getExtension(it)), modpacks -> { installWorld(modpacks.get(0)); }); - showAll.addListener(e -> { - if (worlds != null) - itemsProperty().setAll(worlds.stream() - .filter(world -> isShowAll() || world.getGameVersion() == null || world.getGameVersion().equals(gameVersion)) - .map(world -> new WorldListItem(this, world, backupsDir)).toList()); - }); + showAll.addListener(e -> updateWorldList()); } @Override - protected ToolbarListPageSkin createDefaultSkin() { + protected Skin createDefaultSkin() { return new WorldListPageSkin(); } @@ -79,33 +94,46 @@ public void loadVersion(Profile profile, String id) { refresh(); } - public void remove(WorldListItem item) { - itemsProperty().remove(item); + private void updateWorldList() { + if (worlds == null) { + getItems().clear(); + } else if (showAll.get()) { + getItems().setAll(worlds); + } else { + GameVersionNumber gameVersion = profile.getRepository().getGameVersion(id).map(GameVersionNumber::asGameVersion).orElse(null); + getItems().setAll(worlds.stream() + .filter(world -> world.getGameVersion() == null || world.getGameVersion().equals(gameVersion)) + .toList()); + } } public void refresh() { if (profile == null || id == null) return; + int currentRefresh = ++refreshCount; + setLoading(true); - Task.runAsync(() -> gameVersion = profile.getRepository().getGameVersion(id).orElse(null)) - .thenApplyAsync(unused -> { - try (Stream stream = World.getWorlds(savesDir)) { - return stream.parallel().collect(Collectors.toList()); - } - }) - .whenComplete(Schedulers.javafx(), (result, exception) -> { - worlds = result; - setLoading(false); - if (exception == null) { - itemsProperty().setAll(result.stream() - .filter(world -> isShowAll() || world.getGameVersion() == null || world.getGameVersion().equals(gameVersion)) - .map(world -> new WorldListItem(this, world, backupsDir)) - .collect(Collectors.toList())); - } else { - LOG.warning("Failed to load world list page", exception); - } - }).start(); + Task.supplyAsync(Schedulers.io(), () -> { + // Ensure the game version number is parsed + profile.getRepository().getGameVersion(id); + try (Stream stream = World.getWorlds(savesDir)) { + return stream.toList(); + } + }).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (refreshCount != currentRefresh) { + // A newer refresh task is running, discard this result + return; + } + + worlds = result; + updateWorldList(); + + if (exception != null) + LOG.warning("Failed to load world list page", exception); + + setLoading(false); + }).start(); } public void add() { @@ -128,39 +156,59 @@ private void installWorld(Path zipFile) { // Or too many input dialogs are popped. Task.supplyAsync(() -> new World(zipFile)) .whenComplete(Schedulers.javafx(), world -> { - Controllers.prompt(i18n("world.name.enter"), (name, resolve, reject) -> { + Controllers.prompt(i18n("world.name.enter"), (name, handler) -> { Task.runAsync(() -> world.install(savesDir, name)) .whenComplete(Schedulers.javafx(), () -> { - itemsProperty().add(new WorldListItem(this, new World(savesDir.resolve(name)), backupsDir)); - resolve.run(); + handler.resolve(); + refresh(); }, e -> { if (e instanceof FileAlreadyExistsException) - reject.accept(i18n("world.import.failed", i18n("world.import.already_exists"))); + handler.reject(i18n("world.import.failed", i18n("world.import.already_exists"))); else if (e instanceof IOException && e.getCause() instanceof InvalidPathException) - reject.accept(i18n("world.import.failed", i18n("install.new_game.malformed"))); + handler.reject(i18n("world.import.failed", i18n("install.new_game.malformed"))); else - reject.accept(i18n("world.import.failed", e.getClass().getName() + ": " + e.getLocalizedMessage())); + handler.reject(i18n("world.import.failed", e.getClass().getName() + ": " + e.getLocalizedMessage())); }).start(); - }, world.getWorldName()); + }, 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.import.invalid")); }).start(); } - public boolean isShowAll() { - return showAll.get(); + private void showManagePage(World world) { + Controllers.navigate(new WorldManagePage(world, backupsDir, profile, id)); } - public BooleanProperty showAllProperty() { - return showAll; + public void export(World world) { + WorldManageUIUtils.export(world); + } + + public void delete(World world) { + WorldManageUIUtils.delete(world, this::refresh); } - public void setShowAll(boolean showAll) { - this.showAll.set(showAll); + public void copy(World world) { + WorldManageUIUtils.copyWorld(world, this::refresh); } - private final class WorldListPageSkin extends ToolbarListPageSkin { + public void reveal(World world) { + FXUtils.openFolder(world.getFile()); + } + + public void launch(World world) { + Versions.launchAndEnterWorld(profile, id, world.getFileName()); + } + + public void generateLaunchScript(World world) { + Versions.generateLaunchScriptForQuickEnterWorld(profile, id, world.getFileName()); + } + + public BooleanProperty showAllProperty() { + return showAll; + } + + private final class WorldListPageSkin extends ToolbarListPageSkin { WorldListPageSkin() { super(WorldListPage.this); @@ -177,5 +225,162 @@ protected List initializeToolbar(WorldListPage skinnable) { createToolbarButton2(i18n("world.add"), SVG.ADD, skinnable::add), createToolbarButton2(i18n("world.download"), SVG.DOWNLOAD, skinnable::download)); } + + @Override + protected ListCell createListCell(JFXListView listView) { + return new WorldListCell(getSkinnable()); + } + } + + private static final class WorldListCell extends ListCell { + + private final WorldListPage page; + + private final RipplerContainer graphic; + private final ImageView imageView; + private final Tooltip leftTooltip; + private final TwoLineListItem content; + + 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 ImageView(); + left.getChildren().add(imageView); + FXUtils.limitSize(imageView, 32, 32); + } + + { + this.content = new TwoLineListItem(); + root.setCenter(content); + content.setMouseTransparent(true); + } + + { + HBox right = new HBox(8); + root.setRight(right); + right.setAlignment(Pos.CENTER_RIGHT); + + JFXButton btnMore = new JFXButton(); + right.getChildren().add(btnMore); + btnMore.getStyleClass().add("toggle-icon4"); + btnMore.setGraphic(SVG.MORE_VERT.createIcon()); + btnMore.setOnAction(event -> { + World world = getItem(); + if (world != null) + showPopupMenu(world, 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, JFXPopup.PopupHPosition.LEFT, event.getX(), event.getY()); + }); + } + + @Override + protected void updateItem(World world, boolean empty) { + super.updateItem(world, empty); + + this.content.getTags().clear(); + + if (empty || world == null) { + 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()) : ""); + + if (world.getGameVersion() != null) + content.addTag(I18n.getDisplayVersion(world.getGameVersion())); + if (world.isLocked()) + content.addTag(i18n("world.locked")); + + content.setSubtitle(i18n("world.datetime", formatDateTime(Instant.ofEpochMilli(world.getLastPlayed())))); + + setGraphic(graphic); + } + } + + // Popup Menu + + public void showPopupMenu(World world, JFXPopup.PopupHPosition hPosition, double initOffsetX, double initOffsetY) { + PopupMenu popupMenu = new PopupMenu(); + JFXPopup popup = new JFXPopup(popupMenu); + + if (world.getGameVersion() != null && world.getGameVersion().isAtLeast("1.20", "23w14a")) { + + IconedMenuItem launchItem = new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch_and_enter_world"), () -> page.launch(world), popup); + launchItem.setDisable(world.isLocked()); + popupMenu.getContent().add(launchItem); + + popupMenu.getContent().addAll( + new IconedMenuItem(SVG.SCRIPT, i18n("version.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 (world.getGameVersion() != null && world.getGameVersion().compareTo("1.13") >= 0) { + 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); + IconedMenuItem deleteMenuItem = new IconedMenuItem(SVG.DELETE, i18n("world.delete"), () -> page.delete(world), popup); + IconedMenuItem duplicateMenuItem = new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), () -> page.copy(world), popup); + boolean worldLocked = world.isLocked(); + Stream.of(exportMenuItem, deleteMenuItem, duplicateMenuItem) + .forEach(iconedMenuItem -> iconedMenuItem.setDisable(worldLocked)); + + popupMenu.getContent().addAll( + new MenuSeparator(), + exportMenuItem, + deleteMenuItem, + duplicateMenuItem + ); + + popupMenu.getContent().addAll( + new MenuSeparator(), + new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.world"), () -> page.reveal(world), popup) + ); + + JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(this, popup); + popup.show(this, vPosition, hPosition, initOffsetX, vPosition == JFXPopup.PopupVPosition.TOP ? initOffsetY : -initOffsetY); + } } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java index 64679346ba..2569f1042d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.ui.versions; import com.jfoenix.controls.JFXPopup; +import javafx.application.Platform; import javafx.beans.property.ObjectProperty; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.SimpleObjectProperty; @@ -26,6 +27,8 @@ import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.game.World; +import org.jackhuang.hmcl.setting.Profile; +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; @@ -33,7 +36,7 @@ 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.versioning.GameVersionNumber; +import org.jackhuang.hmcl.util.StringUtils; import java.io.IOException; import java.nio.channels.FileChannel; @@ -50,6 +53,10 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco private final ObjectProperty state; private final World world; private final Path backupsDir; + private final Profile profile; + private final String id; + + private boolean loadFailed = false; private final TabHeader header; private final TabHeader.Tab worldInfoTab = new TabHeader.Tab<>("worldInfoPage"); @@ -60,15 +67,25 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco private FileChannel sessionLockChannel; - public WorldManagePage(World world, Path backupsDir) { + public WorldManagePage(World world, Path backupsDir, Profile profile, String id) { this.world = world; this.backupsDir = backupsDir; + this.profile = profile; + this.id = id; + + sessionLockChannel = WorldManageUIUtils.getSessionLockChannel(world); + try { + world.reloadLevelDat(); + } catch (IOException e) { + LOG.warning("Can not load world level.dat of world: " + world.getFile(), e); + loadFailed = true; + } this.worldInfoTab.setNodeSupplier(() -> new WorldInfoPage(this)); this.worldBackupsTab.setNodeSupplier(() -> new WorldBackupsPage(this)); this.datapackTab.setNodeSupplier(() -> new DatapackListPage(this)); - this.state = new SimpleObjectProperty<>(State.fromTitle(i18n("world.manage.title", world.getWorldName()))); + this.state = new SimpleObjectProperty<>(State.fromTitle(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())))); this.header = new TabHeader(transitionPane, worldInfoTab, worldBackupsTab); header.select(worldInfoTab); @@ -84,7 +101,7 @@ public WorldManagePage(World world, Path backupsDir) { .addNavigationDrawerTab(header, worldBackupsTab, i18n("world.backup"), SVG.ARCHIVE, SVG.ARCHIVE_FILL); if (world.getGameVersion() != null && // old game will not write game version to level.dat - GameVersionNumber.asGameVersion(world.getGameVersion()).isAtLeast("1.13", "17w43a")) { + world.getGameVersion().isAtLeast("1.13", "17w43a")) { header.getTabs().add(datapackTab); sideBar.addNavigationDrawerTab(header, datapackTab, i18n("world.datapack"), SVG.EXTENSION, SVG.EXTENSION_FILL); } @@ -92,36 +109,87 @@ public WorldManagePage(World world, Path backupsDir) { left.setTop(sideBar); AdvancedListBox toolbar = new AdvancedListBox(); + + if (world.getGameVersion() != null && world.getGameVersion().isAtLeast("1.20", "23w14a")) { + toolbar.addNavigationDrawerItem(i18n("version.launch"), SVG.ROCKET_LAUNCH, this::launch, advancedListItem -> advancedListItem.setDisable(isReadOnly())); + } + if (ChunkBaseApp.isSupported(world)) { - PopupMenu popupMenu = new PopupMenu(); - JFXPopup popup = new JFXPopup(popupMenu); + PopupMenu chunkBasePopupMenu = new PopupMenu(); + JFXPopup chunkBasePopup = new JFXPopup(chunkBasePopupMenu); - popupMenu.getContent().addAll( - 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) + 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 (GameVersionNumber.compare(world.getGameVersion(), "1.13") >= 0) { - popupMenu.getContent().add( - new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"), () -> ChunkBaseApp.openEndCityFinder(world), popup)); + if (world.getGameVersion() != null && world.getGameVersion().compareTo("1.13") >= 0) { + 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 -> - popup.show(chunkBaseMenuItem, + 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()), null); + { + PopupMenu managePopupMenu = new PopupMenu(); + JFXPopup managePopup = new JFXPopup(managePopupMenu); + + if (world.getGameVersion() != null && world.getGameVersion().isAtLeast("1.20", "23w14a")) { + managePopupMenu.getContent().addAll( + new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("version.launch"), this::launch, managePopup), + new IconedMenuItem(SVG.SCRIPT, i18n("version.launch_script"), this::generateLaunchScript, managePopup), + new MenuSeparator() + ); + } + + managePopupMenu.getContent().addAll( + new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), () -> WorldManageUIUtils.export(world, sessionLockChannel), managePopup), + new IconedMenuItem(SVG.DELETE, 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.setDisable(isReadOnly()); + }); + + } + BorderPane.setMargin(toolbar, new Insets(0, 0, 12, 0)); left.setBottom(toolbar); - // Does it need to be done in the background? + this.addEventHandler(Navigator.NavigationEvent.EXITED, this::onExited); + this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated); + } + + private void onNavigated(Navigator.NavigationEvent event) { + if (loadFailed) { + Platform.runLater(() -> { + fireEvent(new PageCloseEvent()); + Controllers.dialog(i18n("world.load.fail"), null, MessageDialogPane.MessageType.ERROR); + }); + return; + } + if (sessionLockChannel == null || !sessionLockChannel.isOpen()) { + sessionLockChannel = WorldManageUIUtils.getSessionLockChannel(world); + } + } + + public void onExited(Navigator.NavigationEvent event) { try { - sessionLockChannel = world.lock(); - LOG.info("Acquired lock on world " + world.getFileName()); + WorldManageUIUtils.closeSessionLockChannel(world, sessionLockChannel); } catch (IOException ignored) { } } @@ -143,23 +211,12 @@ public boolean isReadOnly() { return sessionLockChannel == null; } - @Override - public boolean back() { - closePage(); - return true; + public void launch() { + fireEvent(new PageCloseEvent()); + Versions.launchAndEnterWorld(profile, id, world.getFileName()); } - @Override - public void closePage() { - if (sessionLockChannel != null) { - try { - sessionLockChannel.close(); - LOG.info("Releases the lock on world " + world.getFileName()); - } catch (IOException e) { - LOG.warning("Failed to close session lock channel", e); - } - - sessionLockChannel = null; - } + public void generateLaunchScript() { + Versions.generateLaunchScriptForQuickEnterWorld(profile, id, world.getFileName()); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java new file mode 100644 index 0000000000..d91a269b90 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManageUIUtils.java @@ -0,0 +1,152 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.versions; + +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.InputDialogPane; +import org.jackhuang.hmcl.ui.construct.MessageDialogPane; +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()); + Path file = FileUtils.toPath(fileChooser.showSaveDialog(Controllers.getStage())); + 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.dialog(new InputDialogPane( + i18n("world.duplicate.prompt"), + "", + (result, handler) -> { + if (StringUtils.isBlank(result)) { + handler.reject(i18n("world.duplicate.failed.empty_name")); + return; + } + + if (result.contains("/") || result.contains("\\") || !FileUtils.isNameValid(result)) { + handler.reject(i18n("world.duplicate.failed.invalid_name")); + return; + } + + 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(); + })); + } + + 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 (IOException ignored) { + return null; + } + } + +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateChecker.java b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateChecker.java index 1504828d74..7d4fa507c6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateChecker.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateChecker.java @@ -112,7 +112,7 @@ public static void requestCheckUpdate(UpdateChannel channel, boolean preview) { try { result = checkUpdate(channel, preview); LOG.info("Latest version (" + channel + ", preview=" + preview + ") is " + result); - } catch (IOException e) { + } catch (Throwable e) { LOG.warning("Failed to check for update", e); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/ChunkBaseApp.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/ChunkBaseApp.java index f5aff2293c..8556161bda 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/ChunkBaseApp.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/ChunkBaseApp.java @@ -49,7 +49,7 @@ public final class ChunkBaseApp { public static boolean isSupported(@NotNull World world) { return world.getSeed() != null && world.getGameVersion() != null && - GameVersionNumber.asGameVersion(world.getGameVersion()).compareTo(MIN_GAME_VERSION) >= 0; + world.getGameVersion().compareTo(MIN_GAME_VERSION) >= 0; } public static ChunkBaseApp newBuilder(String app, long seed) { @@ -60,7 +60,7 @@ public static void openSeedMap(World world) { assert isSupported(world); newBuilder("seed-map", Objects.requireNonNull(world.getSeed())) - .addPlatform(GameVersionNumber.asGameVersion(world.getGameVersion()), world.isLargeBiomes(), SEED_MAP_GAME_VERSIONS) + .addPlatform(world.getGameVersion(), world.isLargeBiomes(), SEED_MAP_GAME_VERSIONS) .open(); } @@ -68,7 +68,7 @@ public static void openStrongholdFinder(World world) { assert isSupported(world); newBuilder("stronghold-finder", Objects.requireNonNull(world.getSeed())) - .addPlatform(GameVersionNumber.asGameVersion(world.getGameVersion()), world.isLargeBiomes(), STRONGHOLD_FINDER_GAME_VERSIONS) + .addPlatform(world.getGameVersion(), world.isLargeBiomes(), STRONGHOLD_FINDER_GAME_VERSIONS) .open(); } @@ -76,7 +76,7 @@ public static void openNetherFortressFinder(World world) { assert isSupported(world); newBuilder("nether-fortress-finder", Objects.requireNonNull(world.getSeed())) - .addPlatform(GameVersionNumber.asGameVersion(world.getGameVersion()), false, NETHER_FORTRESS_GAME_VERSIONS) + .addPlatform(world.getGameVersion(), false, NETHER_FORTRESS_GAME_VERSIONS) .open(); } @@ -84,7 +84,7 @@ public static void openEndCityFinder(World world) { assert isSupported(world); newBuilder("endcity-finder", Objects.requireNonNull(world.getSeed())) - .addPlatform(GameVersionNumber.asGameVersion(world.getGameVersion()), false, END_CITY_GAME_VERSIONS) + .addPlatform(world.getGameVersion(), false, END_CITY_GAME_VERSIONS) .open(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java index 0d92ada795..789245cdcd 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java @@ -198,7 +198,7 @@ public static Version patchNative(DefaultGameRepository repository, public static SupportStatus checkSupportedStatus(GameVersionNumber gameVersion, Platform platform, OSVersion systemVersion) { if (platform.equals(Platform.WINDOWS_X86_64)) { - if (!systemVersion.isAtLeast(OSVersion.WINDOWS_10) && gameVersion.isAtLeast("1.20.5", "24w14a")) + if (!systemVersion.isAtLeast(OSVersion.WINDOWS_7) && gameVersion.isAtLeast("1.20.5", "24w14a")) return SupportStatus.UNSUPPORTED; return SupportStatus.OFFICIAL_SUPPORTED; @@ -249,7 +249,7 @@ public static SupportStatus checkSupportedStatus(GameVersionNumber gameVersion, if (minVersion != null) { if (gameVersion.compareTo(minVersion) >= 0) { - if (maxVersion != null && gameVersion.compareTo(maxVersion) >= 0) + if (maxVersion != null && gameVersion.compareTo(maxVersion) > 0) return SupportStatus.UNSUPPORTED; String[] defaultGameVersions = GameVersionNumber.getDefaultGameVersions(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/RemoteImageLoader.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/RemoteImageLoader.java new file mode 100644 index 0000000000..f36a1bdeab --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/RemoteImageLoader.java @@ -0,0 +1,117 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util; + +import javafx.beans.value.WritableValue; +import javafx.scene.image.Image; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.io.NetworkUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.lang.ref.WeakReference; +import java.net.URI; +import java.util.*; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// @author Glavo +public abstract class RemoteImageLoader { + private final Map> cache = new HashMap<>(); + private final Map>>> pendingRequests = new HashMap<>(); + private final WeakHashMap, URI> reverseLookup = new WeakHashMap<>(); + + public RemoteImageLoader() { + } + + protected @Nullable Image getPlaceholder() { + return null; + } + + protected abstract @NotNull Task createLoadTask(@NotNull URI uri); + + @FXThread + public void load(@NotNull WritableValue writableValue, String url) { + URI uri = NetworkUtils.toURIOrNull(url); + if (uri == null) { + reverseLookup.remove(writableValue); + writableValue.setValue(getPlaceholder()); + return; + } + + WeakReference reference = cache.get(uri); + if (reference != null) { + Image image = reference.get(); + if (image != null) { + reverseLookup.remove(writableValue); + writableValue.setValue(image); + return; + } + cache.remove(uri); + } + + writableValue.setValue(getPlaceholder()); + + { + List>> list = pendingRequests.get(uri); + if (list != null) { + list.add(new WeakReference<>(writableValue)); + reverseLookup.put(writableValue, uri); + return; + } else { + list = new ArrayList<>(1); + list.add(new WeakReference<>(writableValue)); + pendingRequests.put(uri, list); + reverseLookup.put(writableValue, uri); + } + } + + createLoadTask(uri).whenComplete(Schedulers.javafx(), (result, exception) -> { + Image image; + if (exception == null) { + image = result; + } else { + LOG.warning("Failed to load image from " + uri, exception); + image = getPlaceholder(); + } + + cache.put(uri, new WeakReference<>(image)); + List>> list = pendingRequests.remove(uri); + if (list != null) { + for (WeakReference> ref : list) { + WritableValue target = ref.get(); + if (target != null && uri.equals(reverseLookup.get(target))) { + reverseLookup.remove(target); + target.setValue(image); + } + } + } + }).start(); + } + + @FXThread + public void unload(@NotNull WritableValue writableValue) { + reverseLookup.remove(writableValue); + } + + @FXThread + public void clearInvalidCache() { + cache.entrySet().removeIf(entry -> entry.getValue().get() == null); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/I18n.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/I18n.java index a3d4c32abe..286910be07 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/I18n.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/I18n.java @@ -20,7 +20,9 @@ import org.jackhuang.hmcl.download.RemoteVersion; import org.jackhuang.hmcl.download.game.GameRemoteVersion; import org.jackhuang.hmcl.util.i18n.translator.Translator; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.PropertyKey; import java.io.IOException; import java.io.InputStream; @@ -44,11 +46,11 @@ public static SupportedLocale getLocale() { } public static boolean isUpsideDown() { - return LocaleUtils.getScript(locale.getLocale()).equals("Qabs"); + return LocaleUtils.getScript(locale.getDisplayLocale()).equals("Qabs"); } public static boolean isUseChinese() { - return LocaleUtils.isChinese(locale.getLocale()); + return LocaleUtils.isChinese(locale.getDisplayLocale()); } public static ResourceBundle getResourceBundle() { @@ -59,11 +61,11 @@ public static Translator getTranslator() { return locale.getTranslator(); } - public static String i18n(String key, Object... formatArgs) { + public static String i18n(@PropertyKey(resourceBundle = "assets.lang.I18N") String key, Object... formatArgs) { return locale.i18n(key, formatArgs); } - public static String i18n(String key) { + public static String i18n(@PropertyKey(resourceBundle = "assets.lang.I18N") String key) { return locale.i18n(key); } @@ -75,7 +77,11 @@ public static String formatSpeed(long bytes) { return getTranslator().formatSpeed(bytes); } - public static String getDisplaySelfVersion(RemoteVersion version) { + public static String getDisplayVersion(RemoteVersion version) { + return getTranslator().getDisplayVersion(version); + } + + public static String getDisplayVersion(GameVersionNumber version) { return getTranslator().getDisplayVersion(version); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/MinecraftWiki.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/MinecraftWiki.java index a93219c615..382124fe96 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/MinecraftWiki.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/MinecraftWiki.java @@ -30,7 +30,7 @@ public final class MinecraftWiki { private static final Pattern SNAPSHOT_PATTERN = Pattern.compile("^[0-9]{2}w[0-9]{2}.+$"); public static String getWikiLink(SupportedLocale locale, GameRemoteVersion version) { - String wikiVersion = version.getSelfVersion(); + String wikiVersion = StringUtils.removeSuffix(version.getSelfVersion(), "_unobfuscated"); var gameVersion = GameVersionNumber.asGameVersion(wikiVersion); if (locale.getLocale().getLanguage().equals("lzh")) { @@ -72,7 +72,7 @@ else if (wikiVersion.startsWith("1.0.0-rc2")) else if (wikiVersion.startsWith("1.0.0-rc2")) wikiVersion = "1.0.0-rc2"; } - } else if (gameVersion instanceof GameVersionNumber.Snapshot) { + } else if (gameVersion instanceof GameVersionNumber.LegacySnapshot) { return locale.i18n("wiki.version.game.snapshot", wikiVersion) + variantSuffix; } else { if (wikiVersion.length() >= 6 && wikiVersion.charAt(2) == 'w') { @@ -80,8 +80,6 @@ else if (wikiVersion.startsWith("1.0.0-rc2")) if (SNAPSHOT_PATTERN.matcher(wikiVersion).matches()) { if (wikiVersion.equals("22w13oneblockatatime")) wikiVersion = "22w13oneBlockAtATime"; - else - wikiVersion = StringUtils.removeSuffix(wikiVersion, "_unobfuscated"); return locale.i18n("wiki.version.game.snapshot", wikiVersion) + variantSuffix; } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/SupportedLocale.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/SupportedLocale.java index 820b5c0c25..97d98b25fb 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/SupportedLocale.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/SupportedLocale.java @@ -24,6 +24,7 @@ import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.i18n.translator.Translator; +import org.jetbrains.annotations.PropertyKey; import java.io.IOException; import java.io.InputStream; @@ -41,6 +42,10 @@ public final class SupportedLocale { public static final SupportedLocale DEFAULT = new SupportedLocale(); + public static boolean isExperimentalSupported(Locale locale) { + return "ar".equals(locale.getLanguage()); + } + private static final ConcurrentMap LOCALES = new ConcurrentHashMap<>(); public static List getSupportedLocales() { @@ -50,7 +55,11 @@ public static List getSupportedLocales() { InputStream locales = SupportedLocale.class.getResourceAsStream("/assets/lang/languages.json"); if (locales != null) { try (locales) { - list.addAll(JsonUtils.fromNonNullJsonFully(locales, JsonUtils.listTypeOf(SupportedLocale.class))); + for (SupportedLocale locale : JsonUtils.fromNonNullJsonFully(locales, JsonUtils.listTypeOf(SupportedLocale.class))) { + if (!isExperimentalSupported(locale.getLocale())) { + list.add(locale); + } + } } catch (Throwable e) { LOG.warning("Failed to load languages.json", e); } @@ -72,6 +81,7 @@ public static SupportedLocale getLocaleByName(String name) { private final boolean isDefault; private final String name; private final Locale locale; + private final Locale displayLocale; private final TextDirection textDirection; private ResourceBundle resourceBundle; @@ -84,9 +94,15 @@ public static SupportedLocale getLocaleByName(String name) { this.name = "def"; // TODO: Change to "default" after updating the Config format String language = System.getenv("HMCL_LANGUAGE"); - this.locale = StringUtils.isBlank(language) - ? LocaleUtils.SYSTEM_DEFAULT - : Locale.forLanguageTag(language); + if (StringUtils.isBlank(language)) { + this.locale = LocaleUtils.SYSTEM_DEFAULT; + this.displayLocale = isExperimentalSupported(this.locale) + ? Locale.ENGLISH + : this.locale; + } else { + this.locale = Locale.forLanguageTag(language); + this.displayLocale = this.locale; + } this.textDirection = LocaleUtils.getTextDirection(locale); } @@ -94,6 +110,7 @@ public static SupportedLocale getLocaleByName(String name) { this.isDefault = false; this.name = locale.toLanguageTag(); this.locale = locale; + this.displayLocale = locale; this.textDirection = LocaleUtils.getTextDirection(locale); } @@ -109,6 +126,15 @@ public Locale getLocale() { return locale; } + /// Used to represent the text display language of HMCL. + /// + /// Usually equivalent to [#getLocale()], + /// but for [experimentally supported languages][#isExperimentalSupported(Locale)], + /// it falls back to ENGLISH by default. + public Locale getDisplayLocale() { + return displayLocale; + } + public TextDirection getTextDirection() { return textDirection; } @@ -166,7 +192,7 @@ public String getDisplayName(SupportedLocale inLocale) { public ResourceBundle getResourceBundle() { ResourceBundle bundle = resourceBundle; if (resourceBundle == null) - resourceBundle = bundle = ResourceBundle.getBundle("assets.lang.I18N", locale, + resourceBundle = bundle = ResourceBundle.getBundle("assets.lang.I18N", displayLocale, DefaultResourceBundleControl.INSTANCE); return bundle; @@ -175,7 +201,7 @@ public ResourceBundle getResourceBundle() { public ResourceBundle getLocaleNamesBundle() { ResourceBundle bundle = localeNamesBundle; if (localeNamesBundle == null) - localeNamesBundle = bundle = ResourceBundle.getBundle("assets.lang.LocaleNames", locale, + localeNamesBundle = bundle = ResourceBundle.getBundle("assets.lang.LocaleNames", displayLocale, DefaultResourceBundleControl.INSTANCE); return bundle; @@ -183,11 +209,11 @@ public ResourceBundle getLocaleNamesBundle() { public List getCandidateLocales() { if (candidateLocales == null) - candidateLocales = List.copyOf(LocaleUtils.getCandidateLocales(locale)); + candidateLocales = List.copyOf(LocaleUtils.getCandidateLocales(displayLocale)); return candidateLocales; } - public String i18n(String key, Object... formatArgs) { + public String i18n(@PropertyKey(resourceBundle = "assets.lang.I18N") String key, Object... formatArgs) { try { return String.format(getResourceBundle().getString(key), formatArgs); } catch (MissingResourceException e) { @@ -199,7 +225,7 @@ public String i18n(String key, Object... formatArgs) { return key + Arrays.toString(formatArgs); } - public String i18n(String key) { + public String i18n(@PropertyKey(resourceBundle = "assets.lang.I18N") String key) { try { return getResourceBundle().getString(key); } catch (MissingResourceException e) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/translator/Translator.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/translator/Translator.java index 3441797cb9..7bdcfc8038 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/translator/Translator.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/translator/Translator.java @@ -19,6 +19,7 @@ import org.jackhuang.hmcl.download.RemoteVersion; import org.jackhuang.hmcl.util.i18n.SupportedLocale; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import java.time.ZoneId; import java.time.format.DateTimeFormatter; @@ -28,25 +29,29 @@ /// @author Glavo public class Translator { protected final SupportedLocale supportedLocale; - protected final Locale locale; + protected final Locale displayLocale; public Translator(SupportedLocale supportedLocale) { this.supportedLocale = supportedLocale; - this.locale = supportedLocale.getLocale(); + this.displayLocale = supportedLocale.getDisplayLocale(); } public final SupportedLocale getSupportedLocale() { return supportedLocale; } - public final Locale getLocale() { - return locale; + public final Locale getDisplayLocale() { + return displayLocale; } public String getDisplayVersion(RemoteVersion remoteVersion) { return remoteVersion.getSelfVersion(); } + public String getDisplayVersion(GameVersionNumber versionNumber) { + return versionNumber.toNormalizedString(); + } + /// @see [#formatDateTime(TemporalAccessor)] protected DateTimeFormatter dateTimeFormatter; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/translator/Translator_lzh.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/translator/Translator_lzh.java index 3bf7b6474c..a6302a5336 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/translator/Translator_lzh.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/translator/Translator_lzh.java @@ -92,22 +92,38 @@ public static String translateGameVersion(GameVersionNumber gameVersion) { appendDigitByDigit(builder, String.valueOf(release.getPatch())); } - //noinspection StatementWithEmptyBody - if (release.getEaType() == GameVersionNumber.Release.TYPE_GA) { - // do nothing - } else if (release.getEaType() == GameVersionNumber.Release.TYPE_PRE) { - builder.append("之預"); - appendDigitByDigit(builder, release.getEaVersion().toString()); - } else if (release.getEaType() == GameVersionNumber.Release.TYPE_RC) { - builder.append("之候"); - appendDigitByDigit(builder, release.getEaVersion().toString()); - } else { - // Unsupported - return gameVersion.toString(); + switch (release.getEaType()) { + case GA -> { + // do nothing + } + case PRE_RELEASE -> { + builder.append("之預"); + appendDigitByDigit(builder, release.getEaVersion().toString()); + } + case RELEASE_CANDIDATE -> { + builder.append("之候"); + appendDigitByDigit(builder, release.getEaVersion().toString()); + } + default -> { + // Unsupported + return gameVersion.toString(); + } + } + + switch (release.getAdditional()) { + case NONE -> { + } + case UNOBFUSCATED -> { + builder.append("涇渭"); + } + default -> { + // Unsupported + return gameVersion.toString(); + } } return builder.toString(); - } else if (gameVersion instanceof GameVersionNumber.Snapshot snapshot) { + } else if (gameVersion instanceof GameVersionNumber.LegacySnapshot snapshot) { StringBuilder builder = new StringBuilder(); appendDigitByDigit(builder, String.valueOf(snapshot.getYear())); @@ -120,6 +136,9 @@ public static String translateGameVersion(GameVersionNumber gameVersion) { else builder.append(suffix); + if (snapshot.isUnobfuscated()) + builder.append("涇渭"); + return builder.toString(); } else if (gameVersion instanceof GameVersionNumber.Special) { String version = gameVersion.toString(); @@ -130,6 +149,7 @@ public static String translateGameVersion(GameVersionNumber gameVersion) { case "2.0_purple" -> "二點〇紫"; case "1.rv-pre1" -> "一點真視之預一"; case "3d shareware v1.34" -> "躍然享件一點三四"; + case "13w12~" -> "一三週一二閏"; case "20w14infinite", "20w14~", "20w14∞" -> "二〇週一四宇"; case "22w13oneblockatatime" -> "二二週一三典"; case "23w13a_or_b" -> "二三週一三暨"; @@ -179,6 +199,11 @@ public String getDisplayVersion(RemoteVersion remoteVersion) { return translateGenericVersion(remoteVersion.getSelfVersion()); } + @Override + public String getDisplayVersion(GameVersionNumber versionNumber) { + return translateGameVersion(versionNumber); + } + @Override public String formatDateTime(TemporalAccessor time) { LocalDateTime localDateTime; diff --git a/HMCL/src/main/resources/assets/HMCLauncher.sh b/HMCL/src/main/resources/assets/HMCLauncher.sh index a402158180..1dfff64590 100644 --- a/HMCL/src/main/resources/assets/HMCLauncher.sh +++ b/HMCL/src/main/resources/assets/HMCLauncher.sh @@ -37,6 +37,8 @@ case "$(uname -m)" in _HMCL_ARCH="riscv64";; loongarch64) _HMCL_ARCH="loongarch64";; + mips64) + _HMCL_ARCH="mips64el";; *) _HMCL_ARCH="unknown";; esac diff --git a/HMCL/src/main/resources/assets/about/deps.json b/HMCL/src/main/resources/assets/about/deps.json index d6d705f822..d72871ec0c 100644 --- a/HMCL/src/main/resources/assets/about/deps.json +++ b/HMCL/src/main/resources/assets/about/deps.json @@ -78,5 +78,10 @@ "title": "EasyTier", "subtitle": "Copyright 2024-present Easytier Programme within The Commons Conservancy", "externalLink": "https://github.com/EasyTier/EasyTier" + }, + { + "title": "MonetFX", + "subtitle": "Copyright © 2025 Glavo.\nLicensed under the Apache 2.0 License.", + "externalLink": "https://github.com/Glavo/MonetFX" } ] \ No newline at end of file diff --git a/HMCL/src/main/resources/assets/about/thanks.json b/HMCL/src/main/resources/assets/about/thanks.json index 2782c38bf5..8e4540d9c2 100644 --- a/HMCL/src/main/resources/assets/about/thanks.json +++ b/HMCL/src/main/resources/assets/about/thanks.json @@ -71,7 +71,10 @@ "externalLink" : "https://github.com/mcmod-info-mirror" }, { - "image" : "/assets/img/github.png", + "image" : { + "light" : "/assets/img/github.png", + "dark" : "/assets/img/github-white.png" + }, "titleLocalized" : "about.thanks_to.contributors", "subtitleLocalized" : "about.thanks_to.contributors.statement", "externalLink" : "https://github.com/HMCL-dev/HMCL/graphs/contributors" diff --git a/HMCL/src/main/resources/assets/css/blue.css b/HMCL/src/main/resources/assets/css/blue.css index c02cbb1b89..98c5336b4a 100644 --- a/HMCL/src/main/resources/assets/css/blue.css +++ b/HMCL/src/main/resources/assets/css/blue.css @@ -1,28 +1,60 @@ -/** - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -.root { - -fx-base-color: #5C6BC0; - -fx-base-darker-color: derive(-fx-base-color, -10%); - -fx-base-check-color: derive(-fx-base-color, 30%); - -fx-rippler-color: rgba(92, 107, 192, 0.3); - -fx-base-rippler-color: derive(rgba(92, 107, 192, 0.3), 100%); - -fx-base-disabled-text-fill: rgba(256, 256, 256, 0.7); - -fx-base-text-fill: white; - - -theme-thumb: rgba(92, 107, 192, 0.7); +* { + -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-secondary-container-transparent-50: #D0D5FD80; + -monet-surface-transparent-50: #FBF8FF80; + -monet-surface-transparent-80: #FBF8FFCC; + -monet-on-surface-variant-transparent-38: #45465161; + -monet-surface-container-low-transparent-80: #F5F2FACC; + -monet-secondary-container-transparent-80: #D0D5FDCC; + -monet-inverse-surface-transparent-80: #303036CC; } \ No newline at end of file diff --git a/HMCL/src/main/resources/assets/css/brightness-dark.css b/HMCL/src/main/resources/assets/css/brightness-dark.css new file mode 100644 index 0000000000..5d869db728 --- /dev/null +++ b/HMCL/src/main/resources/assets/css/brightness-dark.css @@ -0,0 +1,20 @@ +.hint { + -fx-hyperlink-fill: -monet-inverse-primary; +} + +.two-line-list-item { + -fixed-warning-tag-background: #2c0b0e; +} + +.log-window { + -fixed-log-toggle-selected: #DEE2E6; + -fixed-log-toggle-unselected: #6C757D; + -fixed-log-text-fill: #FFFFFF; + -fixed-log-trace: #495057; + -fixed-log-debug: #343A40; + -fixed-log-info: #212529; + -fixed-log-warn: #331904; + -fixed-log-error: #58151C; + -fixed-log-fatal: #842029; + -fixed-log-selected: #6C757D; +} diff --git a/HMCL/src/main/resources/assets/css/brightness-light.css b/HMCL/src/main/resources/assets/css/brightness-light.css new file mode 100644 index 0000000000..6235056083 --- /dev/null +++ b/HMCL/src/main/resources/assets/css/brightness-light.css @@ -0,0 +1,20 @@ +.hint { + -fx-hyperlink-fill: -monet-primary; +} + +.two-line-list-item { + -fixed-warning-tag-background: #F1AEB5; +} + +.log-window { + -fixed-log-toggle-selected: #000000; + -fixed-log-toggle-unselected: #6C757D; + -fixed-log-text-fill: #000000; + -fixed-log-trace: #EEE9E0; + -fixed-log-debug: #EEE9E0; + -fixed-log-info: #FFFFFF; + -fixed-log-warn: #FFEECC; + -fixed-log-error: #FFCCBB; + -fixed-log-fatal: #F7A699; + -fixed-log-selected: #C4C4C4; +} diff --git a/HMCL/src/main/resources/assets/css/root.css b/HMCL/src/main/resources/assets/css/root.css index 0d7df39dbe..3076120ea5 100644 --- a/HMCL/src/main/resources/assets/css/root.css +++ b/HMCL/src/main/resources/assets/css/root.css @@ -15,31 +15,38 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ + .root { } .svg { - -fx-fill: black; + -fx-fill: -monet-on-surface; +} + +.label { + -fx-text-fill: -monet-on-surface; } .scroll-bar .thumb { - -fx-fill: -theme-thumb; + -fx-fill: -monet-surface-tint; -fx-arc-width: 0; -fx-arc-height: 0; } -.disabled Label { - -fx-text-fill: rgba(0, 0, 0, 0.5); -} - .title-label { - -fx-font-size: 16.0px; - -fx-padding: 14.0px; - -fx-text-fill: rgba(0.0, 0.0, 0.0, 0.87); + -fx-text-fill: -monet-on-surface; } .subtitle-label { - -fx-text-fill: rgba(0, 0, 0, 0.5); + -fx-text-fill: -monet-on-surface-variant; +} + +.hyperlink { + -fx-text-fill: -monet-primary; +} + +.hyperlink:focused { + -fx-border-color: -monet-primary; } .hint { @@ -49,24 +56,23 @@ -fx-padding: 6; } -/* - Colors are picked from bootstrap - - https://getbootstrap.com/docs/5.3/components/alerts/ - */ +.hint .hyperlink { + -fx-fill: -monet-primary; + -fx-text-fill: -monet-primary; +} .hint.info { - -fx-background-color: #cfe2ff; - -fx-border-color: #9ec5fe; + -fx-background-color: -monet-primary-fixed-dim; + -fx-border-color: -monet-outline; } .hint.info Text, .hint.info .svg { - -fx-fill: #052c65; + -fx-fill: -monet-on-primary-fixed; } .hint.success { -fx-background-color: #d1e7dd; - -fx-border-color: #a3cfbb; + -fx-border-color: -monet-outline; } .hint.success Text, .hint.success .svg { @@ -74,21 +80,26 @@ } .hint.error { - -fx-background-color: #f8d7da; - -fx-border-color: #f1aeb5; + -fx-background-color: -monet-error-container; + -fx-border-color: -monet-outline; } .hint.error Text, .hint.error .svg { - -fx-fill: #58151c; + -fx-fill: -monet-on-error-container; } .hint.warning { - -fx-background-color: #fff3cd; - -fx-border-color: #ffe69c; + -fx-background-color: -monet-tertiary-fixed-dim; + -fx-border-color: -monet-outline; } .hint.warning Text, .hint.warning .svg { - -fx-fill: #664d03; + -fx-fill: -monet-on-tertiary-fixed; +} + +.hint.warning .hyperlink { + -fx-fill: -fx-hyperlink-fill; + -fx-text-fill: -fx-hyperlink-fill; } .skin-pane .jfx-text-field { @@ -103,7 +114,7 @@ } .memory-used { - -fx-background-color: -fx-base-darker-color; + -fx-background-color: -monet-primary; } .memory-used:disabled { @@ -111,7 +122,7 @@ } .memory-allocate { - -fx-background-color: derive(-fx-base-color, 100%); + -fx-background-color: -monet-primary-transparent-50; } .memory-allocate:disabled { @@ -119,7 +130,7 @@ } .memory-total { - -fx-background-color: -fx-base-rippler-color; + -fx-background-color: -monet-surface-container; } .memory-total:disabled { @@ -127,7 +138,7 @@ } .update-label { - -fx-text-fill: red; + -fx-text-fill: -monet-tertiary; } .radio-button-title-label { @@ -141,20 +152,27 @@ } .announcement .title { + -fx-text-fill: -monet-on-surface; -fx-font-size: 14px; -fx-font-weight: BOLD; } -.announcement JFXHyperlink, .announcement Text { +.announcement Text { + -fx-fill: -monet-on-surface; + -fx-font-size: 13px; +} + +.announcement .hyperlink { + -fx-fill: -monet-primary; -fx-font-size: 13px; } .rippler-container { - -jfx-rippler-fill: #a2a2a2; + -jfx-rippler-fill: -monet-on-surface-variant; } -.rippler-container:selected .label { - -fx-text-fill: -fx-base-text-fill; +.advanced-list-item .rippler-container { + -jfx-rippler-fill: -monet-on-secondary-container; } .advanced-list-item > .rippler-container > .container { @@ -178,16 +196,16 @@ } .advanced-list-item:selected > .rippler-container > .container { - -fx-background-color: -fx-base-rippler-color; + -fx-background-color: -monet-secondary-container-transparent-50; } .advanced-list-item:selected > .rippler-container > .container > .two-line-list-item > .first-line > .title { - -fx-text-fill: -fx-base-color; + -fx-text-fill: -monet-on-secondary-container; -fx-font-weight: bold; } .advanced-list-item:selected .svg { - -fx-fill: -fx-base-color; + -fx-fill: -monet-on-secondary-container; } .navigation-drawer-item > .rippler-container > .container > VBox { @@ -207,20 +225,20 @@ } .profile-list-item:selected > .rippler-container > BorderPane { - -fx-background-color: -fx-base-rippler-color; + -fx-background-color: -monet-secondary-container-transparent-50; } .profile-list-item:selected > .rippler-container > BorderPane > .two-line-list-item > .first-line > .title { - -fx-text-fill: -fx-base-color; + -fx-text-fill: -monet-on-secondary-container; -fx-font-weight: bold; } .profile-list-item:selected .svg { - -fx-fill: -fx-base-color; + -fx-fill: -monet-on-secondary-container; } .notice-pane > .label { - -fx-text-fill: #0079FF; + -fx-text-fill: -monet-on-surface; -fx-font-size: 20; -fx-wrap-text: true; -fx-text-alignment: CENTER; @@ -231,6 +249,14 @@ -fx-padding: 8 16 8 16; } +.class-title Text { + -fx-fill: -monet-on-surface; +} + +.class-title Rectangle { + -fx-fill: -monet-on-surface-variant; +} + .advanced-list-box-item { } @@ -239,8 +265,12 @@ -fx-spacing: 0; } +.no-padding .advanced-list-box-content { + -fx-padding: 0 0 0 0; +} + .iconed-item { - -jfx-rippler-fill: -fx-base-color; + -jfx-rippler-fill: -monet-secondary-container; } .iconed-item .iconed-item-container { @@ -251,7 +281,7 @@ } .iconed-menu-item { - -jfx-rippler-fill: -fx-base-color; + -jfx-rippler-fill: -monet-secondary-container; } .iconed-menu-item .iconed-item-container { @@ -266,7 +296,12 @@ } .popup-menu .scroll-bar .thumb { - -fx-fill: rgba(0, 0, 0, 0.5); + -fx-fill: -monet-surface-tint; +} + +.popup-menu .scroll-pane > .scroll-bar:vertical, +.popup-menu .scroll-pane > .scroll-bar:horizontal { + -fx-opacity: 0; } .popup-menu-content { @@ -274,28 +309,32 @@ } .two-line-list-item > .first-line > .title { - -fx-text-fill: #292929; - -fx-font-size: 15px; + -fx-text-fill: -monet-on-surface; + -fx-font-size: 15px; -fx-padding: 0 8 0 0; } .two-line-list-item > HBox > .subtitle { - -fx-text-fill: rgba(0, 0, 0, 0.5); + -fx-text-fill: -monet-on-surface-variant; -fx-font-weight: normal; -fx-font-size: 12px; } +.two-line-list-item > .second-line > .subtitle Text { + -fx-fill: -monet-on-surface-variant; +} + .two-line-list-item > .first-line > .tag { - -fx-text-fill: -fx-base-color; - -fx-background-color: -fx-base-rippler-color; + -fx-text-fill: -monet-on-secondary-container; + -fx-background-color: -monet-secondary-container; -fx-padding: 2; -fx-font-weight: normal; -fx-font-size: 12px; } .two-line-list-item > .first-line > .tag-warning { - -fx-text-fill: #d34336;; - -fx-background-color: #f1aeb5; + -fx-text-fill: -monet-on-error-container; + -fx-background-color: -fixed-warning-tag-background; -fx-padding: 2; -fx-font-weight: normal; -fx-font-size: 12px; @@ -306,60 +345,81 @@ } .two-line-item-second-large > .first-line > .title, .two-line-item-second-large-title { - -fx-text-fill: rgba(0, 0, 0, 0.5); + -fx-text-fill: -monet-on-surface-variant; -fx-font-weight: normal; -fx-font-size: 12px; } .two-line-item-second-large > HBox > .subtitle { - -fx-text-fill: #292929; + -fx-text-fill: -monet-on-surface; -fx-font-size: 15px; } +.terracotta-hint Text { + -fx-fill: -monet-on-surface; +} + +.game-crash-window { + -fx-background-color: -monet-surface-container; +} + +.game-crash-window .crash-reason-text-flow Text { + -fx-fill: -monet-on-surface; +} + +.game-crash-window .crash-reason-text-flow .hyperlink { + -fx-fill: -monet-primary; +} + .wrap-text > HBox > .subtitle { -fx-wrap-text: true; } .bubble { - -fx-background-color: rgba(0, 0, 0, 0.5); + -fx-background-color: -monet-inverse-surface-transparent-80; -fx-background-radius: 2px; - -fx-text-fill: white; } .bubble > HBox > .two-line-list-item > .first-line > .title, .bubble > HBox > .two-line-list-item > HBox > .subtitle { - -fx-text-fill: white; + -fx-text-fill: -monet-inverse-on-surface; +} + +.bubble .svg { + -fx-fill: -monet-inverse-on-surface; } .sponsor-pane { -fx-cursor: hand; } -.installer-item { - -fx-padding: 8px; +.installer-item-wrapper { + -fx-background-color: -monet-surface; + -fx-background-radius: 4; + -fx-pref-width: 180px; } -.installer-item:list-item { - -fx-border-color: #e0e0e0; +.installer-item-wrapper:card { + -fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.26), 10, 0.12, -1, 2); +} + +.installer-item-wrapper .installer-item:list-item { + -fx-padding: 8px; + -fx-border-color: -monet-outline-variant; -fx-border-width: 0 0 1 0; -fx-alignment: center-left; } -.installer-item:list-item > .installer-item-name { +.installer-item-wrapper .installer-item:list-item > .installer-item-name { -fx-pref-width: 80px; } -.installer-item:list-item > .installer-item-status { +.installer-item-wrapper .installer-item:list-item > .installer-item-status { -fx-max-width: infinity; } -.installer-item:card { - -fx-background-color: white; - -fx-background-radius: 4; +.installer-item-wrapper .installer-item:card { -fx-alignment: center; - -fx-pref-width: 180px; - - -fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.26), 10, 0.12, -1, 2); } /******************************************************************************* @@ -368,34 +428,49 @@ * * ******************************************************************************/ +.launch-pane { + -fx-max-height: 57px; + -fx-min-height: 57px; + -fx-max-width: 230px; + -fx-min-width: 230px; +} + .launch-pane > .jfx-button { - -fx-background-color: -fx-base-color; + -fx-translate-y: 1px; + -fx-max-height: 55px; + -fx-min-height: 55px; + -fx-background-color: -monet-primary-container; -fx-cursor: hand; } +.launch-pane > .jfx-button.launch-button { + -fx-max-width: 207px; + -fx-min-width: 207px; + -fx-border-width: 0 3px 0 0; + -fx-border-color: -monet-on-surface-variant; + -fx-background-radius: 4px 0 0 4px; +} + +.launch-pane > .jfx-button.menu-button { + -fx-max-width: 20px; + -fx-min-width: 20px; + -fx-font-size: 15px; + -fx-background-radius: 0 4px 4px 0; +} + .launch-pane > .jfx-button > StackPane > .jfx-rippler { - -jfx-rippler-fill: white; + -jfx-rippler-fill: -monet-on-primary-container; -jfx-mask-type: CIRCLE; - -fx-padding: 0.0; + -fx-padding: 0; } .launch-pane > .jfx-button, .jfx-button * { - -fx-text-fill: -fx-base-text-fill; + -fx-text-fill: -monet-on-primary-container; -fx-font-size: 14px; } -.launch-pane > Rectangle { - -fx-fill: -fx-base-darker-color; -} - -/******************************************************************************* - * * - * Tooltip * - * * - ******************************************************************************/ - -.tooltip { - -fx-text-fill: WHITE; +.launch-pane .svg { + -fx-fill: -monet-on-primary-container; } /******************************************************************************* @@ -405,15 +480,16 @@ ******************************************************************************/ .tab-header-background { - -fx-background-color: -fx-base-check-color; + -fx-background-color: -monet-surface; } .tab-selected-line { - -fx-background-color: derive(-fx-base-color, -30%); + -fx-background-color: derive(-monet-primary-container, -30%); } +/* TODO: It seems not actually used */ .tab-rippler { - -jfx-rippler-fill: -fx-base-check-color; + -jfx-rippler-fill: derive(-monet-primary-container, 30%); } .jfx-tab-pane .jfx-rippler { @@ -446,7 +522,8 @@ .jfx-dialog-layout { -fx-padding: 24.0px 24.0px 16.0px 24.0px; - -fx-text-fill: rgba(0.0, 0.0, 0.0, 0.87); + -fx-background-color: -monet-surface-container-high; + -fx-text-fill: -monet-on-surface; } .jfx-layout-heading { @@ -460,6 +537,10 @@ -fx-wrap-text: true; } +.jfx-layout-body Text { + -fx-fill: -monet-on-surface; +} + .jfx-layout-actions { -fx-pref-width: 400; -fx-padding: 10.0px 0.0 0.0 0.0; @@ -467,19 +548,26 @@ } .dialog-error { - -fx-text-fill: #d32f2f; + -fx-text-fill: -monet-error; -fx-padding: 0.7em 0.8em; } .dialog-accept { - -fx-text-fill: #03A9F4; + -fx-text-fill: -monet-primary; -fx-padding: 0.7em 0.8em; } .dialog-cancel { + -fx-text-fill: -monet-on-surface-variant; -fx-padding: 0.7em 0.8em; } +.task-executor-dialog-layout { + -fx-background-color: -monet-surface-container-high; + -fx-text-fill: -monet-on-surface; +} + + /******************************************************************************* * * * JFX Pop Up * @@ -491,7 +579,7 @@ } .jfx-popup-container { - -fx-background-color: WHITE; + -fx-background-color: -monet-surface; -fx-background-radius: 4; } @@ -500,15 +588,15 @@ } .jfx-snackbar-content { - -fx-background-color: #323232; + -fx-background-color: -monet-inverse-surface; } .jfx-snackbar-toast { - -fx-text-fill: WHITE; + -fx-text-fill: -monet-inverse-on-surface; } .jfx-snackbar-action { - -fx-text-fill: #ff4081; + -fx-text-fill: -monet-inverse-primary; } /******************************************************************************* @@ -542,7 +630,7 @@ } .jfx-tool-bar.background { - -fx-background-color: -fx-base-color; + -fx-background-color: -monet-primary-container; } /*.jfx-tool-bar HBox {*/ @@ -561,15 +649,15 @@ } .jfx-tool-bar .jfx-decorator-button .svg { - -fx-fill: -fx-base-text-fill; + -fx-fill: -monet-on-primary-container; } .jfx-tool-bar Label { - -fx-text-fill: WHITE; + -fx-text-fill: -monet-on-surface; } .jfx-tool-bar.gray-background Label { - /* -fx-text-fill: BLACK; */ + -fx-text-fill: BLACK; } .jfx-tool-bar .jfx-options-burger { @@ -581,35 +669,36 @@ } .jfx-tool-bar .jfx-rippler { - -jfx-rippler-fill: WHITE; + -fx-text-fill: -monet-on-surface; } .jfx-tool-bar-second { -fx-pref-height: 42; -fx-padding: 2 2 2 2; - -fx-background-color: -fx-base-check-color; + -fx-background-color: -monet-primary-container; -fx-alignment: CENTER-LEFT; -fx-spacing: 8; } .jfx-tool-bar-second .label { - -fx-text-fill: -fx-base-text-fill; + -fx-text-fill: -monet-on-primary-container; -fx-font-size: 16; -fx-font-weight: bold; } .jfx-tool-bar-second .jfx-rippler { - -jfx-rippler-fill: WHITE; + -jfx-rippler-fill: -monet-on-surface; } .jfx-tool-bar-button { + -fx-text-fill: -monet-on-surface; -fx-toggle-icon4-size: 37px; -fx-pref-height: -fx-toggle-icon4-size; -fx-max-height: -fx-toggle-icon4-size; -fx-min-height: -fx-toggle-icon4-size; -fx-background-radius: 5px; -fx-background-color: transparent; - -jfx-toggle-color: white; + -jfx-toggle-color: -monet-on-surface; -jfx-untoggle-color: transparent; } @@ -618,18 +707,11 @@ -fx-padding: 10.0; } -.jfx-tool-bar-button .jfx-rippler { - -jfx-rippler-fill: white; - -jfx-mask-type: CIRCLE; -} - .jfx-decorator-button { -fx-max-width: 40px; -fx-background-radius: 5px; -fx-max-height: 40px; -fx-background-color: transparent; - -jfx-toggle-color: rgba(128, 128, 255, 0.2); - -jfx-untoggle-color: transparent; } .jfx-decorator-button .icon { @@ -638,7 +720,6 @@ } .jfx-decorator-button .jfx-rippler { - -jfx-rippler-fill: white; -jfx-mask-type: CIRCLE; -fx-padding: 0.0; } @@ -649,17 +730,10 @@ * * ******************************************************************************/ - .jfx-radio-button { - -jfx-selected-color: -fx-base-check-color; - } - -.jfx-radio-button .radio { - -fx-stroke-width: 2.0px; - -fx-fill: transparent; -} - -.jfx-radio-button .dot { - -fx-fill: -fx-base-check-color; +.jfx-radio-button { + -jfx-selected-color: -monet-primary; + -jfx-unselected-color: -monet-on-surface-variant; + -fx-text-fill: -monet-on-surface; } /******************************************************************************* @@ -669,8 +743,8 @@ ******************************************************************************/ .svg-slider .thumb { - -fx-stroke: black; - -fx-fill: black; + -fx-stroke: -monet-on-surface; + -fx-fill: -monet-on-surface; } .jfx-slider:disabled { @@ -687,8 +761,12 @@ -jfx-indicator-position: right; } +.jfx-slider .track { + -fx-background-color: -monet-on-surface-variant-transparent-38; +} + .jfx-slider .thumb { - -fx-background-color: -fx-base-color; + -fx-background-color: -monet-primary; } /******************************************************************************* @@ -713,42 +791,47 @@ } .jfx-button .jfx-rippler { - -jfx-rippler-fill: -fx-base-check-color; + -jfx-rippler-fill: -monet-on-surface; -jfx-mask-type: CIRCLE; -fx-padding: 0.0; } .jfx-button-raised { - -fx-background-color: -fx-base-color; + -fx-background-color: -monet-primary; } .jfx-button-raised .jfx-rippler { - -jfx-rippler-fill: white; + -jfx-rippler-fill: -monet-on-primary; -jfx-mask-type: CIRCLE; -fx-padding: 0.0; } .jfx-button-raised, .jfx-button-raised * { - -fx-text-fill: -fx-base-text-fill; + -fx-text-fill: -monet-on-primary; -fx-font-size: 14px; } .jfx-button-border { - -fx-border-color: gray; + -fx-background-color: transparent; + -fx-border-color: -monet-outline; -fx-border-radius: 5px; -fx-border-width: 0.2px; -fx-padding: 8px; } .jfx-button-border, .jfx-button-border * { - -fx-text-fill: -fx-base-darker-color; + -fx-text-fill: -monet-primary; } .jfx-button-raised-round { - -fx-background-color: -fx-base-color; + -fx-background-color: -monet-primary-container; -fx-background-radius: 50px; } +.menu-up-down-button .label { + -fx-text-fill: -monet-on-surface; +} + /******************************************************************************* * * * JFX Check Box * @@ -757,7 +840,13 @@ .jfx-check-box { -fx-font-weight: BOLD; - -jfx-checked-color: -fx-base-check-color; + -fx-text-fill: -monet-on-surface; + -jfx-checked-color: -monet-primary; + -jfx-unchecked-color: transparent; +} + +.table-view .jfx-check-box .jfx-rippler { + -jfx-rippler-disabled: true; } /******************************************************************************* @@ -776,11 +865,11 @@ } .jfx-progress-bar > .track { - -fx-background-color: #E0E0E0; + -fx-background-color: -monet-secondary-container; } .jfx-progress-bar > .bar { - -fx-background-color: -fx-base-check-color; + -fx-background-color: -monet-primary; } /******************************************************************************* @@ -790,18 +879,26 @@ *******************************************************************************/ .jfx-text-field, .jfx-password-field, .jfx-text-area { - -fx-background-color: #f1f3f4; + -fx-background-color: -monet-surface-container-highest; + -fx-highlight-fill: -monet-primary; + -fx-text-fill: -monet-on-surface; -fx-font-weight: BOLD; - -fx-prompt-text-fill: #808080; + -fx-prompt-text-fill: -monet-on-surface-variant; -fx-alignment: top-left; -fx-max-width: 1000000000; - -jfx-focus-color: -fx-base-check-color; + -jfx-focus-color: -monet-primary; -fx-padding: 8; -jfx-unfocus-color: transparent; } -.jfx-text-area .viewport { - -fx-background-color: #ffffff; +.jfx-text-field .context-menu { + -fx-background-color: -monet-surface-container; + -fx-text-fill: -monet-on-surface; +} + +.jfx-text-field .context-menu .menu-item:focused { + -fx-background-color: -monet-secondary-container; + -fx-text-fill: -monet-on-secondary-container; } /******************************************************************************* @@ -815,7 +912,6 @@ } .jfx-list-cell, .list-cell { - /*-fx-background-color: WHITE;*/ -fx-background-color: transparent; } @@ -825,13 +921,13 @@ } .jfx-list-cell .jfx-rippler { - -jfx-rippler-fill: -fx-base-color; + -jfx-rippler-fill: -monet-primary-container; } -.list-cell:odd:selected > .jfx-rippler > StackPane, -.list-cell:even:selected > .jfx-rippler > StackPane { - -fx-background-color: derive(-fx-base-check-color, 30%); -} +/*.list-cell:odd:selected > .jfx-rippler > StackPane,*/ +/*.list-cell:even:selected > .jfx-rippler > StackPane {*/ +/* -fx-background-color: derive(-monet-primary, 30%);*/ +/*}*/ .jfx-list-view { -fx-background-insets: 0.0; @@ -893,7 +989,7 @@ } .card { - -fx-background-color: rgba(255, 255, 255, 0.8); + -fx-background-color: -monet-surface-container-low-transparent-80; -fx-background-radius: 4; -fx-padding: 8px; @@ -901,47 +997,56 @@ } .card-non-transparent { - -fx-background-color: white; + -fx-background-color: -monet-surface-container-low; -fx-background-radius: 4; -fx-padding: 8px; -fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.26), 5, 0.06, -0.5, 1); } -.card:selected { - -fx-background-color: derive(-fx-base-color, 60%); -} - .card-list { -fx-padding: 10; -fx-spacing: 10; } .md-list-cell { - -fx-border-color: #e0e0e0; + -fx-border-color: -monet-outline-variant; -fx-border-width: 0 0 1 0; } .md-list-cell:selected { - -fx-background-color: derive(-fx-base-color, 60%); + -fx-background-color: -monet-secondary-container; } .mod-info-list-cell:warning { - -fx-background-color: #F8D7DA; + -fx-background-color: -monet-error-container; } .options-sublist { - -fx-background-color: white; + -fx-background-color: -monet-surface; } .options-list-item { - -fx-background-color: white; - -fx-border-color: #e0e0e0; + -fx-background-color: -monet-surface; + -fx-border-color: -monet-outline-variant; -fx-border-width: 1 0 0 0; -fx-padding: 10 16 10 16; -fx-font-size: 12; } +.options-list-item .svg { + -fx-fill: -monet-on-surface; + -fx-border-color: -monet-outline-variant; +} + +.options-list-item .label { + -fx-text-fill: -monet-on-surface; +} + +.options-list-item .label.subtitle { + -fx-text-fill: -monet-on-surface-variant; +} + .options-list-item.no-padding { -fx-padding: 0; } @@ -970,14 +1075,53 @@ * * *******************************************************************************/ -.jfx-toggle-button { - -jfx-toggle-color: -fx-base-check-color; +.jfx-toggle-button, +.jfx-toggle-button:armed, +.jfx-toggle-button:hover, +.jfx-toggle-button:focused, +.jfx-toggle-button:selected, +.jfx-toggle-button:focused:selected { + -fx-background-color: TRANSPARENT, TRANSPARENT, TRANSPARENT, TRANSPARENT; + -fx-background-radius: 3px; + -fx-background-insets: 0px; + + -jfx-toggle-color: -monet-primary; + -jfx-toggle-line-color: -monet-primary-container; + -jfx-untoggle-color: -monet-outline; + -jfx-untoggle-line-color: -monet-surface-container-highest; + -jfx-size: 10; +} + + +.jfx-toggle-button Line { + -fx-stroke: -jfx-untoggle-line-color; +} +.jfx-toggle-button:selected Line{ + -fx-stroke: -jfx-toggle-line-color; +} + +.jfx-toggle-button Circle{ + -fx-fill: -jfx-untoggle-color; +} + +.jfx-toggle-button:selected Circle{ + -fx-fill: -jfx-toggle-color; +} + +.jfx-toggle-button .jfx-rippler { + -jfx-rippler-fill: -jfx-untoggle-line-color; +} + +.jfx-toggle-button:selected .jfx-rippler { + -jfx-rippler-fill: -jfx-toggle-line-color; } + .toggle-label { -fx-font-size: 14.0px; } +/* unused */ .toggle-icon1 .icon { -fx-fill: #4285F4; -fx-padding: 20.0; @@ -1004,6 +1148,7 @@ -fx-background-color: transparent; } +/* unused */ .toggle-icon2 .icon { -fx-fill: RED; } @@ -1013,6 +1158,7 @@ -jfx-mask-type: CIRCLE; } +/* unused */ .toggle-icon3 { -fx-toggle-icon4-size: 35px; -fx-pref-width: -fx-toggle-icon4-size; @@ -1037,6 +1183,7 @@ -jfx-mask-type: CIRCLE; } +/* used */ .toggle-icon4 { -fx-toggle-icon4-size: 30px; -fx-pref-width: -fx-toggle-icon4-size; @@ -1047,7 +1194,7 @@ -fx-min-height: -fx-toggle-icon4-size; -fx-background-radius: 50px; -fx-background-color: transparent; - -jfx-toggle-color: -fx-base-check-color; + -jfx-toggle-color: -monet-primary; -jfx-untoggle-color: transparent; } @@ -1057,7 +1204,7 @@ } .toggle-icon4 .jfx-rippler { - -jfx-rippler-fill: -fx-base-check-color; + -jfx-rippler-fill: -monet-primary; -jfx-mask-type: CIRCLE; } @@ -1071,7 +1218,7 @@ -fx-min-height: -fx-toggle-icon-tiny-size; -fx-background-radius: 25px; -fx-background-color: transparent; - -jfx-toggle-color: -fx-base-check-color; + -jfx-toggle-color: -monet-primary; -jfx-untoggle-color: transparent; } @@ -1081,7 +1228,7 @@ } .toggle-icon-tiny .jfx-rippler { - -jfx-rippler-fill: -fx-base-check-color; + -jfx-rippler-fill: -monet-primary; -jfx-mask-type: CIRCLE; } @@ -1098,7 +1245,7 @@ } .announcement-close-button .jfx-rippler { - -jfx-rippler-fill: -fx-base-check-color; + -jfx-rippler-fill: -monet-primary; -jfx-mask-type: CIRCLE; } @@ -1112,28 +1259,62 @@ * * *******************************************************************************/ -.log-toggle:selected { - -fx-background-color: transparent; +.log-toggle { -fx-border: 1px; - -fx-border-color: black; - -fx-text-fill: black; + -fx-border-color: -fixed-log-toggle-unselected; + -fx-text-fill: -fixed-log-toggle-unselected; } -.log-toggle { - -fx-background-color: transparent; +.log-toggle:selected { -fx-border: 1px; - -fx-border-color: gray; - -fx-text-fill: gray; + -fx-border-color: -fixed-log-toggle-selected; + -fx-text-fill: -fixed-log-toggle-selected; +} + +.log-toggle.fatal { + -fx-background-color: -fixed-log-fatal; +} + +.log-toggle.error { + -fx-background-color: -fixed-log-error; +} + +.log-toggle.warn { + -fx-background-color: -fixed-log-warn; +} + +.log-toggle.info { + -fx-background-color: -fixed-log-info; +} + +.log-toggle.debug { + -fx-background-color: -fixed-log-debug; +} + +.log-toggle.trace { + -fx-background-color: -fixed-log-trace; +} + +.log-window-list-cell:selected { + -fx-background-color: -fixed-log-selected; +} + +.log-window { + -fx-background-color: -monet-surface-container; } .log-window .scroll-bar .thumb { - -fx-fill: rgba(0, 0, 0, 0.5); + -fx-fill: -monet-surface-tint; +} + +.log-window .jfx-button { + -fx-text-fill: -monet-on-surface; } .log-window-list-cell { - -fx-text-fill: black; + -fx-text-fill: -fixed-log-text-fill; -fx-border-width: 0 0 1 0; - -fx-border-color: #dddddd; + -fx-border-color: -monet-outline-variant; } .log-window-list-cell:empty { @@ -1141,31 +1322,38 @@ } .log-window-list-cell:fatal { - -fx-background-color: #F7A699; + -fx-text-fill: -fixed-log-text-fill; + -fx-background-color: -fixed-log-fatal; } .log-window-list-cell:error { - -fx-background-color: #FFCCBB; + -fx-text-fill: -fixed-log-text-fill; + -fx-background-color: -fixed-log-error; } .log-window-list-cell:warn { - -fx-background-color: #FFEECC; + -fx-text-fill: -fixed-log-text-fill; + -fx-background-color: -fixed-log-warn; } .log-window-list-cell:info { - -fx-background-color: #FFFFFF; + -fx-text-fill: -fixed-log-text-fill; + -fx-background-color: -fixed-log-info; } .log-window-list-cell:debug { - -fx-background-color: #EEE9E0; + -fx-text-fill: -fixed-log-text-fill; + -fx-background-color: -fixed-log-debug; } .log-window-list-cell:trace { - -fx-background-color: #EEE9E0; + -fx-text-fill: -fixed-log-text-fill; + -fx-background-color: -fixed-log-trace; } .log-window-list-cell:selected { - -fx-background-color: #C4C4C4; + -fx-text-fill: -fixed-log-text-fill; + -fx-background-color: -fixed-log-selected; } /******************************************************************************* @@ -1177,7 +1365,11 @@ .jfx-spinner > .arc { -fx-stroke-width: 3.0; -fx-fill: transparent; - -fx-stroke: -fx-base-color; + -fx-stroke: -monet-primary-container; +} + +.jfx-spinner .arc { + -fx-stroke-line-cap: round; } .first-spinner { @@ -1269,11 +1461,35 @@ .jfx-combo-box { -jfx-focus-color: transparent; -jfx-unfocus-color: transparent; - -fx-background-color: #f1f3f4; + -fx-background-color: -monet-surface-container-highest; -fx-padding: 4; -fx-max-width: 1000000000; } +.jfx-combo-box .text { + -fx-fill: -monet-on-surface; +} + +.jfx-combo-box .text-field { + -fx-text-fill: -monet-on-surface; +} + +.combo-box-popup .list-view { + -fx-background-color: -monet-surface-container; +} + +.combo-box-popup .list-view .jfx-list-cell { + -fx-background-color: -monet-surface-container; + -fx-text-fill: -monet-on-surface; + -fx-background-insets: 0.0; +} + +.combo-box-popup .list-view .list-cell:odd:selected > .jfx-rippler > StackPane, +.combo-box-popup .list-view .list-cell:even:selected > .jfx-rippler > StackPane { + -fx-background-color: -monet-secondary-container; + -fx-text-fill: -monet-on-secondary-container; +} + .jfx-combo-box-warning { -jfx-focus-color: #D34336; -jfx-unfocus-color: #D34336; @@ -1283,12 +1499,157 @@ -fx-fill: #D34336; } -.combo-box-popup .list-view .jfx-list-cell { - -fx-background-insets: 0.0; +/*.combo-box-popup .list-view .jfx-list-cell .jfx-rippler {*/ +/* -jfx-rippler-fill: -monet-primary-container;*/ +/*}*/ + +/******************************************************************************* +* * +* JFX Color Picker * +* * +*******************************************************************************/ + +.jfx-color-picker:armed, +.jfx-color-picker:hover, +.jfx-color-picker:focused, +.jfx-color-picker { + -fx-background-color: TRANSPARENT, TRANSPARENT, TRANSPARENT, TRANSPARENT; + -fx-background-radius: 3px; + -fx-background-insets: 0px; + -fx-min-height: 25px; } -.combo-box-popup .list-view .jfx-list-cell .jfx-rippler { - -jfx-rippler-fill: -fx-base-color; +.color-palette-region { +} + +.color-palette-region .jfx-button { + -fx-text-fill: -monet-on-surface; +} + +.color-palette { + -fx-background-color: -monet-surface; +} + +.custom-color-dialog .jfx-tab-pane { + -fx-background-color: -monet-surface; +} + +.custom-color-dialog .custom-color-field { + -jfx-unfocus-color: -monet-on-surface; + -fx-background-color: TRANSPARENT; + -fx-font-weight: BOLD; + -fx-alignment: top-left; + -fx-max-width: 300; +} + +.custom-color-dialog .tab .tab-label { + -fx-text-fill: -monet-on-surface; +} + +.custom-color-dialog .tab .jfx-rippler { + -jfx-rippler-fill: -monet-on-surface; +} + +.custom-color-dialog .tab-selected-line { + -fx-background-color: -monet-on-surface; +} + +/******************************************************************************* + * * + * Table View * + * * + ******************************************************************************/ + +/* WE DEFINITELY NEED JFX TABLE-VIEW */ + +.table-view { + -fx-padding: 3; + -fx-background-color: -monet-surface-container-low; +} + +.table-view, +.table-view .column-header, +.table-view .filler, +.table-view .column-header-background, +.table-row-cell { + -fx-background-radius: 6; + -fx-border-radius: 6; +} + +.table-view .column-header, +.table-view .filler { + -fx-background-color: transparent; +} + +.table-view .column-header-background { + -fx-background-color: -monet-surface-container-highest; +} + +.table-row-cell { + -fx-background-color: -monet-surface-container; + -fx-table-cell-border-color: -monet-surface-container-lowest; +} + +.table-row-cell:odd { + -fx-background-color: -monet-surface-container-high; +} + +.table-cell { + -fx-background-color: transparent; + -fx-text-fill: -monet-on-surface; +} + +.table-view > .virtual-flow > .clipped-container > .sheet > .table-row-cell:filled:selected { + -fx-background-color: -monet-secondary-container; +} + +.table-view > .virtual-flow > .scroll-bar { + -fx-skin: "org.jackhuang.hmcl.ui.construct.FloatScrollBarSkin"; +} + +.table-view > .virtual-flow > .scroll-bar .track { + -fx-fill: transparent; +} + +/******************************************************************************* + * * + * JFX Tree View * + * * + ******************************************************************************/ + +.tree-view { + -fx-padding: 5; + -fx-background-radius: 6; + -fx-background-color: -monet-surface-container-low; + -fx-border-radius: 6; +} + +.tree-cell { + -fx-padding: 5; + -fx-background-radius: 6; + -fx-background-color: transparent; + -fx-text-fill: -monet-on-surface; + -fx-border-radius: 6; +} + +.tree-cell:focused { + -fx-background-color: -monet-secondary-container; +} + +.tree-cell .arrow { + -fx-background-color: -monet-on-surface; +} + +.tree-cell .jfx-rippler { + -jfx-rippler-disabled: true; +} + +.tree-view > .virtual-flow > .scroll-bar { + -fx-skin: "org.jackhuang.hmcl.ui.construct.FloatScrollBarSkin"; +} + +.tree-view > .virtual-flow > .scroll-bar .track { + -fx-fill: transparent; } /******************************************************************************* @@ -1298,7 +1659,7 @@ *******************************************************************************/ .jfx-decorator { - -fx-decorator-color: -fx-base-color; + -fx-decorator-color: -monet-primary-container; } .jfx-decorator-drawer { @@ -1306,24 +1667,9 @@ } .jfx-decorator-title { - -fx-text-fill: -fx-base-text-fill; - -fx-font-size: 14; -} - -.jfx-decorator-title:transparent { - -fx-text-fill: black; -} - -.jfx-decorator-tab .tab-container .tab-label { - -fx-text-fill: -fx-base-disabled-text-fill; -fx-font-size: 14; } -.jfx-decorator-tab .tab-container:selected .tab-label { - -fx-text-fill: -fx-base-text-fill; - -fx-font-weight: BOLD; -} - .window { -fx-background-color: transparent; -fx-padding: 8; @@ -1335,15 +1681,11 @@ } .content-background { - -fx-background-color: rgba(244, 244, 244, 0.5); + -fx-background-color: -monet-surface-transparent-50; } .gray-background { - -fx-background-color: rgba(244, 244, 244, 0.5); -} - -.white-background { - -fx-background-color: rgb(255, 255, 255); + -fx-background-color: -monet-surface-transparent-50; } /******************************************************************************* @@ -1383,6 +1725,14 @@ -fx-shape: "M298 598l214-214 214 214h-428z"; } +.jfx-combo-box .scroll-bar { + -fx-skin: "org.jackhuang.hmcl.ui.construct.FloatScrollBarSkin"; +} + +.jfx-combo-box .scroll-bar .track { + -fx-fill: transparent; +} + /******************************************************************************* * * * Scroll Pane * @@ -1406,7 +1756,7 @@ .scroll-pane > .viewport { -fx-background-color: null; - } +} .scroll-pane .scroll-bar { -fx-skin: "org.jackhuang.hmcl.ui.construct.FloatScrollBarSkin"; @@ -1423,28 +1773,28 @@ *******************************************************************************/ .error-label { - -fx-text-fill: #D34336; + -fx-text-fill: -monet-error; -fx-font-size: 0.75em; -fx-font-weight: bold; } .error-icon { - -fx-fill: #D34336; + -fx-fill: -monet-error; -fx-font-size: 1.0em; } .jfx-text-field:error, .jfx-password-field:error, .jfx-text-area:error, .jfx-combo-box:error { - -jfx-focus-color: #D34336; - -jfx-unfocus-color: #D34336; + -jfx-focus-color: -monet-error; + -jfx-unfocus-color: -monet-error; } .jfx-text-field .error-label, .jfx-password-field .error-label, .jfx-text-area .error-label { - -fx-text-fill: #D34336; + -fx-text-fill: -monet-error; -fx-font-size: 0.75em; } .jfx-text-field .error-icon, .jfx-password-field .error-icon, .jfx-text-area .error-icon { - -fx-fill: #D34336; + -fx-fill: -monet-error; -fx-font-size: 1.0em; } @@ -1463,7 +1813,7 @@ } .html-hyperlink { - -fx-fill: blue; + -fx-fill: -monet-primary; } .html-h1 { @@ -1485,3 +1835,18 @@ .html-italic { -fx-font-style: italic; } + +/******************************************************************************* + * * + * Tooltip * + * * + ******************************************************************************/ + +.tooltip { + -fx-text-fill: -monet-inverse-on-surface; + -fx-background-color: -monet-inverse-surface; +} + +.tooltip .text { + -fx-fill: -monet-inverse-on-surface; +} diff --git a/HMCL/src/main/resources/assets/img/github-white.png b/HMCL/src/main/resources/assets/img/github-white.png new file mode 100644 index 0000000000000000000000000000000000000000..e7cc199bc7c9630f30ca3355e0281202941b0b31 GIT binary patch literal 851 zcmV-Z1FZasP)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00002VoOIv0RM-N%)bBt010qN zS#tmYH(US!H(UYB$E3Lc000McNliru=?fGMAOlfh_jmvR0=P*;K~y-)jg`-9R96(o zKkvoNXrpPwsMQeLPK*lHLY1yW(NQ;=xG}aCT2K)5FDP_TbSX$TVvA4$r3lhZRw)!3 z1zoxD2TdV@;Ex%TA*M}9s3tM3^XVebn>T63=l3q&Ip?15_uY5Tz2`;Lz_~|`0w3`q z@9{P;L5V@`@{n18h`JE5#Uozvku#<-<0G%wV~ej|vM9OHE(aD+Yh|w5W#b|uBDQ+o zNwYBNc~}2WLfl}V)l9l-)jl`4fWF@THzL16zw1rWo9s;{#vJnRy0m8wSxM|(n-U2l zPx&GVf8|<>e($(CO)YU}Qjt0X+~%Jl|BP`=8%M%!E~O2k*9>4C%R_z- zZ83Z341qrwCJUofg5AlUnEm8KpqHDO0>5*SYGUWvhlAmZ#@%5iU)fQ64fQd0m0SJ7KA+X}hs)*>i%zJw-0c=` zsru3}FBpnt&T~FZ`oKIU+$V@RFR40Yhx;8)`ra|4)F*T;(yK3-Gnwx2j;h}nyUTC= z$**m#*@Hsr{LkTzg>+XeT2?C_NH>pE1=@XBLoZuQWr&g8medh1c-&9@+?Lk}ls#;; zu(9LbWDB+GupM;;Zd7{ literal 0 HcmV?d00001 diff --git a/HMCL/src/main/resources/assets/img/github-white@2x.png b/HMCL/src/main/resources/assets/img/github-white@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..94427900199d67675a7c2926a2f8b52f750f89a2 GIT binary patch literal 1648 zcmV-$29NoPP)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00002VoOIv0RM-N%)bBt010qN zS#tmYH(US!H(UYB$E3Lc000McNliru=?fGMA34vn1oi*`1=UGJK~z}7wV7LNR8#XBaih|@; zo_zAjtAX-J>QF!}`d3YAQlFS z)#;8%)^QT!kfeU9)LhL}sRr~iJfQPBs)MRkTdL78f+Ge}%>izcirnS^iy1_Seg@#V zEaV97DS>y(eU7n++%!obT=>~dTUy}V(#mcsaivTFk;^i!WCY$V^(-Yf1qujJ$o4b? zZ<2PlQJCabAjAX?IR@S>hbT*e0zy>si{tPBr>N+K0zypUx5okx@H^!RC?G^JCm$O; zz)8m0Qb73l>aoHDe1#7i_$)Tk6*K%g_1qztCfHr-xf}eOCv(jZ$mPUt(`cqMf}Ggwj&PAp0Z&sFZ9~lA zew^oPJlV4ih%9y*;JbJ=gU9p{fhpWGc;8MRBw3QA3e7jpwlc<-B$u%+nXd{-vXG=j z8ft|1DU*VtTZSmxpe<0fge*x#dckJuM7cA=Cgdf{Xq=~_P>`!sVl%%`eKTIar4crk z5>-i-EX}lGcuOCtHDdxneX2U6B}=npi2_dBWV3Zxj@$zA8XZQ2(-eqjX)zklGRA=e z7@{zMAB-3+Ot(~NQ|a$ly(9RN6wn?84f_5nw^U}l?QJ^jz-NV|u$SYyW-z%`X{ppW zt3l@+TfZbV+8o4UOHbGkxZ@~+|8&KuFR4h{9g{@)?Z{mQ99|X4+(kbR=7IY;M(gOSLvP)UO$i z5%B9dqrOvTEY<3^(U_xU@;Xru$fG41Vl>{?8R6xGO+nJhTHKjYK!{h2WjVlcyh3co zPMTSqMuRCDeg>Cq{eOsrn4O>#c7vtlq(=eKhXq`;_1(to5P`v*f&|01Ga=RL5aD7p z8)#18`DeU@!?ZR+eTMgVpMz2RR?jA?$WLZF5H515;9bu4(pPvZ{B?*Z;Zl!!AA=CN zRL6YRx9}sIc!{SNX=pGA;ieEj3t7h@ZuZ*LxJ)S#oY(gl9`a?Vo5d6^#7%ohfSu$U z31qXL7CK2zHble|Q9@mW@elk6@v`9p-QpQ@n+Y*7!HJKSx}Np-T$hzRj2LhTA0mfO z;->C4Z9q>XHzx)Ekk?}tff&s95ktCIjSvOwjcULISz_PmL(H}L28x#N@W%Iu#55YB zf;5g$>uwFT+@*t7>e z7!h%w%+k&Zo9m3oWlKcB|D-gvpACqj1eq_itdA44?h!urL<~R3_*5t)L0~UKNa#Qm zMzw4IVl|T(L^f_b44@DX=@ZyTQ8NAVLOzekV?l26E8no6Z#l(@UJju>fgoQnJlQ`6 zL)mKR?S)QoNm>N%vyEX%ZzrN3tGH=!)EJe)Oa%Tm%xdCI@=|(F5pHJkQ(X7_24x8e zjG{g+^e1>ejmj#V2jAhJln9KD`P0_O2TA{~GCH&%^WhBmdy6_avrG-p$2mAiiBC?YYD;#){_lp}IW z?z7w!`X)w>RcNH~oA3Ag`{(z1Jf838^L4&o|GnRFHdZHhiOPrq0I-WhB-rv z+5b%4>40SaGbx3ycMQy*1OB_x7JHPh{_WDz)ZEEh^feMy5=XKD04hl$n39QYkIsa~ zyW0P;-IE!_;XU4*3~>4|L3gTNs%RnmQBlNiAnzOm5m18e7xJ(;q7@C3N2O%%H)_J% zR*WT3ZxF2<<72KTkZkhyZcRZ>})ZyxT z^>!!rPO#P^RnWjog{r6-$ld;J=_nBZ_X}O~PASYN!$etET%};Qr?K7*T`_sFa3ev~ z0LSa=esio>>groC^twGD)aaghnilRRqVO(re{WQKL)iv$prPh)H`wMxyu>DE-qPo^ z_GKT4#Z5@Hjr2z^m_Y30Yi>;qr`~%3tv~r~=6$5`#Y?xweJRlB!6cc?kcJwpL)K8k zZc8K-@Kgu({>GVinmlhxc1*gSugR^kB_mGdvLjh~UCVyxTTg_vymBjI zGmC$9=vWX~tdJgWp_gVFY(HW9<~!#uEiIYP(ZGUVr7>-XdA|bvc2^<;UKlRih}|TF zQ`)kI$-aK@G~cqzSsT;?@6?e9WZ{U6z*9fT^MF8EO!61+x>+kdgF}9HlH<3WJONOO zBJ29AOW}{*D~uvYe+KY2rfS*mbSjP3x?+MTo4*1k>O+>ArQX6_Lwj1kZOjDMOA`qz8)iuQc(NKiX}Bp<>1B-4S=Xp2keV$zGV+&U zmQVNIf;Q3fYhO1%hvB9)Efgt8$7Du7s5v+N*6n7B^05d8Rt`&~U}V)v$G-@`B{43g zF4iqJQZT&aQa3J;;`a0&L-tMHrQgld694%CWEo+*I_m^@76gl_`eh+@sbOTuKu_gI z+7sv}vEm5##UQwrKvXR3-ue9VdmypuwXEH_6}B?|1+o|96qYgDzyFN!ch~NVTK)MG z?5!s=q49E~7)hREDoj<(FQnzjRq}4ZY11_8ntidseYLFdJ~+(O?Udu8yJV3ks#{^x z#I@I@aQNvAoxH!o<6?nQW?VZK1mv(XeDc()Qy$M!it=EWqV=2yyYI?o zjO!bOP++&lwvL_5GhE7ES@qyTTUZP_BctfR+Lv_kMm!$hbEZts!C}7Q)MUld3;C+9 zQ_!^I?jB}$fS>zUgq}l?hQ;*MXG+5-~+nG*gZnGr2WvJBzxsf{XCa8+!Wa{I_9pNn5iI)*npY1YEZD6(*PvOe%I@;yxa|s3GxF`xGNH<>0D7HvP|1{mgW)3Se=72cgbaryJ_tGSzqNf z;$)~gV$n)^I>5lEzM;SHEq6q{>01t2Umv_q4VX=o!`-DVX2vS}G!fKOR7l$<71KjS zGvv-a{mrdrAE)rqX+b4(;q^%YjO4c`?`lCzQx2zer#iTv*(W32(V2VQaCvl5#o=7$ z!^DADL)~cycy!+ES3A3y*0e-bQYQ9X3AH6)DtzcH+D7VCxG93+jG*yS1JPDi53XTI zl%vA4Cywdi6oQX`Z#ObjH3v0U7R0Pj6j4&h^8V^!M|AG(tRUg3W1~6-ul+ca9iJQh zi)Q!D1SVp+h1bDK<%{E%4=dW9gbfompiBWzxdPZx&l*WmG*)tplS+BTxG7oKfc z3DLyFYsmKGoKFv>)k1AdH0Urin_~3fk88pxO9OLHLTtM?Q&kFuFC=&t*$+`m#G#FP zF~mCCR*T>a{5~}RLLNGM@UeWp9u(h~&Q0eQJ~@E}uz|7&kE51$-#<5lK0beRQyW-l z5hIU1|LU`R!fR22nAuub2vbB;+W-@47)!9P`$^P6B>~_jP>45gw#jEa6D(k{G?5;N zC@xhz^e~vyf<(~JO@c!10+`-{5C{QuBVYLD^Daeq>z>Ymz*gz+sIB;2jngR$Dhs8J q7TQ_s%N5$`b|L^M=b)&{yGT%PF>N~C1vIzwRUnyJ5sHty-}o>6KQ+_< literal 0 HcmV?d00001 diff --git a/HMCL/src/main/resources/assets/img/legacyfabric@2x.png b/HMCL/src/main/resources/assets/img/legacyfabric@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..dcc9bfcf719629ae03e4b628c8c8cbdc9b81b03f GIT binary patch literal 3575 zcmZ8jcRUpS|G(od`!YXB*>Wh3v&Xr^aVL8eGRsk9Wz=Plj1amb+mVv&J_aYbuJZ%yzt=tkqJ6>XWH~ZF_~iUx|(L2IOx@ zHBraPtuqA>gKJ5Ku zmGu?nvf*vbvUeO#>Ocz)dP_kgGS$BGJFa!_e*Kh+lC%NpvVg{At4EQxlvCpC?WC4P zE-zopjuUlK*Z4HW6a#B(csf2&nx5$hj|bij}NQIWRY!$WxxK=8AF`nyPa+T&<;V+i_@d%uiI6mps-oQC2|9ga>=& zrAHMPg_MvU+1cm~QVP&PVG354G5T;*1FfWp;LOxeoP)uWNB&PJyO!pf0bVAd!MIyJ zrx-i)r0#DCJ^RUhzvCz;;a#U5eLwB3l^oET9;TRU=*z~5FYQvY#KIrQtMYsHe!lfE zzNRGWileG`0?|lY$k0?lM2VkKkCV?vQr1KJdT8hkQyE=jC`5_zj5dpxCt3v~q;}su z=PBL-DXgO_W@#qFFUP2f5P(8iL7mZWMtY$Xd5zThBf{-N!>*N; zMny!L-NT#2#k$1B+dd(9LQ@m-@gLXu4t~AYTvoOb0;HYRmMcEdY}* z#?~JIfb{$YaKPuKD=o+ufVK&+^l=Ng=W-VZFmt)>9UyChwh~s7m6uh!<2{d{)imO4 zux?naG=P!jfC1-#U;vP&3~3PfpZ>dkP$2!kaXOlo2hsu9X>3Qs?KsfCaSxjQYtA(M z?f*aif0qJi3!KUT0MRO73UC;Z^xvs}JHVv>P9oCuU!$YVq5HSeLQR#X{?{cBgZ<4K zjZrWXDKjM6%NwwIC@Vhx=Utw@R$oFXpBAZF+xc}W6|!Gz=~(!I%4cpQwPf0fKaS~R ztcEwF8zpKG0tQ*@y}#dQefK$0TQSQ|0h{YbxQ|gNw3H;|SZ>^gVI&&4$)7#CX1n?P z3G52hC=$}NNn2M=z)_)wU3zkF@P0CR*7l>5Nh9iO-g2%+fBJt`qi08 z&1RltG4G4-QU~6sF|?fFOayN)dquohg1mSxEMFq+k?0sEtPsJ*G+ZOo_p*M)4({gU z25IsyIwJBTIV9!JFlUajS^HRD6#X=n_Waq9S}MaGof4 z@{+mFK4)6=-xEx=wp-Xw8A1{B)!K+}G;Nq~Rle5A9F}46A%k3o%KJmH9-@g5CAI~* znHx_&MW>m{t|xa`%?&5M-V&74KK3?%dgfYO_kSj+oa}uqEJ|@GyS6k5{yYI_w8bzRu18YaiyA7)f-W zJzqQ%ds+0OGac?|!)?#!p&#dIcmQb8ah$w)$Hysf)RyiyU{0sYSF_t!^X~HUpE1ms zZMOIJFBPv8acqqg`Up}a>vRlpQt$uZGqAMa>5_dcHO@T+@Wc3r4{EeM$+(FSGJFvf z;upu#-+#mI9@l~3X30`n#2x%67Flrc%$9=yez#P2v$dqX%-H|O6sGpMdubGlS!dnN zKU^`I#}=EpUj+{@s(k&t>xT^XthpWb^Ovqc#)V1*&tl4(Hh|Ti^UA1KVVzd7(Jb~y zb)MX_c*(dt21!Y6UA>^-TO^rdHhkgAQP+GcBfVbc=b#NP-Tl-4`+FstpSr_7;_dE2 zBs^r?W(c~+oPxSIDEjgHJCCHX7bUZ%-ymSlmOG;wJw@GyQqE_OkE*m=5C<*1yE}(3 z#%AooQ3x1dc0p06f)#4WSlZDeX(yJx^t=nOO6q zleM&n8=1wue9ZskX>X-n;ntSc!epnHh-YZ|4KJ8gs5wpU2hfDYnPkr*omJjD@EvLZMXq(9OR6b(t@> z)JX7x@|1xC2pet*0ox9HM1D19=&%*Ju-v6p)+l8Axga;N?ON#4@An0rJFiD_RKmYq z|L~O8e3DL!sCf*PIOx(%2zf}=f8f?fA9U0y$nfPPo7^~DKs|Xp5U(&=Gw&d^Fam38 z+GuYgq|P%wD=f=W{c{bmH}mwfVgd%@dv|oQ32`_e;?kG@BjKy-)`-d{7v`dI z0&a!77&@z#DFPScN&whaEOtvpHhMlych+iDIa}z7f;geq?lvMdp8F5Vz@2X9(XvIk4bb=;@ALISdwN3hae6uU-Ex zXZ_+Q5K#|lwM!=Wf1TWPIB}lu(RK`SoqC$Uc~gNIw-WK*v|curx~^Ll66QA3x~R2$ zHxFoZuk6I}g#)C$rszuXi)|6dtlXDb<+0>$+X0*_=;K3%Pdrzv#Fjk<#`-~HI`T?Z z@Q3sPqCUxOCdRW4hgWQ7hK9Cl@0EDN1?(Xue!#DDKzq1MUw^lj zf!1zi270Vu5CvJU{%I%qSNgTLc9!SZ;i$6i1~O&CPJ0zf>g57o(pwyNa;AECGURV$ zOtxHci`5n3T77=DHlpH>qKoKB=;pKZ&wHk&K1Is{l%jWIM1!H&x?-8BUZ0s#Qr$_* z-0%Dk^V8l()84CmD@1b`>pB#?HaBd1)iRWe=RK)r{Reyvci&t5!ss+>oLDo*Li4Kb zU1@c-f`f)sfL^g6{M}RGfak4cf%T^aVMdBY^YyklKZxfr1c4vi`+Sv}N}T4dgg<1It@ zNY9!22ze19kyQ3bk}+}#e~$j~u5P3}{;l9)MoE;^8a@Wf&U}fVfoU&er?C72pIIc7 zE|Ct6jARA@_aZh6r8uv+j}NaMfEoEj08U_$J%p+n#XLgw3`)$IZl^&%l5jJ7jb8Ad zLvCAOR5GX^xOnY}BxtpIS1Qd@*a*%j908Q4SjE#NGKoSdC6xUdaEPH|{J0UokCiJV z=B!Jm@YXT%jWAz@RCO);KGR#Z8$`ZGqI^i-ge6XJRD4bXupi1Tp+g)>;QJ?AI~6S2 z%Lv_LGQZKx!^TB4GF!D+wcrNdjR7!$R3Q8}xN-4Zx`U)6g6lHSPZ+{q31{3{td0Iy Uc{W@5;O}P+i#E}#)Nzjb4`9|-6aWAK literal 0 HcmV?d00001 diff --git a/HMCL/src/main/resources/assets/img/nbt/TAG_Byte.png b/HMCL/src/main/resources/assets/img/nbt/TAG_Byte.png index 02ef065ad5c359671e4d6f28dd9f355d9e22d986..c24398c91a168ba787cd0af048a09fce75747677 100644 GIT binary patch literal 523 zcmV+m0`&cfP)$4o8OcGD2#KZ2($?nX{@6OEe z)6(+T*7V)n_vYpJ>gxII>-p^L`tR@h@bLTc^854h`}Fht_VxVt_xcawyRQr=Ta2`OV|768@Oi$V9MXZ0~k|4-T-S7 zn1D%vs13BmAd+7J`mKiFVLcZd;}&W zaDgEOYOG1aAIvo^QyzH0IZoaK+!8sIx6Jtt)iiiI^SqQmFNbbE!Fc2gS5VGAQ>J_S0BnXJ0AZ@LsEr|O@)S!Y?h%sye zmaVd=Y>J?ege-*MR$CBhZD|X%wbhFKQ>W9Jwsx?JN_sofKmE~j=KG#`-F!@1dGV27HWgUfJiz$p!Kgw<d&6RSgB1PjXcwX$gwE`b+V4$FJh*H*H>pWR>M*&svLkZ2TyK*XcYl`E2{7a zurYJP)Z~<-tz*pLdOG{$*O{4)n3Qf#lnc=sAo|HbK^HZ2^mv`Mwe2u1)WQtAT3kNT ze6EEhaU$F-rDs{vMxLyWCu!h@pMfKLQI(Uca`F@|SncFSA7s%AcngbolB0ZS5iBW2BqlVn z2#F}*hZUmYLT*SQTX>iyG%^?LXNDdM5N7i@>hqEw;vbVXrzGleY-lKv5Hw?QY69(N zlGu+IGx6{-pRhQ&IcbLouKc$M0D#HdL?!!3fG12!j`hLPEtLY+<z7B9h<#>Bq5Y`Nw&uwrH0 zs(4(Vuo_QHN?wz)7Nn-dty_<$XKcuP{f#%*Y%m+`)StIa+D98Q;e&@<#6 zzH;^2^6NJ~0yjVYWMuT#*zNI8KfCk!7ZYECufCp~n*Qe7@9uv8!@c`IKA1`8>=7y- zip|8Hq{f7e#w|IbFuCl`z<&aJ^S12V WR`CCUO-3U`1VC~^n%=r9`}p6#61IT= diff --git a/HMCL/src/main/resources/assets/img/nbt/TAG_Byte@2x.png b/HMCL/src/main/resources/assets/img/nbt/TAG_Byte@2x.png index d74c1bd5679c68c632bdfacebc709994da979c6c..f14d0c570672e6bf3e395e186ee32333127bd15e 100644 GIT binary patch literal 768 zcmV+b1ONPqP)k8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10XR@h zR7HFP3ByT4!%9TNOGU&?M#N1=#ZO7aPfEs8O~zAA$5czO$XHUySyahe zRmoge%3WB>URcUrS<7Ku%VJ&2V_wW-Ue0S~&unMUZD`MKY0z+M&~k0ja&FOcZqj#i z(|B~#dUe!%c-Db^)`EW4gn!qCf!BtD*^7wTjELEdiQAEl+mn#omXqC=liiq<-kO%* zotogGp5UUN;iRGBrlaGlsN}A!=CZHmv#{s4wdc6C=(x7&ySeMZzw5)n?8U?F$;j@? z$nMO_@6OHd&(81A&hXLC@Y2!o)zkCY*z?@m^xfR_-re-y-u2+$_2J<5;^Ow@GdVXlZ0Bb6z9!rDJF5JHAIkvl4+gO+lvF^d;TnG1{IGGX2 zJSgr_1bPeM*!bo<6;}~-kgOAGjwL$Eby=!LQJ}KVX&8_%s(O|vATFqDgnc=5EdPAT3ty7@2<$TqX?!P~0A yIVjgFO&(5K@~N6fJGZ|!9rOPy`@pXN|2&`JRt~#enO&;@0000004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00006 zVoOIv0RI600RN!9r;`8x00(qQO+^Ri0UrT5A}FaziU0rr5MyCZb^rhXfB*mhfB*mh z000000F?;>^Z)<`7fD1xR7l5tmP?3bXBCCNwg3Ozx>a4>)uB5vc5G9arxBdQQ6v#( zY6LroNI;Z`5eFg!lz>BV>_G5=f+2)J)TqNiqS5#Q2?~-2Nw6Pw9vwUVN~)`>@4e^W z%fY!-sqS=ghUY(L|7-2N_Fh}xD|=dNs-@XsfD~4nl|)&RE~&kNJ}}5((IQiXQ_!`9 zKwTfvB7rWGY*s$E-&Imklty&|fFV(&KqUzX0P6f)m`EiAMNkohtw<+=EEt#t5m8Bx zh?@_tcF8t_gv5Z1h3(Y*R31P?3LuOeLC)zb!&&56$!Crq4zd!8RM&f-m zz-)^IPz;U%2M``kBBF^LSSrX)wvwo`proB9F<~&+fD;n|l#Jfl=9frzjVRDsHqVHN z2r<7{25j62qnesyfjRtyEM1YbR@;i;CLXVu|ScB0GCi zBtcqXoI@(17F4#P_mM+RrcvLB(bu1PoR!K5`kqoKB1Laby#CtXo_^|=zd8G#Bg@$@ zH7`V&Wu)m9kRq^ykX>U3c8dh1TV*=#Sg0&k#?Zb?6F;cedY&WxTin*3ItgX-1On; z^7wq`HviBU2YFlH7dN#mm(8nXY52aT)17F?YBiy5DJh`52~P~_4JLt2bIO8d+)pN?yuc@q0~)F zrAQ$~NL$iWDBhF7(2fxBH3g-FEw(cV2b#4V3b+yAF+xpL+7AzZg$u zqj?t%1K!5zjF6&4zC$+i=mXit-YI*Jp1kV$SNHw<$TL5G_<`?ymt(Imo3aoez3bDH z_pMwAUtaO=siN&XdffWGYqNdlHm_W}aP|6h^Mxa4Uw8?E?78;9@^v5ds9vIoRFadZL*{f}il_Ps>?{3k~tdUSg)VS(Hs~&YJByETCdoZ|kY?LUmwrH0xa+S(}xNNtF z2cb2TxbGPi%J(09m}$}=LfFAV#Uh8B9bg zkM*Nu*2Cp>F1-X_UdOx3{r~aI1uzRmOH-B+=%6Ly(YN z=AH?=lr18aX11d@92r0~WoQyu-j;j8vjU+SrCRhwN8dOR5p&y1hcn1w@lB$aQ<==R zrXNbnL`&O}Y-b}CL*qxbRNu>K)t6RwZ+VP}GVDcqTLp73YPi=7<)9O#$qX`WgDq!C zA{DkvX3#g+005y$L_t(IPrcL05`r)gMbT(G4~UBMjG|yv^!@+G8MG)Vu{*A~i&TcQ>?kEy z{^y;9RDt09D!u-kGGJy(L9)*Q`syb*nGRd8$^yJQHh3_g`uL8i1c-d>WDsC3f!5>^ zwRw=V?+_0*i*<;b#{ARZ9|v?&T!UE)Bt9Iy_I jW#qUD0=EBKvb;Y5tym|nepABN00000NkvXXu0mjfD+ks< literal 1293 zcmZ{kX;4#V6ox-s>Q*b(wr*{;i`Ht>22wC)58MzCg9d18?FPXJD2e3eW&v4}keh@} zQB0(=DOxwwNP!Siu+#+=Y!QNNm1-71P#`EJMaA^m>5u;Dk8_@R-#PEhnfbmsnOnlx z^XD#`3jmlO5)6f7^nOvZr(=B$Zb1Zw8L>Oy!v$E@Sv@!rl<^|x%%4r96fq5gz^I&)|yH8Xs#gm z>QU*lNmpJnUmLl$rT)~!cz?bm^Ez`k}&?s-08JgjLjh@L)v z&}qG)O9&nr=+yIV^mSf2n|VOY(I;=qmqldpDHjXjac8q<>WS0VoiB%! z0_cz!U#XhhTmI%-Z?6C=-+A}F_gAg{V9keX*L{Th_!EHl@g;of z=f9q~fkdWIX>`C~`UV6-tRQx9$i~o3oG`F?3pYF>lJME*Uu^wy+gD$I1HS!^8y*$S zi`mZK5gQl3a~DVuaD}@QMX(r=NM-V*W zd<{6L=jsfH3cf#FSX5k6dgLhBTz2gE4<}BZI(?@6?78#C3!vi1%BpHpjk(tHQ(gVV zpBun0mo8tq+Ia2yji%~ zWYk5@lZv?MO6k8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10Q^u) zR7HFP35^vPjujZgNkhX*M8r);#ZE}YPfEp5O2<`D$5&CvSyRbdRmxsi%VAv1WM9l? zV9jP>&1hrJX=Ki7WzK76&uwVXaBR?WZP9dZ(RFarb#c>rb<})#)O~r^hJx6LgxQOS z*^7wVkd51sjog%w+?JExnwH+1nBJV3-=Cb|p`PHPpW&mR;-;kHr=;VlrsJxodF~ z<@e|2_xSku>+1RJ?E3HS`tR@i^YZ)j^!)Yp{P*|$`S|_$`TqR;|Nj2}|NqUTn_2(> z0V+vEK~z}7?blgP!ax*;;p)_-idDf~5f%4}`@Y~tMO$&1_y7MFFIp2qOo-ve## z0x&lK;|gFEpr`-}03`(w0Bk6LbATl|khg#x5&}J=2k!uvf@6%rFnqL0DLxPDO1V0v z>V?|gz_*(zOj5ZkpYG)Qe&gdR2Gj#aiw{|yS@~ZBt^WVprCk33B^&iCo>oir00000 LNkvXXu0mjfu!>-) literal 2011 zcmV<12PF83P)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00006 zVoOIv0RI600RN!9r;`8x00(qQO+^Ri0UrVeHy`Xd1ONa45MyCZb^rhXfB*mhfB*mh z00000K;zXzWB>pL!%0LzR7l5tR&9@7RTW)popbN=JTslnw6q<{$h0`E1!9YmCSnLg zNCXu^OfY=Z-=Y7)nE1&*U?hg5Accr;AB=|gQcB-~lwyZAZDBf{ndiCpp1u5Vr%l0hx%aHI*IsMy!(aUT$x+~eZFb0}5C$N_Hh{4EB}@QB$0!KP>H%yA0LZo(NQ#m#jPA(H zNu?|;?pCxab|5HNMhgHI00I4T5XGb{bcm8jg&c`Yi3LDVK|m3zj=%pg>D)!B+#y;z z0x76K9R%G|AtI6+p#ww+plqTu45XsmvL;fZ(Wl2qsnB5;+ZhNjY`5lc_O9J~?i-gZ zRp`b0rhLW zW!Jrj9}CTNAw^4-#PYX)yuP?4NfLqxBFPa9IJbp&FWtLs&jXEzhQbAm<+I1moIIWe zge4;f>SVz})XZD3eNmpOoxy+tZmzCrex#ABB)SAa10)3_VK5kB>s-xQP)Gxm(pDE% z*D#$bunLzfLxwC{a0V-ISX#XF(hH99pvI)_CNVWmRmCtnSVG8M4|V4FvG&aJN;h13 z;JbVF-ydnX(WTW%*%1uNc211Mz6_;A3j%g&$A3KX+ihFxy6O%r?Oj}09Oj(hL7m6r z_0O+fTDf|mPKBlU@Wk7zV_hESUGs}vx<82|Lv<*V1eA#&K;yrHF-kE%Qw?i9{GG%5 zAAJU$5Cy$vS#V|f_?v(I;ElgjZ03fjhr=2z96;E%qaf@8BI8ye2TL%GA>#WhJ9&VYN zzjGd_&Yu0SX#piDZ8j8(Oq(2#BsT#Z7J2iYK?T$z6=sB?bl<@Tw{73HFh6_$p$AcK zjncgM!t=va3ZijWPyoZF?+~^N+_#GV7p4FKvh~p8Paq~x^ly$HIdb9LxrN!PN{vw< zLcIk^8KOmqw*?^GkAw&UBGbte$F8kh**-nmckm#HA3golHx_1Jef~GX*rfx$QbpZF z(X9}o|3~hN5U;-S@<*rM8+jt*`%nM)(Wjr;cle=C&c3^R{Iyi6n1JDa9QX@OexV}s z<{B{MAfh;ef(?{ujPD$leRJi&4xW1bPw*Jf-h+oPt(P$?C;Zmm08_7gTC?K^lgofJ9aOWjg{RyXBHOs-1FG8Kt}k~d+$vT zD?~{kK#0^PIh()UMxHVh6diOR0(tPm=Y9&bC@pNT266Pr@85mv?|Y{=X3~_<-EyZ4 zz(f$yGjG5E)N6UfUKE-e51{3|d2Vfp+=cT8($cCNcI76AmKzaRvP ziENQUG7*W)Fp;vEpZdwqwr|y_eD>O_FJC|U+O@T&Z2__?jiMdqG*zelJBJTH^2qe| zT@9yp?l}Ntz)UELqL}0;=}U@24xs}&lu1?Phrai?+Uu29UwHm^J7?-`mAjH4QA33) zG7DhYchB9UJ%@tR%rkJWZ;-Oodo9cT))8pfuH&qnBx@jbsaeNxrkSc+D@7EE zbb)165oxl%F`BduiD)UjZMD(JkpKlDG`qI4N=;H5?sBjC!VJ1n za&oFsOvC*gNKpmdIRVLp85RN1CEVr2=~N8|OisNZ!K%&*MlgWL2?oSJ0t*U&v3L9j$=MjJkOU|l0000bbVXQnWMOn=I%9HW zVRU5xGB7eTEif@HGci;!F*-CjIx{&dFfckWFnW6_JOBUyC3HntbYx+4WjbwdWNBu3 t05UK#Gc7PNEi*AxFflqaI65&mD=;uRFfcF&_#*%S002ovPDHLkV1k3(h{gZ_ diff --git a/HMCL/src/main/resources/assets/img/nbt/TAG_Compound.png b/HMCL/src/main/resources/assets/img/nbt/TAG_Compound.png index dfd9d695b7b21ca7196e4d05b0504e37e64d3740..63e2226764d36e0d72c9c90eda9e79bf4123d5c9 100644 GIT binary patch literal 656 zcmV;B0&o3^P)jxfJeV~Eq<<=jZF^=98_@uz>(lP40Aa?AY| zLarf&l3a_Z=lg#TFV2|F+0mFW!@ko8zaz^mAx6Je~@GSyaX3a?{>AZOCLM9>Dl%b|cHhSdz?YzSCqL4hS^1Wa?Fz$%jh zk`!(55F#%i!))UV7H@Dy@1EfAxL(C_vgzHDFJB)Z{MsTcSs26 z<5=Bdg?Rxk1qD)U3rLVtAi{}&ITn5WSVaMAOeo+HJ*}E*<0?#AwWvft7v>J=uj-X+ q*UP5KQ#OC`@;__;EeQPbe}4d@xABys)|!(50000>_Lo zc*kzsFCvH_APhwu$TluxgE6*uDr{pHVH>dCPW{vAoacGI=lh)V`RjY0CnYG**JhdH zG5~;$zn^z7qPBncQVV3dxUC35#PYmXfENJOSbn#3un@KvwZ` z0Is3{cpV0SCjo%fmkXMX?FGPmEa*t6Pf-yQDygASYscH2-R!J>PS4}<@p!yet1V-dk;!DSSWF}kWH4-$ z$>i5!RC)MC-iT4D(du;Sx4K1>X-qUuCXsYH9g#?sN~IJErJvJhtZNcmE}ycnmN~mC z(sNAn2K|EJ@#8i+ojz}zXE9kg98M0yl;UE3_cQ77GgH9aWWJIEoH!V4f3K#hRw$Ov zwRdzf7>tF5g@S^Drp87r9DX*o8U!y6m{SVtt9FO;+ALj?uV?#pd?( zJg=yzXl-r9=3)gC!_`9kt*ZS;an{>QU93aSo3Ek(%fgt1o~dbNUtfQDdHJMtvV>C7 zz^*?%5aiHq;mEPt)Mvjc;SjLT1ps$4lHJ_GW-w@ay&i&~%*;%IKtLuHKI1-35@GDy zf!zz>&}-pX=(g1lzwhAvH>OG`5{GPpc$US6JPT*MGT8=KzS#I{Cv zIi7kR+&(reh3BW#MlO$Eke@G?%c)dqdU`sS%S}s98=D(?^?~w`_I!*mc_{ zZrgVNlsnpE=ck|T+P!O!tLNu?_kl0I-0yYZD{r5JzJC6P4jW+U$6Xzxq2-+=6Wm$xN-CMxLffF ziMNyfxPwmq6QEO4)6z3Cv$F5zn6mcQ+6B zP!!q|wa0TOdK(Jmi9%&x0OkJyE?zntb?*BA0gv7P07v5$2N3}H`viJd90*JN3oT~B Ah5!Hn diff --git a/HMCL/src/main/resources/assets/img/nbt/TAG_Compound@2x.png b/HMCL/src/main/resources/assets/img/nbt/TAG_Compound@2x.png index efa4da374979ee01c6099837e5ca8caa0c088ca0..e51480e5141233e0f0342f7cba05f2b82ac085a7 100644 GIT binary patch literal 1092 zcmV-K1iSl*P)k8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10i;k& zR7GZHW{8N0lPWcnD>jrYHMLv~qd1b9uFMdbW0aws(EEcz?NkgSvi%yMKkdfQGw) zhrEJ_yn~6ogo?k2j=zbHz>1H+ijcsIk-?0T!H$%}k(R@fm&BBr#g&@Imz>9%pUIt~ z%Almmp{2{Arp%?O&Z(`>s;%+BA<&)?3_;Lp+E(9+@3)8W(9;?&jR)YjtF*W=dLgDC?=H~0?=j-U`>*?w2=;`e0>h0|8 z?(OaF?(R3&m_z^o0wGC6K~z}7?bq2?(@+q=;qq$}M5}_Jpr{2D+!ZQGcE^F7=3XB4bOF#r5sRAbgchdI46XXO{{9{?4CLxO;ii+8Z*Eu@lj2`vDl1fH44b zXu-16MXu5912`!G2TCiZw*X-Y*a}oSDv$)YBmtELy-EaT0Y)UC2H33w;{a0<&;(TJ z)p!G7K?1@6pH-j-;DZDV0z6lNRv_?80!9kzR0QgP9bzIs1h}sOX#;2xfq8(zY$>0% zBU#U`+^zvjCJ?x17sh8!0~}Y+pdA3S?P6{+xu53mh5)#!0*MA;z<98>SG}l@ICih$ z2^>t7G-RbBSHg!wp*+A}durNU@0@7GUMAD!S-A`p2P*#a|K}AmM!2xp-mpgi0000< KMNUMnLSTYy<_}f? literal 2230 zcmZ9Oc`)1S8pnTHMJ>_Tsaq*kYE7&WYHJk7u8E>3O+@W_V~t~LOYMqdYg3A<3XZBK zP7u_-JJhb~*!NmuKbDiuy)$>_&O6WhnfX4?_x$Rc8P| zWCMVpAF;+ti;-Y?WMYT}&VR1AwBmGz!s>5qX280{BzEEQ&7GD@8VuG64!N~uH zGlLLyH2MjnI@cJfhcappu$lIC7zUr=`lvyhgoyE4N|iY=?WHp@5~FLhafdHXTHd`# zJX+8xSWjQZH{*B-73LnIhYHi(Gr3}2M7d?gZcS1FS&6o**4 z1h-*mQ58K*8IM~moIani*mB*5+~g-`A~WKM{bg}=Y+x7|h-A9LbOZU0Bm*5G$;bk+ zBo`Yr&?BdiAnuv(NC}hlO}s9?c^ei-N;_~?LL!@(A;Efp#8VlgxN;I30ErhzN0Sbm zfaLM36aYEjl_3iNww~K>z+f;Fv<+~61n@OxGkx&*>*@t|9U^|j9(}r8nDUnkIwPm~ z^4rn1DJtGADCja%U_C@>1t`g zzQISmCk56{S!f@<8}?w5&gv$u(zqLEnhu(*t-#=nJ=Qg(|G|%`$!gyQdY`5hU$icI zw=)mDm!MexSnc)5gnqtd399W|9>wn-3gzGP*}4QJ!;dj79I$nC3_Gsps%tvhnz^%i zT4a*dN7N`JS@F5r3D}-1rs-zeq7q?ZC#x49+1uGs1J*_+CMI@vb`C{SI4(KEMMW?I zf`Z(6_6QQz?#ZA12cbv)+1;X{(aU3X!GO%X0ovciB`W8u%4rlE@_BsZ>%l2@N1gbAK(X`N!znRW$Z7n7f8o5?rh8rs5?O35GNs2RfpI15O4ZW zl|l*5Zc921r;auCPL2*ljcNxiyZ{HsdmDN48^UyHI(EGIs_V_2+3M=*9IB<9*2S$| z!*ZL+Z{NIBM9BAw@y+0R`}VAJ?fPjEFSWq)K$>QRmUftooztt}IsMPU9w^hgL^-<; zQj*-$!DEnum6a7YKwrPfP9dwDoO11UR~|gN@9mSls*>-1f!QHlgY_npD~086+Gu}Q z^#4n~{-bV(o&mGzIum+AXN=qZFr0ZtgU4#QQN=6--ZIJ^bRO>gJ;cIqmdmiM5}%DA zl-cTlWVl|eThA$_bnu&0N-BzrE5Ts-1ugTfys7BnKbMh1KGg>E^PTCI-Vyu45&FMMg;8L>YrtvG%?y&E_;puRv8?a!W^o~5hp)7vDWEJkXS z2Nz-u-;W<3*N|ci#Xd}KAJlHvhwiNoCnY8MRC_KS_Fv7@S)Tr}(C|ZH?{)EPZZ9b9 zcVSgn@N+WHL|Bpiq5e&BdZx|(}&+4_lPL1XAs*YUOg7@@a99jJ$-IJoK_(=<5JVE%V!`y zxZ z-|sD5T8(iH3=9knt)22=9lQ=>oPY#@K!B;4S;(f;r-{z4S&vN$6Px7=qgxefaVv-P zXjz?bcj_Tw;0B8aZ?f0>!O~$5rVH0(NnjR!z zLT=h_I!PTIAW#LU*g+c(1Umm!r|8wEB)FuF3GsaiDdvE;j(0$L4w2PeL6hF5$vVal{yB&wzwj(u_)AKMj3A^Jo)0B*cdCLFQzxA>q79} z-sKJo3UzfaExJdtwfH$XE2RpGtJkv^ySloZq09b=k%`XE&huKo%*;%Yq`R4}y*TCW z!JIBr>`wS^cFD19oKU?ywnzwz0oKh;Dpn2CBU@3o^SNK@Q*=)0@M>#02 zR7DnJBU6(}RFg_nluA{UOIVgpSe8#(mrz=mQC^x=Uz=87n^$I^U1_0UYocOrq-J%f zZF{V9g0Xvqv3!KGeU7+@n7)#o!kD4NoTJ5_rN*DB$)vB%tFg|kv(T@!(6G1Cv%1u_ zz16wC*1Evgyu{kU#@oWi+r!G<$Ial%&f(0@;mpwC&C}!2*XGpO=hxxv;N$M%=I`a{ z@#yUH>+baJ@b>TY`1AVw`TG3&{Qdm>{{8;{{{R2~pKurG0001+Nkl+)0000< KMNUMnLSTZXhw8!r literal 1269 zcmZ`&X;4#F7`kd=_30SEc1xzG6hD2MsAxLX$t)kXyt&U@7sG&eL|2<2n_s?GA^u1fUQcl183XRuvNvhU<``_vZ9a;L+nbHLkZh(mQ@9t zRiWBwc6k!hg)_Y}b}t^&n=a^`BXZ{@y0T-O8IV;GVUxmr7}ASF9w}_c!!$gWMu^x| zQEnAx>p7t5r(Bq~@F>t~|c#^NlV# zyB_Y5Lv}Sy%Vp{LuoFi-c-3G-Q;!j9kuu#l)Fn6GIA79L4Rxl#4mGVJI#SO^oG9!; zsV0ebsNtBUm2Q+pcvMJd%E_A@JI>TX9dg)74vc|4WUfRv*wAQgW3(wEJQ%MF?|(Aj z_xaZx-^}Vp8RN%zh(iH;u~4(5$<#t`Ob+dmEWJ|l(m!_h!M(J7^EozEgi{?}#fu_y z$xySnVyMpS?Vi`Yto-`^kr$8lw%1RspUH8jL3R~YA04erjPlB8X5rr9rqQucpU?N_ zUoYyrn-llU;#vsAst&8-@^k{&rGmXmhC{Z{v!S`Kt<~1nWNNNCy)*y7^7x|}Tx%Mu z7h{mybSP=sX;Ia&kX1={sUQ~~aZyS?nS`7WL&g+ldkWHt!Y(yplf!nD=~OU!&`=#m zP>edW^$0oxV!{ZE9T~;qOaakcUd+^K(_`b}`3ZtV;S7)@ zN)}5}q%yez#c-uc4N}w6GiDN*v$AGq&zUs%vUVVdn?C zh~0bkez@6?092S^9j&$*x1@eoc!ceJMro1GiT3%^JN{M zbrN0O#08W2qM*kDtTwyD>FT}Y?(=vrU-|qC(EsIEUw`xMz}3N_Ys24tfBgsW(Deofi`8*U!C7dXCk8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10Q^u) zR7DnJBU6(}RFg_nluA{UOIVgqS(i^*m{D7pQe2r*UYb>7omym`TV|hKXrNzdp48&rD%1iZF;P7d#rPNtaN^^cY?8dg|dE#w1JAXgN(O@j<|@Cx{H#!i;}yH zle>+VzLA)}lbgVmp2C=)!@MYug$Bm&aAW0ue8vxx6-q@ z)3v+tsP@%HfY_wn=h^7Q!g_WAbu{QCU;{Qds@{{Q~}|NjtP`?vrA z0X9iQK~z}7?bh2)0x=i_V7jibqH+-Ngd(V*prR`ue%5#(e=2`7(p-uwAtpoU6CjsGfAl$QnNa7s)Gz*9pws1-VTL4~Y zz@6e4V=N#Zu!T(;I0tY*10?`e8rTPLMFTkiH#DIC<1Gzr0BF;|4uB>NnHSeJ^^sY0?rV?9t-HJG>(!$VgjH-zRKwV){i_2a0_p` zEaZYuCNH&pHZaq*W;fo|^@0^JJGLtr-Hc7ed2eQem8_{W8TFnTrR-wZl|$f{fPbDZ X=`_rSEEi#`00000NkvXXu0mjfz^hjS literal 1944 zcmV;J2WR++P)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00006 zVoOIv0RI600RN!9r;`8x00(qQO+^Ri0UrVeG=}A9z5oCK5MyCZb^rhXfB*mhfB*mh zAOHXW0942=@Bjb?fJsC_R7l5dmd~$URTalS-?jHS_ucoSwDbq1paPbvAYvqh#9&NJ z6B02d9WZd@z=4t&2TmP1lZ1f-{{S?`K%x!|#%PRIBg9l3C@4q?SV-iDKx^N7_q}`1 zUTZlx_r12HJ14n&pWL(dTI>5=-?entqj$e_Op#5fj|t!wp-?&ySW=Z{NsRzpmO=zz z832la6kx?GuqboVMLDwbel3O2K<6249s?9CAjky3V4j#KKAOW20F1}IjR12qfYLzO zWJT38>Eh)5CtEit4ImB3(WML^1YpVjesp~N9Ual$hd?T9QC>TDKEi9Ept<*Lx6CpU zur#Gb3YMwJeWL&o*Bl9jrCHjzhmgu7S0D;zg$}RjWMo>TE&Zw`g24?pfEY7Y$ZDYo z;bdk4y6VDEgaw2Wyeh2%g~FA%O=&f365VvyS97)3WTjiFwQFr>>+<#@!!5F-;e*^e zcMBn5nQlp7OQ;f{LWIJ?EDblMSz6!v_1%^4fBCWFY)qLLD?%`LhF$E{`GptXJpatg z=U&^oaCx%5-OZy9bBPL=kYouKsi>hyYhj^1hyV+l%qJVH9plhJ)*S@K)bR!7_~gjF zx83#dZ4W(r`Oi=M=EwhO?+w<5krqoM3N5V}YA7I55qOY|bdof~g6ey>W=fO_l{mok zCN|C-IQ_jd-#L{hrdypw4mNm+DA_7uhV@ccL$Ob|eY8SEW?(E6%HuzH{H@)0H;x`S za`VyKKXUtnAA7K)OsStLcRqUhYu|n9vFIbZ7!*yR#0erZvU0tZ!Yz`0ru*6F|1>`} z+1y>U^V^e`y3hUluU~!WD|c|z(1YurdE}E{`pGN5*j0-uO)Zs*BpccGN+TnerTc*u zKtVZW`|#q@v7P^&Xcvz!&c6NJ6Tg3Ihv=v)^k*J;xV#(PwrZ1wE#Z_TYzd0&DlRYU zRs;k@;?B^z#eA}}y_%P9h<6TcoqhA%UoO6|L#ya8hfW>&=z(Fa^GYP31fobL35n}3 zGh!^}P`C|4X|1$HU2IKfo736fUjO@mJK0n2x%KXDyOc0l8ex_QDNQnDl637gN*f_I zMzOS{11s4|Hqp}BTib8Vn1hUReC-&Ut%Ly?NhA@OStZ#ureh!HeF`N67W;1$7KGY6 zm);H>FzncYW45pu(o{l78vPzkj1k3ELKdNL2&z*ZVakj&4OX2Bl#IUX4TG#y-x(}x zcwTW-#)z>-F^lW6=t?QX7>WoB6qX!bKUgrZA&1MGmx(O3jy~=S@D=jNuED^P0tWiv zh0QV`O4#j(KGai3?b+PA*p*adF+)l-q=M>R9emJz?O7i~Q0^TQuFEjz@*l7N%cmMK zADcPVq&=y~h-;hF$ebmmdTJTG3s$_;ER&%>Jli<^i7#{*nBceP{;*XS^Hs|gGf7q? zVZu~xuL2}-RifPRy?zVPsgz+c9jp%BQEvR!7r%c0+NU>|COLTg`t0v^*UIi%dd<;Y zWkNbxmCJ4p7(tFw5m!9`r$77ow@++OZaMJbBey)Te*bX}Pg$wtK>Xp5W7vY^LU_rd{-W$LC}d25#7 z3Y4ZOxDC!?C^4);m+6BtP(dnLQYc9aGjpr|TrkjNVwef$7EG;c7Spy4Fmda3M2qyxG zq5+xQ#-xvP?VM6r6rkRC>E@3wtVo{e<9KNqiZRkjGeSx4DaVU)?1&lZC`DCGkANV+ z6oz6EL#ZV*55x`s2aucXO1Unb(f|MeC3HntbYx+4WjbSWWnpw>05UK#Gc7PNEi*Ax zFflqbI65;qD=;uRFfe+1DLeoG03~!qSaf7zbY(hiZ)9m^c>ppnGBYhOF)cGOR4_3* eGdMaiH!CnOIxsLW2lyiZ0000s}E diff --git a/HMCL/src/main/resources/assets/img/nbt/TAG_Float.png b/HMCL/src/main/resources/assets/img/nbt/TAG_Float.png index 0f972a8c22b86a5e9228638c517256378edebe03..05611fd9aa1206ea06631db05e6b176b7435a74a 100644 GIT binary patch literal 276 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dyoCO|{#S9Fx?I6t9|MX)jP*AeO zHKHUqKdq!Zu_%?Hyu4g5GcUV1Ik6yBFTW^#_B$IXpd!}*pAgsLFrM{2I-4dKZ<=Vb zX`<5R}W9Tess#s6SMALT>bI&tuOB% ze*5_B`{$Sc|NsB<+9w%kV1TEKV~EA+JWt_25^MSB41EUjHMS+IE z(^jT38LpNORz(vu1Rl0An7J`MJm9&FDUpvOLGsK)hiulErlvn8e&3#GFAxBFLz^dy Tv3v6tpxF$bu6{1-oD!MOj5MYF8$|8`E8}H4$eO978r_(8c>3UB8^hf7>-~HY1cfQ~Ge&@UQU}nbB zz=<;_0ssQjmT6Zb40@Oo#-dZHnzROi|JLO6WB|?|4H(WIhw9LrWvkNx*dGPJfdT;j zM$`c(0DDLP+#3K$&;by#i)vhz2*8-u%#~TGhmBYTHQQjGt}hRvOfxBCC}o;MnL`Yw zV1scsWehf!O)oW1)tjfB)M-vvM6&&HtPQg>I7?&PfRdx5Ia+}_g(ha^Ivp^hR;tYJJuxCQ8SAFjJtW-)2nE-kp!5Yt@0 zu&p&tU}C&H8jb0yBRrl%O--iGj%2qhO%{{9&P2DUky*mc;XD&vVj%R@5j;cMZD{Th zpYOqg2Y-%^iawufc=&F2*LoYRmN}J+$2kTop$MubN@Pek4><;Uy+uCXuq+83UCq7y zEgkI_td+}cHjRT<$O0^(R8&o*NE03&;~CuJF1lE!H(Fv_S`!=V<8O4ti-Ow0g*yZ# zYg6hdNS%q|2h<)O3do74u&Ke^)us8(Gv6Z-4pAj>n1faF4AdEjvf3z*fQt-1=qhZi zH*{WwZdom|3MY#@?a;*$!hl+DAdR*2L>hW{QW_wJoW~j(jO|x259;Kg#6kICs2u_+ z(3n9F%g=^87&yoiZkBY~v)ivzrn0TKdI`6TOFWbXT(B$j$YgB~~6@O0v$#b8NNa>_H> z)MuA2OIyBTWqJm9E^}4NYVDe==hwcl?#1;lWq_9v@rw4<*EVEt%-OVg%U1CEwpDMu zxqZi5J9q8QeLHW@UhvMl`}V*0{s$j^bl~IsPYNb~3O+0R{9uvx&=-e~96fft_{2$Y z>U4>&RI4{oWk!=Zue<_O7FJoT+UlA!XU(-=es%6^@Xh%P7wfd&eplb{ePdJer5`}c zkFA&6u3Y`;+Vz6=X1t>lblvFg>HYa;W?%m=w{G9Ldk+lIjLpt+ydX-l!|8HQYsih; z-OXP|+ltIfU6;2hJ8yGBPVQy|KtpO`Rb-4RHYSVIB#;Xd=97>l6Hu&OUh*Hoj-8vf YZQ1w#f)xSx;ll?>OU=-pOWBbB5C5liUH||9 diff --git a/HMCL/src/main/resources/assets/img/nbt/TAG_Float@2x.png b/HMCL/src/main/resources/assets/img/nbt/TAG_Float@2x.png index 6ed909054afa47f9c79bc76da3a9ae7c2e55397d..00cea23028ebb1f1ae221c699bf6cdbcc53f90fb 100644 GIT binary patch literal 300 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+ru!I14-?iy0WK`9PTQ%(jKiKtah8 z*NBqf{Irtt#G+J&^73-M%)IR4gTe~DWM4f9r}QM literal 1647 zcmV-#29WuQP)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00006 zVoOIv0RI600RN!9r;`8x00(qQO+^Ri0UrVeGH-^>ivR!s5MyCZb^rhXfB*mhfB*mh zKmY&$0L}VZy#N3OS4l)cR7l5lmc6eeM-|0?=T`O1yzzc`Sz#LqTb2O}5m6+?$ z>z;egxuqZf>dhCQ++W$K9ZE}Kg-jS+@d`i<5+yX#ao`mM8X;lXuRKIrkVv6Y50_u7 zMGo~^M&~Zoq=Y~b0D&q1xgADEzvbE5Wp4OdFP#bKly}S<2N(9ky*}rd_3w1URj;9OeKW zBntGXhxPVqwSA~CG)-!7jnJy6=FU!hvCab3m@-I`=p1{HI%+u5c{o(_*|CSS1Swju zt7UBMQH=?-NC>0rVAQndiu_asaim&k^OK*xb>q1QDmya-hz1}3=cD(p9)GCK1Gp^5 z4+X?>K59XQrz-$X%{OUo-+BdJgbu?Zy8nLgmtU{fm#@C&i>=56GV;WZ#ZM$f80Wcp zrkv0qF%e-KR

0V<<2@e84zvR92EuL+u*8%4;kNW_AVVmCS;A`|V$>HY2f{6i|9C zdh+j&K79T&8@o>y5@b@7C}*En9lLz)|Bi5+v2J(okH7uuo15FWc0miLqKfwAujJ=n ziCP{98AqqmWF~XUnmx1k%*^aXPwQbSOwv&IZ@lrs^SAfKrC3-nkn8ou7}aUVfttc^;#P&W1PbL&SnkB23dlQSQqPnd_*=fg!UC8CrPA<9wWF%9vP zk|W6Cv9JqeaJ&N?tBmO_rZk~gK&~mC@#Lw=ZX!!k5>6Uq?vq3F{+OAWg1`kL{%?%T z*eUDsnrCovR##m6;Hd=`R?cHt30dU`GtG*iL8K#S&G+E}Jc1jLQ9Klt zs!TpDPsDtAX^^ROrj;q`Jh>caXo6Z@UHJ{h-Bbf@{{O z+2#^*i58Nog;ml+ja8=6lzE5p;>OhvzIS)Ouh~5eFK;g%MFCu%8hP_2Fmr<1F?cxS zDqO=+M_R_v^P&uc-KGRUD$3fohP|>+*CoZ;C;zzSyf~x+6HOv>)%%7RaMY+}zxN;R z{Y|aas6eV&NX4nlqO_fku$yAJnN4ARYDaHozQ7@esUt(Hs`cvS-dAJGLUESa0HF1L zY)od>Qd;j(OJ*+7J^i)(4!chYNLjQ}dcXNUd~!CdzB(z20000bbVXQnWMOn=I%9HW zVRU5xGB7eTEif@HGci;!F*-CjIx{&dFfckWFnW6_JOBUyC3HntbYx+4WjbwdWNBu3 t05UK#Gc7PNEi*AxFflqaI65&mD=;uRFfcF&_#*%S002ovPDHLkV1hDg>GS{q diff --git a/HMCL/src/main/resources/assets/img/nbt/TAG_Int.png b/HMCL/src/main/resources/assets/img/nbt/TAG_Int.png index 5f8dba8fef65b6fca74e3dc5e32f126fbb523669..aff0815d7f866bbe229bc9dca030c2c0a88a24b3 100644 GIT binary patch literal 236 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnF3?v&v(vJfv&H|6fVg?4;b`WOlfBG>MC@5Lt z8c`CQpH@mmtT}V`<;yxP?2tcPl&6gWM-J#o@k$avA+9f zO}sv5(v78aZZDsI=ji?yM-RL>e(>e-LoXlP{rvLzxBvhD^XyzO4XDw<)5S4F<9u?0 z0uM_d!$qc3#~cz`R0`h93hX(m{$N_!JKrd-u7#iD7Th$dVVTnVeXo*X!=clS3#Kf| eIH2#q!N72EqDQD1ON;{0ECx?kKbLh*2~7aMbzA%Z literal 1299 zcmZ`&X;c$e7=5@U1dt^t$%G(FB*`SRPm-C$uqaeep)Rzws~{k*#U&`vfN`(gltNpr z+KO800$FD!0jaiX)z+f8wTfD+wSv;t)>eD8bs5j;pZ@5N@7;6ndG~zhyWe|jCl-#4 zl0h;6KvaHS&SVi|o|JT;c5VslenT$a*%Cn@`MtSUX zYX%dr(_SdtrHY-I!L-}0oi=lbr8`)X%eJ+SvA1Q}y%{#2h2mM0U^NSNTW1!-D<$=+ z=rTJUu$hBail>qGOxAy+|IQzG|GIns-lfZb=GeAbj9!)!EkQfkrk2#GVk%gYx6nZg zCYTK&682Uv*?a%*hkZBuSFPEZW!*#>JQh4?BSS3VQ%al+Aso&Gr`{2aM9@@}^q;j}c zAYjgf@@CAdkkqN-W?K-B!U7n~p_-sB$!ADw>FaxVy!$)}HyDxwSUQjEIT{g6s3&2F z(}D;T#$kF6P2-Pu_mH|qILVJ`{Dd|@8^vpGh15x5JPPxOhS#YB22D$^*uDEAp>NbD z`3NmX899}F9)ku=sDOzRr3>%~-Z*k+S62_FYcizpxF$eo1qumTP_I%}55>=6Fy4ak zgi*lt?Qk*=YnuCd@0~k$jm8?_R4=LJDL7=p+8N9TMbxW?S1^R1B{&ntBXGMJ^Bg&N z{n4XGN00QIiDne`n~)9*(GEpARS9!hGBk<~v1E`!LpI9Yvi)%D?)~mv`*t)R$j#VF z8+kLamoarPlwT3K6dG2^5J4LqUQF^3QcEkQ-I1*j zUw_#2@nds3$DO)*OC%CUenGCJH*CZJIeX&D?F3PZDJ>~nEMnHbC4LkD8l+LHMXM@5 zC%Z^-IxHsa)wM{4Np)gAyuL}X#_}4Nlnv?OxNo4 zu)&C+7{CdVqD^KC!&+^l>=~ILD|_^qoZP3z=H-tYKcS%TX^=c|(&Q;c&pi9w)aPG# z@uinv0n>_0rk9qL�I=YxbPE^Iip&^A{{!Z^R2hvS+~C7-Nw~T?}@_uA8gq8;ik>&w|w;R*5+;7cYse?KHa%%_ny`^ zx5w-A2ROhB!BBh0-pN`tA4Id1tFHq;^Gih-r;<grQG}CL+-#*y1{}BI_>M#6?*AC(RhG?~QS<+Xvl}nPJy}S8Zefmlbn)`X DFkjTt diff --git a/HMCL/src/main/resources/assets/img/nbt/TAG_Int@2x.png b/HMCL/src/main/resources/assets/img/nbt/TAG_Int@2x.png index f6675ee4a0759dcf59c5a73bf7b9d3c0ee865e51..f7d2365ce3abe67b257fce3df47119111a0922e7 100644 GIT binary patch literal 279 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+ru!I14-?iy0WK`9PTQ%(jKiKtah8 z*NBqf{Irtt#G+J&^73-M%)IR4gcPpCtjaD`TFv?x0lbq`||ns_iulGeE;(w2$ny{0Gg2G z>Eaj?aro`E-FyuSJPv_dxb_|V|36ar^bJQ3pPoCrOCRW@t|%_K9?JG1(}8XCKf?|4 z)1ti()MNz8AG7g0n*D|A$E$jvIY971*uhO#L*SD}j0Wq0S!cxZQX9$}kL_lttXG$@ Su=PI*Qse3B=d#Wzp$PzQ<#aOu literal 1613 zcmV-T2D15yP)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00006 zVoOIv0RI600RN!9r;`8x00(qQO+^Ri0UrVeE#i*AegFUf5MyCZb^rhXfB*mhfB*mh zU;qFB01P{Ixc~qKHAzH4R7l6Imd%b`M-_#?wW@Bn?bsbFPE3qI3=HCD5Ktxt1QLS4 z1c_%r%y5CjQDgm@6=$L+r7)Lsng+-@63@`Td4 z=bn4+uB!dB*4m|yUHh8O?(79^7eGZ8kJ6Ch$fYW+5hWAvh7C|qU;s*vsNjVb3|6Ws zs?lSOO8RcClyHg2sL65*F13;XWN>*~v=Sg0QipO>V#vf&v)dR;bL+vQ?_PNEPPvtE zAd9NTz@cQI7(ih#>CB;m6_8{~tN`r@Y&SCg{Kr3})~@DZr}Z@^6p)aN3P4B#J*g6; z6%DY2YHlM0?zW_;S-om)@>((KB?<-^HA1h7q6E~EP-_w}G_s`zs~7@kAd%MFI#x-+ zR(5TttVvLHz~IB=BqS+;DkM#umhYJdGcoRMM1Xw6xT+izmi>@qTcvU`MlwuMqmx1H z$)d6X2^ES`Qm9(BcYpW$Z>w#YUqds5*Iwnj-}zCN8{!zZlBvYQl}hcjwyaIVJT#J| zru(Q$r78h|7#W*gTTx-$&aPj_eZQ>xDrKZ1RVY=}S~b_~!IAfi&PK2T1XU_ZRiZ5Y zCqI7u_9xrdU;3y;(eJ&OyDUs47yR zG`C4A>}q-L?YGYE{-cw zJ_LGdH`YrvJf=FN?@uq?fB%YgNnDN!fmO3u$-_=>JYdwJDu-&b1dVb^5;SZiw%O^V zSHK}MNiYei5>-88+rW}7re(6RGZPHTffz`SdbasNNlqn7YJ$W`CZm!(T7wJsL&kBE z)SA>7SOr)NAU-4(`68+h9Oj`3*#t5vCG)BzRh8Nz$mT(&grt%R2Q7Gf^oyM`>wa!n z7tfX)nDs|$;6$mQQidu~7Zq&uPcELrtC&n$*m&EZk?X=ix;2BLor>;MQ#Kx-Hg0vnNQV-9@Yl;?a;W6e_4_ zdzgXZnVQH;@9*r6-a9#=LT|kAxlbQmelWPJc1l}lp+!DysZB2@4%^cww`+X(#&gf7 z#Gz~oW3w*n-b>%O{mpNE>Puh#kcGspTUS5+iO*cVa{Sl7{cByM-qf1aEX^a1rQUh# z&40G`s8mP-wP;u-Q#!5n`#-qTj~R6l{^!4N5h$Qpg!DvVN z1O)`6$ca=^tVE1~d_g`H&EwjYv45~E`zsTQIC7H`CsK5TmY$7DhRhZLX;X3tigfhV zul(@&TiB=KtkHm2V?0G}Xf%h`7%3D~XdV}4vr<MP(0qSw|+dbR4w4_Lo-^nWZu^n~{n*5wU};qC?F%8c9@xNysEl&D71~q*(jd zH>H=}ZT$^#JgIJ$DZ=w~o~c1wpYBniRjiRzY2D{4urABg2GP`A>mH9$MC@5Lt z8c`CQpH@mmtT}V`<;yxP?14^Pl&6gWTuHqovCVFnA@I| z&_k)=htk3i6{Z|twdnT4d!Ijj{PpGY?{8m!fB*L9`;Q<0|No!KVRHdgcgQhd6@2l6fwQFK0Bgdli7aVMjmDb}topk@oK=}y z;(ERNRig~nb9eq%t4XeZeqm3)pu-DCkHS3(F58);YTb%jt+v+Ms+3BVJ){Ig zL_j2fR;m_d1dt3_F(jl~AUGl_MHC~Zf6wWce&~n$?z#88d(J!Wymu-lD%{12e*O9 zZzLorNc?&tw{ZnK6UQq@Z_(Q&_hTg*G}Mh2_o0#o?Ib3Q!~jxsAW4UUdaxq3U2+d< z>;2hK(JZzcD;-EF8W!|E9-kvsuRfrthvnvb6SGO}k;zG-~mNE-v6@<2Dr_*lPKQeybl;2AEoV)}4`w znP#Pyw7C$&(qq`_R>P#x{LBlgB=K`xBjA2jNt^lAjFloTb>-b1owD8R)Xia3W0-?j zPG^g8!Z>E}f+|S-EPPllild^6&Lg6GT{)^d9Ru#sm4IciV|5RipDqzzB=OGTLb6aC zc!052GjFM)5)d7H$W{a2Lk_R`-B`^!g>ZXe1AXe)k z?VPl}@6s9<##JL&Lui(^%`oXLta1*>d}V#(51j*Hw`Fqu8=G}}>UHADc65}=Ext>6 z|K-r=Q*Wq>#LaRH$VBnAy$>d2d3`w6)z!(hYh)$!*)@zb(`|$G&9u!p{A9VLYA!d& zX+s7Y>XVfYH7HHZ>fs$ZH!-m-m;@s#u^%w+1syllcR#w`X2`4US}IWxdAZKCbSLp3 zPN*U98VQ^l*Mw_WK@Ub~z(59!Kxdz*apyP9OROdd6*#GKPC_2u{|IJ7E|!*!U3c0p zAOjne?GTvn5R&TuALgKc@!`Wfw^krG!O?jUp}|aIF9MHv{vMV{6dGfP zwRdoIa(2PF0=%31tl4ws5{M*^dGi-6Tm(E9FY#LHy=?i4k5;bw*yoc^0oj*AUG2AK z?Pvb9b?Y|-1cIR8kk3QuVc{DiHf@e%L`8#`*tmEmi_PKk_z8&uAp}HXi8LuWMfSy( zFSl;nzGElYwR_LrRR4YZ4}5j-(AS5Ld;`8cD%tzpvG4aE|KZ0I+fJTJ13#tzd^+Qo zGiNiivU76t@(Vy=5heay@vkNF(z5gAc^3*mMLOkT<)y0Xn#;9U>J;@2pz*4NdhNIC zO~2o`dFzkn+jl@qYg@arqqFO7w@R(i-s=Iqefv8kw?+ mOs0{^K9MU${|Rj0nY1P4;Qt2_-Od`}0EnPRgP%JZ!rb=6 z`tHw;JyMi%yg2PdMfS;_#!H9yK7aD)%j=il-@g9w@x#yWKYslG|9}3Zlkq_94W2HJ zArXh)UcbxLpuod+;p_aV*M8gQ`fw$&EMm@1zCU$^wRn{w>w~2igYJv6@dR+b>A!F` zKii)5m$t}`=aU^?J$S{)eR2Y~j7o87!qEzyuZ0$3vWuJ#80H;hVCFG!VBe6)#H7fl zURm3C_tw7G=iacleQ*!U;|XB(`2T;~nP0+gTe~DWM4f|5AJ& literal 1951 zcmV;Q2VnS#P)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00006 zVoOIv0RI600RN!9r;`8x00(qQO+^Ri0UrVk0<*9ctpET35MyCZb^rhXfB*mhfB*mh zAOHXWKq3@uU;qFHheg z3+uv5+iRG&q93rRk%O4=gQlvq(m;?yZVK!GJ%9M)XO`YM<^fdXX~;VZuTV>2YI+Ok=vTk{&+&_Q zF7%h*|DJa|`p&Ccu*2c#qrnT&fa)y0$C#C_AO`@Jsqg&g+5LX$kk>EcTEkB~c6G1c z7?>JfG2A#9|Nig8NsFg{`{J+Tl}o-+b{>5DBUcNEjGTfVUFxN#({Su;_``idyaNr#rX~e!h|!(aL7@)FC+;B z7Tr55c8_y=#aPsf*@m5@igo9rUfLV?*ZsV`+gvtmCsh!j8E*nQ5ZN;`ou1|3Gs$ z(Jd91u`^Cu9GuK8@!a#zA8Z!OzBjd@HRCX0V$SUXm2P#M@oI=(6KCERrY1GTY-t+} zlXshUp(0RbWGXp3J!jsRqRz8D3w(BhNp6~PGb#1xrq2h>q2;3I5Q^eVGH9rAuli|+ zC~Kh8@y;<1IY32Hu>#402m#30>Zp@<-B%`j7yi7@gQ-f>vz%J!y$__KD^j6Tq~>zf zRGa8oyuqEKl(Rs+o132*(+J!lAoR78rf9 zm3Ow-AAN*RCbNPQIEFQ-#MT&v)yV$&ym2mX4*i%@k#>~b?zYplTXS_0^ByzM)?fX? z$N$?d-L7x>*0-NtYVo5z%J^$LkY*@1+ZjXzPedzIJa5V7Urw&gxTgQFxW|pDtWmzGm&!Mdt;@yw%@pqLy zziB%tB26lYQ_&czoT#BpSY~D$tu(9gX8r)@NmqmON^X7#(&&6dh+{!zm&HY{YYuoVO8qV;~F(;i!Db<`fNVM zoAlAoe{=oD3;TQdifE>(lz-k_uHt;pF`0l`oOIV1Ce`Z)`NQAb-17dyD><9Ip)B>r z<#2Igz=n(n+mYQCb?3PkZvL6u+Z+#Ci`K2CofBPYb!cr#xXxxccm-RycFwI7lh;Ic zdqSNtoCB-y>2IgVC^_@H$xH1{8mm_dCy_C17(2bW+pZBIZ*KkV_qfm!8k$f;prDKp zc!WSE@^r7O0A*1jOE^w$3Q5Aq)HrJ;QG{LJ2G%b4KkxB6j`KFHK0rqTWKk* zB1KFFqRieKgosdy%pjx4%9H;AtUOMli*^cs0000bbVXQnWMOn=I%9HWVRU5xGB7eT zEif@HGci;!GCDCaIyE*cFfckWFnZLx6aWAKC3HntbYx+4WjbwdWNBu305UK#Gc7PN lEi*AxFflqaI65&mD=;uRFfcF&_#*%S002ovPDHLkV1lE$qgns} diff --git a/HMCL/src/main/resources/assets/img/nbt/TAG_List.png b/HMCL/src/main/resources/assets/img/nbt/TAG_List.png index 37a8d367252bacce88f743eb649483887fde3a33..8422d604db02f3a9062db6ffa0abdb9422c03ba6 100644 GIT binary patch literal 185 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnH3?%tPCZz)@&H|6fVg?4;b`WOlfBG>MC@5Lt z8c`CQpH@mmtT}V`<;yxP!UgnPl)RhZ{?5eY(LM>`Tzev zm*@T(ptzo=i(`mHcyhu4hBXTl1RACbZp+dVV3d6Le?606N1>^SNsWPF0$;QBRF3*Y a28Icy5wSVf`%eRPGI+ZBxvX~Rco!bRjDXg6_5&HN%ABC5-_5e_&V)BozDEu`ObG{zL`6B<_I=Lt;1p6 zumAvDM0nU1MD71B%q*lY^s(EDh}Evp$WQ>T9kZRGp%70_4&M?9z<~t-Fu4G{Lsm>R z0J#tVa3TO9rvV`5h}$-=0Kl?q(}rk@D2{x2yG>m@z99}@A8S`1hky+Di?R6n7+gav zu09rbF%EZan{8!0L7qU=@G!6dtt-ZkanNHNl#XMqMPQ>w*(hDHm9Au#mV>EDK%Gr+ zoe)^+OTh#m7Y+k7>@j?lq&n`Q;bPBk$H=KpBS*-l(FaDj^VT8n{?Y|A@IBL; zVUuCNG&N|RRGKE0W~2J8=HZQ^rBzhqNra&zq#5`T+}~TB9oQ@InpWL6>h*>R?WA63 z7}pvNQ>uX;aZYe$0#1_TJW`CkS?F^zBk0CHkKrQ6RyuSdBeZM}r92a1?TM_l6TYFrw-ooZl=QZgyi}CDY~%Me=XJ8@ z$P!R8nuD^OWS&vKF^<>f`uFj?Oh%>IY&MxbyqVIR%U@oRXj79!7!(kmHLx0`rSeRu zk2UvkU14ioWoxadwYExeL8Pd!x>_Tw&h{7Ww38*fsJYmVqD9wpmpm#&gq|j5Ah>@i@4IL$!$2;C?U)(w9J;e9?@cN$l{itbLZGH!v zr!}T&%{1I~`(SX@PHS9MKDAEFoGwss`y9uXEA?b2moZ!u;KIR8oezhc3a#x+v5Q>VaLuyT2gXKYTB;d z=^1-KW>)szoPGQ0jNH6$^9v4q2MP}!Dq^zO94-f;n8ybt0%7UlBS()NFDoxUQE{>o zoH{K!Qze#2Wz{v+^0Vj8gA28F-`6)>yma}>)oYi2Xlw$_E!P#TZ9m?)dF$3sF}Hud z1Ae)C@7LdczyIK2`yY=w9zW>>T~Ggf_WVV6PjAo5-oC$H^@9QB;Lz}hO09ukk7{*e z~}U&Kt((OJ|V74yp=z;vwfLw@&EsS zpR;eRf#PPKE{-7qv#%@4Y}17(8A5T-G@yGywoa#6G0} literal 1961 zcmV;a2UhrrP)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00006 zVoOIv0RI600RN!9r;`8x00(qQO+^Ri0UrVh2Fa==@c;k-5MyCZb^rhXfB*mhfB*mh zKmY&$K)WUzEdT%pkx4{BR7l5lmd}q|M-j(ARo(B+&z;#_+lfOUiDMB(iE`kCxN<{C z9Doz~FE}6}t|Sr>LL9gw1hE8v1X4&KoDdR_$Wo#ZC9z|#o%MQmc4pq|t}ln#SvyW> zJ+=C^T3ucB{noe2Uj5OxpWr&tk-T6)3cy3gc9@x@%^Zq^Z}z1C%>3a+GLk}yng<_F zc5?DNd&^I9COQfLIdD;hHn;n^82|#`nhpWD5dtGQ5+;%mEDN_rd)Zt#-}>fPU=cDC zDct3#o;@={#zP*+&rA^#;ARk5kj{=}Z~EH4{f`5z%ea}7e5p7#h0BfXV5&v{7&12Z zh9o$T-ImO29n&iZ!Hm<&{vSQV1R8g0K(kND>9S`6A7|XhHwFnkOqNQ4@?=k zwz2TEdAX1tvY;*cVT8$&6e*dPSQFtQ2N?(uxo1|!Ppy31dCk$;{exDkMx|P=!^b-s zw-i$FAVlXR4nr31Sz+1NoPOFE#a2MZQzVv=Vto1XcYebBDwa9^=*?@d?Z&73wv5qZ zU;Dw&*m;I9aKaw&RCBdXu#oDN#iYj4e zoMrpz{8wjdN(#mNoq{Tl7+{%vZP6x5z>!O^GGSuiQ`-o7-^ zFgbFmuI}HUUt7oomE5O@jOuBB{L$b4^vic|+F?IZEyI%!<~z+WR$3jt`PZ*N z@k$M#Ee4V{AiFl{A@fM(v%%`d%y&zPTr9^nnObA2(@W!~Rhx;uX?tnhx2$U)qd30M zjx)AY0r#yuZRqrXRH`{^fad$dOXGS%Z$wV+3S5Jf$K%2LiJjeDYdlV-_iTqQbsc#$ z8}7#5e4g_<@MvA>>{0HUIcy^;k&Q++TprB)6j$W_ER%(+89n>mmzh6rL-VMd5|lMNldxEqwU&5xO_d|eqL&)_CJ65W!+o3Whr!}-bGdlb?z zyJyv&le@j&=+n3;OJ?LpDjHTxM9Lmf`;+6^uPv0x7AbF%u=YLOy}w=i&A)Ewh#|FW zmN!S^+OjUIyMMm+>;K%gMQxNuwc0t0LIJpWCBYx)W;3$K+D<+kuvG2H!^vcBv-#Y{eI&DNTaeEa?Li4jp0!A){m`p{dF1Re z$GzzX7oYszkC{9Nnd6V&c>NU%Yg1)>`RO12lu8xbq zmBWyzPUlm0uCVtaI5v3ipq3$%VfHj)`Yab-!Wv{>uBHN2Lbwy@z1zAnYYGWG2f0Eu zk7NW?ky)j5SjQJ)62rA5JYfwqh#-v57aR-=LS#WYRdX+9huxwutX<6^GslTk7c6ds zc9xz_j&6ybB&ix}jy^&A#m0SDsQM!n%^kuhQWWg;=vI`~#;eo}vQd(eqi6luU$6cA z`ki!V*`k`)A5Z3<M}%_o6Jlowp~kd-)J?8>{KF2wdMp(PbSB* zMC@5Lt z8c`CQpH@mmtT}V`<;yxP?1)EPlzjc{{R2KTA3> z7n#(~WiVCe_j4`axTDG5W;8C$tnL_9m${xG#LFvKW1ZRYiP_8w>e NgQu&X%Q~loCIBUbN;d!i literal 1227 zcmZ`%SyWS36g~I7B%l!U5~@(8RoiN9EhP-qg0_l21S((%0hK`#5)wi{Fa!uwl9#-Y zIJ?floTcJ-%g_0N_?`snIUKl*X@UVE>7&bjN}8&H)N zNLR044FJ-jLQ*X-u~PB4fZNh!Rn^MNYZdC6q_S#- zs#>nnDOB|e)v?qAwMc7PMSJl8hYxTV?FWw+Q9hBI6H%Pl#Yre$OmT?j&;S?j@ewKu zUTc>47~luNA4~|a>jL3KIt=bG5|9>f5P8GM7X&7V`~5_j8P!?D!enk3dTGn-{DWAG zx^xY!PDF>m&4QZ;4-a$@qj`+>$qt#3)*_}kj2(e4sx!J6i^U=ruizFt<_?2{LoObp zLYO;<3yVAqv6457X&wcHysJIBAZoHBX9)UO=;tsek6aw`3}POZC~HLZO=1s+1xDKE z79!J^!PEf`9(oz1w}?C}#teheh8#iYVWF1;7mEx{BAUUNA?R|9PhUo+4%8onejZ#w z@C+iF#f%oPJ0jCpaC0Z-3}V9Zs31@*cv(W-h>RAAhZPB9yPej#g;VD*f}sud`N8gm z9xqS~a&yRgQm!^5Q?rC-MC=gsIIYq73yU|?eJ8WWCbLE-l%o+j9Yk&(`GznrN2r^S zxmo06aDE7S9j@y){#sgEj>VRimzQEO&Dc2f4IpMnJiro~W~{LlV|WxA0k1!A>g@Q< zTNAUhQ?qlE(YdMU>|}I){{V|q0o)&uYb;=C0Va$B98ff7>k(P)_=!G=wL9)ar=-mu z=kVhG5FX$q9t=Iz^0<{v01EPCl+aY-p0 zQkAL8$$U*k<>4botEy|D_E?=-OX}+NhRVW!j0U4=#b`7)S@g{-iI(H7ZCdh#wY|yK z(cIZp1NP&s-CDAzx3Axkj*PxMcIuVWXT~Ei z@#<@nXUVB^uaj@Q`PSR#FTgv~7vH^f`O3`IYqQb0&GQSeD8GK==B?ZBz5l_7AAS7E zr=P*+cfPoL@5`^gzW>d)-+lkXk3SV8H%3n1&72XgYl|)ah|Qq0HRS584FW-?GBYbp znUR*2p;2b$DtF{&@7%6b<|>t0)i&RM8d_QnW@GpNH|&$#*|qW_Mfs)V^ggZoZ`8S{ AOaK4? diff --git a/HMCL/src/main/resources/assets/img/nbt/TAG_Long@2x.png b/HMCL/src/main/resources/assets/img/nbt/TAG_Long@2x.png index 3e77c4bff1967dd831a7a8dbcd3baea95b81aaaa..90cd905ece90e6f5e3f4b6bf5c1fbc1212f7ed2e 100644 GIT binary patch literal 248 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0J3?w7mbKU|e&H|6fVg?3lJ`iR+vu$BBP*AeO zHKHUqKdq!Zu_%?Hyu4g5GcUV1Ik6yBFTW^#_B$IXpdy0+pAc6z`z)FI$qG$VwL4~+ z^v(;MyC!1c`k2KV3)bx}*|@iN|H-L`&#pRuebbfOAHMzg|NsB&^yf@Ky`G*fjv*e$ zlM^IF90d3!+PJ3l_V#|zS^-q?dqqtZf9(Fv;`ywB5^(c#P}Bcrk;K~a&lw?%{{ oDR{znK1P}L{7ipmR%Skiz4?Nbm+B2?0S#sFboFyt=akR{0GN+ixBvhE literal 1409 zcmV-{1%CR8P)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00006 zVoOIv0RI600RN!9r;`8x00(qQO+^Ri0UrVeFARX8QUCw|5MyCZb^rhXfB*mhfB*mh z00000Ag*U3JOBU$X-PyuR7l62mcNcQtln(l@_xsv>1ooQ<7qxdKk5OkbDPS%Eepleo!L z%FVtKhg1IcyFWm6)ghJ9`n6&y2!T^Ai7lX{prsGElUH{D(*hZpV)Xg~Mv3S(G+Sb3 z$r2F@q%uc3$3#hUQqrQFJx1OEB1#EFLJV?J5GgIyWtGLwv06qy-M-}Ckl#s}avWNvmM3MxjpZpZQ2=Bv};q4dC9(%SJyd_f5{+y3eN@0=0rO;89 zZE=v4BC|r`_2LME$73AFRGTWoPJGc*DL&h7HR7yaDt!}}poW!%1kc+4BRIg-bc+>M zG44eYpb9A4fK_?5jym`*ly7iW2si8!u*fb4rMBv0<)JnOx~RuJ?e1v42H-n>OQh?r zIyU$V!bORd&dx)u1y078V~j0^=T?!||FvXY0Q`UFe6^H^nE`z%NG^@}ujelPcmMJ* z02hr@7omM)DXI#aAl+kG+Umrw#7?hZ1d?c-yxOrv1pAXcN6xnQ``5kckr0$N-8*@2 zeD@GZO8qZ&u(f4TAhyqK68#6e_5u0tFMY))BfTE%bNjn*@_GDLuq1FP(Y<&GXua)? zAd}#zA2J3NRfCeWf+mC{ZS&wk?w6;vlG94d%OYvY9MzkJlZVq(std`wmup%*z>(BR zMG=$OYpS@a)0C|VX&ZT|GXNGo=h8(|o!iWJzWuGQK6(1=c$Bj;d-c50+V4O9(_f$c zU3z(GP#{5^i_A+U385pa1IDzxd5>PraSg zQwFuwR7)xK^RD1M(YF;3j~732~r%d;O*1(TlnvL{YUo2oa@~+QdXg& z8cED-?fR;J2JgXP_oQFcQ8=va!lH$PIY$3fRtou|+w^;Gf|yk|t$+Wck3RV9`O`U) zskpl~p(SBCwtRf_Y4YNDHEN*?Wnb=GxQ`BHHC1n(tSRovZR?+m`TWU~<8(4B#WtNr zQ@}V-u6AARrq}J&feNHZ7d0oIHD^Zy=yL#>NwTJ~MbvKk;qy*1Nm;!@F{B75iACvz zC>_+gQ<);n@hEvtggdz*$CrQp7!J5dkYS)Y4dEi76Ef+f$lMIHNs#bF4H{E>w;wO? zT9*1(Za!Jdw`k2JXBML%RpBtZ@X%@PeMz>>S57kLET|btGp*b!AkUULPdJbL^k0+} z$ySWk%2@ya03~!qSaf7zbY(hYa%Ew3WdJfTGBYhOF)cGOR4_3*G&njlIV&(QIxsML zdnr5s001R)MObuXVRU6WZEs|0W_bWIFfubOFflDNF;p-yIx{#rF*hqPFgh?WFbDV} P00000NkvXXu0mjf{5gWx diff --git a/HMCL/src/main/resources/assets/img/nbt/TAG_Long_Array.png b/HMCL/src/main/resources/assets/img/nbt/TAG_Long_Array.png index 8b962bc5c36389655c3574aeba7de6be3989b897..5ae2bf4afe804c0fc34f68177c9da395dfdbfd54 100644 GIT binary patch literal 253 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnF3?v&v(vJfv&H|6fVg?4;b`WOlfBG>MC@5Lt z8c`CQpH@mmtT}V`<;yxP?1)EPlzj@~ literal 1290 zcmZ{iSx{4V6vqGAxFLX9Ac9NP3RqFmfNiZ{TT!T>f{JLhRV$)c7ZfpWVGV>3Kny4( zH@VppiYtnu34|qVk_xV9i{J)C5>X*qwG}Ye>r7wz(1&w=^L;bl%$aje?#ieoZj|v9 z0DxOWcxW_6+NUBDus+?Nx*7v%`OIbcT)B?;($EI@@1s>dZi_Cu*;eG%Wxz1=rovT8EU6tf_Y%G8C26Y!xL# z10h$G-MynI+VP|(@nktpP)bm?;MGk)Y+7vSF!tEq3=iM7KA&8m@!%npG%X~pQa-S} z{^xygVrrQ5TybZwuc8*fEjVQ>L2C5mYt5E{&VH*8U&`bml$Ixdxob4B$5M2~(5pmCGRo zNpxlQk-|X~T`w%d=hqMjW&B{e?OfIJMWh{iS^oU%h2e^^i-i8MRU&U!_U-*8*clRT_YFi8=t-E!Uhu{ z&ifGvHfXvngTwDa!{Px`t>L_Wt!OTKhpAC8oq_hmN%RVtAH7L3) zDC%e*c$s7{&&h;?1=0lq$-Iohgj1L5UcCGpMY}9dr{zlNX$W->3@Hs;TJM~>b+@wf zetcO4RB)cGXdo(U09?KB^3BtomWyp2MOCIr1u9y)jJ_LoQyN|6dS;%|D@QRgzl`ZZSbsIwkG&;lG zgE`jIYuxw=6F-|Y8BF8^70M!@Qfsuj(%;HXo;rQztm0fbIDety;-$*VSM-LeYGX}p9jG@oG&VK2 zTx~V~eyy$j`VDaN*6lkTckli2XXpK{?gtMafyYmJdiyL-`>nQT1J7T)9F#c@UJ$^TE1v==0^kIN+AFM diff --git a/HMCL/src/main/resources/assets/img/nbt/TAG_Long_Array@2x.png b/HMCL/src/main/resources/assets/img/nbt/TAG_Long_Array@2x.png index a66fab0e71ee04fe07f7629f0fbda563bda57859..d1703d92d5808ac4749358f8cafcbe5980753af3 100644 GIT binary patch literal 274 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0J3?w7mbKU|e&H|6fVg?3lJ`iR+vu$BBP*AeO zHKHUqKdq!Zu_%?Hyu4g5GcUV1Ik6yBFTW^#_B$IXpd#G>pAc6z`z&t%5}EqRsx8ws z+Gc9C&$61Z(0%&y$b}n{mv1fKv~TUj8=J4({{G{~|Ns9}GtQd=HKuvGIEHu}Pfj?% zFTr`jm?PjQgQ`tKVvFVuZZ?BNmi|>-J&g>@7>*nZXn1F{NhD*AWpjMvwcX{5x!go~ z>{&At9<)R=viK-1S=BS&tN$X@`Y|Iz&h*vI64#wT Per51<^>bP0l+XkKYOY~d literal 1879 zcmV-d2dMaoP)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00006 zVoOIv0RI600RN!9r;`8x00(qQO+^Ri0UrVk2gBOoA^-pY5MyCZb^rhXfB*mhfB*mh z00000VA#83tN;K8KS@MER7l5tRa?(pRTUj$%(c(uJN{;KtcdAOc?+G8GsrJ04l)*04oUqneF&G%fBWU zO>Ag~1I0uVa0V7%m_P*u7PqM=j=lmI|A z2q*%;I%;jNEyD?xq~z?SCA(??l2H;&vuBg*g=su?;o|sc(Pg%#+Lie@o_qPvi_wA0 z)Y${i12^zAx%uBOl-@N>}gND(@ zB$vYB2roVV%l^b{t%*RAMgt+Mq++vv{PPb!d4S_9Be=;#r{BKy{_kG>`_lz(HU-C4mau0Hz;a8_(`LEDey!#s)wRCQIeXg3l1Ar-KC5fHyab>Cq9( zbm1DA1!<&ZmBb`D1C9gk+fSbT*&$*vnRJQSda>4M9n*%cks#7+aHU@S(?8}|pFFzu z#Frm>Im#xqxb-k&;iJtk)^eev?A?^|@!7v6aH zqh!-4Rlw}Yc~`Ey^6IY_^D~DZo;Z8<3m4Cg2)Kqb21@|N-ueqxLk?o@!PR)Sn4WC= zr!GJC#J4WrzX87c?90D+?QP6XuOJIx_ADOH9OFsRA|?$;K+Z@AIao(_Iz0p_0t7%U zIPSZ>MJg0C;C|rXqyL;*elY3RY95G18jLI>cca0QpA2omGwrm~fPrSTh!vxk?3@7m zz~1WSWIgX{RWq8=2&jQebDc1C3p9x&OxRgOrjrsHWC$=|A(c&8163)20FgAZB{C%> zz$``sV05@+bPQo9fP()M%;ENjKa~P7xD088Yyt&DLisW9TRB_?itfRk5vkG?DQst( z;8N*^1_v6*A?01Ae=Ld&eFC3GBqF!v@9HVC(uN5bYaf)Vb(fDCRqyj8yMV8%AmwhIkCSPy?IJs}Uy5nkwM1#u7-o z|7{?h65m#*!dSdJ$NH5SM0x!?T$otlxUPyq^16dV{QNtRvh6z`TANvI6vcfiw+ zeq{s9cWN=v1Mj@JUgm~aGTSg_?6lol4pHcxjfT-Q^q8L3O;=Zr6^#Y3*x`IbG>8X$Q&ivrVKmFCSwJqoBdpb>! zBvMHA{gY2U_2{`X6SvFf&Xyr}Rv<;fMOn&V9|Bg^;09kRWWl&L+JLZx~$zj@LRUSFt2Dr+}>Zo;*b=Acb z%fsctg&X7jpc`6KT7=8E<+3i`$FX&f$-3Qif0PyJ>+=m1uwW%ncJj(9y>)c-{pbI< zf9>jCTbo*^9c9^dH=^7ejigdtn-U^iO6IEQ%3uHX@4xh;8`nCtmbH|4{b+t;G;vWC zz`X5)O?3aP{ngjs8XsPd)iS!VtT2|JI=`BBeI(i7*eNJ$Am+~yLmL-%-J?n zOK~2tIC~al)C$?VkYx<+SC&#*iluN?IIa~6gc2d`VN_CL;GoXn zq0Zr@(Bh`i{`~#_{{R2~|D7h> z5&!@I+(|@1R5;7E(zikaK@^2yHjoraE+&HHAc$FlAh^Vpw8;Pd&tjoe%uXSAs#|=` zoHH)h0isB9aG*ufxophq1l-jr0Cqbp1ip8+`MMK<7oc?+GV(pR7XXh9Pe13?vnl{V z;8gaC6Cfi4+9%9Ys-%Y^!1y8PT-2=%fLZoCH5qtY17IZ_w>T=90GpI#tY~usGp}sT z8z3$No;8$YKwUvW0P3ON;~Y5K1vQqB$BLrp*A|Sl9iVprCjE~&urNehRIKfP85-iE oe91=F9CTkV^nd*Je+7Hy8*iX2&;zo7EC2ui07*qoM6N<$g2snKAOHXW literal 1227 zcmZ`&X;4#F82#=;vXKaCEiScMTd5l~pjM4q0Ush&C@Qw1wPg_$1cWRs0YX?rv_*_s ziwlbc-^)w#US1Xwl3*(;Rja5)s%@Q)TPvOZai%k^g@L3Gr+@mRKfZhBocYdo@0@$@ zq2#1^iD-rh03-<;@GTsMjF%vQTjz%dZ{=XZo|wcK0BcjwXnG*W=VWf!k_ey<1JKq1 zjBt|nHvp9s;P*5DSu;Rr;aSh-)c}|-c~eSU+N6TQpt5}vi}NJKdnM|8aao?YGEY*u zf1)N|tSt~%<%`uh5_N8nCT)^jA*d%Y+JI~%vQuC+U{(^j^gt89n7~Ry7YPmm>?AN9 z6HF>zZGlirA(jD;3A$KhXTZ^pS&IpfbCZi8D65ybrn*}F<(F#GOLQq42-3;AE z%tio9O|?8c|J47t-{)CoIm z**&AreZIeZL=4*oG>Nftk=ihxC%2c&2wpu{8O(bGS}tp@JGGt@(7_@viCN@AH3cjU zP8zvefbQLP{YK7B{dB4&)NsG;!8sLq66r1AB`~&7q&6ZGg}T~#%&$E|qr+okBV+wN zcd8d3G>+$KQ}HW{xE`P_;9|ya(8ezH^y#kNx-f9f*-zCOv=Q|!Jd%O#Qh~~V+KtF; z0)_^*6NqC-*Cf^*5;y4rPqgvQlNe(J)(qB8j4lhT&IneP@~h>dD!H&$A*fXe>l7lb zidR=CY)}dhtN0Ca!2yNfV4zKeUcC}U$hjt@JUdpW{aCM3r3htO^dL3rZd1LOIZ_NvPd;Wrjix!7Rz&p~&s3l96y}NwHO4;01 ztJlDLYolXg*Wq#N<2NL{zj0F{Bz+K_ym`yklx-h=v^{ml&P4cl*N(_fKE>10GctE) zW$)Yrd)MyB$=#QS=NIfROdS`BwplQd*`?F0XhgRBEcU)it$s2UgWrz`=$? zhd;~uyzxjAepGktcm-@E_Ak3U7#?#&$> z7<$6RHcuJ1UAa45nI+4VXK?^g(x@fj(#Y^7ktxzh*|O!bWh)j*r822>;WJC@e+>Ei Zcjsi6{l8(SHk8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10h>@v zR7Gh9OuRFeyfl`*HJ80Nn7%ogzB-z}JDR^do4-Aqz&@P7Kb*iooxnkz!9ktDLY~4z zpTb6F6w9s$0&~UZUbGOoUxYBpI(|EbldAihlyVQNW)qlO! zfWFp&zSo7o*oeZ|iNe{8#MzC++Kt89kjC4Q#@mv|+>^)Ll*rwd$=#R9-I>bXn#cHOX!Qbn`;OxcW?a1Tp$>Z+Jhjs^^V#h4+U)e-@AcvD_2KaL#@{{8;{{{R2~|16Oo zs{jB2(Md!>R9M69mPb3y$3MNF1h=KugLP1fCV8$FVBka#M^dc;9 znCX*M?z?T*%vZfL-LFR^67-`Kqy)g<2juAOZr`!r=b7tPGxUJEl^`3{%|sa;K(WTg zDdZ@k0My>IiF!5?15__;Qq8B50VHSKhJd3>uQixm0q(n(FoQu@9<)fg0HfLX4onjM z0&@+grr}-6IqPHy0TqydlfO30Dki;!W&jw`m1qP&D;m%OfFU%X9spNjEFj|@2%b!A zH?DGZb8TR7097bbyI0m?lpl=5)W6qUN)Je#~eCQCio@d&QGG5D3bD_X`7a?pR2a9wxl0^-6GHE^lU`LNO z8vu6Dfc!AD7k;?j!fe3mYgfg)BR1k!ed(i5CQ(V ZJ^`}k8gTP^OMU004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00006 zVoOIv0RI600RN!9r;`8x00(qQO+^Ri0UrT5Iy=SD+yDRo5MyCZb^rhXfB*mhfB*mh zAOHXWAaAA(H~;_!$w@>(R7l5-mfwqAR~5%UYwdl`J@?+3$(>0uV}2NIP-&PnQcGp1 zR-~mEBNP;WD~jTS;J=_g_$Y$D2nvd&`cnJ}wzl-8*w)%#k2UVh>wDJs`(0}OpdFAV?}6-Ueg57|xd(gkA_kU$8g&8~?`VC>Dg1~<7F zkV{VYUlS*)s-P{a%2bNEp)LTyGe8|P0;PdtA|43ps(3?-WEHRpN2Z8jCe)e)1B*%P zOzob*>k!Z^(Nv|ZJtGnKKu|;oiX_iqPI67r%^i1lM+6lB zbI%zAkA$SQRrNP@5Tp^4jImrBsaDv2GQq#6>_+1I5LgP{h(h zK~M@IIDj=gx<<@vOT+fS7v(eGJ~sc!9h9KpAduMn>-aA}dt~LA^_i_1Ox)GLiAKN) zlp)6mCRa6LTQ9A%=a6zW+_$v%?)&a!20A!M05Cy~?kCF6?YZ}-U;Nc*GztXG>gYrd zQw4R@#4^bx8D@=C9&r_s&9HT8{?gq)xRV{w0Y!{APA}(;?Jxz8z3}9?SEo+OYIY?$ zC}x(?)L z&UM>sJ6eNou*mh+JKtI(go>19*tKVt2nHE8HC!sER(H&9&6eoqYJYXD--=ax&<=G7 zTz{#1cCwqe`?VWxBDJ1<^&T{ACsI{3ix!{c(ZxAXR=`8IUwC8j_=0?pXKXIh_qf@8PE-rn5 z9w?Nw50rS+?9O*3058AyjAFNPZZ~;d^ z6p1GNmHv~D{$pon-_Ao_1R|7lk5orKdnj+#r%#?L>Y~V{OLNx21yanbdHHf{JsjyQ z?cN5Es}eOCNH7T=(jdI>?29iSe`R5Ie&$_MM5v&}{@eGCm+SKne`R^Jgv`f9{3DH{Vh&1lmyIYx3%guNK3y$ff%K0w`kdD6~msH)a(? zAUK+2F3s**nU@pc;U7K1It)RW|6s2_wUUbZivR2em3(!}-5obE*PO{tR#S1+kxxrU zMt1VKS6PJ~1ZEG;=9!!|-N^6PU1sE@^=5VXV_&$VUbx)vE~jd%)?qI4fDtX2g#gN# zbN{j6Z)^H*%fPh}Q@6WNlSNS7EQae-tNXq?_x108<)vS~^vW}*&YgNA)wXwW@4a8W zhc-B5&iwoJU=91`qSmxzZ{#4lU>KN?KpkAnL`BF|efyVgWdRBnzqh#Pi%ri!AwmS@ z?1>9c{^9ZN+EkHTNhT$zZnrfP;v_X`eqXnraYvnx9{Vr`0V5Wt;4B8Szp>-Ycg z{^_-uYOQM3rMM#3l5Sw(aCd2bt23v&qg9PBg)EpL+3ar*J-rb2^!7~AMkAuQp~iS^ z@bGUQdf+F&kFT|+PfzvM=Q?#KcoAF`6mm#!<>h|05rd%^&DeuB{U?A!$J@FgfBx}< z{Rh@M?{9VYP51WAU3_zS_05&R>Uz0Vbl0cbE7P;XU7fnCuFcc~akyD*ZNyEFValdw>t3u22BEC0m|D(+NWd@=HKCx0Rz$KH zc^tM>)kN_KI&pxxBA!z@jB(U4!G$^)5bF?gR99RwBM-Isd87zq@|)ue zAbB!aM5USV2#94H*CQYhotO}Zhy*4Bq)+;fHqV9{Q4(V|A;t5Rr(O{e6;B~=_Y%jm zo2aXaxw<$Z3;0OQH3SH_PDbE{@!Z^I+0ETW#1X5;{{=-LqdPC4P+I^103~!qSaf7z zbY(hYa%Ew3WdJfTGBYhOF)cGOR4_3*G&njlIV&(QIxsMLdnr5s001R)MObuXVRU6W zZEs|0W_bWIFfubOFflDNF;p-yIx{#rF*hqPFgh?WFbDV}00000NkvXXu0mjf`UbJz diff --git a/HMCL/src/main/resources/assets/img/nbt/TAG_String.png b/HMCL/src/main/resources/assets/img/nbt/TAG_String.png index 732baeeac90f13d594fcf33497516d520a102ee9..879aa1db527a0b46ef8694cbea0b6da2e4556054 100644 GIT binary patch literal 374 zcmV-+0g3*JP)@UdSE0mpht zn8DKp5Xc4=vzRazZnbkdW2{_d$k}=Y%~*JpRA*sH4z*IuO)Px5$_=2gWwnwrgAic( zzVRl#zKQ8tu#JaYS5QAO(MxsBG(x-DT0ytkSQ;z*XGv{BOS&{xbo_$kvMNi5#)>C` zE1g5slbtkVQ`f+QY6-%$xEDcioSm43!ERv;qoZs;A+zJY0VC&i^F^f>{`tGV0j(1z Upt3I?wg3PC07*qoM6N<$f{V45ivR!s literal 980 zcmZvZYfPGV6vqE-Yl_-howYR0lKN${$>Mxf)^?^Jv_h=uG+t1*w9P=o&MaiC;-y=b zIA=jERIG9p%1s54%lndLZKg46yd^qQ9SBamfS@Ac1@V5}+iYK!>?F^5a?Z)`d^o2* zJ1hNs=#@|a!1;_jgk01k-U|CJnycm6==CzA;f2BQ!f22i4GNP{3BxJ` zRwIZSF{{mHwMHw2Rpz<5Ij?tldV1RH_4@s*Yin!k>+2gEfxt#!Gq8z9a5L!ht-vs1 zRGOBSmTWfL=;-M7_V&)s&febM{{H^a;nDH&@yW?aFc>r=X1(0ta=A<97eYzh}mrJ>FLo*IyoGUOePx~ z9F#~TQmM4FvlBMKZNiS(*;%{YzP!A=u&}VWxajx$jYgx2uXap31_lP&+S=4=b$fez zM@NUjU{EwGr>3U*`udbgrO)SEU0v0q7vae#Cnudwr^Df}TCFQ9D_vb((q}T8-6j%= z#9}d*%aw3iCnhHP`}?(8txl&K9v-f%s}nsHcXxMtJf5MUA&bQ#tQU=pj99Ezr_1%% z)Rac675u~!a(@xCl?x2VxwBDkqb^wPhZ|E zMIQ(-MR(H)4NtzVtU!yi%#2Lp*(J=?ccMOf+LAzU&Wvv;s2_EjG`Aa z2&tsY7Al!sTvS4#(5OpJk7s_-Lvb#+aS<2azZ4lAC5#ZZJTKuj@tX^?Nx8WNIr;hd zd3N%c^-tS4f6S6MR(KsE@u4E2}Fj_uwjjL_%k(%-xVocTJL4nMe$4K|L=!M-kL{h{~sKEUCG)3-31qnM}nSvC5q-o2`@ z2l1(|Yj4we)zX3GTIbrcv|C z?tXLchxAzBElY{NdhPTIdZ!PW#4nh|6eca1`iO=aNQ4rT;-G}Mqy!R_m<-)a#^3lD nf|4O9?l}SfAAtUCacRlp{{is-0QA_!XcPb$#4LjGHo4|+kx2Td diff --git a/HMCL/src/main/resources/assets/img/nbt/TAG_String@2x.png b/HMCL/src/main/resources/assets/img/nbt/TAG_String@2x.png index 5f534e240993fd1cb5d28a8625e15c65bda1099f..7c7665481a532980640061223f419cd96c6653dd 100644 GIT binary patch literal 627 zcmV-(0*w8MP)k8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H10su)w zK~z}7?U&C_6Hyd}`LDmWSVJtP0gVY_BM@p7E5RSFMp4ulQ#Ce%fncbR7Oi3n8`QM5 zt&9|oi#LNVX3?t~qP?p#xsz|^%{%A4Hv{}-4g4bj{bBV1p@44T?B}D4j}tGgfSoZI z?>g<>jxe}m0~AjHH})h+jWOWdwh6Evh2gT(?K8mrebW%W#3A|B@1=pK#*cUzV!=jMm)ZlJwdrPE&%&486`@3jyH$Qm}ZyZfYGW6u;J$Zx+Hl+03_NyXW_N9-~iEvq^j?v=RG@d zPI~3bHA&5=(=!%8_llDRNh&x($^wu!A5Q{HZWGSufT^Cd@Xj?~{IA_f!ZcG_COw!-QAw88X&Th9#WaUeE36b`5nK}m zStL<*Tu@0;b0cjzxTa<pr%)@to=YIEmZ}(o(`LpiE>wjDi0D!Tl z2NbR&=(`M8>nfB>ex-x{4HuXT02C%0E%~qcJ`D7L!vFxi2LNJl0I;C5VnzTU0t^6S z2mpX&0DyTItIYct0IV84f5yixFN-M<5_o)^fQJ+E@jNb8z#|CxM1e3)BqE8$WU(Yp zA|~@iaa=)KVVXQACx^q~WM^k*Wo2=>Tpo`n5{ZODVG%_>Gcz+gJFC%Xv|8=L!ovLg z{LB}PfSd(+3c@hzow<7$K0ZDn zA)%|QtFyDSySuxmr$?ny4Gs>DkB`e_G8_&^Bof=(+dqB!M4?a!1VTzmijX9x(P(31 zV+{=radB}UKYmO~N)p70s8s6c=xB0sazR0XSS;odg-WIJ`Sa&>b#+{VAT>30cz8Gm z&#$PcC@U+gs;a84uCA%6sjaPj`}S=%j+ceWPE1T>$8ZJ)2IO*iNlA%9p~#HR8X6j6 zJ$_0ek^1}lb8~Y`OG|rud!wVH8yg#$kJ!!4&5S5keSQ7Ackk$EW>Zs>R4S!M(P>fi z^z?LE6qELV!Fa&rJR&i$AsI2jR7@}p8%mG8%OarIqzDcvGMjvl8-HJr{7{(kD3=nG zmyRi7;0kHjqKw#L22sRGTv=J6*(5mYwqaGkX?JM0RMnTDYu1K)!rayluiB|+Xlt}y z6{NFF0{zcM>KOB{PU;BKq@g1KKyTg?>f+<1yqLP^OF>w??kk$O78~-*hn}m=cfhs* zhaGGj?T^?6kOJ}Pm$;Xyc}kQUGVte$|oyx^uV_u~PRYK?xg zn&`15WS0dMIXda*Q5UF=1hsIBa}Ho5xz5x9dl|^k&bB(CYV{VdzdK_fWAZnL6!I&0 z;fE>%v&g9}bQPS6yooV`xA@p-ntt;8P)sxb6LqiY1fD-41c?1d8D3U&=;Eg7L6^%7 z=7)CFebrcwO&;CwWp2)VD3i4D++Ou+u}yHSyVJ6e?@36$8U#5Cp==Jn^TyVAJHy&{ z!AT-UWA%kdOGmt7M7yrr);PfeAL=bTM|k-*u1OT@P`mEumeHJ&0NSb7jTs4VfE}m3 zugP7b>X~60)}WJPH4VBEvF|Dg#2x%&1h*k;7U?cWbxvrZk%M|cDAbHq*e!DG!L5cI zB~mpe@k-^32nNC{l+A1Q#DpQCa}e6Th)P>Z{rh`g5~-paK{Jepsevx{txKPs$}upF zPjM$+Xh?Fo>mwsCv;A%UdWf6D4Z~_^V-YuN zF?LCmeGK-rYw&Z6-!bo`1AD#R5EN-3k{P#aXHA6K5{xi5HLBaXO@`2_)zN;UkF%}q zPf)%cOH0kom@oO^4Cp~~bK13vD_79V3&zqHOB&VUrk%e&UidPvq!sj4u4w0t;ItLj z(y=z(5dgcx-TcC@`-dYTfheR7z#;G<`vYLR1NL@4U^|F|6U5$mKNt)FgV9m@bZ_=Q kfsnh`Zv{R0U%=i8Vt3@90C=r-mo5N!x}AmQyCSfE1An6$Z~y=R diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 32f4abca59..ebb0b29f08 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -21,7 +21,7 @@ about=About about.copyright=Copyright -about.copyright.statement=Copyright © 2025 huangyuhui +about.copyright.statement=Copyright © 2026 huangyuhui about.author=Author about.author.statement=bilibili @huanghongxun about.claim=EULA @@ -60,6 +60,7 @@ account.create.microsoft=Add a Microsoft Account account.create.offline=Add an Offline Account account.create.authlibInjector=Add an authlib-injector Account account.email=Email +account.empty=No Accounts account.failed=Failed to refresh account. account.failed.character_deleted=The player has already been deleted. account.failed.connect_authentication_server=Failed to connect to the authentication server, your network connection may be down. @@ -74,6 +75,7 @@ account.failed.server_disconnected=Failed to connect to the authentication serve If the issue persists after multiple attempts, please try logging in to the account again. account.failed.server_response_malformed=Invalid server response. The authentication server may be down. account.failed.ssl=An SSL error occurred while connecting to the server. Please try updating your Java. +account.failed.dns=An SSL error occurred while connecting to the server. DNS resolution may be incorrect. Please try changing your DNS server or using a proxy service. account.failed.wrong_account=You have logged in to the wrong account. account.hmcl.hint=You need to click "Log in" and complete the process in the opened browser window. account.injector.add=New Auth Server @@ -99,31 +101,32 @@ account.methods=Login Type account.methods.authlib_injector=authlib-injector account.methods.microsoft=Microsoft account.methods.microsoft.birth=How to Change Your Account Birth Date +account.methods.microsoft.code=Code (Copied to Clipboard) account.methods.microsoft.close_page=Microsoft account authorization is now completed.\n\ \n\ There are some extra works for us, but you can safely close this tab for now. account.methods.microsoft.deauthorize=Deauthorize -account.methods.microsoft.error.add_family=An adult must add you to a family in order for you to play Minecraft because you are not yet 18 years old. -account.methods.microsoft.error.add_family_probably=Please check if the age indicated in your account settings is at least 18 years old. If not and you believe this is an error, you can click "How to Change Your Account Birth Date" to learn how to change it. +account.methods.microsoft.error.add_family=Please click here to change your account birth date to be over 18 years old, or add your account to a family. account.methods.microsoft.error.country_unavailable=Xbox Live is not available in your current country/region. -account.methods.microsoft.error.missing_xbox_account=Your Microsoft account does not have a linked Xbox account yet. Please click "Create Profile / Edit Profile Name" to create one before continuing. -account.methods.microsoft.error.no_character=Please ensure you have purchased Minecraft: Java Edition.\nIf you have already purchased the game, a profile may not have been created yet.\nClick "Create Profile / Edit Profile Name" to create it. -account.methods.microsoft.error.banned=Your account may have been banned by Xbox Live.\nYou can click "Ban Query" for more details. +account.methods.microsoft.error.missing_xbox_account=Your Microsoft account does not have a linked Xbox account yet. Please click here to link one. +account.methods.microsoft.error.no_character=Please confirm that you have purchased Minecraft: Java Edition.\n\ + If you have already purchased it, a game profile may not have been created. Please click here to create a game profile. +account.methods.microsoft.error.banned=Your account may have been banned by Xbox Live.\n\ + You can click here to check the ban status of your account. account.methods.microsoft.error.unknown=Failed to log in, error code: %d. -account.methods.microsoft.error.wrong_verify_method=Please log in using your password on the Microsoft account login page, and do not use a verification code to log in. +account.methods.microsoft.error.wrong_verify_method=Failed to log in. Please try logging into your account using PASSWORD instead of other login methods. account.methods.microsoft.logging_in=Logging in... -account.methods.microsoft.hint=Please click "Log in" and copy the code displayed here to complete the login process in the browser window that opens.\n\ - \n\ - If the token used to log in to the Microsoft account is leaked, you can click "Deauthorize" to deauthorize it. -account.methods.microsoft.manual=Your device code is %1$s. Please click here to copy.\n\ - \n\ - After clicking "Log in", you should complete the login process in the opened browser window. If that does not show up, you can navigate to %2$s manually.\n\ - \n\ - If the token used to log in to the Microsoft account is leaked, you can click "Deauthorize" to deauthorize it. account.methods.microsoft.makegameidsettings=Create Profile / Edit Profile Name +account.methods.microsoft.hint=Click the "Log in" button to start adding your Microsoft account. +account.methods.microsoft.manual=Please enter the code shown above on the pop-up webpage to complete the login.\n\ + \n\ + If the website fails to load, please open %s manually in your browser.\n\ + \n\ + If your internet connection is bad, it may cause web pages to load slowly or fail to load altogether. You may try again later or switch to a different internet connection. account.methods.microsoft.profile=Account Profile account.methods.microsoft.purchase=Buy Minecraft -account.methods.microsoft.snapshot=You are using an unofficial build of HMCL. Please download the official build to log in. +account.methods.microsoft.snapshot=You are using an unofficial build of HMCL. Please download the official build to log in. +account.methods.microsoft.snapshot.tooltip=You are using an unofficial build of HMCL. Please download the official build to refresh the account. account.methods.microsoft.snapshot.website=Official Website account.methods.offline=Offline account.methods.offline.name.special_characters=Use only letters, numbers, and underscores (max 16 chars) @@ -186,6 +189,7 @@ button.export=Export button.no=No button.ok=OK button.ok.countdown=OK (%d) +button.reset=Reset button.reveal_dir=Reveal button.refresh=Refresh button.remove=Remove @@ -197,7 +201,15 @@ button.select_all=Select All button.view=View button.yes=Yes -chat=Join Group Chat +contact=Feedback +contact.chat=Join Group Chat +contact.chat.discord=Discord +contact.chat.discord.statement=Welcome to join our Discord server. +contact.chat.qq_group=HMCL User QQ Group +contact.chat.qq_group.statement=Welcome to join our user QQ group. +contact.feedback=Feedback Channel +contact.feedback.github=GitHub Issues +contact.feedback.github.statement=Submit an issue on GitHub. color.recent=Recommended color.custom=Custom Color @@ -370,12 +382,14 @@ exception.access_denied=HMCL is unable to access the file "%s". It may be locked If not, please check if your user account has adequate permissions to access it. exception.artifact_malformed=Cannot verify the integrity of the downloaded files. exception.ssl_handshake=Failed to establish SSL connection because the SSL certificate is missing from the current Java installation. You can try opening HMCL with another Java installation and try again. +exception.dns.pollution=Failed to establish an SSL connection. DNS resolution may be incorrect. Please try changing your DNS server or using a proxy service. extension.bat=Windows Batch File extension.mod=Mod File extension.png=Image File extension.ps1=Windows PowerShell Script extension.sh=Shell Script +extension.command=macOS Shell Script fatal.create_hmcl_current_directory_failure=Hello Minecraft! Launcher cannot create the HMCL directory (%s). Please move HMCL to another location and reopen it. fatal.javafx.incompatible=Missing JavaFX environment.\n\ @@ -432,15 +446,6 @@ fatal.unsupported_platform.windows_arm64=Hello Minecraft! Launcher has provided If you are using the Qualcomm platform, you may need to install the OpenGL Compatibility Pack before playing games.\n\ Click the link to navigate to the Microsoft Store and install the compatibility pack. -feedback=Feedback -feedback.channel=Feedback Channel -feedback.discord=Discord -feedback.discord.statement=Welcome to join our Discord server. -feedback.github=GitHub Issues -feedback.github.statement=Submit an issue on GitHub. -feedback.qq_group=HMCL User QQ Group -feedback.qq_group.statement=Welcome to join our user QQ group. - file=File folder.config=Configs @@ -714,7 +719,9 @@ install.installer.depend=Requires %s install.installer.do_not_install=Do not install install.installer.fabric=Fabric install.installer.fabric-api=Fabric API -install.installer.fabric-api.warning=Warning: Fabric API is a mod and will be installed into the mod directory of the game instance. Please do not change the working directory of the game, or the Fabric API will not function. If you do want to change the directory, you should reinstall it. +install.installer.legacyfabric=Legacy Fabric +install.installer.legacyfabric-api=Legacy Fabric API +install.installer.fabric-quilt-api.warning=%1$s is a mod and will be installed into the mod directory of the game instance. Please do not change the working directory of the game, or the %1$s will not function. If you do want to change the directory, you should reinstall it. install.installer.forge=Forge install.installer.neoforge=NeoForge install.installer.game=Minecraft @@ -874,6 +881,7 @@ message.info=Information message.success=Operation successfully completed message.unknown=Unknown message.warning=Warning +message.question=Question modpack=Modpacks modpack.choose=Choose Modpack @@ -1059,7 +1067,9 @@ mods.category=Category mods.channel.alpha=Alpha mods.channel.beta=Beta mods.channel.release=Release -mods.check_updates=Update +mods.check_updates=Mod update process +mods.check_updates.button=Update +mods.check_updates.confirm=Update mods.check_updates.current_version=Current Version mods.check_updates.empty=All mods are up-to-date mods.check_updates.failed_check=Failed to check for updates. @@ -1067,7 +1077,6 @@ mods.check_updates.failed_download=Failed to download some files. mods.check_updates.file=File mods.check_updates.source=Source mods.check_updates.target_version=Target Version -mods.check_updates.update=Update mods.choose_mod=Choose mod mods.curseforge=CurseForge mods.dependency.embedded=Built-in Dependencies (Already packaged in the mod file by the author. No need to download separately) @@ -1099,6 +1108,14 @@ mods.install=Install mods.save_as=Save As mods.unknown=Unknown Mod +menu.undo=Undo +menu.redo=Redo +menu.cut=Cut +menu.copy=Copy +menu.paste=Paste +menu.deleteselection=Delete +menu.selectall=Select All + nbt.entries=%s entries nbt.open.failed=Failed to open file nbt.save.failed=Failed to save file @@ -1127,6 +1144,13 @@ world.chunkbase.end_city=End City world.chunkbase.seed_map=Seed Map world.chunkbase.stronghold=Stronghold world.chunkbase.nether_fortress=Nether Fortress +world.duplicate=Duplicate the World +world.duplicate.prompt=Please enter the name of the duplicated world +world.duplicate.failed.already_exists=Directory already exists +world.duplicate.failed.empty_name=Name cannot be empty +world.duplicate.failed.invalid_name=Name contains invalid characters +world.duplicate.failed=Failed to duplicate the world +world.duplicate.success.toast=Successfully duplicated the world world.datapack=Datapacks world.datetime=Last played on %s world.delete=Delete the World @@ -1139,6 +1163,15 @@ world.export.location=Save As world.export.wizard=Export World "%s" world.extension=World Archive world.game_version=Game Version +world.icon=World Icon +world.icon.change=Change world icon +world.icon.change.fail.load.title=Failed to parse image +world.icon.change.fail.load.text=This image appears to be corrupted, and HMCL cannot parse it. +world.icon.change.fail.not_64x64.title=Image size error +world.icon.change.fail.not_64x64.text=The image resolution is %d×%d instead of 64×64. Please provide a 64×64 image and try again. +world.icon.change.succeed.toast=Successfully updated the world icon. +world.icon.change.tip=A 64×64 PNG image is required. Images with an incorrect resolution cannot be parsed by Minecraft. +world.icon.choose.title=Select world icon world.import.already_exists=This world already exists. world.import.choose=Choose world archive you want to import world.import.failed=Failed to import this world: %s @@ -1153,6 +1186,7 @@ world.info.difficulty.peaceful=Peaceful world.info.difficulty.easy=Easy world.info.difficulty.normal=Normal world.info.difficulty.hard=Hard +world.info.difficulty_lock=Lock Difficulty world.info.failed=Failed to read the world info world.info.game_version=Game Version world.info.last_played=Last Played @@ -1173,6 +1207,7 @@ world.info.player.xp_level=Experience Level world.info.random_seed=Seed world.info.time=Game Time world.info.time.format=%s days +world.load.fail=Failed to load world world.locked=In use world.locked.failed=The world is currently in use. Please close the game and try again. world.manage=Worlds @@ -1206,6 +1241,11 @@ repositories.chooser=HMCL requires JavaFX to work.\n\ repositories.chooser.title=Choose download source for JavaFX resourcepack=Resource Packs +resourcepack.add=Add +resourcepack.manage=Resource Packs +resourcepack.download=Download +resourcepack.add.failed=Failed to add resource pack +resourcepack.delete.failed=Failed to delete resource pack resourcepack.download.title=Download Resource Pack - %1s reveal.in_file_manager=Reveal in File Manager @@ -1266,6 +1306,7 @@ settings.advanced.custom_commands.hint=The following environment variables are p \ · $INST_LITELOADER: set if LiteLoader is installed.\n\ \ · $INST_OPTIFINE: set if OptiFine is installed.\n\ \ · $INST_FABRIC: set if Fabric is installed.\n\ + \ · $INST_LEGACYFABRIC: set if Legacy Fabric is installed.\n\ \ · $INST_QUILT: set if Quilt is installed. settings.advanced.dont_check_game_completeness=Do not check game integrity settings.advanced.dont_check_jvm_validity=Do not check JVM compatibility @@ -1352,6 +1393,10 @@ settings.icon=Icon settings.launcher=Launcher Settings settings.launcher.appearance=Appearance +settings.launcher.brightness=Theme Mode +settings.launcher.brightness.auto=Follow System Settings +settings.launcher.brightness.dark=Dark Mode +settings.launcher.brightness.light=Light Mode settings.launcher.common_path.tooltip=HMCL will put all game assets and dependencies here. If there are existing libraries in the game directory, then HMCL will prefer to use them first. settings.launcher.debug=Debug settings.launcher.disable_auto_game_options=Do not switch game language @@ -1368,7 +1413,7 @@ settings.launcher.font.anti_aliasing.auto=Auto settings.launcher.font.anti_aliasing.gray=Grayscale settings.launcher.font.anti_aliasing.lcd=Sub-pixel settings.launcher.general=General -settings.launcher.language=Language (Applies After Restart) +settings.launcher.language=Language settings.launcher.launcher_log.export=Export Launcher Logs settings.launcher.launcher_log.export.failed=Failed to export logs. settings.launcher.launcher_log.export.success=Logs have been exported to "%s". @@ -1385,9 +1430,9 @@ settings.launcher.proxy.password=Password settings.launcher.proxy.port=Port settings.launcher.proxy.socks=SOCKS settings.launcher.proxy.username=Username -settings.launcher.theme=Theme +settings.launcher.theme=Theme Color settings.launcher.title_transparent=Transparent Titlebar -settings.launcher.turn_off_animations=Disable Animation (Applies After Restart) +settings.launcher.turn_off_animations=Disable Animation settings.launcher.version_list_source=Version List settings.launcher.background.settings.opacity=Opacity @@ -1402,6 +1447,7 @@ settings.memory.unit.mib=MiB settings.memory.used_per_total=%1$.1f GiB Used / %2$.1f GiB Total settings.physical_memory=Physical Memory Size settings.show_log=Show Logs +settings.enable_debug_log_output=Output debug log settings.tabs.installers=Loaders settings.take_effect_after_restart=Applies After Restart settings.type=Settings Type of Instance @@ -1412,6 +1458,8 @@ settings.type.special.enable=Enable Instance-specific Settings settings.type.special.edit=Edit Current Instance Settings settings.type.special.edit.hint=Current instance "%s" has enabled the "Instance-specific Settings". All options on this page will NOT affect that instance. Click here to edit its own settings. +shaderpack.download.title=Download Shader - %1s + sponsor=Donors sponsor.bmclapi=Downloads for the Chinese Mainland are provided by BMCLAPI. Click here for more information. sponsor.hmcl=Hello Minecraft! Launcher is a FOSS Minecraft launcher that allows users to manage multiple Minecraft instances easily. Click here for more information. @@ -1420,13 +1468,17 @@ system.architecture=Architecture system.operating_system=Operating System terracotta=Multiplayer -terracotta.easytier=About EasyTier terracotta.terracotta=Terracotta | Multiplayer terracotta.status=Lobby terracotta.back=Exit terracotta.feedback.title=Fill Out Feedback Form terracotta.feedback.desc=As HMCL updates Multiplayer Core, we hope you can take 10 seconds to fill out the feedback form. terracotta.sudo_installing=HMCL must verify your password before installing Multiplayer Core +terracotta.difficulty.easiest=Excellent network: almost connected! +terracotta.difficulty.simple=Good network: connection may take some time +terracotta.difficulty.medium=Average network: enabling fallback routes, though connection may fail +terracotta.difficulty.tough=Poor network: enabling fallback routes, though connection may fail +terracotta.difficulty.estimate_only=Success rate is an estimate based on host and client NAT types, for reference only! terracotta.from_local.title=Third-party download channels for Multiplayer Core terracotta.from_local.desc=In some areas, the built-in default download channel may be unstable. terracotta.from_local.guide=Please download Multiplayer Core package named %s. Once downloaded, drag the file into the current page to install it. @@ -1494,6 +1546,7 @@ terracotta.unsupported=Multiplayer is not yet supported on the current platform. terracotta.unsupported.os.windows.old=Multiplayer requires Windows 10 or later. Please update your system. terracotta.unsupported.arch.32bit=Multiplayer is not supported on 32-bit systems. Please upgrade to a 64-bit system. terracotta.unsupported.arch.loongarch64_ow=Multiplayer is not supported on Linux LoongArch64 Old World distributions. Please update to a New World distribution (such as AOSC OS). +terracotta.unsupported.region=The multiplayer feature is currently only available to users in Chinese Mainland and may not be available in your region. unofficial.hint=You are using an unofficial build of HMCL. We cannot guarantee its security. @@ -1515,7 +1568,7 @@ update.channel.nightly.hint=You are currently using a Nightly channel build of t Follow @huanghongxun on Bilibili to stay up to date on important HMCL news, or @Glavo to learn about HMCL development progress. update.channel.nightly.title=Nightly Channel Notice update.channel.stable=Release -update.checking=Checking for Updates +update.checking=Checking for updates update.failed=Failed to update update.found=Update Available! update.newest_version=Latest version: %s @@ -1526,7 +1579,7 @@ update.latest=This is the latest version update.no_browser=Cannot open in system browser. But we copied the link to your clipboard, and you can open it manually. update.tooltip=Update update.preview=Preview HMCL releases early -update.preview.tooltip=Enable this option to receive new versions of HMCL early for testing before their official release. +update.preview.subtitle=Enable this option to receive new versions of HMCL early for testing before their official release. version=Games version.name=Instance Name @@ -1547,6 +1600,7 @@ version.game.support_status.unsupported=Unsupported version.game.support_status.untested=Untested version.game.type=Type version.launch=Launch Game +version.launch_and_enter_world=Play World version.launch.empty=Start Game version.launch.empty.installing=Installing Game version.launch.empty.tooltip=Install and launch the latest official release @@ -1592,4 +1646,4 @@ wiki.version.game.snapshot=https://minecraft.wiki/w/Java_Edition_%s wizard.prev=< Prev wizard.failed=Failed wizard.finish=Finish -wizard.next=Next > +wizard.next=Next > \ No newline at end of file diff --git a/HMCL/src/main/resources/assets/lang/I18N_ar.properties b/HMCL/src/main/resources/assets/lang/I18N_ar.properties new file mode 100644 index 0000000000..eea4389812 --- /dev/null +++ b/HMCL/src/main/resources/assets/lang/I18N_ar.properties @@ -0,0 +1,1576 @@ +# +# Hello Minecraft! Launcher +# Copyright (C) 2025 huangyuhui and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +# Contributors: dxNeil, machinesmith42 +# and Byacrya for basically retranslating it + +about=حول +about.copyright=حقوق الطبع والنشر +about.copyright.statement=حقوق النشر © 2026 huangyuhui +about.author=المطور +about.author.statement=bilibili @huanghongxun +about.claim=اتفاقية الترخيص +about.claim.statement=انقر على هذا الرابط للحصول على النص الكامل. +about.dependency=المكتبات الخارجية +about.legal=الإقرار القانوني +about.thanks_to=شكر خاص لـ +about.thanks_to.bangbang93.statement=لتوفير مرآة تنزيل BMCLAPI. يرجى التبرع! +about.thanks_to.burningtnt.statement=ساهم بالكثير من الدعم الفني لـ HMCL. +about.thanks_to.contributors=جميع المساهمين على GitHub +about.thanks_to.contributors.statement=بدون مجتمع المصادر المفتوحة الرائع، لم يكن HMCL ليصل إلى هذا الحد. +about.thanks_to.gamerteam.statement=لتوفير صورة الخلفية الافتراضية. +about.thanks_to.glavo.statement=مسؤول عن صيانة HMCL. +about.thanks_to.zekerzhayard.statement=ساهم بالكثير من الدعم الفني لـ HMCL. +about.thanks_to.zkitefly.statement=مسؤول عن صيانة وثائق HMCL. +about.thanks_to.mcbbs=MCBBS (منتدى ماين كرافت الصيني) +about.thanks_to.mcbbs.statement=لتوفير مرآة تنزيل mcbbs.net لمستخدمي الصين القارية. (لم تعد متاحة) +about.thanks_to.mcim=mcmod-info-mirror +about.thanks_to.mcim.statement=لتوفير خدمة تسريع ذاكرة التخزين المؤقت لمعلومات التعديلات لمستخدمي الصين القارية. +about.thanks_to.mcmod=MCMod (mcmod.cn) +about.thanks_to.mcmod.statement=لتوفير الترجمات الصينية المبسطة والويكي لمختلف التعديلات. +about.thanks_to.red_lnn.statement=لتوفير صورة الخلفية الافتراضية. +about.thanks_to.shulkersakura.statement=لتوفير شعار HMCL. +about.thanks_to.users=أعضاء مجموعة مستخدمي HMCL +about.thanks_to.users.statement=شكراً على التبرعات، والإبلاغ عن الأخطاء، وما إلى ذلك. +about.thanks_to.yushijinhun.statement=لتوفير الدعم المتعلق بـ authlib-injector. +about.open_source=مفتوح المصدر +about.open_source.statement=GPL v3 (https://github.com/HMCL-dev/HMCL) + +account=الحسابات +account.cape=العباءة +account.character=اللاعب +account.choose=اختر لاعباً +account.create=إضافة حساب +account.create.microsoft=إضافة حساب Microsoft +account.create.offline=إضافة حساب غير متصل +account.create.authlibInjector=إضافة حساب authlib-injector +account.email=البريد الإلكتروني +account.failed=فشل تحديث الحساب. +account.failed.character_deleted=تم حذف اللاعب بالفعل. +account.failed.connect_authentication_server=فشل الاتصال بخادم المصادقة، قد يكون اتصال الشبكة معطلاً. +account.failed.connect_injector_server=فشل الاتصال بخادم المصادقة. يرجى التحقق من شبكتك والتأكد من إدخال عنوان URL الصحيح. +account.failed.injector_download_failure=فشل تنزيل authlib-injector. يرجى التحقق من شبكتك، أو محاولة التبديل إلى مصدر تنزيل مختلف. +account.failed.invalid_credentials=كلمة مرور غير صحيحة أو تم تجاوز الحد المسموح. يرجى المحاولة مرة أخرى لاحقاً. +account.failed.invalid_password=كلمة مرور غير صالحة. +account.failed.invalid_token=يرجى المحاولة بتسجيل الدخول مرة أخرى. +account.failed.migration=يحتاج حسابك إلى الترحيل إلى حساب Microsoft. إذا قمت بذلك بالفعل، يجب عليك تسجيل الدخول باستخدام حساب Microsoft المهاجر مرة أخرى. +account.failed.no_character=لا توجد شخصيات مرتبطة بهذا الحساب. +account.failed.server_disconnected=فشل الاتصال بخادم المصادقة. يمكنك تسجيل الدخول باستخدام الوضع غير المتصل أو محاولة تسجيل الدخول مرة أخرى.\n\ + إذا استمرت المشكلة بعد محاولات متعددة، يرجى محاولة تسجيل الدخول إلى الحساب مرة أخرى. +account.failed.server_response_malformed=استجابة خادم غير صالحة. قد يكون خادم المصادقة معطلاً. +account.failed.ssl=حدث خطأ SSL أثناء الاتصال بالخادم. يرجى محاولة تحديث Java الخاص بك. +account.failed.dns=حدث خطأ SSL عند الاتصال بالخادم. قد تكون هناك مشكلة في تحليل أسماء النطاقات (DNS). يرجى محاولة تغيير خادم DNS أو استخدام خدمة وكيل. +account.failed.wrong_account=لقد قمت بتسجيل الدخول إلى حساب خاطئ. +account.hmcl.hint=يجب عليك النقر على "تسجيل الدخول" وإكمال العملية في نافذة المتصفح المفتوحة. +account.injector.add=خادم مصادقة جديد +account.injector.empty=لا يوجد (يمكنك النقر على "+" لإضافة واحد) +account.injector.http=تحذير: يستخدم هذا الخادم بروتوكول HTTP غير الآمن. يمكن لأي شخص يعترض اتصالك رؤية بيانات اعتمادك بنص عادي. +account.injector.link.homepage=الصفحة الرئيسية +account.injector.link.register=التسجيل +account.injector.server=خادم المصادقة +account.injector.server_url=عنوان URL للخادم +account.injector.server_name=اسم الخادم +account.login=تسجيل الدخول +account.login.hint=نحن لا نحفظ كلمة المرور أبداً. +account.login.skip=تسجيل الدخول دون اتصال +account.login.retry=إعادة المحاولة +account.login.refresh=تسجيل الدخول مرة أخرى +account.login.refresh.microsoft.hint=يجب عليك تسجيل الدخول إلى حساب Microsoft الخاص بك مرة أخرى لأن ترخيص الحساب غير صالح. +account.login.restricted=قم بتسجيل الدخول إلى حساب Microsoft الخاص بك لتفعيل هذه الميزة +account.logout=تسجيل الخروج +account.register=التسجيل +account.manage=قائمة الحسابات +account.copy_uuid=نسخ UUID للحساب +account.methods=نوع تسجيل الدخول +account.methods.authlib_injector=authlib-injector +account.methods.microsoft=Microsoft +account.methods.microsoft.birth=كيفية تغيير تاريخ ميلاد حسابك +account.methods.microsoft.close_page=اكتمل ترخيص حساب Microsoft الآن.\n\ + \n\ + هناك بعض الأعمال الإضافية لنا، ولكن يمكنك إغلاق هذه النافذة بأمان الآن. +account.methods.microsoft.deauthorize=إلغاء الترخيص +account.methods.microsoft.makegameidsettings=إنشاء ملف تعريف / تعديل اسم الملف الشخصي +account.methods.microsoft.profile=ملف تعريف الحساب +account.methods.microsoft.purchase=شراء Minecraft +account.methods.offline=غير متصل +account.methods.offline.name.special_characters=استخدم الحروف والأرقام والشرطات السفلية فقط (بحد أقصى 16 حرفاً) +account.methods.offline.name.invalid=يوصى باستخدام الحروف الإنجليزية والأرقام والشرطات السفلية فقط لاسم المستخدم، ويجب ألا يتجاوز الطول 16 حرفاً.\n\ + \n\ + \ · مشروع: HuangYu, huang_Yu, Huang_Yu_123؛\n\ + \ · غير مشروع: Huang Yu, Huang-Yu_%%%, Huang_Yu_hello_world_hello_world.\n\ + \n\ + استخدام اسم المستخدم غير الصالح سيمنعك من الانضمام إلى معظم الخوادم وقد يتعارض مع بعض التعديلات، مما يتسبب في تعطل اللعبة. +account.methods.offline.uuid=UUID +account.methods.offline.uuid.hint=UUID هو معرف فريد للاعبي Minecraft، وقد ينشئ كل مشغل UUIDs بشكل مختلف. تغييره إلى UUID الذي تم إنشاؤه بواسطة مشغلات أخرى يسمح لك بالاحتفاظ بعناصرك في مخزون حسابك غير المتصل.\n\ + \n\ + هذا الخيار للمستخدمين المتقدمين فقط. لا نوصي بتغيير هذا الخيار ما لم تعرف ما تفعله. +account.methods.offline.uuid.malformed=تنسيق غير صالح. +account.methods.forgot_password=نسيت كلمة المرور +account.methods.ban_query=استعلام الحظر +account.missing=لا توجد حسابات +account.missing.add=انقر هنا لإضافة واحد. +account.move_to_global=تحويل إلى حساب عام\nسيتم حفظ معلومات الحساب في ملف التكوين لدليل المستخدم الحالي للنظام. +account.move_to_portable=تحويل إلى حساب محمول\nسيتم حفظ معلومات الحساب في ملف التكوين في نفس الدليل الخاص بـ HMCL. +account.not_logged_in=غير مسجل الدخول +account.password=كلمة المرور +account.portable=محمول +account.skin=المظهر +account.skin.file=ملف المظهر +account.skin.model=النموذج +account.skin.model.default=كلاسيكي +account.skin.model.slim=نحيف +account.skin.type.alex=Alex +account.skin.type.csl_api=Blessing Skin +account.skin.type.csl_api.location=العنوان +account.skin.type.csl_api.location.hint=عنوان URL لـ CustomSkinAPI +account.skin.type.little_skin=LittleSkin +account.skin.type.little_skin.hint=يجب عليك إنشاء لاعب بنفس اسم اللاعب لحسابك غير المتصل على موقع مزود المظهر الخاص بك. سيتم الآن تعيين مظهرك على المظهر المخصص للاعبك على موقع مزود المظهر. +account.skin.type.local_file=ملف مظهر محلي +account.skin.type.steve=Steve +account.skin.upload=رفع/تحرير المظهر +account.skin.upload.failed=فشل رفع المظهر. +account.skin.invalid_skin=ملف مظهر غير صالح. +account.username=اسم المستخدم + +archive.author=المؤلف (المؤلفون) +archive.date=تاريخ النشر +archive.file.name=اسم الملف +archive.version=الإصدار + +assets.download=تنزيل الأصول +assets.download_all=التحقق من سلامة الأصول +assets.index.malformed=ملفات فهرس الأصول المحملة تالفة. يمكنك حل هذه المشكلة بالنقر على "إدارة ← تحديث أصول اللعبة" في صفحة "تحرير النسخة". + +button.cancel=إلغاء +button.change_source=تغيير مصدر التنزيل +button.clear=مسح +button.copy_and_exit=نسخ والخروج +button.delete=حذف +button.do_not_show_again=لا تظهر مرة أخرى +button.edit=تحرير +button.install=تثبيت +button.export=تصدير +button.no=لا +button.ok=موافق +button.ok.countdown=موافق (%d) +button.reveal_dir=إظهار +button.refresh=تحديث +button.remove=إزالة +button.remove.confirm=هل أنت متأكد من أنك تريد إزالته نهائياً؟ لا يمكن التراجع عن هذا الإجراء! +button.retry=إعادة المحاولة +button.save=حفظ +button.save_as=حفظ باسم +button.select_all=تحديد الكل +button.view=عرض +button.yes=نعم + +contact=الملاحظات +contact.chat=الانضمام إلى دردشة جماعية +contact.chat.discord=Discord +contact.chat.discord.statement=مرحباً بك للانضمام إلى خادم Discord الخاص بنا. +contact.chat.qq_group=مجموعة QQ لمستخدمي HMCL +contact.chat.qq_group.statement=مرحباً بك للانضمام إلى مجموعة QQ الخاصة بمستخدمينا. +contact.feedback=قناة الملاحظات +contact.feedback.github=GitHub Issues +contact.feedback.github.statement=إرسال مشكلة على GitHub. + +color.recent=موصى به +color.custom=لون مخصص + +crash.NoClassDefFound=يرجى التحقق من سلامة هذا البرنامج، أو محاولة تحديث Java الخاص بك. +crash.user_fault=تعطل المشغل بسبب Java أو بيئة النظام التالفة. يرجى التأكد من تثبيت Java أو نظام التشغيل بشكل صحيح. + +curse.category.0=الكل + +# https://addons-ecs.forgesvc.net/api/v2/category/section/4471 +curse.category.4474=خيال علمي +curse.category.4481=صغير / خفيف +curse.category.4483=قتال +curse.category.4477=لعبة صغيرة +curse.category.4478=مهام +curse.category.4484=متعدد اللاعبين +curse.category.4476=استكشاف +curse.category.4736=Skyblock +curse.category.4475=مغامرة وRPG +curse.category.4487=FTB +curse.category.4480=قائم على الخريطة +curse.category.4479=Hardcore +curse.category.4482=كبير جداً +curse.category.4472=تقني +curse.category.4473=سحر +curse.category.5128=Vanilla+ +curse.category.7418=رعب + +# https://addons-ecs.forgesvc.net/api/v2/category/section/6 +curse.category.5299=تعليمي +curse.category.5232=Galacticraft +curse.category.5129=Vanilla+ +curse.category.5189=فائدة وجودة الحياة +curse.category.6814=أداء +curse.category.6954=Integrated Dynamics +curse.category.6484=Create +curse.category.6821=إصلاح الأخطاء +curse.category.6145=Skyblock +curse.category.5190=جودة الحياة +curse.category.5191=فائدة وجودة الحياة +curse.category.5192=FancyMenu +curse.category.423=خريطة ومعلومات +curse.category.426=إضافات +curse.category.434=دروع وأدوات وأسلحة +curse.category.409=هياكل +curse.category.4485=Blood Magic +curse.category.420=تخزين +curse.category.429=Industrial Craft +curse.category.419=سحر +curse.category.412=تكنولوجيا +curse.category.4557=Redstone +curse.category.428=Tinker's Construct +# ' +curse.category.414=نقل اللاعبين +curse.category.4486=Lucky Blocks +curse.category.432=Buildcraft +curse.category.418=علم الوراثة +curse.category.4671=تكامل Twitch +curse.category.5314=KubeJS +curse.category.408=خامات وموارد +curse.category.4773=CraftTweaker +curse.category.430=Thaumcraft +curse.category.422=مغامرة وRPG +curse.category.413=معالجة +curse.category.417=طاقة +curse.category.415=نقل الطاقة والسوائل والعناصر +curse.category.433=Forestry +curse.category.425=متنوع +curse.category.4545=Applied Energistics 2 +curse.category.416=زراعة +curse.category.421=API ومكتبة +curse.category.4780=Fabric +curse.category.424=تجميلي +curse.category.406=توليد العالم +curse.category.435=أداة الخادم +curse.category.411=كائنات +curse.category.407=بيئات حيوية +curse.category.427=Thermal Expansion +curse.category.410=أبعاد +curse.category.436=طعام +curse.category.4558=Redstone +curse.category.4843=أتمتة +curse.category.4906=MCreator +curse.category.7669=Twilight Forest + +# https://addons-ecs.forgesvc.net/api/v2/category/section/12 +curse.category.5244=حزم الخطوط +curse.category.5193=حزم البيانات +curse.category.399=بخاري +curse.category.396=128x +curse.category.398=512x وأعلى +curse.category.397=256x +curse.category.405=متنوع +curse.category.395=64x +curse.category.400=واقعي فوتوغرافي +curse.category.393=16x +curse.category.403=تقليدي +curse.category.394=32x +curse.category.404=متحرك +curse.category.4465=دعم التعديلات +curse.category.402=قروسطي +curse.category.401=حديث + +# https://addons-ecs.forgesvc.net/api/v2/category/section/17 +curse.category.4464=عالم معدل +curse.category.250=خريطة اللعبة +curse.category.249=إبداع +curse.category.251=باركور +curse.category.253=البقاء على قيد الحياة +curse.category.248=مغامرة +curse.category.252=لغز + +# https://addons-ecs.forgesvc.net/api/v2/category/section/4546 +curse.category.4551=Hardcore Questing Mode +curse.category.4548=Lucky Blocks +curse.category.4556=تقدم +curse.category.4752=Building Gadgets +curse.category.4553=CraftTweaker +curse.category.4554=وصفات +curse.category.4549=دليل +curse.category.4547=تكوين +curse.category.4550=مهام +curse.category.4555=توليد العالم +curse.category.4552=نصوص برمجية + +curse.sort.author=المؤلف +curse.sort.date_created=تاريخ الإنشاء +curse.sort.last_updated=آخر تحديث +curse.sort.name=الاسم +curse.sort.popularity=الشعبية +curse.sort.total_downloads=إجمالي التنزيلات + +datetime.format=MMM d, yyyy, h\:mm\:ss a + +download=تنزيل +download.hint=تثبيت الألعاب وحزم التعديلات أو تنزيل التعديلات وحزم الموارد والظلال والعوالم. +download.code.404=لم يتم العثور على الملف "%s" على الخادم البعيد. +download.content=إضافات +download.shader=الظلال +download.curseforge.unavailable=هذا الإصدار من HMCL لا يدعم الوصول إلى CurseForge. يرجى استخدام الإصدار الرسمي للوصول إلى CurseForge. +download.existing=لا يمكن حفظ الملف لأنه موجود بالفعل. يمكنك النقر على "حفظ باسم" لحفظ الملف في مكان آخر. +download.external_link=زيارة موقع التنزيل +download.failed=فشل تنزيل "%1$s"، رمز الاستجابة: %2$d. +download.failed.empty=لا توجد إصدارات متاحة. يرجى النقر هنا للعودة. +download.failed.no_code=فشل التنزيل +download.failed.refresh=فشل في جلب قائمة الإصدارات. يرجى النقر هنا لإعادة المحاولة. +download.game=New Game +download.provider.bmclapi=BMCLAPI (bangbang93, https://bmclapi2.bangbang93.com/) +download.provider.mojang=الرسمي (OptiFine يتم توفيره بواسطة BMCLAPI) +download.provider.official=من المصادر الرسمية +download.provider.balanced=من الأسرع المتاح +download.provider.mirror=من المرآة +download.java=تنزيل Java +download.java.override=هذا الإصدار من Java موجود بالفعل. هل تريد إلغاء التثبيت وإعادة التثبيت؟ +download.java.process=عملية تنزيل Java +download.javafx=تنزيل التبعيات للمشغل... +download.javafx.notes=نحن نقوم حالياً بتنزيل تبعيات HMCL من الإنترنت.\n\ + \n\ + يمكنك النقر على "تغيير مصدر التنزيل" لاختيار مصدر التنزيل، أو\nالنقر على "إلغاء" للتوقف والخروج.\n\ + ملاحظة: إذا كانت سرعة التنزيل بطيئة جداً، يمكنك محاولة التبديل إلى مرآة أخرى. +download.javafx.component=تنزيل الوحدة "%s" +download.javafx.prepare=التحضير للتنزيل +download.speed.byte_per_second=%d B/s +download.speed.kibibyte_per_second=%.1f KiB/s +download.speed.megabyte_per_second=%.1f MiB/s + +exception.access_denied=HMCL غير قادر على الوصول إلى الملف "%s". قد يكون مقفلاً بواسطة عملية أخرى.\n\ + \n\ + لمستخدمي Windows، يمكنك فتح "مراقب الموارد" للتحقق مما إذا كانت عملية أخرى تستخدمه حالياً. إذا كان الأمر كذلك، يمكنك المحاولة مرة أخرى بعد إنهاء تلك العملية.\n\ + إذا لم يكن الأمر كذلك، يرجى التحقق مما إذا كان حساب المستخدم الخاص بك لديه أذونات كافية للوصول إليه. +exception.artifact_malformed=لا يمكن التحقق من سلامة الملفات المحملة. +exception.ssl_handshake=فشل إنشاء اتصال SSL لأن شهادة SSL مفقودة من تثبيت Java الحالي. يمكنك محاولة فتح HMCL باستخدام تثبيت Java آخر والمحاولة مرة أخرى. +exception.dns.pollution=فشل إنشاء اتصال SSL. قد تكون هناك مشكلة في تحليل أسماء النطاقات (DNS). يرجى محاولة تغيير خادم DNS أو استخدام خدمة وكيل. + +extension.bat=ملف Batch لـ Windows +extension.mod=ملف التعديل +extension.png=ملف صورة +extension.ps1=نص PowerShell لـ Windows +extension.sh=نص Shell + +fatal.create_hmcl_current_directory_failure=Hello Minecraft! Launcher لا يمكنه إنشاء دليل HMCL (%s). يرجى نقل HMCL إلى موقع آخر وإعادة فتحه. +fatal.javafx.incompatible=بيئة JavaFX مفقودة.\n\ + Hello Minecraft! Launcher لا يمكنه تثبيت JavaFX تلقائياً على Java <11.\n\ + يرجى تحديث Java الخاص بك إلى الإصدار 11 أو أحدث. +fatal.javafx.incomplete=بيئة JavaFX غير مكتملة.\n\ + يرجى محاولة استبدال Java الخاص بك أو إعادة تثبيت OpenJFX. +fatal.javafx.missing=بيئة JavaFX مفقودة. يرجى فتح Hello Minecraft! Launcher باستخدام Java، والذي يتضمن OpenJFX. +fatal.config_change_owner_root=أنت تستخدم حساب المسؤول (root) لفتح Hello Minecraft! Launcher. قد يمنعك هذا من فتح HMCL بحساب آخر في المستقبل.\n\ + هل تريد الاستمرار؟ +fatal.config_in_temp_dir=أنت تفتح Hello Minecraft! Launcher في دليل مؤقت. قد تفقد إعداداتك وبيانات اللعبة.\n\ + يوصى بنقل HMCL إلى موقع آخر وإعادة فتحه.\n\ + هل تريد الاستمرار؟ +fatal.config_loading_failure=لا يمكن تحميل ملفات التكوين.\n\ + يرجى التأكد من أن Hello Minecraft! Launcher لديه حق الوصول للقراءة والكتابة إلى "%s" والملفات الموجودة فيه.\n\ + بالنسبة لـ macOS، حاول وضع HMCL في مكان بأذونات غير "سطح المكتب" أو "التنزيلات" أو "المستندات" والمحاولة مرة أخرى. +fatal.config_loading_failure.unix=Hello Minecraft! Launcher لم يتمكن من تحميل ملف التكوين لأنه تم إنشاؤه بواسطة المستخدم "%1$s".\n\ + يرجى فتح HMCL كمستخدم مسؤول (غير موصى به)، أو تنفيذ الأمر التالي في الطرفية لتغيير ملكية ملف التكوين إلى المستخدم الحالي:\n%2$s +fatal.config_unsupported_version=تم إنشاء ملف التكوين الحالي بواسطة إصدار أحدث من Hello Minecraft! Launcher، وهذا الإصدار من HMCL لا يمكنه تحميله بشكل صحيح.\n\ + يرجى التحديث وإعادة تشغيل HMCL.\n\ + قبل تحديث المشغل، لن يتم حفظ أي إعدادات تقوم بتعديلها. +fatal.mac_app_translocation=Hello Minecraft! Launcher معزول إلى دليل مؤقت بواسطة نظام التشغيل بسبب آليات أمان macOS.\n\ + يرجى نقل HMCL إلى دليل مختلف قبل محاولة الفتح. وإلا، قد تفقد إعداداتك وبيانات اللعبة بعد إعادة التشغيل.\n\ + هل تريد الاستمرار؟ +fatal.migration_requires_manual_reboot=تم ترقية Hello Minecraft! Launcher. يرجى إعادة تشغيل المشغل. +fatal.apply_update_failure=نأسف، ولكن Hello Minecraft! Launcher غير قادر على التحديث تلقائياً.\n\ + \n\ + يمكنك التحديث يدوياً عن طريق تنزيل إصدار أحدث من المشغل من %s.\n\ + إذا استمرت المشكلة، يرجى التفكير في الإبلاغ عن ذلك لنا. +fatal.apply_update_need_win7=Hello Minecraft! Launcher لا يمكنه التحديث تلقائياً على Windows XP/Vista.\n\ + \n\ + يمكنك التحديث يدوياً عن طريق تنزيل إصدار أحدث من المشغل من %s. +fatal.deprecated_java_version=سيتطلب HMCL Java 17 أو أحدث للتشغيل في المستقبل، ولكنه سيستمر في دعم تشغيل الألعاب باستخدام Java 6~16.\n\ +\n\ +يوصى بتثبيت أحدث إصدار من Java لضمان عمل HMCL بشكل صحيح.\n\ +\n\ +يمكنك الاستمرار في الاحتفاظ بالإصدار القديم من Java. يمكن لـ HMCL التعرف على إصدارات Java المتعددة وإدارتها وسيختار تلقائياً Java المناسب لك بناءً على إصدار اللعبة. +fatal.deprecated_java_version.update=سيتطلب HMCL Java 17 أو أحدث للتشغيل في المستقبل. يرجى تثبيت أحدث إصدار من Java لضمان قدرة HMCL على إكمال الترقية.\n\ +\n\ +يمكنك الاستمرار في الاحتفاظ بالإصدار القديم من Java.\ +يمكن لـ HMCL التعرف على إصدارات Java المتعددة وإدارتها وسيختار تلقائياً Java المناسب لك بناءً على إصدار اللعبة. +fatal.deprecated_java_version.download_link=تنزيل Java %d +fatal.samba=إذا فتحت Hello Minecraft! Launcher من محرك شبكة Samba، فقد لا تعمل بعض الميزات. يرجى محاولة تحديث Java الخاص بك أو نقل المشغل إلى دليل آخر. +fatal.illegal_char=يحتوي مسار المستخدم الخاص بك على حرف غير قانوني "=". لن تتمكن من استخدام authlib-injector أو تغيير مظهر حسابك غير المتصل. +fatal.unsupported_platform=Minecraft غير مدعوم بالكامل على منصتك حتى الآن، لذلك قد تواجه ميزات مفقودة أو حتى تكون غير قادر على تشغيل اللعبة.\n\ + \n\ + إذا لم تتمكن من تشغيل Minecraft 1.17 وما بعده، يمكنك محاولة تبديل "العارض" إلى "البرمجيات" في "الإعدادات العامة/الخاصة بالنسخة ← الإعدادات المتقدمة" لاستخدام عرض CPU لتوافق أفضل. +fatal.unsupported_platform.loongarch=Hello Minecraft! Launcher وفر دعماً لمنصة Loongson.\n\ + إذا واجهت مشاكل عند لعب اللعبة، يمكنك زيارة https://docs.hmcl.net/groups.html للحصول على المساعدة. +fatal.unsupported_platform.macos_arm64=Hello Minecraft! Launcher وفر دعماً لمنصة Apple silicon، باستخدام Java ARM الأصلي لتشغيل الألعاب للحصول على تجربة ألعاب أكثر سلاسة.\n\ + إذا واجهت مشاكل عند لعب اللعبة، فإن تشغيل اللعبة باستخدام Java بناءً على بنية x86-64 قد يوفر توافقاً أفضل. +fatal.unsupported_platform.windows_arm64=Hello Minecraft! Launcher وفر دعماً أصلياً لمنصة Windows on Arm. إذا واجهت مشاكل عند لعب اللعبة، يرجى محاولة تشغيل اللعبة باستخدام Java بناءً على بنية x86.\n\ + \n\ + إذا كنت تستخدم منصة Qualcomm، فقد تحتاج إلى تثبيت حزمة توافق OpenGL قبل لعب الألعاب.\n\ + انقر على الرابط للانتقال إلى متجر Microsoft وتثبيت حزمة التوافق. + +file=ملف + +folder.config=التكوينات +folder.game=دليل العمل +folder.logs=السجلات +folder.mod=التعديلات +folder.resourcepacks=حزم الموارد +folder.shaderpacks=حزم الظلال +folder.saves=الحفظ +folder.schematics=المخططات +folder.screenshots=لقطات الشاشة +folder.world=دليل العالم + +game=الألعاب +game.crash.feedback=يرجى عدم مشاركة لقطات شاشة أو صور لهذه الواجهة مع الآخرين! إذا طلبت المساعدة من الآخرين، يرجى النقر على "تصدير سجلات التعطل" وإرسال الملف المصدر للآخرين للتحليل. +game.crash.info=معلومات التعطل +game.crash.reason=سبب التعطل +game.crash.reason.analyzing=جاري التحليل... +game.crash.reason.multiple=تم اكتشاف عدة أسباب:\n\n +game.crash.reason.block=تعطلت اللعبة بسبب كتلة في العالم.\n\ + \n\ + يمكنك محاولة إزالة هذه الكتلة باستخدام MCEdit أو حذف التعديل الذي أضافها.\n\ + \n\ + نوع الكتلة: %1$s\n\ + الموقع: %2$s +game.crash.reason.bootstrap_failed=تعطلت اللعبة بسبب التعديل "%1$s".\n\ + \n\ + يمكنك محاولة حذفه أو تحديثه. +game.crash.reason.config=تعطلت اللعبة لأن التعديل "%1$s" لم يتمكن من تحليل ملف التكوين الخاص به "%2$s". +game.crash.reason.debug_crash=تعطلت اللعبة لأنك قمت بتشغيله يدوياً. لذا ربما تعرف السبب :) +game.crash.reason.duplicated_mod=لا يمكن للعبة الاستمرار في التشغيل بسبب التعديلات المكررة "%1$s".\n\ + \n\ + %2$s\n\ + \n\ + يمكن تثبيت كل تعديل مرة واحدة فقط. يرجى حذف التعديل المكرر والمحاولة مرة أخرى. +game.crash.reason.entity=تعطلت اللعبة بسبب كيان في العالم.\n\ + \n\ + يمكنك محاولة إزالة الكيان باستخدام MCEdit أو حذف التعديل الذي أضافه.\n\ + \n\ + نوع الكيان: %1$s\n\ + الموقع: %2$s +game.crash.reason.modmixin_failure=تعطلت اللعبة لأن بعض التعديلات فشلت في الحقن.\n\ + \n\ + هذا يعني عموماً أن التعديل به خطأ أو غير متوافق مع البيئة الحالية.\n\ + \n\ + يمكنك التحقق من السجل للعثور على التعديل الخاطئ. +game.crash.reason.mod_repeat_installation=تعطلت اللعبة بسبب التعديلات المكررة.\n\ + \n\ + يمكن تثبيت كل تعديل مرة واحدة فقط. يرجى حذف التعديل المكرر ثم إعادة تشغيل اللعبة. +game.crash.reason.forge_error=قد يكون Forge/NeoForge قد قدم معلومات الخطأ.\n\ + \n\ + يمكنك عرض السجل والمعالجة وفقاً لمعلومات السجل في تقرير الخطأ.\n\ + \n\ + إذا لم تشاهد رسالة الخطأ، يمكنك عرض تقرير الخطأ لفهم كيفية حدوث الخطأ.\n\ + %1$s +game.crash.reason.mod_resolution0=تعطلت اللعبة بسبب بعض مشاكل التعديلات. يمكنك التحقق من السجلات للعثور على التعديل (التعديلات) الخاطئ. +game.crash.reason.mixin_apply_mod_failed=تعطلت اللعبة لأنه لا يمكن تطبيق mixin على التعديل "%1$s".\n\ + \n\ + يمكنك محاولة حذف أو تحديث التعديل لحل المشكلة. +game.crash.reason.java_version_is_too_high=تعطلت اللعبة لأن إصدار Java حديث جداً للاستمرار في التشغيل.\n\ + \n\ + يرجى استخدام إصدار Java الرئيسي السابق في "الإعدادات العامة/الخاصة بالنسخة ← Java" ثم تشغيل اللعبة.\n\ + \n\ + إذا لم يكن لديك، يمكنك التنزيل من java.com (Java 8) أو BellSoft Liberica Full JRE (Java 17) والتوزيعات الأخرى لتنزيل وتثبيت واحد (أعد تشغيل المشغل بعد التثبيت). +game.crash.reason.need_jdk11=تعطلت اللعبة بسبب إصدار Java غير مناسب.\n\ + \n\ + تحتاج إلى تنزيل وتثبيت Java 11، وتعيينه في "الإعدادات العامة/الخاصة بالنسخة ← Java". +game.crash.reason.mod_name=تعطلت اللعبة بسبب مشاكل في أسماء ملفات التعديلات.\n\ + \n\ + يجب أن تستخدم أسماء ملفات التعديلات الحروف الإنجليزية فقط (A~Z، a~z)، والأرقام (0~9)، والشرطات (-)، والشرطات السفلية (_)، والنقاط (.) بنصف العرض.\n\ + \n\ + يرجى الانتقال إلى دليل التعديلات وتغيير جميع أسماء ملفات التعديلات غير المتوافقة باستخدام الأحرف المتوافقة أعلاه. +game.crash.reason.incomplete_forge_installation=لا يمكن للعبة الاستمرار في التشغيل بسبب تثبيت Forge/NeoForge غير مكتمل.\n\ + \n\ + يرجى إعادة تثبيت Forge/NeoForge في "تحرير النسخة ← المحملات". +game.crash.reason.fabric_version_0_12=Fabric Loader 0.12 أو أحدث غير متوافق مع التعديلات المثبتة حالياً. تحتاج إلى التقليل إلى 0.11.7. +game.crash.reason.fabric_warnings=حذر Fabric Loader:\n\ + \n\ + %1$s +game.crash.reason.file_already_exists=تعطلت اللعبة لأن الملف "%1$s" موجود بالفعل.\n\ + \n\ + يمكنك محاولة النسخ الاحتياطي وحذف هذا الملف، ثم إعادة تشغيل اللعبة. +game.crash.reason.file_changed=تعطلت اللعبة لأن التحقق من الملف فشل.\n\ + \n\ + إذا قمت بتعديل ملف Minecraft.jar، فستحتاج إلى التراجع عن التعديل أو إعادة تنزيل اللعبة. +game.crash.reason.gl_operation_failure=تعطلت اللعبة بسبب بعض التعديلات أو الظلال أو حزم الموارد/الأنسجة.\n\ + \n\ + يرجى تعطيل التعديلات أو الظلال أو حزم الموارد/الأنسجة التي تستخدمها ثم المحاولة مرة أخرى. +game.crash.reason.graphics_driver=تعطلت اللعبة بسبب مشكلة في برنامج تشغيل الرسومات الخاص بك.\n\ + \n\ + يرجى المحاولة مرة أخرى بعد تحديث برنامج تشغيل الرسومات إلى أحدث إصدار.\n\ + \n\ + إذا كان جهاز الكمبيوتر الخاص بك يحتوي على بطاقة رسومات مخصصة، فأنت بحاجة إلى التحقق مما إذا كانت اللعبة تستخدم الرسومات المدمجة/الأساسية. إذا كان الأمر كذلك، يرجى فتح المشغل باستخدام بطاقة الرسومات المخصصة الخاصة بك. إذا استمرت المشكلة، فربما يجب عليك التفكير في استخدام بطاقة رسومات جديدة أو كمبيوتر جديد.\n\ + \n\ + إذا كنت تستخدم بطاقة الرسومات المدمجة الخاصة بك، يرجى ملاحظة أن Minecraft 1.16.5 أو الإصدارات السابقة تتطلب Java 1.8.0_51 أو الإصدارات السابقة لسلسلة معالجات Intel(R) Core(TM) 3000 أو الإصدارات السابقة. +game.crash.reason.macos_failed_to_find_service_port_for_display=لا يمكن للعبة المتابعة لأن نافذة OpenGL على منصة Apple silicon فشلت في التهيئة.\n\ + \n\ + بالنسبة لهذه المشكلة، لا يمتلك HMCL حلولاً مباشرة في الوقت الحالي. يرجى محاولة فتح أي متصفح والدخول في وضع ملء الشاشة، ثم العودة إلى HMCL، وتشغيل اللعبة، والعودة بسرعة إلى صفحة المتصفح قبل أن تظهر نافذة اللعبة، انتظر حتى تظهر نافذة اللعبة، ثم انتقل مرة أخرى إلى نافذة اللعبة. +game.crash.reason.illegal_access_error=تعطلت اللعبة بسبب بعض التعديلات.\n\ + \n\ + إذا كنت تعرف هذا: "%1$s"، يمكنك تحديث أو حذف التعديل (التعديلات) ثم المحاولة مرة أخرى. +game.crash.reason.install_mixinbootstrap=تعطلت اللعبة بسبب MixinBootstrap المفقود.\n\ + \n\ + يمكنك محاولة تثبيت MixinBootstrap لحل المشكلة. إذا تعطل بعد التثبيت، حاول إضافة علامة تعجب (!) أمام اسم ملف هذا التعديل لمحاولة حل المشكلة. +game.crash.reason.optifine_is_not_compatible_with_forge=تعطلت اللعبة لأن OptiFine غير متوافق مع تثبيت Forge الحالي.\n\ + \n\ + يرجى الانتقال إلى الموقع الرسمي لـ OptiFine، والتحقق مما إذا كان إصدار Forge متوافقاً مع OptiFine، وإعادة تثبيت النسخة بدقة وفقاً للإصدار المقابل، أو تغيير إصدار OptiFine في "تحرير النسخة ← المحملات".\n\ + \n\ + بعد الاختبار، نعتقد أن إصدارات OptiFine العالية جداً أو المنخفضة جداً قد تسبب تعطل. +game.crash.reason.mod_files_are_decompressed=تعطلت اللعبة لأن ملف التعديل تم استخراجه.\n\ + \n\ + يرجى وضع ملف التعديل بالكامل مباشرة في دليل التعديلات!\n\ + \n\ + إذا تسبب الاستخراج في أخطاء في اللعبة، يرجى حذف التعديل المستخرج في دليل التعديلات ثم تشغيل اللعبة. +game.crash.reason.shaders_mod=تعطلت اللعبة لأن OptiFine و Shaders mod مثبتان في نفس الوقت.\n\ + \n\ + فقط قم بإزالة تعديل Shader لأن OptiFine لديه دعم مدمج للظلال. +game.crash.reason.rtss_forest_sodium=تعطلت اللعبة لأن RivaTuner Statistical Server (RTSS) غير متوافق مع Sodium.\n\ + \n\ + انقر هنا لمزيد من التفاصيل. +game.crash.reason.too_many_mods_lead_to_exceeding_the_id_limit=تعطلت اللعبة لأنك قمت بتثبيت عدد كبير جداً من التعديلات وتجاوزت حد معرف اللعبة.\n\ + \n\ + يرجى محاولة تثبيت JEID أو حذف بعض التعديلات الكبيرة. +game.crash.reason.night_config_fixes=تعطلت اللعبة بسبب بعض المشاكل مع Night Config.\n\ + \n\ + يمكنك محاولة تثبيت تعديل Night Config Fixes، والذي قد يساعدك في هذه المشكلة.\n\ + \n\ + لمزيد من المعلومات، قم بزيارة مستودع GitHub لهذا التعديل. +game.crash.reason.optifine_causes_the_world_to_fail_to_load=قد لا تتمكن اللعبة من الاستمرار في التشغيل بسبب OptiFine.\n\ + \n\ + تحدث هذه المشكلة فقط في إصدار OptiFine معين. يمكنك محاولة تغيير إصدار OptiFine في "تحرير النسخة ← المحملات". +game.crash.reason.jdk_9=تعطلت اللعبة لأن إصدار Java حديث جداً لهذه النسخة.\n\ + \n\ + تحتاج إلى تنزيل وتثبيت Java 8 واختياره في "الإعدادات العامة/الخاصة بالنسخة ← Java". +game.crash.reason.jvm_32bit=تعطلت اللعبة لأن تخصيص الذاكرة الحالي تجاوز حد JVM 32 بت.\n\ + \n\ + إذا كان نظام التشغيل الخاص بك 64 بت، يرجى تثبيت واستخدام إصدار Java 64 بت. خلاف ذلك، قد تحتاج إلى إعادة تثبيت نظام تشغيل 64 بت أو الحصول على جهاز كمبيوتر أكثر حداثة.\n\ + \n\ + أو، يمكنك تعطيل خيار "تخصيص تلقائي" في "الإعدادات العامة/الخاصة بالنسخة ← الذاكرة" وتعيين الحد الأقصى لحجم تخصيص الذاكرة إلى 1024 MiB أو أقل. +game.crash.reason.loading_crashed_forge=تعطلت اللعبة بسبب التعديل "%1$s" (%2$s).\n\ + \n\ + يمكنك محاولة حذفه أو تحديثه. +game.crash.reason.loading_crashed_fabric=تعطلت اللعبة بسبب التعديل "%1$s".\n\ + \n\ + يمكنك محاولة حذفه أو تحديثه. +game.crash.reason.mac_jdk_8u261=تعطلت اللعبة لأن إصدار Forge أو OptiFine الحالي غير متوافق مع تثبيت Java الخاص بك.\n\ + \n\ + يرجى محاولة تحديث Forge و OptiFine، أو محاولة استخدام Java 8u251 أو الإصدارات السابقة. +game.crash.reason.forge_repeat_installation=تعطلت اللعبة بسبب تثبيت Forge مكرر. هذه مشكلة معروفة\n\ + \n\ + يوصى بإرسال ملاحظات على GitHub مع هذا السجل حتى نتمكن من العثور على المزيد من الأدلة وحل المشكلة.\n\ + \n\ + حالياً يمكنك إلغاء تثبيت Forge وإعادة تثبيته في "تحرير النسخة ← المحملات". +game.crash.reason.optifine_repeat_installation=تعطلت اللعبة بسبب تثبيت OptiFine مكرر.\n\ + \n\ + يرجى حذف OptiFine في دليل التعديلات أو إلغاء تثبيته في "تحرير النسخة ← المحملات". +game.crash.reason.memory_exceeded=تعطلت اللعبة لأنه تم تخصيص الكثير من الذاكرة لملف صفحة صغير.\n\ + \n\ + يمكنك محاولة تعطيل خيار "تخصيص تلقائي" في "الإعدادات العامة/الخاصة بالنسخة ← الذاكرة" وضبط القيمة حتى تشغيل اللعبة.\n\ + \n\ + يمكنك أيضاً محاولة زيادة حجم ملف الصفحة في إعدادات النظام. +game.crash.reason.mod=تعطلت اللعبة بسبب التعديل "%1$s".\n\ + \n\ + يمكنك تحديث أو حذف التعديل ثم المحاولة مرة أخرى. +game.crash.reason.mod_resolution=لا يمكن للعبة الاستمرار في التشغيل بسبب مشاكل تبعيات التعديلات.\n\ + \n\ + قدم Fabric التفاصيل التالية:\n\ + \n\ + %1$s +game.crash.reason.forgemod_resolution=لا يمكن للعبة الاستمرار في التشغيل بسبب مشاكل تبعيات التعديلات.\n\ + \n\ + قدم Forge/NeoForge التفاصيل التالية:\n\ + %1$s +game.crash.reason.forge_found_duplicate_mods=تعطلت اللعبة بسبب مشكلة تعديل مكرر. قدم Forge/NeoForge المعلومات التالية:\n\ + %1$s +game.crash.reason.mod_resolution_collection=تعطلت اللعبة لأن إصدار التعديل غير متوافق.\n\ + \n\ + "%1$s" يتطلب التعديل "%2$s".\n\ + \n\ + تحتاج إلى ترقية أو تقليل "%3$s" قبل المتابعة. +game.crash.reason.mod_resolution_conflict=تعطلت اللعبة بسبب تعديلات متعارضة.\n\ + \n\ + "%1$s" غير متوافق مع "%2$s". +game.crash.reason.mod_resolution_missing=تعطلت اللعبة لأن بعض التعديلات التابعة لم يتم تثبيتها.\n\ + \n\ + "%1$s" يتطلب التعديل "%2$s".\n\ + \n\ + هذا يعني أنه يجب عليك تنزيل وتثبيت "%2$s" أولاً للمتابعة في اللعب. +game.crash.reason.mod_resolution_missing_minecraft=تعطلت اللعبة لأن تعديلاً غير متوافق مع إصدار Minecraft الحالي.\n\ + \n\ + "%1$s" يتطلب إصدار Minecraft %2$s.\n\ + \n\ + إذا كنت تريد اللعب مع هذا الإصدار من التعديل مثبتاً، يجب عليك تغيير إصدار اللعبة لنسختك.\n\ + \n\ + خلاف ذلك، يجب عليك تثبيت إصدار متوافق مع إصدار Minecraft هذا. +game.crash.reason.mod_resolution_mod_version=%1$s (الإصدار: %2$s) +game.crash.reason.mod_resolution_mod_version.any=%1$s (أي إصدار) +game.crash.reason.modlauncher_8=تعطلت اللعبة لأن إصدار Forge الحالي غير متوافق مع تثبيت Java الخاص بك. يرجى محاولة تحديث Forge. +game.crash.reason.no_class_def_found_error=لا يمكن للعبة الاستمرار في التشغيل بسبب كود غير مكتمل.\n\ + \n\ + نسخة لعبتك مفقودة "%1$s". قد يكون هذا بسبب فقدان تعديل، أو تثبيت تعديل غير متوافق، أو بعض الملفات التالفة.\n\ + \n\ + قد تحتاج إلى إعادة تثبيت اللعبة وجميع التعديلات أو طلب المساعدة من شخص ما. +game.crash.reason.no_such_method_error=لا يمكن للعبة الاستمرار في التشغيل بسبب كود غير مكتمل.\n\ + \n\ + قد تكون نسخة لعبتك مفقودة لتعديل، أو مثبت تعديل غير متوافق، أو قد تكون بعض الملفات تالفة.\n\ + \n\ + قد تحتاج إلى إعادة تثبيت اللعبة وجميع التعديلات أو طلب المساعدة من شخص ما. +game.crash.reason.opengl_not_supported=تعطلت اللعبة لأن برنامج تشغيل الرسومات الخاص بك لا يدعم OpenGL.\n\ + \n\ + إذا كنت تبث اللعبة عبر الإنترنت أو تستخدم بيئة سطح مكتب بعيد، يرجى لعب اللعبة على جهازك المحلي.\n\ + \n\ + أو، يمكنك تحديث برنامج تشغيل الرسومات إلى أحدث إصدار ثم المحاولة مرة أخرى.\n\ + \n\ + إذا كان جهاز الكمبيوتر الخاص بك يحتوي على بطاقة رسومات مخصصة، يرجى التأكد من أن اللعبة تستخدمها بالفعل للعرض. إذا استمرت المشكلة، يرجى التفكير في الحصول على بطاقة رسومات جديدة أو كمبيوتر جديد. +game.crash.reason.openj9=اللعبة غير قادرة على العمل على JVM من OpenJ9. يرجى التبديل إلى Java يستخدم Hotspot JVM في "الإعدادات العامة/الخاصة بالنسخة ← Java" وإعادة تشغيل اللعبة. إذا لم يكن لديك واحد، يمكنك تنزيل واحد. +game.crash.reason.out_of_memory=تعطلت اللعبة لأن الكمبيوتر نفد من الذاكرة.\n\ + \n\ + ربما لا توجد ذاكرة كافية متاحة أو تم تثبيت عدد كبير جداً من التعديلات. يمكنك محاولة حلها عن طريق زيادة الذاكرة المخصصة في "الإعدادات العامة/الخاصة بالنسخة ← الذاكرة".\n\ + \n\ + إذا كنت لا تزال تواجه هذه المشاكل، فقد تحتاج إلى كمبيوتر أفضل. +game.crash.reason.resolution_too_high=تعطلت اللعبة لأن دقة حزمة الموارد/الأنسجة عالية جداً.\n\ + \n\ + يجب عليك التبديل إلى حزمة موارد/أنسجة بدقة أقل أو التفكير في شراء بطاقة رسومات أفضل مع المزيد من VRAM. +game.crash.reason.stacktrace=سبب التعطل غير معروف. يمكنك عرض تفاصيله بالنقر على "السجلات".\n\ + \n\ + هناك بعض الكلمات الرئيسية التي قد تحتوي على بعض أسماء التعديلات. يمكنك البحث عنها عبر الإنترنت لمعرفة المشكلة بنفسك.\n\ + \n\ + %s +game.crash.reason.too_old_java=تعطلت اللعبة لأنك تستخدم إصدار Java قديماً.\n\ + \n\ + تحتاج إلى التبديل إلى إصدار Java أحدث (%1$s) في "الإعدادات العامة/الخاصة بالنسخة ← Java" ثم إعادة تشغيل اللعبة. يمكنك تنزيل Java من هنا. +game.crash.reason.unknown=لسنا قادرين على معرفة سبب تعطل اللعبة. يرجى الرجوع إلى سجلات اللعبة. +game.crash.reason.unsatisfied_link_error=فشل تشغيل Minecraft بسبب المكتبات المفقودة: %1$s.\n\ + \n\ + إذا قمت بتحرير مسار المكتبة الأصلية، يرجى التأكد من وجود هذه المكتبات بالفعل. أو، يرجى محاولة التشغيل مرة أخرى بعد إعادته إلى الافتراضي.\n\ + \n\ + إذا لم تقم بذلك، يرجى التحقق مما إذا كانت لديك تعديلات تبعية مفقودة.\n\ + \n\ + خلاف ذلك، إذا كنت تعتقد أن هذا ناتج عن HMCL، يرجى تقديم ملاحظات لنا. +game.crash.title=تعطلت اللعبة +game.directory=مسار اللعبة +game.version=نسخة اللعبة + +help=مساعدة +help.doc=وثائق Hello Minecraft! Launcher +help.detail=لصانعي حزم البيانات والتعديلات. + +input.email=يجب أن يكون اسم المستخدم عنوان بريد إلكتروني. +input.number=يجب أن يكون الإدخال أرقاماً. +input.not_empty=هذا حقل مطلوب. +input.url=يجب أن يكون الإدخال عنوان URL صالح. + +install=نسخة جديدة +install.change_version=تغيير الإصدار +install.change_version.confirm=هل أنت متأكد من أنك تريد تبديل %1$s من الإصدار %2$s إلى %3$s؟ +install.change_version.process=عملية تغيير الإصدار +install.failed=فشل التثبيت +install.failed.downloading=فشل تنزيل بعض الملفات المطلوبة. +install.failed.downloading.detail=فشل تنزيل الملف: %s +install.failed.downloading.timeout=انتهت مهلة التنزيل عند جلب: %s +install.failed.install_online=فشل التعرف على الملف المقدم. إذا كنت تقوم بتثبيت تعديل، انتقل إلى صفحة "التعديلات". +install.failed.malformed=الملفات المحملة تالفة. يمكنك محاولة حل هذه المشكلة بالتبديل إلى مصدر تنزيل آخر في "الإعدادات ← التنزيل ← مصدر التنزيل". +install.failed.optifine_conflict=لا يمكن تثبيت OptiFine و Fabric معاً على Minecraft 1.13 أو أحدث. +install.failed.optifine_forge_1.17=بالنسبة لـ Minecraft 1.17.1، Forge متوافق فقط مع OptiFine H1 pre2 أو أحدث. يمكنك تثبيتها عن طريق تحديد "اللقطات" عند اختيار إصدار OptiFine في HMCL. +install.failed.version_mismatch=يتطلب هذا المحمل إصدار اللعبة %1$s، لكن الإصدار المثبت هو %2$s. +install.installer.change_version=%s غير متوافق +install.installer.choose=اختر إصدار %s الخاص بك +install.installer.cleanroom=Cleanroom +install.installer.depend=يتطلب %s +install.installer.do_not_install=عدم التثبيت +install.installer.fabric=Fabric +install.installer.fabric-api=Fabric API +install.installer.fabric-quilt-api.warning=تحذير: %1$s هو تعديل وسيتم تثبيته في دليل التعديلات لنسخة اللعبة. يرجى عدم تغيير دليل عمل اللعبة، أو لن يعمل %1$s. إذا كنت تريد تغيير الدليل، فيجب عليك إعادة تثبيته. +install.installer.forge=Forge +install.installer.neoforge=NeoForge +install.installer.game=Minecraft +install.installer.incompatible=غير متوافق مع %s +install.installer.install=تثبيت %s +install.installer.install_offline=التثبيت/التحديث من ملف محلي +install.installer.install_offline.extension=مثبت (Neo)Forge/Cleanroom/OptiFine +install.installer.install_offline.tooltip=ندعم استخدام مثبت (Neo)Forge و Cleanroom و OptiFine المحلي. +install.installer.install_online=التثبيت عبر الإنترنت +install.installer.install_online.tooltip=ندعم حالياً Forge و NeoForge و Cleanroom و OptiFine و Fabric و Quilt و LiteLoader. +install.installer.liteloader=LiteLoader +install.installer.not_installed=غير مثبت +install.installer.optifine=OptiFine +install.installer.quilt=Quilt +install.installer.quilt-api=QSL/QFAPI +install.installer.version=%s +install.installer.external_version=%s (مثبت بواسطة عملية خارجية، لا يمكن تكوينه) +install.installing=جاري التثبيت +install.modpack=تثبيت حزمة تعديلات +install.modpack.installation=تثبيت حزمة التعديلات +install.name.invalid=يحتوي الاسم على أحرف خاصة (مثل الرموز التعبيرية أو أحرف CJK).\nيوصى بتغيير الاسم ليتضمن الحروف الإنجليزية والأرقام والشرطات السفلية فقط لتجنب المشاكل المحتملة عند تشغيل اللعبة.\nهل تريد متابعة التثبيت؟ +install.new_game=تثبيت نسخة +install.new_game.already_exists=اسم النسخة هذا موجود بالفعل. يرجى استخدام اسم آخر. +install.new_game.current_game_version=إصدار النسخة الحالي +install.new_game.installation=تثبيت النسخة +install.new_game.malformed=اسم غير صالح. +install.select=اختر العملية +install.success=تم التثبيت بنجاح. + +java.add=إضافة Java +java.add.failed=هذا Java غير صالح أو غير متوافق مع المنصة الحالية. +java.disable=تعطيل Java +java.disable.confirm=هل أنت متأكد من أنك تريد تعطيل هذا Java؟ +java.disabled.management=Java المعطل +java.disabled.management.remove=إزالة هذا Java من القائمة +java.disabled.management.restore=إعادة تفعيل هذا Java +java.download=تنزيل Java +java.download.banshanjdk-8=تنزيل Banshan JDK 8 +java.download.load_list.failed=فشل تحميل قائمة الإصدارات +java.download.more=المزيد من توزيعات Java +java.download.prompt=يرجى اختيار إصدار Java الذي تريد تنزيله: +java.download.distribution=التوزيع +java.download.version=الإصدار +java.download.packageType=نوع الحزمة +java.management=إدارة Java +java.info.architecture=البنية +java.info.vendor=المورد +java.info.version=الإصدار +java.info.disco.distribution=التوزيع +java.install=تثبيت Java +java.install.archive=مسار المصدر +java.install.failed.exists=هذا الاسم مملوك بالفعل +java.install.failed.invalid=هذا الأرشيف ليس حزمة تثبيت Java صالحة، لذلك لا يمكن تثبيته. +java.install.failed.unsupported_platform=هذا Java غير متوافق مع المنصة الحالية، لذلك لا يمكن تثبيته. +java.install.name=الاسم +java.install.warning.invalid_character=حرف غير قانوني في الاسم +java.installing=تثبيت Java +java.uninstall=إلغاء تثبيت Java +java.uninstall.confirm=هل أنت متأكد من أنك تريد إلغاء تثبيت هذا Java؟ لا يمكن التراجع عن هذا الإجراء! + +lang.default=استخدام إعدادات النظام المحلية + +launch.advice=%s هل لا تزال تريد الاستمرار في التشغيل؟ +launch.advice.multi=تم اكتشاف المشاكل التالية:\n\n%s\n\nقد تمنعك هذه المشاكل من تشغيل اللعبة أو تؤثر على تجربة اللعب.\nهل لا تزال تريد الاستمرار في التشغيل؟ +launch.advice.java.auto=إصدار Java الحالي غير متوافق مع النسخة.\n\nانقر على "نعم" لاختيار إصدار Java الأكثر توافقاً تلقائياً. أو، يمكنك الانتقال إلى "الإعدادات العامة/الخاصة بالنسخة ← Java" لاختيار واحد بنفسك. +launch.advice.java.modded_java_7=يتطلب Minecraft 1.7.2 والإصدارات السابقة Java 7 أو أقدم. +launch.advice.cleanroom=يمكن تشغيل Cleanroom فقط على Java 21 أو أحدث. يرجى استخدام Java 21 أو الإصدارات الأحدث. +launch.advice.corrected=لقد حللنا مشكلة Java. إذا كنت لا تزال تريد استخدام اختيارك لإصدار Java، يمكنك تعطيل "عدم التحقق من توافق JVM" في "الإعدادات العامة/الخاصة بالنسخة ← الإعدادات المتقدمة". +launch.advice.uncorrected=إذا كنت لا تزال تريد استخدام اختيارك لإصدار Java، يمكنك تعطيل "عدم التحقق من توافق JVM" في "الإعدادات العامة/الخاصة بالنسخة ← الإعدادات المتقدمة". +launch.advice.different_platform=يوصى بإصدار Java 64 بت لجهازك، لكنك قمت بتثبيت إصدار 32 بت. +launch.advice.forge2760_liteloader=إصدار Forge 2760 غير متوافق مع LiteLoader. يرجى التفكير في ترقية Forge إلى الإصدار 2773 أو أحدث. +launch.advice.forge28_2_2_optifine=إصدار Forge 28.2.2 أو أحدث غير متوافق مع OptiFine. يرجى التفكير في تقليل Forge إلى الإصدار 28.2.1 أو أقدم. +launch.advice.forge37_0_60=إصدارات Forge قبل 37.0.60 غير متوافقة مع Java 17. يرجى تحديث Forge إلى 37.0.60 أو أحدث، أو تشغيل اللعبة باستخدام Java 16. +launch.advice.java8_1_13=يمكن تشغيل Minecraft 1.13 وما بعده فقط على Java 8 أو أحدث. يرجى استخدام Java 8 أو الإصدارات الأحدث. +launch.advice.java8_51_1_13=قد يتعطل Minecraft 1.13 على إصدارات Java 8 قبل 1.8.0_51. يرجى تثبيت أحدث إصدار Java 8. +launch.advice.java9=لا يمكنك تشغيل Minecraft 1.12 أو الإصدارات السابقة باستخدام Java 9 أو أحدث. يرجى استخدام Java 8 بدلاً من ذلك. +launch.advice.modded_java=قد لا تكون بعض التعديلات متوافقة مع إصدارات Java الأحدث. يوصى باستخدام Java %1$s لتشغيل Minecraft %2$s. +launch.advice.modlauncher8=إصدار Forge الذي تستخدمه غير متوافق مع إصدار Java الحالي. يرجى محاولة تحديث Forge. +launch.advice.newer_java=أنت تستخدم إصدار Java أقدم لتشغيل اللعبة. يوصى بالتحديث إلى Java 8، وإلا فقد تتسبب بعض التعديلات في تعطل اللعبة. +launch.advice.not_enough_space=لقد خصصت حجم ذاكرة أكبر من %d MiB الفعلية من الذاكرة المثبتة على جهاز الكمبيوتر الخاص بك. قد تواجه أداءً منخفضاً أو حتى تكون غير قادر على تشغيل اللعبة. +launch.advice.require_newer_java_version=يتطلب إصدار اللعبة الحالي Java %s، لكن لم نتمكن من العثور على واحد. هل تريد تنزيل واحد الآن؟ +launch.advice.too_large_memory_for_32bit=لقد خصصت حجم ذاكرة أكبر من حد الذاكرة لتثبيت Java 32 بت. قد تكون غير قادر على تشغيل اللعبة. +launch.advice.vanilla_linux_java_8=يدعم Minecraft 1.12.2 أو الإصدارات السابقة Java 8 فقط لمنصة Linux x86-64 لأن الإصدارات اللاحقة لا يمكنها تحميل مكتبات أصلية 32 بت مثل liblwjgl.so\n\nيرجى التنزيل من java.com أو تثبيت OpenJDK 8. +launch.advice.vanilla_x86.translation=Minecraft غير مدعوم بالكامل على منصتك، لذلك قد تواجه ميزات مفقودة أو حتى تكون غير قادر على تشغيل اللعبة.\nيمكنك تنزيل Java لبنية x86-64 من هنا للحصول على تجربة ألعاب كاملة. +launch.advice.unknown=لا يمكن تشغيل اللعبة للأسباب التالية: +launch.failed=فشل التشغيل +launch.failed.cannot_create_jvm=لسنا قادرين على إنشاء JVM. قد يكون ذلك بسبب معاملات JVM غير صحيحة. يمكنك محاولة حلها بإزالة جميع المعاملات التي أضفتها في "الإعدادات العامة/الخاصة بالنسخة ← الإعدادات المتقدمة ← خيارات JVM". +launch.failed.creating_process=لسنا قادرين على إنشاء عملية جديدة. يرجى التحقق من مسار Java الخاص بك.\n +launch.failed.command_too_long=يتجاوز طول الأمر الطول الأقصى لنص دفعي. يرجى محاولة تصديره كنص PowerShell. +launch.failed.decompressing_natives=فشل استخراج المكتبات الأصلية.\n +launch.failed.download_library=فشل تنزيل المكتبات "%s". +launch.failed.executable_permission=فشل جعل نص التشغيل قابلاً للتنفيذ. +launch.failed.execution_policy=تعيين سياسة التنفيذ +launch.failed.execution_policy.failed_to_set=فشل تعيين سياسة التنفيذ +launch.failed.execution_policy.hint=تمنع سياسة التنفيذ الحالية تنفيذ نصوص PowerShell.\n\nانقر على "موافق" للسماح للمستخدم الحالي بتنفيذ نصوص PowerShell، أو انقر على "إلغاء" للاحتفاظ بها كما هي. +launch.failed.exited_abnormally=تعطلت اللعبة. يرجى الرجوع إلى سجل التعطل لمزيد من التفاصيل. +launch.failed.java_version_too_low=إصدار Java الذي حددته منخفض جداً. يرجى إعادة تعيين إصدار Java. +launch.failed.no_accepted_java=فشل العثور على إصدار Java متوافق، هل تريد تشغيل اللعبة باستخدام Java الافتراضي؟\nانقر على "نعم" لتشغيل اللعبة باستخدام Java الافتراضي.\nأو، يمكنك الانتقال إلى "الإعدادات العامة/الخاصة بالنسخة ← Java" لاختيار واحد بنفسك. +launch.failed.sigkill=تم إنهاء اللعبة بالقوة من قبل المستخدم أو النظام. +launch.state.dependencies=حل التبعيات +launch.state.done=تم التشغيل +launch.state.java=التحقق من إصدار Java +launch.state.logging_in=تسجيل الدخول +launch.state.modpack=تنزيل الملفات المطلوبة +launch.state.waiting_launching=انتظار تشغيل اللعبة +launch.invalid_java=مسار Java غير صالح. يرجى إعادة تعيين مسار Java. + +launcher=المشغل +launcher.agreement=الشروط والأحكام واتفاقية ترخيص المستخدم النهائي +launcher.agreement.accept=قبول +launcher.agreement.decline=رفض +launcher.agreement.hint=يجب عليك الموافقة على اتفاقية ترخيص المستخدم النهائي لاستخدام هذا البرنامج. +launcher.background=صورة الخلفية +launcher.background.choose=اختر صورة الخلفية +launcher.background.classic=كلاسيكي +launcher.background.default=افتراضي +launcher.background.default.tooltip=أو "background.png/.jpg/.gif/.webp" والصور في دليل "bg" +launcher.background.network=من عنوان URL +launcher.background.paint=لون صلب +launcher.cache_directory=دليل التخزين المؤقت +launcher.cache_directory.clean=مسح التخزين المؤقت +launcher.cache_directory.choose=اختر دليل التخزين المؤقت +launcher.cache_directory.default=افتراضي ("%APPDATA%/.minecraft" أو "~/.minecraft") +launcher.cache_directory.disabled=معطل +launcher.cache_directory.invalid=فشل إنشاء دليل التخزين المؤقت، الرجوع إلى الافتراضي. +launcher.contact=اتصل بنا +launcher.crash=Hello Minecraft! Launcher واجه خطأً فادحاً! يرجى نسخ السجل التالي وطلب المساعدة على Discord أو مجموعة QQ أو GitHub أو منتدى Minecraft آخر. +launcher.crash.java_internal_error=Hello Minecraft! Launcher واجه خطأً فادحاً لأن Java الخاص بك تالف. يرجى إلغاء تثبيت Java الخاص بك وتنزيل Java مناسب من هنا. +launcher.crash.hmcl_out_dated=Hello Minecraft! Launcher واجه خطأً فادحاً! المشغل الخاص بك قديم. يرجى تحديث المشغل الخاص بك! +launcher.update_java=يرجى تحديث إصدار Java الخاص بك. + +libraries.download=تنزيل المكتبات + +login.empty_username=لم تقم بتعيين اسم المستخدم الخاص بك بعد! +login.enter_password=يرجى إدخال كلمة المرور الخاصة بك. + +logwindow.show_lines=إظهار رقم الصف +logwindow.terminate_game=إنهاء عملية اللعبة +logwindow.title=السجل +logwindow.help=يمكنك الانتقال إلى مجتمع HMCL والعثور على آخرين للحصول على المساعدة. +logwindow.autoscroll=التمرير التلقائي +logwindow.export_game_crash_logs=تصدير سجلات التعطل +logwindow.export_dump=تصدير تفريغ مكدس اللعبة +logwindow.export_dump.no_dependency=Java الخاص بك لا يحتوي على التبعيات لإنشاء تفريغ المكدس. يرجى الانضمام إلى Discord أو مجموعة QQ للحصول على المساعدة. + +main_page=الرئيسية + +message.cancelled=تم إلغاء العملية +message.confirm=تأكيد +message.copied=تم النسخ إلى الحافظة +message.default=افتراضي +message.doing=يرجى الانتظار +message.downloading=جاري التنزيل +message.error=خطأ +message.failed=فشلت العملية +message.info=معلومات +message.success=اكتملت العملية بنجاح +message.unknown=غير معروف +message.warning=تحذير + +modpack=حزم التعديلات +modpack.choose=اختر حزمة التعديلات +modpack.choose.local=الاستيراد من ملف محلي +modpack.choose.local.detail=يمكنك سحب ملف حزمة التعديلات هنا. +modpack.choose.remote=التنزيل من عنوان URL +modpack.choose.remote.detail=مطلوب رابط تنزيل مباشر لملف حزمة التعديلات البعيد. +modpack.choose.repository=تنزيل حزمة التعديلات من CurseForge أو Modrinth +modpack.choose.repository.detail=يمكنك اختيار حزمة التعديلات المطلوبة في الصفحة التالية. +modpack.choose.remote.tooltip=يرجى إدخال عنوان URL لحزمة التعديلات الخاصة بك +modpack.completion=تنزيل التبعيات +modpack.desc=صف حزمة التعديلات الخاصة بك، بما في ذلك مقدمة وربما بعض سجل التغييرات. Markdown والصور من عنوان URL مدعومة حالياً. +modpack.description=وصف حزمة التعديلات +modpack.download=تنزيل حزم التعديلات +modpack.download.title=تنزيل حزمة التعديلات - %1s +modpack.enter_name=أدخل اسماً لحزمة التعديلات هذه. +modpack.export=التصدير كحزمة تعديلات +modpack.export.as=تصدير حزمة التعديلات كـ... +modpack.file_api=بادئة عنوان URL لحزمة التعديلات +modpack.files.blueprints=مخططات BuildCraft +modpack.files.config=ملفات تكوين التعديلات +modpack.files.dumps=ملفات إخراج تصحيح NEI +modpack.files.hmclversion_cfg=ملف تكوين المشغل +modpack.files.liteconfig=ملفات متعلقة بـ LiteLoader +modpack.files.mods=التعديلات +modpack.files.mods.voxelmods=خيارات VoxelMods +modpack.files.options_txt=ملف خيارات Minecraft +modpack.files.optionsshaders_txt=ملف خيارات الظلال +modpack.files.resourcepacks=حزم الموارد/الأنسجة +modpack.files.saves=العوالم +modpack.files.scripts=ملف تكوين MineTweaker +modpack.files.servers_dat=ملف قائمة الخوادم +modpack.installing=تثبيت حزمة التعديلات +modpack.installing.given=تثبيت حزمة التعديلات %s +modpack.introduction=حزم تعديلات Curse و Modrinth و MultiMC و MCBBS مدعومة حالياً. +modpack.invalid=حزمة تعديلات غير صالحة، يمكنك محاولة تنزيلها مرة أخرى. +modpack.mismatched_type=نوع حزمة التعديلات غير متطابق، النسخة الحالية من نوع %1$s، لكن المقدمة من نوع %2$s. +modpack.name=اسم حزمة التعديلات +modpack.not_a_valid_name=اسم حزمة تعديلات غير صالح. +modpack.origin=المصدر +modpack.origin.url=الموقع الرسمي +modpack.origin.mcbbs=MCBBS +modpack.origin.mcbbs.prompt=معرف المنشور +modpack.scan=تحليل فهرس حزمة التعديلات +modpack.task.install=استيراد حزمة التعديلات +modpack.task.install.error=فشل التعرف على حزمة التعديلات هذه. ندعم حالياً حزم تعديلات Curse و Modrinth و MultiMC و MCBBS فقط. +modpack.type.curse=Curse +modpack.type.curse.error=فشل تنزيل التبعيات. يرجى المحاولة مرة أخرى أو استخدام خادم بروكسي. +modpack.type.curse.not_found=بعض التبعيات لم تعد متاحة. يرجى محاولة تثبيت إصدار أحدث من حزمة التعديلات. +modpack.type.manual.warning=حزمة التعديلات معبأة يدوياً من قبل الناشر، والتي قد تحتوي بالفعل على مشغل. يوصى بمحاولة استخراج حزمة التعديلات وتشغيل اللعبة بالمشغل الخاص بها. لا يزال بإمكان HMCL استيرادها، بدون ضمان قابليتها للاستخدام. هل تريد المتابعة؟ +modpack.type.mcbbs=MCBBS +modpack.type.mcbbs.export=يمكن استيرادها بواسطة Hello Minecraft! Launcher +modpack.type.modrinth=Modrinth +modpack.type.modrinth.export=يمكن استيرادها بواسطة مشغلات طرف ثالث شائعة +modpack.type.multimc=MultiMC +modpack.type.multimc.export=يمكن استيرادها بواسطة Hello Minecraft! Launcher و MultiMC +modpack.type.server=حزمة تعديلات التحديث التلقائي من الخادم +modpack.type.server.export=يسمح لمالك الخادم بتحديث نسخة اللعبة عن بعد +modpack.type.server.malformed=بيان حزمة تعديلات غير صالح. يرجى الاتصال بصانع حزمة التعديلات لحل هذه المشكلة. +modpack.unsupported=تنسيق حزمة تعديلات غير مدعوم +modpack.update=تحديث حزمة التعديلات +modpack.wizard=دليل تصدير حزمة التعديلات +modpack.wizard.step.1=الإعدادات الأساسية +modpack.wizard.step.1.title=بعض المعلومات الأساسية لحزمة التعديلات. +modpack.wizard.step.2=اختر الملفات +modpack.wizard.step.2.title=اختر الملفات التي تريد إضافتها إلى حزمة التعديلات. +modpack.wizard.step.3=نوع حزمة التعديلات +modpack.wizard.step.3.title=اختر نوع حزمة التعديلات التي تريد التصدير كـ. +modpack.wizard.step.initialization.exported_version=نسخة اللعبة للتصدير +modpack.wizard.step.initialization.force_update=فرض تحديث حزمة التعديلات إلى أحدث إصدار (ستحتاج إلى خادم استضافة ملفات) +modpack.wizard.step.initialization.include_launcher=تضمين المشغل +modpack.wizard.step.initialization.modrinth.info=سيطابق المشغل موارد CurseForge/Modrinth البعيدة بدلاً من الملفات المحلية (بما في ذلك التعديلات وحزم الموارد وحزم الظلال) أثناء إنشاء حزمة التعديلات لتقليل حجم حزمة التعديلات، ووضع علامة على الملفات بامتداد ".disabled" كاختيارية للتثبيت. +modpack.wizard.step.initialization.no_create_remote_files=عدم مطابقة الملفات البعيدة +modpack.wizard.step.initialization.skip_curseforge_remote_files=عدم مطابقة موارد CurseForge البعيدة +modpack.wizard.step.initialization.save=التصدير إلى... +modpack.wizard.step.initialization.warning=قبل إنشاء حزمة تعديلات، يرجى التأكد من إمكانية تشغيل اللعبة بشكل طبيعي وأن Minecraft إصدار رسمي وليس لقطة. سيحفظ المشغل إعدادات التنزيل الخاصة بك.\n\ + \n\ + تذكر أنه لا يُسمح لك بإضافة تعديلات وحزم موارد تنص صراحةً على أنه لا يمكن توزيعها أو وضعها في حزمة تعديلات. +modpack.wizard.step.initialization.server=انقر هنا لمزيد من المعلومات حول كيفية إنشاء حزمة تعديلات خادم يمكن تحديثها تلقائياً. + +modrinth.category.adventure=مغامرة +modrinth.category.atmosphere=جو +modrinth.category.audio=صوت +modrinth.category.babric=Babric +modrinth.category.blocks=كتل +modrinth.category.bloom=إشراق +modrinth.category.bta-babric=BTA (Babric) +modrinth.category.bukkit=Bukkit +modrinth.category.bungeecord=BungeeCord +modrinth.category.canvas=Canvas +modrinth.category.cartoon=كرتوني +modrinth.category.challenging=تحدي +modrinth.category.colored-lighting=إضاءة ملونة +modrinth.category.combat=قتال +modrinth.category.core-shaders=ظلال أساسية +modrinth.category.cursed=ملعون +modrinth.category.datapack=حزمة بيانات +modrinth.category.decoration=ديكور +modrinth.category.economy=اقتصاد +modrinth.category.entities=كيانات +modrinth.category.environment=بيئة +modrinth.category.equipment=معدات +modrinth.category.fabric=Fabric +modrinth.category.fantasy=خيالي +modrinth.category.folia=Folia +modrinth.category.foliage=أوراق الشجر +modrinth.category.fonts=خطوط +modrinth.category.food=طعام +modrinth.category.forge=Forge +modrinth.category.game-mechanics=آليات اللعبة +modrinth.category.gui=واجهة المستخدم +modrinth.category.high=عالي +modrinth.category.iris=Iris +modrinth.category.items=عناصر +modrinth.category.java-agent=Java Agent +modrinth.category.kitchen-sink=Kitchen-Sink +modrinth.category.legacy-fabric=Legacy Fabric +modrinth.category.library=مكتبة +modrinth.category.lightweight=خفيف الوزن +modrinth.category.liteloader=LiteLoader +modrinth.category.locale=لغة +modrinth.category.low=منخفض +modrinth.category.magic=سحر +modrinth.category.management=إدارة +modrinth.category.medium=متوسط +modrinth.category.minecraft=Minecraft +modrinth.category.minigame=لعبة صغيرة +modrinth.category.misc=متنوع +modrinth.category.mobs=كائنات +modrinth.category.modded=معدل +modrinth.category.models=نماذج +modrinth.category.modloader=محمل التعديلات +modrinth.category.multiplayer=متعدد اللاعبين +modrinth.category.neoforge=NeoForge +modrinth.category.nilloader=NilLoader +modrinth.category.optifine=OptiFine +modrinth.category.optimization=تحسين +modrinth.category.ornithe=Ornithe +modrinth.category.paper=Paper +modrinth.category.path-tracing=تتبع المسار +modrinth.category.pbr=PBR +modrinth.category.potato=بطاطس +modrinth.category.purpur=Purpur +modrinth.category.quests=مهام +modrinth.category.quilt=Quilt +modrinth.category.realistic=واقعي +modrinth.category.reflections=انعكاسات +modrinth.category.rift=Rift +modrinth.category.screenshot=لقطة شاشة +modrinth.category.semi-realistic=شبه واقعي +modrinth.category.shadows=ظلال +modrinth.category.simplistic=بسيط +modrinth.category.social=اجتماعي +modrinth.category.spigot=Spigot +modrinth.category.sponge=Sponge +modrinth.category.storage=تخزين +modrinth.category.technology=تكنولوجيا +modrinth.category.themed=موضوعي +modrinth.category.transportation=نقل +modrinth.category.tweaks=تعديلات +modrinth.category.utility=فائدة +modrinth.category.vanilla=Vanilla +modrinth.category.vanilla-like=شبيه Vanilla +modrinth.category.velocity=Velocity +modrinth.category.waterfall=Waterfall +modrinth.category.worldgen=توليد العالم +modrinth.category.8x-=8x- +modrinth.category.16x=16x +modrinth.category.32x=32x +modrinth.category.48x=48x +modrinth.category.64x=64x +modrinth.category.128x=128x +modrinth.category.256x=256x +modrinth.category.512x+=512x+ + +mods=التعديلات +mods.add=إضافة تعديل +mods.add.failed=فشل إضافة التعديل %s. +mods.add.success=تمت إضافة %s بنجاح. +mods.broken_dependency.title=تبعية معطلة +mods.broken_dependency.desc=كانت هذه التبعية موجودة من قبل، لكنها لم تعد موجودة الآن. جرب استخدام مصدر تنزيل آخر. +mods.category=الفئة +mods.channel.alpha=ألفا +mods.channel.beta=بيتا +mods.channel.release=إصدار +mods.check_updates=تحديث +mods.check_updates.current_version=الإصدار الحالي +mods.check_updates.empty=جميع التعديلات محدثة +mods.check_updates.failed_check=فشل التحقق من التحديثات. +mods.check_updates.failed_download=فشل تنزيل بعض الملفات. +mods.check_updates.file=ملف +mods.check_updates.source=المصدر +mods.check_updates.target_version=الإصدار المستهدف +mods.check_updates.update=تحديث +mods.choose_mod=اختر تعديلاً +mods.curseforge=CurseForge +mods.dependency.embedded=تبعيات مدمجة (معبأة بالفعل في ملف التعديل من قبل المطور. لا حاجة للتنزيل بشكل منفصل) +mods.dependency.optional=تبعيات اختيارية (إذا كانت مفقودة، ستعمل اللعبة بشكل طبيعي، ولكن قد تكون ميزات التعديل مفقودة) +mods.dependency.required=تبعيات مطلوبة (يجب تنزيلها بشكل منفصل. قد يمنع الفقدان تشغيل اللعبة) +mods.dependency.tool=تبعيات مطلوبة (يجب تنزيلها بشكل منفصل. قد يمنع الفقدان تشغيل اللعبة) +mods.dependency.include=تبعيات مدمجة (معبأة بالفعل في ملف التعديل من قبل المطور. لا حاجة للتنزيل بشكل منفصل) +mods.dependency.incompatible=تعديلات غير متوافقة (تثبيت هذه التعديلات في نفس الوقت سيمنع تشغيل اللعبة) +mods.dependency.broken=تبعيات معطلة (كان هذا التعديل موجوداً من قبل، لكنه لم يعد موجوداً الآن. جرب استخدام مصدر تنزيل آخر.) +mods.disable=تعطيل +mods.download=تنزيل تعديل +mods.download.title=تنزيل تعديل - %1s +mods.download.recommend=إصدار تعديل موصى به - Minecraft %1s +mods.enable=تفعيل +mods.game.version=إصدار اللعبة +mods.manage=التعديلات +mods.mcbbs=MCBBS +mods.mcmod=MCMod +mods.mcmod.page=صفحة MCMod +mods.mcmod.search=البحث في MCMod +mods.modrinth=Modrinth +mods.name=الاسم +mods.not_modded=يجب عليك تثبيت محمل تعديلات (Forge، NeoForge، Fabric، Quilt، أو LiteLoader) أولاً لإدارة تعديلاتك! +mods.restore=استعادة +mods.url=الصفحة الرسمية +mods.update_modpack_mod.warning=تحديث التعديلات في حزمة تعديلات يمكن أن يؤدي إلى نتائج لا يمكن إصلاحها، مما قد يفسد حزمة التعديلات بحيث لا يمكن تشغيلها. هل أنت متأكد من أنك تريد التحديث؟ +mods.warning.loader_mismatch=محمل التعديلات غير متطابق +mods.install=تثبيت +mods.save_as=حفظ باسم +mods.unknown=تعديل غير معروف + +nbt.entries=%s إدخالات +nbt.open.failed=فشل فتح الملف +nbt.save.failed=فشل حفظ الملف +nbt.title=عرض الملف - %s + +datapack=حزم البيانات +datapack.add=تثبيت حزمة بيانات +datapack.choose_datapack=اختر حزمة بيانات للاستيراد +datapack.extension=حزمة بيانات +datapack.title=عالم [%s] - حزم البيانات + +web.failed=فشل تحميل الصفحة +web.open_in_browser=هل تريد فتح هذا العنوان في المتصفح:\n%s +web.view_in_browser=عرض الكل في المتصفح + +world=العوالم +world.add=إضافة عالم +world.backup=نسخ احتياطي للعالم +world.backup.create.new_one=إنشاء نسخة احتياطية جديدة +world.backup.create.failed=فشل إنشاء النسخة الاحتياطية.\n%s +world.backup.create.success=تم إنشاء نسخة احتياطية جديدة بنجاح: %s +world.backup.delete=حذف هذه النسخة الاحتياطية +world.backup.processing=جارٍ النسخ الاحتياطي... +world.chunkbase=Chunk Base +world.chunkbase.end_city=مدينة النهاية +world.chunkbase.seed_map=خريطة البذرة +world.chunkbase.stronghold=القلعة +world.chunkbase.nether_fortress=قلعة النذر +world.datapack=حزم البيانات +world.datetime=آخر لعب في %s +world.delete=حذف العالم +world.delete.failed=فشل حذف العالم.\n%s +world.download=تنزيل عالم +world.download.title=تنزيل عالم - %1s +world.export=تصدير العالم +world.export.title=اختر الدليل لهذا العالم المُصدر +world.export.location=حفظ باسم +world.export.wizard=تصدير العالم "%s" +world.extension=أرشيف العالم +world.game_version=إصدار اللعبة +world.import.already_exists=هذا العالم موجود بالفعل. +world.import.choose=اختر أرشيف العالم الذي تريد استيراده +world.import.failed=فشل استيراد هذا العالم: %s +world.import.invalid=فشل تحليل العالم. +world.info=معلومات العالم +world.info.basic=معلومات أساسية +world.info.allow_cheats=السماح بالأوامر/الغش +world.info.dimension.the_nether=النذر +world.info.dimension.the_end=النهاية +world.info.difficulty=الصعوبة +world.info.difficulty.peaceful=سلمي +world.info.difficulty.easy=سهل +world.info.difficulty.normal=عادي +world.info.difficulty.hard=صعب +world.info.failed=فشل قراءة معلومات العالم +world.info.game_version=إصدار اللعبة +world.info.last_played=آخر لعب +world.info.generate_features=توليد الهياكل +world.info.player=معلومات اللاعب +world.info.player.food_level=مستوى الجوع +world.info.player.game_type=وضع اللعب +world.info.player.game_type.adventure=مغامرة +world.info.player.game_type.creative=إبداعي +world.info.player.game_type.hardcore=هاردكور +world.info.player.game_type.spectator=مشاهد +world.info.player.game_type.survival=البقاء على قيد الحياة +world.info.player.health=الصحة +world.info.player.last_death_location=موقع آخر وفاة +world.info.player.location=الموقع +world.info.player.spawn=موقع الظهور +world.info.player.xp_level=مستوى الخبرة +world.info.random_seed=البذرة +world.info.time=وقت اللعبة +world.info.time.format=%s أيام +world.locked=قيد الاستخدام +world.locked.failed=العالم قيد الاستخدام حاليًا. يرجى إغلاق اللعبة والمحاولة مرة أخرى. +world.manage=العوالم +world.manage.button=إدارة العوالم +world.manage.title=العالم - %s +world.name=اسم العالم +world.name.enter=أدخل اسم العالم +world.show_all=إظهار الكل + +profile=أدلة اللعبة +profile.already_exists=هذا الاسم موجود بالفعل. يرجى استخدام اسم مختلف. +profile.default=الحالي +profile.home=مشغل Minecraft +profile.instance_directory=دليل اللعبة +profile.instance_directory.choose=اختر دليل اللعبة +profile.manage=قائمة أدلة النسخ +profile.name=الاسم +profile.new=دليل جديد +profile.title=أدلة اللعبة +profile.selected=محدد +profile.use_relative_path=استخدام مسار نسبي لدليل اللعبة إن أمكن + +repositories.custom=مستودع Maven مخصص (%s) +repositories.maven_central=عالمي (Maven Central) +repositories.tencentcloud_mirror=مرآة الصين القارية (مستودع Tencent Cloud Maven) +repositories.chooser=يتطلب HMCL JavaFX للعمل.\n\ + \n\ + يرجى النقر على "موافق" لتنزيل JavaFX من المستودع المحدد، أو النقر على "إلغاء" للخروج.\n\ + \n\ + المستودعات: +repositories.chooser.title=اختر مصدر التنزيل لـ JavaFX + +resourcepack=حزم الموارد +resourcepack.download.title=تنزيل حزمة موارد - %1s + +reveal.in_file_manager=إظهار في مدير الملفات + +schematics=المخططات +schematics.add=إضافة ملفات مخططات +schematics.add.failed=فشل إضافة ملفات المخططات +schematics.back_to=العودة إلى "%s" +schematics.create_directory=إنشاء دليل +schematics.create_directory.prompt=يرجى إدخال اسم الدليل الجديد +schematics.create_directory.failed=فشل إنشاء الدليل +schematics.create_directory.failed.already_exists=الدليل موجود بالفعل +schematics.create_directory.failed.empty_name=الاسم لا يمكن أن يكون فارغًا +schematics.create_directory.failed.invalid_name=الاسم يحتوي على أحرف غير صالحة +schematics.info.description=الوصف +schematics.info.enclosing_size=الحجم المحيط +schematics.info.name=الاسم +schematics.info.region_count=المناطق +schematics.info.schematic_author=المؤلف +schematics.info.time_created=وقت الإنشاء +schematics.info.time_modified=وقت التعديل +schematics.info.total_blocks=إجمالي الكتل +schematics.info.total_volume=الحجم الإجمالي +schematics.info.version=إصدار المخطط +schematics.manage=المخططات +schematics.sub_items=%d عنصر فرعي + +search=بحث +search.hint.chinese=البحث بالإنجليزية والصينية +search.hint.english=البحث بالإنجليزية فقط +search.enter=أدخل النص هنا +search.sort=ترتيب حسب +search.first_page=الأولى +search.previous_page=السابقة +search.next_page=التالية +search.last_page=الأخيرة +search.page_n=%1$d / %2$s + +selector.choose=اختيار +selector.choose_file=اختيار ملف +selector.custom=مخصص + +settings=الإعدادات + +settings.advanced=الإعدادات المتقدمة +settings.advanced.modify=تحرير الإعدادات المتقدمة +settings.advanced.title=الإعدادات المتقدمة - %s +settings.advanced.custom_commands=أوامر مخصصة +settings.advanced.custom_commands.hint=يتم توفير متغيرات البيئة التالية:\n\ + \ · \u2066$INST_NAME\u2069: اسم النسخة.\n\ + \ · \u2066$INST_ID\u2069: معرف النسخة.\n\ + \ · \u2066$INST_DIR\u2069: المسار المطلق لدليل عمل النسخة.\n\ + \ · \u2066$INST_MC_DIR\u2069: المسار المطلق لدليل اللعبة.\n\ + \ · \u2066$INST_JAVA\u2069: ملف الجافا الثنائي المستخدم للتشغيل.\n\ + \ · \u2066$INST_FORGE\u2069: يُضبط إذا كان \u2066Forge\u2069 مثبتاً.\n\ + \ · \u2066$INST_NEOFORGE\u2069: يُضبط إذا كان \u2066NeoForge\u2069 مثبتاً.\n\ + \ · \u2066$INST_CLEANROOM\u2069: يُضبط إذا كان \u2066Cleanroom\u2069 مثبتاً.\n\ + \ · \u2066$INST_LITELOADER\u2069: يُضبط إذا كان \u2066LiteLoader\u2069 مثبتاً.\n\ + \ · \u2066$INST_OPTIFINE\u2069: يُضبط إذا كان \u2066OptiFine\u2069 مثبتاً.\n\ + \ · \u2066$INST_FABRIC\u2069: يُضبط إذا كان \u2066Fabric\u2069 مثبتاً.\n\ + \ · \u2066$INST_QUILT\u2069: يُضبط إذا كان \u2066Quilt\u2069 مثبتاً. +settings.advanced.dont_check_game_completeness=عدم التحقق من سلامة اللعبة +settings.advanced.dont_check_jvm_validity=عدم التحقق من توافق JVM +settings.advanced.dont_patch_natives=عدم محاولة استبدال المكتبات الأصلية تلقائيًا +settings.advanced.environment_variables=متغيرات البيئة +settings.advanced.game_dir.default=افتراضي (".minecraft/") +settings.advanced.game_dir.independent=معزول (".minecraft/versions/<اسم النسخة>/"، باستثناء الأصول والمكتبات) +settings.advanced.java_permanent_generation_space=مساحة PermGen +settings.advanced.java_permanent_generation_space.prompt=بـ MiB +settings.advanced.jvm=خيارات JVM +settings.advanced.jvm_args=معاملات JVM +settings.advanced.jvm_args.prompt= · إذا كانت المعاملات المدخلة في "معاملات JVM" مطابقة للمعاملات الافتراضية، فلن يتم إضافتها.\n\ + \ · أدخل أي معاملات GC في "معاملات JVM"، وسيتم تعطيل معامل G1 للمعاملات الافتراضية.\n\ + \ · قم بتفعيل "عدم إضافة معاملات JVM الافتراضية" لتشغيل اللعبة بدون إضافة معاملات افتراضية. +settings.advanced.launcher_visibility.close=إغلاق المشغل بعد تشغيل اللعبة +settings.advanced.launcher_visibility.hide=إخفاء المشغل بعد تشغيل اللعبة +settings.advanced.launcher_visibility.hide_and_reopen=إخفاء المشغل وإظهاره عند إغلاق اللعبة +settings.advanced.launcher_visibility.keep=إبقاء المشغل مرئيًا +settings.advanced.launcher_visible=رؤية المشغل +settings.advanced.minecraft_arguments=معاملات التشغيل +settings.advanced.minecraft_arguments.prompt=افتراضي +settings.advanced.natives_directory=مسار المكتبة الأصلية +settings.advanced.natives_directory.choose=اختر موقع المكتبة الأصلية المطلوبة +settings.advanced.natives_directory.custom=مخصص +settings.advanced.natives_directory.default=افتراضي +settings.advanced.natives_directory.default.version_id=<اسم النسخة> +settings.advanced.natives_directory.hint=هذا الخيار مخصص فقط لمستخدمي Apple silicon أو المنصات الأخرى غير المدعومة رسميًا. يرجى عدم تحرير هذا الخيار ما لم تكن تعرف ما تفعله.\n\ + \n\ + قبل المتابعة، يرجى التأكد من توفير جميع المكتبات (مثل lwjgl.dll، libopenal.so) في الدليل المطلوب.\n\ + ملاحظة: يوصى باستخدام مسار بأحرف إنجليزية كاملة لملف المكتبة المحلي المحدد. وإلا قد يؤدي إلى فشل تشغيل اللعبة. +settings.advanced.no_jvm_args=عدم إضافة معاملات JVM الافتراضية +settings.advanced.no_optimizing_jvm_args=عدم إضافة معاملات تحسين JVM الافتراضية +settings.advanced.precall_command=أمر ما قبل التشغيل +settings.advanced.precall_command.prompt=الأوامر التي سيتم تنفيذها قبل تشغيل اللعبة +settings.advanced.process_priority=أولوية العملية +settings.advanced.process_priority.low=منخفض +settings.advanced.process_priority.below_normal=أقل من العادي +settings.advanced.process_priority.normal=عادي +settings.advanced.process_priority.above_normal=أعلى من العادي +settings.advanced.process_priority.high=عالي +settings.advanced.post_exit_command=أمر ما بعد الخروج +settings.advanced.post_exit_command.prompt=الأوامر التي سيتم تنفيذها بعد خروج اللعبة +settings.advanced.renderer=العارض +settings.advanced.renderer.default=OpenGL (افتراضي) +settings.advanced.renderer.d3d12=DirectX 12 (أداء وتوافق ضعيف) +settings.advanced.renderer.llvmpipe=برمجيات (أداء ضعيف، أفضل توافق) +settings.advanced.renderer.zink=Vulkan (أفضل أداء، توافق ضعيف) +settings.advanced.server_ip=عنوان الخادم +settings.advanced.server_ip.prompt=الانضمام تلقائيًا بعد تشغيل اللعبة +settings.advanced.unsupported_system_options=إعدادات غير قابلة للتطبيق على النظام الحالي +settings.advanced.use_native_glfw=[Linux/FreeBSD فقط] استخدام GLFW الخاص بالنظام +settings.advanced.use_native_openal=[Linux/FreeBSD فقط] استخدام OpenAL الخاص بالنظام +settings.advanced.workaround=حلول بديلة +settings.advanced.workaround.warning=خيارات الحلول البديلة مخصصة فقط للمستخدمين المتقدمين. التعديل على هذه الخيارات قد يتسبب في تعطل اللعبة. ما لم تكن تعرف ما تفعله، يرجى عدم تحرير هذه الخيارات. +settings.advanced.wrapper_launcher=أمر الغلاف +settings.advanced.wrapper_launcher.prompt=يسمح بالتشغيل باستخدام برنامج غلاف إضافي مثل "optirun" على Linux + +settings.custom=مخصص + +settings.game=الإعدادات +settings.game.copy_global=نسخ من الإعدادات العامة +settings.game.copy_global.copy_all=نسخ الكل +settings.game.copy_global.copy_all.confirm=هل أنت متأكد من أنك تريد استبدال إعدادات النسخة الحالية؟ لا يمكن التراجع عن هذا الإجراء! +settings.game.current=اللعبة +settings.game.dimension=الدقة +settings.game.exploration=استكشاف +settings.game.fullscreen=ملء الشاشة +settings.game.java_directory=Java +settings.game.java_directory.auto=اختيار تلقائي +settings.game.java_directory.auto.not_found=لم يتم تثبيت إصدار Java مناسب. +settings.game.java_directory.bit=%s بت +settings.game.java_directory.choose=اختيار Java +settings.game.java_directory.invalid=مسار Java غير صحيح +settings.game.java_directory.version=تحديد إصدار Java +settings.game.java_directory.template=%1$s (%2$s) +settings.game.management=إدارة +settings.game.working_directory=دليل العمل +settings.game.working_directory.choose=اختر دليل العمل +settings.game.working_directory.hint=قم بتفعيل خيار "معزول" في "دليل العمل" للسماح للنسخة الحالية بتخزين إعداداتها وحفظها وتعديلاتها في دليل منفصل.\n\ + \n\ + يوصى بتفعيل هذا الخيار لتجنب تعارضات التعديلات، ولكن ستحتاج إلى نقل حفظك يدويًا. + +settings.icon=الأيقونة + +settings.launcher=إعدادات المشغل +settings.launcher.appearance=المظهر +settings.launcher.common_path.tooltip=سيضع HMCL جميع أصول اللعبة والتبعيات هنا. إذا كانت هناك مكتبات موجودة في دليل اللعبة، فسيفضل HMCL استخدامها أولاً. +settings.launcher.debug=تصحيح الأخطاء +settings.launcher.disable_auto_game_options=عدم تبديل لغة اللعبة +settings.launcher.download=التنزيل +settings.launcher.download.threads=الخيوط +settings.launcher.download.threads.auto=تحديد تلقائي +settings.launcher.download.threads.hint=عدد كبير جدًا من الخيوط قد يتسبب في تجميد النظام، وقد تتأثر سرعة التنزيل بمزود خدمة الإنترنت وخوادم التنزيل. ليس دائمًا أن المزيد من الخيوط يزيد من سرعة التنزيل. +settings.launcher.download_source=مصدر التنزيل +settings.launcher.download_source.auto=اختيار مصادر التنزيل تلقائيًا +settings.launcher.enable_game_list=إظهار قائمة النسخ في الصفحة الرئيسية +settings.launcher.font=الخط +settings.launcher.font.anti_aliasing=تنعيم الحواف +settings.launcher.font.anti_aliasing.auto=تلقائي +settings.launcher.font.anti_aliasing.gray=تدرج رمادي +settings.launcher.font.anti_aliasing.lcd=Sub-pixel +settings.launcher.general=عام +settings.launcher.language=اللغة +settings.launcher.launcher_log.export=تصدير سجلات المشغل +settings.launcher.launcher_log.export.failed=فشل تصدير السجلات. +settings.launcher.launcher_log.export.success=تم تصدير السجلات إلى "%s". +settings.launcher.launcher_log.reveal=إظهار السجلات في مدير الملفات +settings.launcher.log=التسجيل +settings.launcher.log.font=الخط +settings.launcher.proxy=البروكسي +settings.launcher.proxy.authentication=يتطلب مصادقة +settings.launcher.proxy.default=استخدام بروكسي النظام +settings.launcher.proxy.host=المضيف +settings.launcher.proxy.http=HTTP +settings.launcher.proxy.none=بدون بروكسي +settings.launcher.proxy.password=كلمة المرور +settings.launcher.proxy.port=المنفذ +settings.launcher.proxy.socks=SOCKS +settings.launcher.proxy.username=اسم المستخدم +settings.launcher.theme=السمة +settings.launcher.title_transparent=شريط العنوان الشفاف +settings.launcher.turn_off_animations=تعطيل الرسوم المتحركة +settings.launcher.version_list_source=قائمة الإصدارات +settings.launcher.background.settings.opacity=الشفافية + +settings.memory=الذاكرة +settings.memory.allocate.auto=%1$.1f GiB الحد الأدنى / %2$.1f GiB مخصص +settings.memory.allocate.auto.exceeded=%1$.1f GiB الحد الأدنى / %2$.1f GiB مخصص (%3$.1f GiB متاح) +settings.memory.allocate.manual=%1$.1f GiB مخصص +settings.memory.allocate.manual.exceeded=%1$.1f GiB مخصص (%3$.1f GiB متاح) +settings.memory.auto_allocate=تخصيص تلقائي +settings.memory.lower_bound=الحد الأدنى للذاكرة +settings.memory.unit.mib=MiB +settings.memory.used_per_total=%1$.1f GiB مستخدم / %2$.1f GiB إجمالي +settings.physical_memory=حجم الذاكرة الفعلية +settings.show_log=إظهار السجلات +settings.tabs.installers=المحملات +settings.take_effect_after_restart=يطبق بعد إعادة التشغيل +settings.type=نوع إعدادات النسخة +settings.type.global=إعدادات عامة (مشتركة بين النسخ بدون تفعيل "إعدادات خاصة بالنسخة") +settings.type.global.manage=الإعدادات العامة +settings.type.global.edit=تحرير الإعدادات العامة +settings.type.special.enable=تفعيل إعدادات خاصة بالنسخة +settings.type.special.edit=تحرير إعدادات النسخة الحالية +settings.type.special.edit.hint=النسخة الحالية "%s" قامت بتفعيل "إعدادات خاصة بالنسخة". جميع الخيارات في هذه الصفحة لن تؤثر على تلك النسخة. انقر هنا لتحرير إعداداتها الخاصة. + +sponsor=المتبرعون +sponsor.bmclapi=التنزيلات للصين القارية مقدمة من BMCLAPI. انقر هنا لمزيد من المعلومات. +sponsor.hmcl=Hello Minecraft! Launcher هو مشغل Minecraft مفتوح المصدر يسمح للمستخدمين بإدارة عدة نسخ من Minecraft بسهولة. انقر هنا لمزيد من المعلومات. + +system.architecture=البنية +system.operating_system=نظام التشغيل + +terracotta=اللعب الجماعي +terracotta.terracotta=Terracotta | اللعب الجماعي +terracotta.status=الردهة +terracotta.back=خروج +terracotta.feedback.title=ملء نموذج الملاحظات +terracotta.feedback.desc=مع تحديث HMCL لنواة اللعب الجماعي، نأمل أن تتمكن من أخذ 10 ثوانٍ لملء نموذج الملاحظات. +terracotta.sudo_installing=يجب على HMCL التحقق من كلمة المرور الخاصة بك قبل تثبيت نواة اللعب الجماعي +terracotta.from_local.title=قنوات تنزيل طرف ثالث لنواة اللعب الجماعي +terracotta.from_local.desc=في بعض المناطق، قد تكون قناة التنزيل الافتراضية المدمجة غير مستقرة. +terracotta.from_local.guide=يرجى تنزيل حزمة نواة اللعب الجماعي باسم %s. بمجرد التنزيل، اسحب الملف إلى الصفحة الحالية لتثبيته. +terracotta.from_local.file_name_mismatch=يجب عليك تنزيل حزمة نواة اللعب الجماعي باسم %1$s بدلاً من %2$s +terracotta.export_log=تصدير سجل نواة اللعب الجماعي +terracotta.export_log.desc=جمع المزيد من المعلومات للتحليل +terracotta.status.bootstrap=جمع المعلومات +terracotta.status.uninitialized.not_exist=نواة اللعب الجماعي: لم يتم التنزيل +terracotta.status.uninitialized.not_exist.title=تنزيل نواة اللعب الجماعي (~ 8 MiB) +terracotta.status.uninitialized.update=نواة اللعب الجماعي: تحديث متاح +terracotta.status.uninitialized.update.title=تحديث نواة اللعب الجماعي (~ 8 MiB) +terracotta.status.uninitialized.desc=أنت تتعهد قانونيًا بالالتزام الصارم بجميع القوانين واللوائح في بلدك أو منطقتك أثناء اللعب الجماعي. +terracotta.confirm.title=إشعار المستخدم +terracotta.confirm.desc=Terracotta هو برنامج مجاني مفتوح المصدر من طرف ثالث. يرجى تقديم الملاحظات حول أي مشاكل تواجهها من خلال القنوات ذات الصلة أثناء الاستخدام.\n\ + Terracotta يعتمد على P2P، لذلك تعتمد التجربة النهائية بشكل كبير على حالة شبكتك.\n\ + أنت تتعهد بالالتزام الصارم بجميع القوانين واللوائح في بلدك أو منطقتك عند اللعب عبر الإنترنت. +terracotta.status.preparing=نواة اللعب الجماعي: جارٍ التنزيل (لا تخرج من HMCL) +terracotta.status.launching=نواة اللعب الجماعي: جارٍ التهيئة +terracotta.status.unknown=نواة اللعب الجماعي: جارٍ التهيئة +terracotta.status.waiting=نواة اللعب الجماعي: جاهز +terracotta.status.waiting.host.title=أريد استضافة جلسة +terracotta.status.waiting.host.desc=إنشاء غرفة وتوليد رمز دعوة للعب مع الأصدقاء +terracotta.status.waiting.host.launch.title=يبدو أنك نسيت تشغيل اللعبة +terracotta.status.waiting.host.launch.desc=لم يتم العثور على لعبة قيد التشغيل +terracotta.status.waiting.host.launch.skip=تم تشغيل اللعبة +terracotta.status.waiting.guest.title=أريد الانضمام إلى جلسة +terracotta.status.waiting.guest.desc=أدخل رمز الدعوة من اللاعب المضيف للإنضمام إلى عالم اللعبة +terracotta.status.waiting.guest.prompt.title=رجاء أدخل رمز الدعوة من المضيف +terracotta.status.waiting.guest.prompt.invalid=رمز دعوة غير صالح +terracotta.status.scanning=فحص عوالم الشبكة المحلية +terracotta.status.scanning.desc=يرجى بدء اللعبة، فتح عالم، اضغط ESC، اختر "فتح للشبكة المحلية"، ثم اختر "بدء عالم الشبكة المحلية". +terracotta.status.scanning.back=سيؤدي هذا أيضًا إلى إيقاف مسح عوالم الشبكة المحلية. +terracotta.status.host_starting=جارٍ إنشاء الغرفة +terracotta.status.host_starting.back=سيؤدي هذا إلى إيقاف إنشاء الغرفة. +terracotta.status.host_ok=تم إنشاء الغرفة +terracotta.status.host_ok.code=رمز الدعوة (تم النسخ) +terracotta.status.host_ok.code.copy=نسخ رمز الدعوة +terracotta.status.host_ok.code.copy.toast=تم نسخ رمز الدعوة إلى الحافظة +terracotta.status.host_ok.code.desc=يرجى تذكير أصدقائك باختيار وضع الضيف في HMCL - اللعب الجماعي أو PCL CE وإدخال رمز الدعوة هذا. +terracotta.status.host_ok.back=سيؤدي هذا أيضًا إلى إغلاق الغرفة، وسيخرج الضيوف الآخرون ولن يتمكنوا من الانضمام مرة أخرى. +terracotta.status.guest_starting=جارٍ الانضمام إلى الغرفة +terracotta.status.guest_starting.back=لن يؤدي هذا إلى منع الضيوف الآخرين من الانضمام إلى الغرفة. +terracotta.status.guest_ok=تم الانضمام إلى الغرفة +terracotta.status.guest_ok.back=لن يؤدي هذا إلى منع الضيوف الآخرين من الانضمام إلى الغرفة. +terracotta.status.guest_ok.title=يرجى تشغيل اللعبة، اختر اللعب الجماعي، وانقر نقرًا مزدوجًا على ردهة Terracotta. +terracotta.status.guest_ok.desc=العنوان الاحتياطي: %s +terracotta.status.exception.back=يرجى المحاولة مرة أخرى +terracotta.status.exception.desc.ping_host_fail=فشل الانضمام إلى الغرفة: الغرفة مغلقة أو الشبكة غير مستقرة +terracotta.status.exception.desc.ping_host_rst=فقدان اتصال الغرفة: الغرفة مغلقة أو الشبكة غير مستقرة +terracotta.status.exception.desc.guest_et_crash=فشل الانضمام إلى الغرفة: تعطل EasyTier، يرجى الإبلاغ عن هذه المشكلة للمطورين +terracotta.status.exception.desc.host_et_crash=فشل إنشاء الغرفة: تعطل EasyTier، يرجى الإبلاغ عن هذه المشكلة للمطورين +terracotta.status.exception.desc.ping_server_rst=إغلاق الغرفة: لقد خرجت من عالم اللعبة، تم إغلاق الغرفة تلقائيًا +terracotta.status.exception.desc.scaffolding_invalid_response=بروتوكول غير صالح: أرسل المستضيف استجابة غير صالحة، يرجى الإبلاغ عن هذه المشكلة للمطورين +terracotta.status.fatal.retry=إعادة المحاولة +terracotta.status.fatal.network=فشل تنزيل نواة اللعب الجماعي. يرجى التحقق من اتصال الشبكة والمحاولة مرة أخرى. +terracotta.status.fatal.install=خطأ فادح: غير قادر على تثبيت نواة اللعب الجماعي. +terracotta.status.fatal.terracotta=خطأ فادح: غير قادر على الاتصال بنواة اللعب الجماعي. +terracotta.status.fatal.unknown=خطأ فادح: غير معروف. +terracotta.player_list=قائمة اللاعبين +terracotta.player_anonymous=لاعب مجهول +terracotta.player_kind.host=المستضيف +terracotta.player_kind.local=أنت +terracotta.player_kind.guest=ضيف +terracotta.unsupported=Multiplayer is not yet supported on the current platform. +terracotta.unsupported.os.windows.old=Multiplayer requires Windows 10 or later. Please update your system. +terracotta.unsupported.arch.32bit=Multiplayer is not supported on 32-bit systems. Please upgrade to a 64-bit system. +terracotta.unsupported.arch.loongarch64_ow=Multiplayer is not supported on Linux LoongArch64 Old World distributions. Please update to a New World distribution (such as AOSC OS). + +unofficial.hint=أنت تستخدم نسخة غير رسمية من HMCL. لا يمكننا ضمان أمانها. + +update=التحديث +update.accept=تحديث +update.changelog=سجل التغييرات +update.channel.dev=تجريبي +update.channel.dev.hint=أنت تستخدم حاليًا نسخة من القناة التجريبية للمشغل. بينما قد تتضمن بعض الميزات الإضافية، فهي أحيانًا أقل استقرارًا من نسخ القناة الرسمية.\n\ + \n\ + إذا واجهت أي أخطاء أو مشاكل، يرجى إرسال الملاحظات عبر القنوات المتوفرة في صفحة الملاحظات.\n\ + \n\ + تابع @huanghongxun على Bilibili للبقاء على اطلاع بأخبار HMCL المهمة، أو @Glavo لمعرفة تقدم تطوير HMCL. +update.channel.dev.title=إشعار القناة التجريبية +update.channel.nightly=ليلي +update.channel.nightly.hint=أنت تستخدم حاليًا نسخة من القناة الليلية للمشغل. بينما قد تتضمن بعض الميزات الإضافية، فهي دائمًا أقل استقرارًا من نسخ القنوات الأخرى.\n\ + \n\ + إذا واجهت أي أخطاء أو مشاكل، يرجى إرسال الملاحظات عبر القنوات المتوفرة في صفحة الملاحظات.\n\ + \n\ + تابع @huanghongxun على Bilibili للبقاء على اطلاع بأخبار HMCL المهمة، أو @Glavo لمعرفة تقدم تطوير HMCL. +update.channel.nightly.title=إشعار القناة الليلية +update.channel.stable=رسمي +update.checking=التحقق من التحديثات +update.failed=فشل التحديث +update.found=تحديث متاح! +update.newest_version=أحدث إصدار: %s +update.bubble.title=تحديث متاح: %s +update.bubble.subtitle=انقر هنا للتحديث +update.note=قد تحتوي القنوات التجريبية والليلية على المزيد من الميزات أو الإصلاحات، ولكنها تأتي أيضًا مع المزيد من المشاكل المحتملة. +update.latest=هذا هو أحدث إصدار +update.no_browser=لا يمكن الفتح في متصفح النظام. ولكننا نسخنا الرابط إلى الحافظة الخاصة بك، ويمكنك فتحه يدويًا. +update.tooltip=تحديث +update.preview=معاينة إصدارات HMCL مبكرًا +update.preview.subtitle=قم بتفعيل هذا الخيار لاستلام إصدارات جديدة من HMCL مبكرًا للاختبار قبل إصدارها الرسمي. + +version=الألعاب +version.name=اسم النسخة +version.cannot_read=فشل تحليل نسخة اللعبة، لا يمكن متابعة التثبيت. +version.empty=لا توجد نسخ +version.empty.add=إضافة نسخة جديدة +version.empty.launch=لا توجد نسخ متاحة.\nيمكنك الانتقال إلى صفحة "التنزيل" للحصول على اللعبة، أو تبديل دليل اللعبة في صفحة "جميع النسخ". +version.empty.launch.goto_download=الذهاب إلى صفحة التنزيل +version.empty.hint=لا توجد نسخ Minecraft هنا.\nيمكنك محاولة التبديل إلى دليل لعبة آخر أو النقر هنا لتنزيل واحدة. +version.game.all=الكل +version.game.april_fools=كذبة إبريل +version.game.old=تاريخي +version.game.release=رسمي +version.game.releases=إصدارات رسمية +version.game.snapshot=لقطة +version.game.snapshots=لقطات +version.game.support_status.unsupported=غير مدعوم +version.game.support_status.untested=غير مختبر +version.game.type=النوع +version.launch=تشغيل اللعبة +version.launch.empty=بدء اللعبة +version.launch.empty.installing=جارٍ تثبيت اللعبة +version.launch.empty.tooltip=تثبيت وتشغيل أحدث إصدار رسمي +version.launch.test=تشغيل تجريبي +version.switch=تبديل النسخة +version.launch_script=تصدير نص التشغيل +version.launch_script.failed=فشل تصدير نص التشغيل. +version.launch_script.save=تصدير نص التشغيل +version.launch_script.success=تم تصدير نص التشغيل كـ %s. +version.manage=جميع النسخ +version.manage.clean=حذف ملفات السجل +version.manage.clean.tooltip=حذف الملفات في أدلة "logs" و "crash-reports". +version.manage.duplicate=تكرار النسخة +version.manage.duplicate.duplicate_save=تكرار الحفظ +version.manage.duplicate.prompt=أدخل اسم النسخة الجديد +version.manage.duplicate.confirm=ستحتوي النسخة المكررة على نسخة من جميع الملفات في دليل النسخة (".minecraft/versions/<اسم النسخة>")، مع دليل عمل وإعدادات معزولة. +version.manage.manage=تحرير النسخة +version.manage.manage.title=تحرير النسخة - %1s +version.manage.redownload_assets_index=تحديث أصول اللعبة +version.manage.remove=حذف النسخة +version.manage.remove.confirm.trash=هل أنت متأكد من أنك تريد إزالة النسخة "%1$s"؟ لا يزال بإمكانك العثور على ملفاتها في سلة المحذوفات باسم "%2$s". +version.manage.remove.confirm.independent=نظرًا لأن هذه النسخة مخزنة في دليل معزول، فإن حذفها سيحذف أيضًا حفظها وبيانات أخرى. هل لا تزال تريد حذف النسخة "%s"؟ +version.manage.remove.failed=خطأ في حذف النسخة. قد تكون بعض الملفات لازالت في الإستخدام. +version.manage.remove_assets=حذف جميع الأصول +version.manage.remove_libraries=حذف جميع المكتبات +version.manage.rename=إعادة تسمية النسخة +version.manage.rename.message=أدخل اسم النسخة الجديد +version.manage.rename.fail=فشل إعادة تسمية النسخة. قد تكون بعض الملفات قيد الاستخدام، أو يحتوي الاسم على حرف غير صالح. +version.search=الاسم +version.search.prompt=أدخل اسم الإصدار للبحث +version.settings=الإعدادات +version.update=تحديث حزمة التعديلات + +warning.java_interpreted_mode=يعمل HMCL في بيئة Java مفسرة، مما سيؤثر بشكل كبير على الأداء.\n\ + \n\ + نوصي باستخدام Java مع دعم JIT لفتح HMCL للحصول على أفضل تجربة. +warning.software_rendering=يستخدم HMCL حاليًا العرض البرمجي، مما سيؤثر بشكل كبير على الأداء. + +wiki.tooltip=صفحة ويكي Minecraft +wiki.version.game=https://minecraft.wiki/w/Java_Edition_%s +wiki.version.game.snapshot=https://minecraft.wiki/w/Java_Edition_%s + +wizard.prev=< السابق +wizard.failed=فشل +wizard.finish=إنهاء +wizard.next=التالي > diff --git a/HMCL/src/main/resources/assets/lang/I18N_es.properties b/HMCL/src/main/resources/assets/lang/I18N_es.properties index f4e0bc6d41..9b1ddca4c7 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_es.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_es.properties @@ -21,7 +21,7 @@ about=Acerca de about.copyright=Copyright -about.copyright.statement=Copyright © 2025 huangyuhui. +about.copyright.statement=Copyright © 2026 huangyuhui. about.author=Autor about.author.statement=bilibili @huanghongxun about.claim=EULA @@ -74,6 +74,7 @@ account.failed.server_disconnected=No se ha podido conectar con el servidor de a Si lo intentas varias veces y sigue fallando, vuelve a intentar iniciar sesión en la cuenta. account.failed.server_response_malformed=Respuesta del servidor no válida, el servidor de autenticación puede no estar funcionando. account.failed.ssl=Se ha producido un error SSL al conectar con el servidor. Por favor, intente actualizar su Java. +account.failed.dns=Se produjo un error SSL al conectar con el servidor. Es posible que la resolución DNS sea incorrecta. Intente cambiar el servidor DNS o usar un servicio proxy. account.failed.wrong_account=Ha iniciado sesión en la cuenta equivocada. account.hmcl.hint=Debe hacer clic en «Iniciar sesión» y completar el proceso en la ventana abierta del navegador. account.injector.add=Nuevo servidor Auth @@ -104,26 +105,9 @@ account.methods.microsoft.close_page=La autorización de la cuenta de Microsoft Hay algunos trabajos extra para nosotros, pero puedes cerrar esta pestaña con seguridad por ahora. account.methods.microsoft.makegameidsettings=Crear perfil / Editar nombre del perfil account.methods.microsoft.deauthorize=Desautorizar -account.methods.microsoft.error.add_family=Un adulto debe añadirte a una familia para que puedas jugar a Minecraft porque aún no tienes 18 años. -account.methods.microsoft.error.add_family_probably=Por favor, compruebe si la edad indicada en la configuración de su cuenta es de al menos 18 años. Si no es así y cree que se trata de un error, puede hacer clic en «Cómo cambiar la fecha de nacimiento de su cuenta» para saber cómo cambiarla. -account.methods.microsoft.error.country_unavailable=Xbox Live no está disponible en tu país/región actual. -account.methods.microsoft.error.missing_xbox_account=Tu cuenta Microsoft aún no tiene una cuenta Xbox vinculada. Haga clic en «Crear perfil / Editar nombre de perfil» para crear una antes de continuar. -account.methods.microsoft.error.no_character=Por favor, asegúrese de que ha comprado Minecraft: Java Edition. \nSi ya lo has comprado, es posible que no hayas creado un perfil de juego.\nPor favor, haga clic en «Crear perfil / Editar nombre de perfil» para crearlo. -account.methods.microsoft.error.banned=Es posible que Xbox Live haya bloqueado tu cuenta.\nPuede hacer clic en «Consulta de bloqueo de cuentas» para obtener más detalles. -account.methods.microsoft.error.unknown=No se ha podido iniciar sesión, error: %d. -account.methods.microsoft.error.wrong_verify_method=Inicie sesión con su contraseña en la página de inicio de sesión de la cuenta Microsoft y no utilice un código de verificación para iniciar sesión. account.methods.microsoft.logging_in=Iniciando sesión... -account.methods.microsoft.hint=Por favor, haga clic en «Iniciar sesión» y copie el código que aparece aquí para completar el proceso de inicio de sesión en la ventana del navegador que se abre.\n\ - \n\ - Si el token utilizado para iniciar sesión en la cuenta de Microsoft se ha filtrado, puedes hacer clic en «Desautorizar» para desautorizarlo. -account.methods.microsoft.manual=El código de su dispositivo es %1$s. Por favor, haga clic aquí para copiarlo.\n\ - \n\ - Después de hacer clic en «Iniciar sesión», debe completar el proceso de inicio de sesión en la ventana abierta del navegador. Si no se muestra, puede navegar a %2$s manualmente.\n\ - \n\ - Si el token utilizado para iniciar sesión en la cuenta de Microsoft se ha filtrado, puedes hacer clic en «Desautorizar» para desautorizarlo. account.methods.microsoft.profile=Perfil de la cuenta account.methods.microsoft.purchase=Comprar Minecraft -account.methods.microsoft.snapshot=Está utilizando una versión no oficial de HMCL. Por favor, descargue la versión oficial para iniciar sesión. account.methods.microsoft.snapshot.website=Sitio web oficial account.methods.offline=Sin conexión account.methods.offline.name.special_characters=Utilice solo letras, números y guiones bajos (máximo 16 caracteres) @@ -198,7 +182,15 @@ button.select_all=Seleccionar todo button.view=Vista button.yes=Sí -chat=Chat de grupo +contact=Comentarios +contact.chat=Chat de grupo +contact.chat.discord=Discord +contact.chat.discord.statement=¡Únase a nuestro servidor Discord! +contact.chat.qq_group=Grupo QQ de usuarios de HMCL +contact.chat.qq_group.statement=¡Únase a nuestro grupo QQ de usuarios! +contact.feedback=Canal de comentarios +contact.feedback.github=GitHub Issues +contact.feedback.github.statement=Envíe una propuesta en GitHub. color.recent=Recomendado color.custom=Color personalizado @@ -368,6 +360,7 @@ exception.access_denied=HMCL no puede acceder al archivo %s. Puede estar bloquea Si no es así, comprueba si tu cuenta tiene permisos suficientes para acceder a ella. exception.artifact_malformed=No se puede verificar la integridad de los archivos descargados. exception.ssl_handshake=No se pudo establecer una conexión SSL porque falta el certificado SSL en la instalación actual de Java. Puede intentar abrir HMCL con otro Java y volver a intentarlo. +exception.dns.pollution=No se pudo establecer una conexión SSL. Es posible que la resolución DNS sea incorrecta. Por favor, intente cambiar el servidor DNS o usar un servicio proxy. extension.bat=Archivo por lotes de Windows extension.mod=Archivo mod @@ -433,15 +426,6 @@ fatal.unsupported_platform.windows_arm64=Hello Minecraft! Launcher ha proporcion Si utilizas la plataforma Qualcomm, es posible que tengas que instalar el paquete de compatibilidad OpenGL antes de jugar.\n\ Haz clic en el enlace para ir a Microsoft Store e instalar el paquete de compatibilidad. -feedback=Comentarios -feedback.channel=Canal de comentarios -feedback.discord=Discord -feedback.discord.statement=¡Únase a nuestro servidor Discord! -feedback.github=GitHub Issues -feedback.github.statement=Envíe una propuesta en GitHub. -feedback.qq_group=Grupo QQ de usuarios de HMCL -feedback.qq_group.statement=¡Únase a nuestro grupo QQ de usuarios! - file=Archivo folder.config=Configuraciones de mod @@ -717,7 +701,7 @@ install.installer.depend=Requiere %s install.installer.do_not_install=No instalar install.installer.fabric=Fabric install.installer.fabric-api=Fabric API -install.installer.fabric-api.warning=Atención: Fabric API es un mod, y se instalará en el directorio de mods de la instancia del juego. Por favor, no cambies el directorio de trabajo del juego, o el Fabric API no funcionará. Si quieres cambiar esta configuración, debes reinstalarlo. +install.installer.fabric-quilt-api.warning=%1$s es un mod, y se instalará en el directorio de mods de la instancia del juego. Por favor, no cambies el directorio de trabajo del juego, o el %1$s no funcionará. Si quieres cambiar esta configuración, debes reinstalarlo. install.installer.forge=Forge install.installer.neoforge=NeoForge install.installer.game=Minecraft @@ -1062,7 +1046,9 @@ mods.category=Categoría mods.channel.alpha=Alpha mods.channel.beta=Beta mods.channel.release=Release -mods.check_updates=Actualizar +mods.check_updates=Proceso de actualización de mods +mods.check_updates.button=Actualizar +mods.check_updates.confirm=Actualizar mods.check_updates.current_version=Versión actual mods.check_updates.empty=Todos los mods están actualizados mods.check_updates.failed_check=No se ha podido comprobar si hay actualizaciones. @@ -1070,7 +1056,6 @@ mods.check_updates.failed_download=No se han podido descargar algunos de los arc mods.check_updates.file=Archivo mods.check_updates.source=Fuente mods.check_updates.target_version=Versión de destino -mods.check_updates.update=Actualización mods.choose_mod=Elige un mod mods.curseforge=CurseForge mods.dependency.embedded=Dependencias incorporadas (Already packaged in the mod file by the author. No need to download separately) @@ -1128,6 +1113,9 @@ world.chunkbase.end_city=Ciudad del End world.chunkbase.seed_map=Vista previa de la generación mundial world.chunkbase.stronghold=Fortaleza world.chunkbase.nether_fortress=Fortaleza del Nether +world.duplicate.failed.already_exists=El directorio ya existe +world.duplicate.failed.empty_name=El nombre no puede estar vacío +world.duplicate.failed.invalid_name=El nombre contiene caracteres no válidos world.datapack=Paquetes de datos world.datetime=Jugado por última vez en %s world.delete=Eliminar este mundo @@ -1387,7 +1375,7 @@ settings.launcher.proxy.socks=SOCKS settings.launcher.proxy.username=Nombre de usuario settings.launcher.theme=Tema settings.launcher.title_transparent=Barra de título transparente -settings.launcher.turn_off_animations=Desactivar animación (Se aplica después de reiniciar) +settings.launcher.turn_off_animations=Desactivar animación settings.launcher.version_list_source=Lista de versiones settings.launcher.background.settings.opacity=Opacidad @@ -1446,7 +1434,7 @@ update.latest=Esta es la última versión. update.no_browser=No se puede abrir en el navegador del sistema. Pero, hemos copiado el enlace a su portapapeles y puede abrirlo manualmente. update.tooltip=Actualización update.preview=Vista previa de actualizaciones anticipadas -update.preview.tooltip=Activa esta opción para recibir nuevas versiones del lanzador antes de su lanzamiento oficial para probarlas +update.preview.subtitle=Activa esta opción para recibir nuevas versiones del lanzador antes de su lanzamiento oficial para probarlas version=Juegos version.name=Nombre de instancia diff --git a/HMCL/src/main/resources/assets/lang/I18N_ja.properties b/HMCL/src/main/resources/assets/lang/I18N_ja.properties index a7dd805c6b..187db50375 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ja.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ja.properties @@ -20,7 +20,7 @@ about=について about.copyright=著作権 -about.copyright.statement=Copyright © 2025 huangyuhui +about.copyright.statement=Copyright © 2026 huangyuhui about.author=著者 about.author.statement=bilibili @huanghongxun about.claim=EULA @@ -90,20 +90,11 @@ account.methods.microsoft=Microsoft Account account.methods.microsoft.birth=誕生日の設定を編集する方法... account.methods.microsoft.deauthorize=アカウントのバインドを解除 account.methods.microsoft.close_page=Microsoftアカウントの認証が終了しました。後で終了するログイン手順がいくつか残っています。このページを今すぐ閉じることができます。 -account.methods.microsoft.error.add_family=まだ18歳ではないため、Minecraftをプレイするには、大人があなたを家族に追加する必要があります。 -account.methods.microsoft.error.add_family_probably=すでに18歳であるかどうかにかかわらず、アカウント設定を確認してください。そうでない場合は、アカウントを家族に追加する必要があります。 -account.methods.microsoft.error.country_unavailable=XBoxLiveはお住まいの国/地域ではご利用いただけません。 -account.methods.microsoft.error.missing_xbox_account=MicrosoftアカウントがXboxアカウントに接続されていません。続行する前に作成してください。 -account.methods.microsoft.error.no_character=アカウントにMinecraftJavaプロファイルがありません。\nゲームファイルが作成されていない可能性があります\n上記のリンクをクリックして作成してください -account.methods.microsoft.error.unknown=ログインに失敗しました。Microsoftはエラーコード %d で応答します。 account.methods.microsoft.logging_in=ログイン... account.methods.forgot_password=パスワードをお忘れの方 account.methods.microsoft.makegameidsettings=プロファイルを作成/プロフィール名を編集する -account.methods.microsoft.hint=「ログイン」ボタンをクリックして、新しく開いたブラウザウィンドウでログインプロセスを続行する必要があります。\nMicrosoftアカウントへのログインに使用されたトークンが誤って漏洩した場合は、下の[アカウントのバインドを解除]をクリックして、ログイン認証をキャンセルできます。\n問題が発生した場合は、右上隅にあるヘルプ ボタンをクリックするとヘルプが表示されます。 -account.methods.microsoft.manual=「ログイン」ボタンをクリックした後、新しく開いたブラウザウィンドウで認証を完了する必要があります。ブラウザウィンドウが表示されない場合は、ここをクリックしてURLをコピーし、ブラウザで手動で開くことができます。\nMicrosoftアカウントへのログインに使用されたトークンが誤って漏洩した場合は、下の[アカウントのバインドを解除]をクリックして、ログイン認証をキャンセルできます。\n問題が発生した場合は、右上隅にあるヘルプ ボタンをクリックするとヘルプが表示されます。 account.methods.microsoft.profile=アカウントプロファイル.. account.methods.microsoft.purchase=Minecraftを購入する -account.methods.microsoft.snapshot=非公式構築 HMCL を使用しているので、公式構築をダウンロードしてマイクロソフトにログインしてください。 account.methods.offline=オフライン account.methods.offline.uuid=UUID account.methods.offline.uuid.hint=UUIDは、Minecraftのゲームキャラクターの一意の識別子です。UUIDの生成方法は、ゲームランチャーによって異なります。UUIDを他のランチャーによって生成されたものに変更すると、オフラインアカウントのバックパック内のゲームブロック/アイテムが残ることが約束されます。このオプションは専門家向けです。何をしているのかわからない限り、このオプションを変更することはお勧めしません。\nこのオプションはサーバーに参加する場合には必要ありません。 @@ -157,7 +148,15 @@ button.select_all=すべて選択 button.view=読む button.yes=はい -chat=グループチャット +contact=フィードバック +contact.chat=グループチャット +contact.chat.discord=Discord +contact.chat.discord.statement=Discordサーバーに参加してください! +contact.chat.qq_group=HMCLユーザーQQグループ +contact.chat.qq_group.statement=ユーザーQQグループに参加してください! +contact.feedback=フィードバックチャンネル +contact.feedback.github=GitHub Issues +contact.feedback.github.statement=GitHubで問題を送信します。 color.recent=推奨 color.custom=カスタムカラー @@ -311,6 +310,7 @@ exception.access_denied=ファイル %s にアクセスできないので、HMCL Windowsユーザーの場合、リソースモニターでプログラムがファイルを占有しているかどうかを確認し、もしそうなら、このファイルを占有している関連プログラムを閉じるか、コンピュータを再起動して、もう一度試してみることも可能です。 exception.artifact_malformed=ダウンロードしたファイルがチェックサムを通過していない。 exception.ssl_handshake=現在のJava仮想マシンに該当するSSL証明書がないため、SSL接続を確立できませんでした。別のJava仮想マシンでHMCLを起動して、もう一度試してみてください。 +exception.dns.pollution=SSL 接続を確立できませんでした。DNS 解決が正しくない可能性があります。DNS サーバーを変更するか、プロキシサービスを使用してみてください。 extension.bat=WindowsBatファイル extension.mod=Modファイル @@ -326,16 +326,6 @@ fatal.apply_update_failure=ごめんなさい、Hello Minecraft! Launcher 何か fatal.samba=If you are trying to run HMCL in a shared folder by Samba, HMCL may not working, please try updating your Java or running HMCL in a local folder. fatal.illegal_char=ユーザーフォルダーのパスに不正な文字'='が含まれています, ログインアカウントやオフラインログインではスキンの変更ができなくなり。 - -feedback=フィードバック -feedback.channel=フィードバックチャンネル -feedback.discord=Discord -feedback.discord.statement=Discordサーバーに参加してください! -feedback.github=GitHub Issues -feedback.github.statement=GitHubで問題を送信します。 -feedback.qq_group=HMCLユーザーQQグループ -feedback.qq_group.statement=ユーザーQQグループに参加してください! - file=ファイル folder.config=Config @@ -439,7 +429,7 @@ install.installer.choose=%s バージョンを選択してください install.installer.depend=%s に依存 install.installer.fabric=Fabric install.installer.fabric-api=Fabric API -install.installer.fabric-api.warning=警告:Fabric APIはmodであり、新しいゲームのmodディレクトリにインストールされます。新しいゲームの実行ディレクトリを変更しないでください。変更すると、FabricAPIのインストールが失われます。その設定を変更したい場合は、FabricAPIを再インストールする必要があります。 +install.installer.fabric-quilt-api.warning=%1$sはmodであり、新しいゲームのmodディレクトリにインストールされます。新しいゲームの実行ディレクトリを変更しないでください。変更すると、%1$sのインストールが失われます。その設定を変更したい場合は、%1$sを再インストールする必要があります。 install.installer.forge=Forge install.installer.game=Minecraft install.installer.incompatible=%s と互換性がありません @@ -670,13 +660,14 @@ mods.add.failed=mods %s の追加に失敗しました。 mods.add.success=mods %s が正常に追加されました。 mods.category=Category mods.check_updates=更新を確認 +mods.check_updates.button=更新 +mods.check_updates.confirm=更新 mods.check_updates.current_version=Current mods.check_updates.failed_check=更新のチェックに失敗しました mods.check_updates.failed_download=一部のファイルのダウンロードに失敗しました mods.check_updates.file=ファイル mods.check_updates.source=Source mods.check_updates.target_version=Target -mods.check_updates.update=更新 mods.choose_mod=modを選択してください mods.curseforge=CurseForge mods.disable=無効にする diff --git a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties index dd1ead57ed..0185a5a45f 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties @@ -20,7 +20,7 @@ about=有涉 about.copyright=持權 -about.copyright.statement=© 2025 huangyuhui 持權 +about.copyright.statement=© 2026 huangyuhui 持權 about.author=作者 about.author.statement=嗶站 @huanghongxun about.claim=客契 @@ -78,6 +78,7 @@ account.failed.server_disconnected=不可訪所入伺服器。戶簿訊更敗矣 君可求助於右上之鈕。 account.failed.server_response_malformed=鑒權伺服器之訊有謬。伺服器或壞。 account.failed.ssl=將訪伺服器而 SSL 有謬。站證或舊,抑爪哇之版舊。宜新爪哇,抑廢代而再試之。\n君可求助於右上之鈕。 +account.failed.dns=將訪伺服器而 SSL 有謬。或由 DNS 解析有誤也。宜更 DNS 伺服,抑用代理以訪。\n君可求助於右上之鈕。 account.failed.wrong_account=入謬戶簿 account.hmcl.hint=子須擊「登戶簿」之紐,並登簿於所見之頁。 account.injector.add=增鑒權伺服器 @@ -105,40 +106,12 @@ account.methods.microsoft=微軟之戶簿 account.methods.microsoft.birth=何以改戶簿之生辰? account.methods.microsoft.deauthorize=除戶簿 account.methods.microsoft.close_page=登微軟之簿畢。啟者自行之餘。頁可閉矣。 -account.methods.microsoft.error.add_family=擊「纂戶簿訊息」於上以改生年,俾晉歲十八,抑錄戶簿於一家。\n君可求助於右上之鈕。 -account.methods.microsoft.error.add_family_probably=擊「纂戶簿訊息」於上以改生年,俾晉歲十八,抑錄戶簿於一家。\n君可求助於右上之鈕。 -account.methods.microsoft.error.country_unavailable=子之國域,無㐅盒展。 -account.methods.microsoft.error.missing_xbox_account=擊「添檔」於上以鏈㐅盒戶簿。\n君可求助於右上之鈕。 -account.methods.microsoft.error.no_character=望君復驗礦藝爪哇版之購否。\n然,則戲檔或未立。擊上之「添檔」以立之。\n君可求助於右上之鈕。 -account.methods.microsoft.error.banned=君之戶簿或見羈於㐅盒展。\n擊「驗戶簿羈否」於上以驗之。 -account.methods.microsoft.error.unknown=未明之謬。謬碼:%d。\n君可求助於右上之鈕。 -account.methods.microsoft.error.wrong_verify_method=君宜行符節於微軟戶簿之登頁,不宜行驗碼也。\n君可求助於右上之鈕。 account.methods.microsoft.logging_in=方登入…… account.methods.microsoft.makegameidsettings=添檔 / 書檔之名 -account.methods.microsoft.hint=增戶簿之法如次:\n\ - \ 一、擊「登入」之鈕;\n\ - \ 二、鍵 HMCL 所書之碼於葉之方立,且後擊「許之」;\n\ - \ 三、唯此頁是從;\n\ - \ 四、凡曰「是否允许此应用访问你的信息?」,則擊「善」;\n\ - \ 五、凡曰「大功告成」,則靜待戶簿增。\n\ - 若曰「有謬」,抑戶簿不能增,宜復試之。\n\ - 網路誠阻,或滯,乃不能成。宜求代而復試。\n\ - 凡有謬,遽求助於右上之鈕。 -account.methods.microsoft.manual=增戶簿之法如次:\n\ - \ 一、擊「登入」之鈕;\n\ - \ 二、鍵 %1$s 於葉之方立 (既錄矣),然後擊「許之」;\n\ - \ 三、从是頁之令;\n\ - \ 四、凡曰「是否允许此应用访问你的信息?」,擊「諾」;\n\ - \ 五、凡曰「大功告成」,則待戶簿之增也。\n\ - 誠不見頁,君可自訪頁於: %2$s\n\ - 誠報曰「有謬」,抑戶簿不能增,宜復試之。\n\ - 網路誠阻,或頁滯,甚者不能成。宜求代而復試之。\n\ - 凡有謬,遽求助於右上之鈕。 account.methods.microsoft.profile=纂戶簿訊息 account.methods.microsoft.purchase=買礦藝 account.methods.forgot_password=亡符節 account.methods.ban_query=檢戶簿羈否 -account.methods.microsoft.snapshot=君所用之 HMCL,蓋他者之版也。宜取官版以登微軟戶簿。 account.methods.microsoft.snapshot.website=官網 account.methods.offline=離綫之式 account.methods.offline.name.special_characters=諫以英文、數、底綫爲名,凡十六字 @@ -206,11 +179,19 @@ button.select_all=悉擇之 button.view=覽 button.yes=然 -chat=會集 - color.recent=薦 color.custom=自定色 +contact=建言 +contact.chat=會集 +contact.chat.discord=齟齬 +contact.chat.discord.statement=恭迎至齟齬伺服器,且循論議之規 +contact.chat.qq_group=HMCL 群組 +contact.chat.qq_group.statement=恭迎至 HMCL 群組,且循論議之規 +contact.feedback=建言之徑 +contact.feedback.github=Github 議題 +contact.feedback.github.statement=舉一 Github 議題 + crash.NoClassDefFound=宜驗 HMCL 之案全否,抑更迭爪哇。\n君可求助於 https://docs.hmcl.net/help.html。 crash.user_fault=君之械網與爪哇或有謬,是以崩。宜驗爪哇與算機。\n君可求助於 https://docs.hmcl.net/help.html。 @@ -372,6 +353,7 @@ exception.access_denied=不能訪案「%s」。HMCL 或無權以訪之,抑既 凡有謬,遽求助於右上之鈕。 exception.artifact_malformed=所引之案未能經校。\n君可求助於右上之鈕 exception.ssl_handshake=無築 SSL 鏈。爪哇缺證。宜改爪哇,抑制廢爾代。\n君可求助於右上之鈕。 +exception.dns.pollution=無築 SSL 鏈。或由 DNS 污染、解析有誤也。宜更 DNS 伺服,抑用代理以訪。\n君可求助於右上之鈕。 extension.bat=視窗角本 extension.mod=改囊案 @@ -401,15 +383,6 @@ fatal.unsupported_platform.loongarch=HMCL 既適龍芯。\n凡有謬,遽求助 fatal.unsupported_platform.macos_arm64=HMCL 既適蘋矽。宜啟以 ARM 之爪哇,以益君之戲事。\n誠有謬,宜啟以 x86-64 之爪哇,以益其兼。\n凡有謬,遽求助於右上之鈕。 fatal.unsupported_platform.windows_arm64=HMCL 既適 ARM 之視窗。誠有謬,宜啟以 x86 之爪哇。\n誠用栝柑,或須置開圖庫兼囊,而後可戯。擊鏈以置之於微軟貨舍。\n君可求助於右上之鈕。 -feedback=建言 -feedback.channel=建言之徑 -feedback.discord=齟齬 -feedback.discord.statement=恭迎至齟齬伺服器,且循論議之規 -feedback.github=Github 議題 -feedback.github.statement=舉一 Github 議題 -feedback.qq_group=HMCL 群組 -feedback.qq_group.statement=恭迎至 HMCL 群組,且循論議之規 - file=案 folder.config=置設案夾 @@ -525,7 +498,7 @@ install.installer.depend=請先裝 %s install.installer.do_not_install=不裝 install.installer.fabric=緞 install.installer.fabric-api=緞界 -install.installer.fabric-api.warning=誡:緞界者,改囊也,將措乎新戲之改囊案夾也。既裝而無改戲之「行徑」。將改,而須復置之。 +install.installer.fabric-quilt-api.warning=%1$s者,改囊也,將措乎新戲之改囊案夾也。既裝而無改戲之「行徑」。將改,而須復置之。 install.installer.forge=鍛 install.installer.neoforge=新鍛 install.installer.game=礦藝 @@ -861,6 +834,8 @@ mods.channel.alpha=預版 mods.channel.beta=試版 mods.channel.release=當版 mods.check_updates=檢改囊之新 +mods.check_updates.button=檢改囊之新 +mods.check_updates.confirm=迭更 mods.check_updates.current_version=當版 mods.check_updates.empty=無改囊可迭更 mods.check_updates.failed_check=檢囊迭更未成 @@ -868,7 +843,6 @@ mods.check_updates.failed_download=有引案未成 mods.check_updates.file=案 mods.check_updates.source=源 mods.check_updates.target_version=將至之版 -mods.check_updates.update=迭更 mods.choose_mod=擇改囊 mods.curseforge=CurseForge mods.dependency.embedded=既存之相依改囊 (既以內於改囊案,無須他引) @@ -1152,7 +1126,7 @@ settings.launcher.download_source=引源 settings.launcher.download_source.auto=自擇所引源 settings.launcher.enable_game_list=見版列於主頁 settings.launcher.font=書體 -settings.launcher.font.anti_aliasing=抗鋸 (復啟而效) +settings.launcher.font.anti_aliasing=抗鋸 settings.launcher.font.anti_aliasing.auto=自調 settings.launcher.font.anti_aliasing.gray=灰階 settings.launcher.font.anti_aliasing.lcd=子像素 @@ -1176,7 +1150,7 @@ settings.launcher.proxy.socks=SOCKS settings.launcher.proxy.username=戶簿 settings.launcher.theme=主題 settings.launcher.title_transparent=通透題欄 -settings.launcher.turn_off_animations=廢動效 (復啟而效) +settings.launcher.turn_off_animations=廢動效 settings.launcher.version_list_source=版列供者 settings.launcher.background.settings.opacity=陰翳 @@ -1201,6 +1175,8 @@ settings.type.special.enable=啟例殊戲設 (不殃余者) settings.type.special.edit=纂例殊戲設 settings.type.special.edit.hint=是戲例「%s」既啟「例殊戲設」,是故本頁諸置設弗行於斯。擊此鏈赴斯版之「戲設」頁。 +shaderpack.download.title=引光影 - %1s + sponsor=資勉 sponsor.bmclapi=本朝迅傳之功在 BMCLAPI 焉。BMCLAPI 久行善業,垂衆以慷慨解囊助作者兼濟天下也。[願聞其詳] sponsor.hmcl=Hello Minecraft! Launcher,乃一免費,自由,開源之礦藝啟者。[願聞其詳] diff --git a/HMCL/src/main/resources/assets/lang/I18N_ru.properties b/HMCL/src/main/resources/assets/lang/I18N_ru.properties index e83949930f..60098a6ee7 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ru.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ru.properties @@ -20,7 +20,7 @@ about=О лаунчере about.copyright=Авторские права -about.copyright.statement=Авторские права © 2025 huangyuhui. +about.copyright.statement=Авторские права © 2026 huangyuhui. about.author=Автор about.author.statement=bilibili @huanghongxun about.claim=EULA @@ -77,6 +77,7 @@ account.failed.server_disconnected=Невозможно получить дос аккаунта. account.failed.server_response_malformed=Неверный ответ сервера, видимо сервер авторизации не работает. account.failed.ssl=При подключении к серверу произошла ошибка SSL. Пожалуйста, попробуйте обновить Java. +account.failed.dns=При подключении к серверу произошла ошибка SSL. Возможно, проблемы с разрешением DNS. Попробуйте сменить DNS‑сервер или воспользоваться прокси-службой. account.failed.wrong_account=Вы вошли в неверный аккаунт. account.hmcl.hint=Необходимо нажать на «Войти» и завершить процесс в открывшейся вкладке вашего браузера. account.injector.add=Новый сервер авторизации @@ -104,29 +105,10 @@ account.methods.microsoft=Microsoft account.methods.microsoft.birth=Как изменить настройки даты рождения... account.methods.microsoft.close_page=Авторизация аккаунта Microsoft завершена. Осталось выполнить некоторые шаги, которые необходимо завершить позже. Вы можете закрыть эту страницу. account.methods.microsoft.deauthorize=Отменить авторизацию аккаунта -account.methods.microsoft.error.add_family=Поскольку тебе ещё нет 18 лет, взрослый должен добавить тебя в семейный доступ, чтобы ты мог играть в Minecraft. -account.methods.microsoft.error.add_family_probably=Проверьте, что возраст, указанный в настройках вашего аккаунта, составляет не менее 18 лет. Если нет и вы считаете, что это ошибка, вы можете нажать «Как изменить настройки даты рождения...», чтобы узнать, как ее изменить. -account.methods.microsoft.error.country_unavailable=Xbox Live недоступен в вашей стране/регионе. -account.methods.microsoft.error.missing_xbox_account=Ваш аккаунт Microsoft не подключена к Xbox. Создайте его, прежде чем продолжить. -account.methods.microsoft.error.no_character=Убедитесь, что вы приобрели Minecraft: Java Edition.\n\ - Если вы уже приобрели игру, профиль может быть еще не создан.\n\ - Нажмите «Создать / Редактировать профиль», чтобы создать его. -account.methods.microsoft.error.banned=Возможно ваш аккаунт был заблокирован Xbox Live.\nВы можете нажать «Просмотреть аккаунт заблокирован» для получения более подробной информации. -account.methods.microsoft.error.unknown=Не удалось войти. Microsoft отвечает с кодом ошибки %d. -account.methods.microsoft.error.wrong_verify_method=Пожалуйста, войдите в систему, используя свой пароль на странице входа в аккаунт Microsoft, и не используйте проверочный код для авторизации. account.methods.microsoft.logging_in=Авторизация... -account.methods.microsoft.hint=Вам необходимо нажать кнопку «Войти» и продолжить процесс авторизации во вновь открывшемся окне браузера.\n\ - \n\ - Если токен, используемый для входа в аккаунт Microsoft случайно утёк, вы можете нажать «Отменить авторизацию аккаунта» ниже, чтобы отменить авторизацию. -account.methods.microsoft.manual=Ваш код устройства - %1$s. Нажмите здесь, чтобы скопировать.\n\ - \n\ - После нажатия кнопки «Войти», вы должны завершить авторизацию во вновь открывшемся окне браузера. Если окно браузера не открылось, вы можете щёлкнуть здесь, чтобы скопировать ссылку и вручную открыть её в браузере.\n\ - \n\ - Если токен, используемый для входа в аккаунт Microsoft случайно утёк, вы можете нажать «Отменить авторизацию аккаунта» ниже, чтобы отменить авторизацию. account.methods.microsoft.makegameidsettings=Создать / редактировать профиль account.methods.microsoft.profile=Профиль аккаунта... account.methods.microsoft.purchase=Купить Minecraft -account.methods.microsoft.snapshot=Вы используете неофициальную версию HMCL, загрузите официальную версию для входа в аккаунт Microsoft. account.methods.microsoft.snapshot.website=Официальный сайт account.methods.offline=Офлайн account.methods.offline.name.special_characters=Использовать только буквы, цифры и подчеркивания (максимум 16 символов) @@ -197,7 +179,15 @@ button.select_all=Выбрать все button.view=Просмотреть button.yes=Да -chat=Групповой чат +contact=Обратная связь +contact.chat=Групповой чат +contact.chat.discord=Discord +contact.chat.discord.statement=Добро пожаловать в наш Discord. +contact.chat.qq_group=Группа QQ пользователя HMCL +contact.chat.qq_group.statement=Добро пожаловать в нашу группу QQ. +contact.feedback=Канал обратной связи +contact.feedback.github=Проблемы GitHub +contact.feedback.github.statement=Отправить проблему на GitHub. color.recent=Рекомендуемые color.custom=Пользовательский цвет @@ -370,6 +360,7 @@ exception.access_denied=Лаунчер не может получить дост Если нет, проверьте, достаточно ли привилегий у вашего аккаунта для доступа к нему. exception.artifact_malformed=Не удалось проверить целостность скачаных файлов. exception.ssl_handshake=Не удалось установить SSL-соединение из-за отсутствия SSL-сертификатов в текущей установке Java. Вы можете попробовать запустить лаунчер в другой версии Java, а затем повторить попытку. +exception.dns.pollution=Не удалось установить SSL‑соединение. Возможно, некорректно разрешаются DNS‑имена. Попробуйте сменить DNS‑сервер или воспользоваться прокси-службой. extension.bat=Пакетный файл Windows extension.mod=Файл мода @@ -429,15 +420,6 @@ fatal.unsupported_platform.windows_arm64=Лаунчер обеспечил на Если вы используете платформу Qualcomm, вам может потребоваться установить пакет совместимости OpenGL перед началом игры.\n\ Щелкните ссылку, чтобы перейти в Microsoft Store и установить пакет совместимости. -feedback=Обратная связь -feedback.channel=Канал обратной связи -feedback.discord=Discord -feedback.discord.statement=Добро пожаловать в наш Discord. -feedback.github=Проблемы GitHub -feedback.github.statement=Отправить проблему на GitHub. -feedback.qq_group=Группа QQ пользователя HMCL -feedback.qq_group.statement=Добро пожаловать в нашу группу QQ. - file=Файл folder.config=Конфигурация мод @@ -710,7 +692,7 @@ install.installer.depend=Требуется %s install.installer.do_not_install=Не устанавливать install.installer.fabric=Fabric install.installer.fabric-api=Fabric API -install.installer.fabric-api.warning=Предупреждение: Fabric API является модом и будет установлен в папку mod в сборке игры. Пожалуйста, не меняйте рабочую папку сборки, иначе Fabric API не будет работать. Если вы все же хотите изменить папку, вам следует переустановить сборку. +install.installer.fabric-quilt-api.warning=%1$s является модом и будет установлен в папку mod в сборке игры. Пожалуйста, не меняйте рабочую папку сборки, иначе %1$s не будет работать. Если вы все же хотите изменить папку, вам следует переустановить сборку. install.installer.forge=Forge install.installer.neoforge=NeoForge install.installer.game=Minecraft @@ -1058,6 +1040,8 @@ mods.channel.alpha=Альфа mods.channel.beta=Бета mods.channel.release=Релиз mods.check_updates=Проверить обновления +mods.check_updates.button=Обновить +mods.check_updates.confirm=Обновить mods.check_updates.current_version=Текущая версия mods.check_updates.empty=Все моды новейшие mods.check_updates.failed_check=Не удалось проверить обновления. @@ -1065,7 +1049,6 @@ mods.check_updates.failed_download=Не удалось скачать некот mods.check_updates.file=Файл mods.check_updates.source=Источник mods.check_updates.target_version=Целевая версия -mods.check_updates.update=Обновить mods.choose_mod=Выберите мод mods.curseforge=CurseForge mods.dependency.embedded=Встроенные зависимости (Уже упакован в файл мода автором. Нет необходимости скачивать отдельно.) @@ -1123,6 +1106,9 @@ world.chunkbase.end_city=Город Края world.chunkbase.seed_map=Предпросмотр генерации мира world.chunkbase.stronghold=Крепость world.chunkbase.nether_fortress=Крепость Нижнего мира +world.duplicate.failed.already_exists=Папка уже существует +world.duplicate.failed.empty_name=Название не может быть пустым +world.duplicate.failed.invalid_name=Название содержит недопустимые символы world.delete=Удалить этот мир world.delete.failed=Не удалось удалить мир.\n%s world.datapack=Наборы данных @@ -1360,7 +1346,7 @@ settings.launcher.font.anti_aliasing.auto=Автоматический settings.launcher.font.anti_aliasing.gray=Оттенки серого settings.launcher.font.anti_aliasing.lcd=Субпиксель settings.launcher.general=Общие -settings.launcher.language=Язык (Применится после перезапуска) +settings.launcher.language=Язык settings.launcher.launcher_log.export=Экспорт логов лаунчера settings.launcher.launcher_log.export.failed=Не удалось экспортировать логи settings.launcher.launcher_log.export.success=Логи экспортированы в %s @@ -1379,7 +1365,7 @@ settings.launcher.proxy.socks=SOCKS settings.launcher.proxy.username=Имя пользователя settings.launcher.theme=Тема settings.launcher.title_transparent=Прозрачная строка заголовка -settings.launcher.turn_off_animations=Отключить анимацию (Применится после перезапуска) +settings.launcher.turn_off_animations=Отключить анимацию settings.launcher.version_list_source=Список версий settings.launcher.background.settings.opacity=Непрозрачность diff --git a/HMCL/src/main/resources/assets/lang/I18N_uk.properties b/HMCL/src/main/resources/assets/lang/I18N_uk.properties index e2684eb773..978448e0cb 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_uk.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_uk.properties @@ -20,7 +20,7 @@ about=Про програму about.copyright=Авторське право -about.copyright.statement=Авторське право © 2025 huangyuhui +about.copyright.statement=Авторське право © 2026 huangyuhui about.author=Автор about.author.statement=bilibili @huanghongxun about.claim=Ліцензійна угода з кінцевим користувачем (EULA) @@ -73,6 +73,7 @@ account.failed.server_disconnected=Не вдалося підключитися Якщо проблема не зникає після кількох спроб, спробуйте знову увійти до облікового запису. account.failed.server_response_malformed=Недійсна відповідь сервера. Можливо, сервер автентифікації не працює. account.failed.ssl=Під час підключення до сервера сталася помилка SSL. Спробуйте оновити Java. +account.failed.dns=Під час підключення до сервера сталася помилка SSL. Можливо, DNS неправильно розв'язується. Спробуйте змінити DNS-сервер або використовувати проксі-сервіс. account.failed.wrong_account=Ви увійшли до неправильного облікового запису. account.hmcl.hint=Вам потрібно натиснути "Увійти" і завершити процес у відкритому вікні браузера. account.injector.add=Новий сервер автентифікації @@ -102,27 +103,10 @@ account.methods.microsoft.close_page=Авторизація облікового \n\ Нам залишилося виконати ще трохи роботи, але ви можете безпечно закрити цю вкладку зараз. account.methods.microsoft.deauthorize=Скасувати авторизацію -account.methods.microsoft.error.add_family=Дорослий повинен додати вас до сім'ї, щоб ви могли грати в Minecraft, оскільки вам ще не виповнилося 18 років. -account.methods.microsoft.error.add_family_probably=Перевірте, чи вік, зазначений у налаштуваннях вашого облікового запису, становить щонайменше 18 років. Якщо ні і ви вважаєте, що це помилка, ви можете натиснути "Як змінити дату народження облікового запису", щоб дізнатися, як це зробити. -account.methods.microsoft.error.country_unavailable=Xbox Live недоступний у вашій поточній країні/регіоні. -account.methods.microsoft.error.missing_xbox_account=Ваш обліковий запис Microsoft ще не має прив'язаного облікового запису Xbox. Натисніть "Створити профіль / Редагувати ім'я профілю", щоб створити його перед продовженням. -account.methods.microsoft.error.no_character=Переконайтеся, що ви придбали Minecraft: Java Edition.\nЯкщо ви вже придбали гру, профіль може ще не бути створений.\nНатисніть "Створити профіль / Редагувати ім'я профілю", щоб створити його. -account.methods.microsoft.error.banned=Ваш обліковий запис може бути заблокований Xbox Live.\nВи можете натиснути "Запит блокування" для отримання додаткової інформації. -account.methods.microsoft.error.unknown=Не вдалося увійти, код помилки: %d. -account.methods.microsoft.error.wrong_verify_method=Будь ласка, увійдіть за допомогою свого пароля на сторінці входу в обліковий запис Microsoft і не використовуйте код підтвердження для входу. account.methods.microsoft.logging_in=Вхід... -account.methods.microsoft.hint=Будь ласка, натисніть "Увійти" і скопіюйте код, показаний тут, щоб завершити процес входу у відкритому вікні браузера.\n\ - \n\ - Якщо токен, який використовується для входу в обліковий запис Microsoft, витік, ви можете натиснути "Скасувати авторизацію", щоб скасувати її. -account.methods.microsoft.manual=Код вашого пристрою %1$s. Натисніть тут, щоб скопіювати.\n\ - \n\ - Після натискання "Увійти" ви повинні завершити процес входу у відкритому вікні браузера. Якщо воно не з'явиться, ви можете перейти до %2$s вручну.\n\ - \n\ - Якщо токен, який використовується для входу в обліковий запис Microsoft, витік, ви можете натиснути "Скасувати авторизацію", щоб скасувати її. account.methods.microsoft.makegameidsettings=Створити профіль / Редагувати ім'я профілю account.methods.microsoft.profile=Профіль облікового запису account.methods.microsoft.purchase=Купити Minecraft -account.methods.microsoft.snapshot=Ви використовуєте неофіційну збірку HMCL. Завантажте офіційну збірку для входу. account.methods.microsoft.snapshot.website=Офіційний сайт account.methods.offline=Автономно account.methods.offline.name.special_characters=Використовуйте лише літери, цифри та підкреслення (макс. 16 символів) @@ -194,7 +178,15 @@ button.select_all=Вибрати все button.view=Переглянути button.yes=Так -chat=Приєднатися до групового чату +contact=Зворотний зв'язок +contact.chat=Приєднатися до групового чату +contact.chat.discord=Discord +contact.chat.discord.statement=Ласкаво просимо приєднатися до нашого сервера Discord. +contact.chat.qq_group=Група користувачів QQ HMCL +contact.chat.qq_group.statement=Ласкаво просимо приєднатися до нашої групи користувачів QQ. +contact.feedback=Канал зворотного зв'язку +contact.feedback.github=GitHub Issues +contact.feedback.github.statement=Надіслати проблему на GitHub. color.recent=Рекомендовані color.custom=Власний колір @@ -366,6 +358,7 @@ exception.access_denied=HMCL не може отримати доступ до ф Якщо ні, перевірте, чи має ваш обліковий запис достатні дозволи для доступу до нього. exception.artifact_malformed=Не вдається перевірити цілісність завантажених файлів. exception.ssl_handshake=Не вдалося встановити SSL-з'єднання, оскільки SSL-сертифікат відсутній у поточній інсталяції Java. Ви можете спробувати відкрити HMCL іншою інсталяцією Java і спробувати знову. +exception.dns.pollution=Не вдалося встановити SSL-з'єднання. Можливо, DNS неправильно розв'язується. Спробуйте змінити DNS-сервер або використовувати проксі-сервіс. extension.bat=Пакетний файл Windows extension.mod=Файл мода @@ -411,15 +404,6 @@ fatal.unsupported_platform.windows_arm64=Hello Minecraft! Лаунчер над Якщо ви використовуєте платформу Qualcomm, вам може знадобитися встановити Пакет сумісності OpenGL перед грою.\n\ Натисніть посилання, щоб перейти до Microsoft Store та встановити пакет сумісності. -feedback=Зворотний зв'язок -feedback.channel=Канал зворотного зв'язку -feedback.discord=Discord -feedback.discord.statement=Ласкаво просимо приєднатися до нашого сервера Discord. -feedback.github=GitHub Issues -feedback.github.statement=Надіслати проблему на GitHub. -feedback.qq_group=Група користувачів QQ HMCL -feedback.qq_group.statement=Ласкаво просимо приєднатися до нашої групи користувачів QQ. - file=Файл folder.config=Конфігурації @@ -656,7 +640,7 @@ install.installer.depend=Вимагає %s install.installer.do_not_install=Не встановлювати install.installer.fabric=Fabric install.installer.fabric-api=Fabric API -install.installer.fabric-api.warning=Попередження: Fabric API - це мод, і він буде встановлений у каталог модів екземпляра гри. Не змінюйте робочий каталог гри, інакше Fabric API не працюватиме. Якщо ви все ж хочете змінити каталог, вам слід перевстановити його. +install.installer.fabric-quilt-api.warning=%1$s - це мод, і він буде встановлений у каталог модів екземпляра гри. Не змінюйте робочий каталог гри, інакше %1$s не працюватиме. Якщо ви все ж хочете змінити каталог, вам слід перевстановити його. install.installer.forge=Forge install.installer.neoforge=NeoForge install.installer.game=Minecraft @@ -1000,6 +984,8 @@ mods.channel.alpha=Альфа mods.channel.beta=Бета mods.channel.release=Реліз mods.check_updates=Перевірити оновлення +mods.check_updates.confirm=Оновити +mods.check_updates.button=Оновити mods.check_updates.current_version=Поточна версія mods.check_updates.empty=Усі моди оновлені mods.check_updates.failed_check=Не вдалося перевірити оновлення. @@ -1007,7 +993,6 @@ mods.check_updates.failed_download=Не вдалося завантажити д mods.check_updates.file=Файл mods.check_updates.source=Джерело mods.check_updates.target_version=Цільова версія -mods.check_updates.update=Оновити mods.choose_mod=Вибрати мод mods.curseforge=CurseForge mods.dependency.embedded=Вбудовані залежності (Вже запаковані в файл мода автором. Не потрібно завантажувати окремо) @@ -1065,6 +1050,9 @@ world.chunkbase.end_city=Кінцеве місто world.chunkbase.seed_map=Карта насіння world.chunkbase.stronghold=Фортеця world.chunkbase.nether_fortress=Форт Незеру +world.duplicate.failed.already_exists=Каталог вже існує +world.duplicate.failed.empty_name=Назва не може бути порожньою +world.duplicate.failed.invalid_name=Назва містить недійсні символи world.datapack=Datapacks world.datetime=Останній раз грали %s world.delete=Видалити цей світ @@ -1302,7 +1290,7 @@ settings.launcher.font.anti_aliasing.auto=Авто settings.launcher.font.anti_aliasing.gray=Відтінки сірого settings.launcher.font.anti_aliasing.lcd=Субпіксельне settings.launcher.general=Загальні -settings.launcher.language=Мова (Застосовується після перезавантаження) +settings.launcher.language=Мова settings.launcher.launcher_log.export=Експортувати журнали лаунчера settings.launcher.launcher_log.export.failed=Не вдалося експортувати журнали. settings.launcher.launcher_log.export.success=Журнали було експортовано до "%s". @@ -1321,7 +1309,7 @@ settings.launcher.proxy.socks=SOCKS settings.launcher.proxy.username=Ім'я користувача settings.launcher.theme=Тема settings.launcher.title_transparent=Прозорий заголовок -settings.launcher.turn_off_animations=Вимкнути анімацію (Застосовується після перезавантаження) +settings.launcher.turn_off_animations=Вимкнути анімацію settings.launcher.version_list_source=Список версій settings.launcher.background.settings.opacity=Непрозорість diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 71882ac121..8a4ddaa8d9 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -20,7 +20,7 @@ about=關於 about.copyright=著作權 -about.copyright.statement=著作權所有 © 2025 huangyuhui +about.copyright.statement=著作權所有 © 2026 huangyuhui about.author=作者 about.author.statement=bilibili @huanghongxun about.claim=使用者協議 @@ -59,6 +59,7 @@ account.create.microsoft=新增 Microsoft 帳戶 account.create.offline=新增離線模式帳戶 account.create.authlibInjector=新增 authlib-injector 帳戶 account.email=電子信箱 +account.empty=沒有帳戶 account.failed=帳戶重新整理失敗 account.failed.character_deleted=已刪除此角色 account.failed.connect_authentication_server=無法連線至認證伺服器。可能是網路問題,請檢查裝置能否正常上網或使用代理服務。 @@ -77,6 +78,7 @@ account.failed.server_disconnected=無法訪問登入伺服器。帳戶資訊重 若嘗試多次無法重新整理,可嘗試重新增加該帳戶,或許可以解決該問題。 account.failed.server_response_malformed=無法解析認證伺服器回應,可能是伺服器故障。 account.failed.ssl=連線伺服器時發生了 SSL 錯誤。可能網站證書已過期或你使用的 Java 版本過低。請嘗試更新 Java。 +account.failed.dns=連線伺服器時發生了 SSL 錯誤。可能是 DNS 解析有誤。請嘗試更換 DNS 伺服器或使用代理服務。 account.failed.wrong_account=登入了錯誤的帳戶 account.hmcl.hint=你需要點擊「登入」按鈕,並在開啟的網頁中完成登入 account.injector.add=新增認證伺服器 @@ -102,35 +104,28 @@ account.methods=登入方式 account.methods.authlib_injector=外部登入 account.methods.microsoft=Microsoft 帳戶 account.methods.microsoft.birth=如何變更帳戶出生日期 -account.methods.microsoft.deauthorize=移除應用存取權 +account.methods.microsoft.code=代碼 (已自動複製) account.methods.microsoft.close_page=已完成 Microsoft 帳戶授權。啟動器將自動執行後續步驟。你現在可以關閉本頁面了。 -account.methods.microsoft.error.add_family=由於你未滿 18 歲,你的帳戶必須被加入到家庭中才能登入遊戲。你也可以點擊上方「編輯帳戶配置檔」更改你的帳戶出生日期,使年齡滿 18 歲以上以繼續登入。 -account.methods.microsoft.error.add_family_probably=請檢查你的帳戶設定,如果你未滿 18 歲,你的帳戶必須被加入到家庭中才能登入遊戲。你也可以點擊上方「編輯帳戶配置檔」更改你的帳戶出生日期,使年齡滿 18 歲以上以繼續登入。 +account.methods.microsoft.deauthorize=移除應用存取權 +account.methods.microsoft.error.add_family=請點擊 此處 更改你的帳戶出生日期,使年齡滿 18 歲以上,或將帳戶加入到家庭中。 account.methods.microsoft.error.country_unavailable=你所在的國家或地區不受 Xbox Live 的支援。 -account.methods.microsoft.error.missing_xbox_account=你的 Microsoft 帳戶尚未關聯 Xbox 帳戶,你必須先建立 Xbox 帳戶,才能登入遊戲。 -account.methods.microsoft.error.no_character=該帳戶未包含 Minecraft: Java 版購買記錄。\n若已購買,則可能未建立遊戲檔案。請點擊上方連結建立。 -account.methods.microsoft.error.banned=你的帳戶可能被 Xbox Live 封禁。\n你可以點擊上方「查詢帳戶是否被封禁」查看帳戶狀態。 -account.methods.microsoft.error.unknown=登入失敗,錯誤碼:%d -account.methods.microsoft.error.wrong_verify_method=請在 Microsoft 帳戶登入頁面使用密碼登入,不要使用驗證碼登入。 +account.methods.microsoft.error.missing_xbox_account=請點擊下方 此處 關聯 Xbox 帳戶。 +account.methods.microsoft.error.no_character=請確認你已經購買了 Minecraft: Java 版。\n若已購買,則可能未建立遊戲檔案。請點擊 此處 建立遊戲檔案。 +account.methods.microsoft.error.banned=你的帳戶可能被 Xbox Live 封禁。\n你可以點擊 此處 按鈕查詢帳戶封禁狀態。 +account.methods.microsoft.error.unknown=登入失敗。錯誤碼:%d。 +account.methods.microsoft.error.wrong_verify_method=登入失敗。請在 Microsoft 帳戶登入頁面嘗試使用密碼登入,不要使用其他登入方式。 account.methods.microsoft.logging_in=登入中…… account.methods.microsoft.makegameidsettings=建立檔案 / 編輯檔案名稱 -account.methods.microsoft.hint=請點擊「登入」按鈕,稍後複製此處顯示的代碼,在開啟的登入網頁中完成登入過程。\n\ - \n\ - 如果登入 Microsoft 帳戶的令牌洩露,可點擊下方「移除應用存取權」,然後等待令牌過期。\n\ - \n\ - 如遇到問題,你可以點擊右上角幫助按鈕進行求助。 -account.methods.microsoft.manual=你的代碼為 %1$s。請點擊此處複製。\n\ - \n\ - 點擊「登入」按鈕後,你應該在開啟的登入網頁中完成登入過程。如果網頁沒有開啟,你可以自行在瀏覽器中訪問 %2$s\n\ - \n\ - 如果登入 Microsoft 帳戶的令牌洩露,可點擊下方「移除應用存取權」,然後等待令牌過期。\n\ - \n\ - 如遇到問題,你可以點擊右上角幫助按鈕進行求助。 -account.methods.microsoft.profile=編輯帳戶配置檔 +account.methods.microsoft.hint=點擊「登入」按鈕開始新增 Microsoft 帳戶。 +account.methods.microsoft.manual=請在彈出的網頁中輸入上方顯示的代碼以完成登入。\n\ + 若網站未能顯示,請手動在瀏覽器中開啟:%s\n\ + 若網路環境不佳,可能會導致網頁載入緩慢甚至無法載入,請稍後再試或更換網路環境後再試。\n +account.methods.microsoft.profile=編輯帳戶個人信息 account.methods.microsoft.purchase=購買 Minecraft account.methods.forgot_password=忘記密碼 account.methods.ban_query=查詢帳戶是否被封禁 -account.methods.microsoft.snapshot=你正在使用第三方提供的 HMCL。請下載官方版本進行登入。 +account.methods.microsoft.snapshot=你正在使用第三方提供的 HMCL。請下載 官方版本 進行登入。 +account.methods.microsoft.snapshot.tooltip=你正在使用第三方提供的 HMCL。請下載官方版本來重新整理帳戶。 account.methods.microsoft.snapshot.website=官方網站 account.methods.offline=離線模式 account.methods.offline.name.special_characters=建議使用英文字母、數字以及底線命名,且長度不超過 16 個字元 @@ -189,6 +184,7 @@ button.export=匯出 button.no=否 button.ok=確定 button.ok.countdown=確定 (%d) +button.reset=重設 button.reveal_dir=開啟目錄 button.refresh=重新整理 button.remove=刪除 @@ -200,7 +196,15 @@ button.select_all=全選 button.view=查看 button.yes=是 -chat=官方群組 +contact=回報 +contact.chat=官方群組 +contact.chat.discord=Discord 伺服器 +contact.chat.discord.statement=歡迎加入 Discord 伺服器,加入後請遵守討論區規則 +contact.chat.qq_group=使用者 QQ 群組 +contact.chat.qq_group.statement=歡迎加入 HMCL 使用者 QQ 群組,加入後請遵守群組規則 +contact.feedback=回報管道 +contact.feedback.github=GitHub Issues +contact.feedback.github.statement=提交一個 GitHub Issue color.recent=建議 color.custom=自訂顏色 @@ -368,12 +372,14 @@ exception.access_denied=無法存取檔案「%s」。因為 HMCL 沒有對該檔 對於 Windows 使用者,你還可以嘗試透過資源監視器查看是否有程式占用了該檔案。如果是,你可以關閉占用此檔案的程式,或者重啟電腦再試。 exception.artifact_malformed=下載的檔案正確,但無法透過校驗。 exception.ssl_handshake=無法建立 SSL 連線。目前 Java 缺少相關的 SSL 證書。你可以嘗試使用其他 Java 或關閉網路代理開啟 HMCL 再試。 +exception.dns.pollution=無法建立 SSL 連線。可能是 DNS 解析有誤。請嘗試更換 DNS 伺服器或使用代理服務。 extension.bat=Windows 批次檔 extension.mod=模組檔案 extension.png=圖片檔案 extension.ps1=PowerShell 指令碼 extension.sh=Bash 指令碼 +extension.command=macOS Shell 指令碼 fatal.create_hmcl_current_directory_failure=Hello Minecraft! Launcher 無法建立 HMCL 資料夾 (%s),請將 HMCL 移動至其他位置再開啟。 fatal.javafx.incompatible=缺少 JavaFX 執行環境。\nHMCL 無法在低於 Java 11 的 Java 環境上自行補全 JavaFX 執行環境。請更新到 Java 11 或更高版本。 @@ -398,15 +404,6 @@ fatal.unsupported_platform.loongarch=Hello Minecraft! Launcher 已為龍芯提 fatal.unsupported_platform.macos_arm64=Hello Minecraft! Launcher 已為 Apple Silicon 平臺提供支援。使用 ARM 原生 Java 啟動遊戲以獲得更流暢的遊戲體驗。\n如果你在遊戲中遭遇問題,使用 x86-64 架構的 Java 啟動遊戲可能有更好的相容性。 fatal.unsupported_platform.windows_arm64=Hello Minecraft! Launcher 已為 Windows on Arm 平臺提供原生支援。如果你在遊戲中遭遇問題,請嘗試使用 x86 架構的 Java 啟動遊戲。\n\n如果你正在使用高通平臺,你可能需要安裝 OpenGL 相容包後才能進行遊戲。點擊連結前往 Microsoft Store 安裝相容包。 -feedback=回報 -feedback.channel=回報管道 -feedback.discord=Discord 伺服器 -feedback.discord.statement=歡迎加入 Discord 伺服器,加入後請遵守討論區規則 -feedback.github=GitHub Issues -feedback.github.statement=提交一個 GitHub Issue -feedback.qq_group=HMCL 使用者 QQ 群組 -feedback.qq_group.statement=歡迎加入 HMCL 使用者 QQ 群組,加入後請遵守群組規則 - file=檔案 folder.config=模組設定目錄 @@ -415,7 +412,7 @@ folder.logs=日誌目錄 folder.mod=模組目錄 folder.resourcepacks=資源包目錄 folder.shaderpacks=著色器包目錄 -folder.saves=遊戲存檔目錄 +folder.saves=遊戲世界目錄 folder.schematics=原理圖目錄 folder.screenshots=截圖目錄 folder.world=世界目錄 @@ -426,13 +423,13 @@ game.crash.info=遊戲訊息 game.crash.reason=崩潰原因 game.crash.reason.analyzing=分析中…… game.crash.reason.multiple=檢測到多個原因:\n\n -game.crash.reason.block=目前遊戲由於某個方塊不能正常工作,無法繼續執行。\n你可以嘗試使用 MCEdit 工具編輯存檔刪除該方塊,或者直接刪除對應的模組。\n方塊類型:%1$s\n方塊坐標:%2$s +game.crash.reason.block=目前遊戲由於某個方塊不能正常工作,無法繼續執行。\n你可以嘗試使用 MCEdit 工具編輯世界刪除該方塊,或者直接刪除對應的模組。\n方塊類型:%1$s\n方塊坐標:%2$s game.crash.reason.bootstrap_failed=目前遊戲由於模組「%1$s」出現問題,無法繼續執行。\n你可以嘗試刪除或更新該模組以解決問題。 game.crash.reason.mixin_apply_mod_failed=目前遊戲由於 Mixin 無法應用於「%1$s」模組,無法繼續執行。\n你可以嘗試刪除或更新該模組以解決問題。 game.crash.reason.config=目前遊戲由於無法解析模組配置檔案,無法繼續執行\n模組「%1$s」的配置檔案「%2$s」無法被解析。 game.crash.reason.debug_crash=目前遊戲由於手動觸發崩潰,無法繼續執行。\n事實上遊戲並沒有問題,問題都是你造成的! game.crash.reason.duplicated_mod=目前遊戲由於模組重複安裝,無法繼續執行。\n%s\n每種模組只能安裝一個,請你刪除多餘的模組再試。 -game.crash.reason.entity=目前遊戲由於某個實體不能正常工作,無法繼續執行。\n你可以嘗試使用 MCEdit 工具編輯存檔刪除該實體,或者直接刪除對應的模組。\n實體類型:%1$s\n實體坐標:%2$s +game.crash.reason.entity=目前遊戲由於某個實體不能正常工作,無法繼續執行。\n你可以嘗試使用 MCEdit 工具編輯世界刪除該實體,或者直接刪除對應的模組。\n實體類型:%1$s\n實體坐標:%2$s game.crash.reason.fabric_version_0_12=Fabric Loader 0.12 及更高版本與目前已經安裝的模組可能不相容,你需要將 Fabric Loader 降級至 0.11.7。 game.crash.reason.fabric_warnings=Fabric 提供了一些警告訊息:\n%1$s game.crash.reason.modmixin_failure=目前遊戲由於某些模組注入失敗,無法繼續執行。\n這一般代表著該模組存在問題,或與目前環境不相容。\n你可以查看日誌尋找出錯模組。 @@ -521,7 +518,9 @@ install.installer.depend=需要先安裝 %s install.installer.do_not_install=不安裝 install.installer.fabric=Fabric install.installer.fabric-api=Fabric API -install.installer.fabric-api.warning=警告:Fabric API 是一個模組,將會被安裝到新遊戲的模組目錄。請你在安裝遊戲後不要修改目前遊戲的「執行路徑」設定。如果你在之後修改了相關設定,則需要重新安裝 Fabric API。 +install.installer.legacyfabric=Legacy Fabric +install.installer.legacyfabric-api=Legacy Fabric API +install.installer.fabric-quilt-api.warning=%1$s 是一個模組,將會被安裝到新遊戲的模組目錄。請你在安裝遊戲後不要修改目前遊戲的「執行路徑」設定。如果你在之後修改了相關設定,則需要重新安裝 %1$s。 install.installer.forge=Forge install.installer.neoforge=NeoForge install.installer.game=Minecraft @@ -710,7 +709,7 @@ modpack.files.mods.voxelmods=VoxelMods 設定,如小地圖 modpack.files.options_txt=遊戲設定 modpack.files.optionsshaders_txt=光影設定 modpack.files.resourcepacks=資源包 (紋理包) -modpack.files.saves=遊戲存檔 +modpack.files.saves=遊戲世界 modpack.files.scripts=MineTweaker 設定 modpack.files.servers_dat=多人遊戲伺服器清單 modpack.installing=安裝模組包 @@ -856,7 +855,9 @@ mods.category=類別 mods.channel.alpha=Alpha mods.channel.beta=Beta mods.channel.release=Release -mods.check_updates=檢查模組更新 +mods.check_updates=模組更新檢查 +mods.check_updates.button=檢查更新 +mods.check_updates.confirm=更新 mods.check_updates.current_version=目前版本 mods.check_updates.empty=沒有需要更新的模組 mods.check_updates.failed_check=檢查更新失敗 @@ -864,7 +865,6 @@ mods.check_updates.failed_download=部分檔案下載失敗 mods.check_updates.file=檔案 mods.check_updates.source=來源 mods.check_updates.target_version=目標版本 -mods.check_updates.update=更新 mods.choose_mod=選取模組 mods.curseforge=CurseForge mods.dependency.embedded=內建相依模組 (作者已經打包在模組檔中,無需單獨下載) @@ -896,6 +896,14 @@ mods.install=安裝到目前實例 mods.save_as=下載到本機目錄 mods.unknown=未知模組 +menu.undo=還原 +menu.redo=重做 +menu.cut=剪下 +menu.copy=複製 +menu.paste=貼上 +menu.deleteselection=刪除 +menu.selectall=全選 + nbt.entries=%s 個條目 nbt.open.failed=開啟檔案失敗 nbt.save.failed=儲存檔案失敗 @@ -924,6 +932,13 @@ world.chunkbase.end_city=終界城地圖 world.chunkbase.seed_map=種子地圖 world.chunkbase.stronghold=要塞地圖 world.chunkbase.nether_fortress=地獄要塞地圖 +world.duplicate=複製此世界 +world.duplicate.prompt=輸入複製後的世界名稱 +world.duplicate.failed.already_exists=目錄已存在 +world.duplicate.failed.empty_name=名稱不能為空 +world.duplicate.failed.invalid_name=名稱中包含無效字元 +world.duplicate.failed=複製世界失敗 +world.duplicate.success.toast=複製世界成功 world.datapack=資料包管理 world.datetime=上一次遊戲時間: %s world.delete=刪除此世界 @@ -934,11 +949,20 @@ world.export=匯出此世界 world.export.title=選取該世界的儲存位置 world.export.location=儲存到 world.export.wizard=匯出世界「%s」 -world.extension=存檔壓縮檔 +world.extension=世界壓縮檔 +world.icon=世界圖示 +world.icon.change=修改世界圖示 +world.icon.change.fail.load.title=圖片解析失敗 +world.icon.change.fail.load.text=該圖片似乎已損毀,HMCL 無法解析它 +world.icon.change.fail.not_64x64.title=圖片大小錯誤 +world.icon.change.fail.not_64x64.text=該圖片的解析度為 %d×%d,而不是 64×64,請提供一張 64×64 解析度的圖片再次嘗試 +world.icon.change.succeed.toast=世界圖示修改成功 +world.icon.change.tip=請提供一張 64×64 PNG 格式的圖片。錯誤解析度的圖片將無法被 Minecraft 解析。 +world.icon.choose.title=選擇世界圖示 world.import.already_exists=此世界已經存在 -world.import.choose=選取要匯入的存檔壓縮檔 +world.import.choose=選取要匯入的世界壓縮檔 world.import.failed=無法匯入此世界: %s -world.import.invalid=無法識別的存檔壓縮檔 +world.import.invalid=無法識別的世界壓縮檔 world.info=世界資訊 world.info.basic=基本資訊 world.info.allow_cheats=允許指令(作弊) @@ -949,6 +973,7 @@ world.info.difficulty.peaceful=和平 world.info.difficulty.easy=簡單 world.info.difficulty.normal=普通 world.info.difficulty.hard=困難 +world.info.difficulty_lock=鎖定難易度 world.info.failed=讀取世界資訊失敗 world.info.game_version=遊戲版本 world.info.last_played=上一次遊戲時間 @@ -969,6 +994,7 @@ world.info.player.xp_level=經驗等級 world.info.random_seed=種子碼 world.info.time=遊戲內時間 world.info.time.format=%s 天 +world.load.fail=世界載入失敗 world.locked=使用中 world.locked.failed=該世界正在使用中,請關閉遊戲後重試。 world.game_version=遊戲版本 @@ -999,6 +1025,11 @@ repositories.chooser=缺少 JavaFX 執行環境。HMCL 需要 JavaFX 才能正 repositories.chooser.title=選取 JavaFX 下載源 resourcepack=資源包 +resourcepack.add=新增資源包 +resourcepack.manage=資源包管理 +resourcepack.download=下載資源包 +resourcepack.add.failed=新增資源包失敗 +resourcepack.delete.failed=刪除資源包失敗 resourcepack.download.title=資源包下載 - %1s reveal.in_file_manager=在檔案管理員中查看 @@ -1059,6 +1090,7 @@ settings.advanced.custom_commands.hint=自訂指令被呼叫時將包含如下 \ · $INST_LITELOADER: 若安裝了 LiteLoader,將會存在本環境變數;\n\ \ · $INST_OPTIFINE: 若安裝了 OptiFine,將會存在本環境變數;\n\ \ · $INST_FABRIC: 若安裝了 Fabric,將會存在本環境變數;\n\ + \ · $INST_LEGACYFABRIC: 若安裝了 Legacy Fabric,將會存在本環境變數;\n\ \ · $INST_QUILT: 若安裝了 Quilt,將會存在本環境變數。 settings.advanced.dont_check_game_completeness=不檢查遊戲完整性 settings.advanced.dont_check_jvm_validity=不檢查 Java 虛擬機與遊戲的相容性 @@ -1132,14 +1164,18 @@ settings.game.java_directory.invalid=Java 路徑不正確 settings.game.java_directory.version=指定 Java 版本 settings.game.java_directory.template=%s (%s) settings.game.management=管理 -settings.game.working_directory=執行路徑 (建議使用模組時選取「各實例獨立」。修改後請自行移動相關遊戲檔案,如存檔、模組設定等) +settings.game.working_directory=執行路徑 (建議使用模組時選取「各實例獨立」。修改後請自行移動相關遊戲檔案,如世界、模組設定等) settings.game.working_directory.choose=選取執行目錄 -settings.game.working_directory.hint=在「執行路徑」選項中選取「各實例獨立」使目前實例獨立存放設定、存檔、模組等資料。使用模組時建議開啟此選項以避免不同實例模組衝突。修改此選項後需自行移動存檔等檔案。 +settings.game.working_directory.hint=在「執行路徑」選項中選取「各實例獨立」使目前實例獨立存放設定、世界、模組等資料。使用模組時建議開啟此選項以避免不同實例模組衝突。修改此選項後需自行移動世界等檔案。 settings.icon=遊戲圖示 settings.launcher=啟動器設定 settings.launcher.appearance=外觀 +settings.launcher.brightness=主題模式 +settings.launcher.brightness.auto=跟隨系統設定 +settings.launcher.brightness.dark=深色模式 +settings.launcher.brightness.light=淺色模式 settings.launcher.common_path.tooltip=啟動器將所有遊戲資源及相依元件庫檔案放於此集中管理。如果遊戲目錄內有現成的將不會使用公共庫檔案。 settings.launcher.debug=除錯 settings.launcher.disable_auto_game_options=不自動切換遊戲語言 @@ -1151,12 +1187,12 @@ settings.launcher.download_source=下載來源 settings.launcher.download_source.auto=自動選取下載來源 settings.launcher.enable_game_list=在首頁內顯示遊戲清單 settings.launcher.font=字體 -settings.launcher.font.anti_aliasing=反鋸齒 (重啟後生效) +settings.launcher.font.anti_aliasing=反鋸齒 settings.launcher.font.anti_aliasing.auto=自動 settings.launcher.font.anti_aliasing.gray=灰階 settings.launcher.font.anti_aliasing.lcd=子像素 settings.launcher.general=一般 -settings.launcher.language=語言 (重啟後生效) +settings.launcher.language=語言 settings.launcher.launcher_log.export=匯出啟動器日誌 settings.launcher.launcher_log.export.failed=無法匯出日誌。 settings.launcher.launcher_log.export.success=日誌已儲存到「%s」。 @@ -1173,9 +1209,9 @@ settings.launcher.proxy.password=密碼 settings.launcher.proxy.port=連線埠 settings.launcher.proxy.socks=SOCKS settings.launcher.proxy.username=帳戶 -settings.launcher.theme=主題 +settings.launcher.theme=主題色 settings.launcher.title_transparent=標題欄透明 -settings.launcher.turn_off_animations=關閉動畫 (重啟後生效) +settings.launcher.turn_off_animations=關閉動畫 settings.launcher.version_list_source=版本清單來源 settings.launcher.background.settings.opacity=不透明度 @@ -1190,6 +1226,7 @@ settings.memory.unit.mib=MiB settings.memory.used_per_total=已使用 %1$.1f GiB / 總記憶體 %2$.1f GiB settings.physical_memory=實體記憶體大小 settings.show_log=查看日誌 +settings.enable_debug_log_output=輸出除錯日誌 settings.tabs.installers=自動安裝 settings.take_effect_after_restart=重啟後生效 settings.type=實例遊戲設定類型 @@ -1200,6 +1237,8 @@ settings.type.special.enable=啟用實例特定遊戲設定 (不影響其他實 settings.type.special.edit=編輯實例特定遊戲設定 settings.type.special.edit.hint=目前實例「%s」啟用了「實例特定遊戲設定」,本頁面選項不對目前實例生效。點擊連結以修改目前實例設定。 +shaderpack.download.title=光影下載 - %1s + sponsor=贊助 sponsor.bmclapi=中國大陸下載源由 BMCLAPI 提供高速下載服務。點選此處查閱詳細訊息。 sponsor.hmcl=Hello Minecraft! Launcher 是一個免費、自由、開源的 Minecraft 啟動器,允許玩家方便快捷地安裝、管理、執行遊戲。點選此處查閱詳細訊息。 @@ -1208,13 +1247,17 @@ system.architecture=架構 system.operating_system=作業系統 terracotta=多人遊戲 -terracotta.easytier=關於 EasyTier terracotta.terracotta=Terracotta | 陶瓦聯機 terracotta.status=聯機大廳 terracotta.back=退出 terracotta.feedback.title=填寫回饋表 terracotta.feedback.desc=在 HMCL 更新聯機核心時,我們歡迎您用 10 秒時間填寫聯機品質回饋收集表。 terracotta.sudo_installing=HMCL 需要驗證您的密碼才能安裝線上核心 +terracotta.difficulty.easiest=當前網路狀況極佳:稍等一下就成功! +terracotta.difficulty.simple=目前網路狀況較好:建立連線需要一段時間… +terracotta.difficulty.medium=目前網路狀態中等:已啟用抗干擾備用線路,連線可能失敗 +terracotta.difficulty.tough=目前網路狀態極差:已啟用抗干擾備用線路,連線可能失敗 +terracotta.difficulty.estimate_only=連接成功率由房主和房客的 NAT 類型推算得到,僅供參考。 terracotta.from_local.title=線上核心第三方下載管道 terracotta.from_local.desc=在部分地區,內建的預設下載管道可能不穩定或連線緩慢 terracotta.from_local.guide=您應下載名為 %s 的線上核心套件。下載完成後,請將檔案拖曳到目前介面來安裝。 @@ -1245,7 +1288,7 @@ terracotta.status.waiting.guest.desc=輸入房主提供的邀請碼加入遊戲 terracotta.status.waiting.guest.prompt.title=請輸入房主提供的邀請碼 terracotta.status.waiting.guest.prompt.invalid=邀請碼錯誤 terracotta.status.scanning=正在掃描區域網路世界 -terracotta.status.scanning.desc=請啟動遊戲,進入單人存檔,按 ESC 鍵,選擇「在區網上公開」,點擊「開始區網世界」。 +terracotta.status.scanning.desc=請啟動遊戲,進入單人世界,按 ESC 鍵,選擇「在區網上公開」,點擊「開始區網世界」。 terracotta.status.scanning.back=這將同時停止掃描區域網路世界。 terracotta.status.host_starting=正在建立房間 terracotta.status.host_starting.back=這將會取消建立房間。 @@ -1266,7 +1309,7 @@ terracotta.status.exception.desc.ping_host_fail=加入房間失敗:房間已 terracotta.status.exception.desc.ping_host_rst=房間連線中斷:房間已關閉或網路不穩定 terracotta.status.exception.desc.guest_et_crash=加入房間失敗:EasyTier 已崩潰,請向開發者回報該問題 terracotta.status.exception.desc.host_et_crash=建立房間失敗:EasyTier 已崩潰,請向開發者回報問題 -terracotta.status.exception.desc.ping_server_rst=房間已關閉:您已退出遊戲存檔,房間已自動關閉 +terracotta.status.exception.desc.ping_server_rst=房間已關閉:您已退出遊戲世界,房間已自動關閉 terracotta.status.exception.desc.scaffolding_invalid_response=協議錯誤:房主發送了錯誤的回應資料,請向開發者回報該問題 terracotta.status.fatal.retry=重試 terracotta.status.fatal.network=未能下載線上核心。請檢查網路連接,然後再試一次 @@ -1282,6 +1325,7 @@ terracotta.unsupported=多人聯機功能尚未支援目前平台。 terracotta.unsupported.os.windows.old=多人聯機功能需要 Windows 10 或更高版本。請更新系統。 terracotta.unsupported.arch.32bit=多人聯機功能不支援 32 位元系統。請更新至 64 位元系統。 terracotta.unsupported.arch.loongarch64_ow=多人聯機功能不支援 Linux LoongArch64 舊世界發行版,請更新至新世界發行版 (如 AOSC OC)。 +terracotta.unsupported.region=多人聯機功能目前僅為中國內地用戶提供服務,在您所在地區可能不可用。 unofficial.hint=你正在使用第三方提供的 HMCL。我們無法保證其安全性,請注意甄別。 @@ -1310,7 +1354,7 @@ update.latest=目前版本為最新版本 update.no_browser=無法開啟瀏覽器。網址已經複製到剪貼簿了,你可以手動複製網址開啟頁面。 update.tooltip=更新 update.preview=提前測試 HMCL 預覽版本 -update.preview.tooltip=啟用此選項,你將可以提前取得 HMCL 的新版本,以便在正式發布前進行測試。 +update.preview.subtitle=啟用此選項,你將可以提前取得 HMCL 的新版本,以便在正式發布前進行測試。 version=遊戲 version.name=遊戲實例名稱 @@ -1331,6 +1375,7 @@ version.game.support_status.unsupported=不支援 version.game.support_status.untested=未經測試 version.game.type=版本類型 version.launch=啟動遊戲 +version.launch_and_enter_world=進入世界 version.launch.empty=開始遊戲 version.launch.empty.installing=安裝遊戲 version.launch.empty.tooltip=安裝並啟動最新正式版遊戲 @@ -1344,7 +1389,7 @@ version.manage=實例清單 version.manage.clean=清理遊戲目錄 version.manage.clean.tooltip=清理「logs」與「crash-reports」目錄 version.manage.duplicate=複製遊戲實例 -version.manage.duplicate.duplicate_save=複製存檔 +version.manage.duplicate.duplicate_save=複製世界 version.manage.duplicate.prompt=請輸入新遊戲實例名稱 version.manage.duplicate.confirm=新的遊戲將複製該實例目錄 (".minecraft/versions/<實例名>") 下的檔案,並帶有獨立的執行目錄和設定。 version.manage.manage=實例管理 @@ -1352,7 +1397,7 @@ version.manage.manage.title=實例管理 - %1s version.manage.redownload_assets_index=更新遊戲資源檔案 version.manage.remove=刪除該實例 version.manage.remove.confirm.trash=真的要刪除實例「%s」嗎? 你可以在系統的資源回收筒 (或垃圾桶) 中還原目錄「%s」來找回該實例。 -version.manage.remove.confirm.independent=由於該實例啟用了「(全域/實例特定) 遊戲設定 → 執行路徑 → 各實例獨立」設定,刪除該實例將導致該遊戲的存檔等資料一同被刪除!真的要刪除實例「%s」嗎? +version.manage.remove.confirm.independent=由於該實例啟用了「(全域/實例特定) 遊戲設定 → 執行路徑 → 各實例獨立」設定,刪除該實例將導致該遊戲的世界等資料一同被刪除!真的要刪除實例「%s」嗎? version.manage.remove.failed=刪除實例失敗。可能檔案被占用。 version.manage.remove_assets=刪除所有遊戲資源檔案 version.manage.remove_libraries=刪除所有支援庫檔案 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index d4a5e9c68c..f089ab84a0 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -20,7 +20,7 @@ about=关于 about.copyright=版权 -about.copyright.statement=版权所有 © 2025 huangyuhui +about.copyright.statement=版权所有 © 2026 huangyuhui about.author=作者 about.author.statement=bilibili @huanghongxun about.claim=用户协议 @@ -59,6 +59,7 @@ account.create.microsoft=添加微软账户 account.create.offline=添加离线模式账户 account.create.authlibInjector=添加外置登录账户 (authlib-injector) account.email=邮箱 +account.empty=没有账户 account.failed=账户刷新失败 account.failed.character_deleted=此角色已被删除 account.failed.connect_authentication_server=无法连接认证服务器。可能是网络问题,请检查设备能否正常上网或使用代理服务。\n你可以点击右上角帮助按钮进行求助。 @@ -78,6 +79,7 @@ account.failed.server_disconnected=无法访问登录服务器。账户信息刷 你可以点击右上角帮助按钮进行求助。 account.failed.server_response_malformed=无法解析认证服务器响应。可能是服务器故障。 account.failed.ssl=连接服务器时发生了 SSL 错误。可能网站证书已过期或你使用的 Java 版本过低。请尝试更新 Java,或关闭网络代理后再试。\n你可以点击右上角帮助按钮进行求助。 +account.failed.dns=连接服务器时发生了 SSL 错误。可能是 DNS 解析有误。请尝试更换 DNS 服务器或使用代理服务。\n你可以点击右上角帮助按钮进行求助。 account.failed.wrong_account=登录了错误的账户 account.hmcl.hint=你需要点击“登录”按钮,并在弹出的网页中完成登录。 account.injector.add=添加认证服务器 @@ -103,42 +105,29 @@ account.methods=登录方式 account.methods.authlib_injector=外置登录 account.methods.microsoft=微软账户 account.methods.microsoft.birth=如何更改账户出生日期 +account.methods.microsoft.code=代码 (已自动复制) account.methods.microsoft.close_page=已完成微软账户授权。其余登录步骤将由启动器自动执行。你现在可以关闭本页面了。 account.methods.microsoft.deauthorize=解除账户授权 -account.methods.microsoft.error.add_family=请点击上方“编辑账户个人信息”更改你的账户出生日期,使年龄满 18 岁以上,或将账户加入到家庭中。\n你可以点击右上角帮助按钮进行求助。 -account.methods.microsoft.error.add_family_probably=请点击上方“编辑账户个人信息”更改你的账户出生日期,使年龄满 18 岁以上,或将账户加入到家庭中。\n你可以点击右上角帮助按钮进行求助。 +account.methods.microsoft.error.add_family=请点击 此处 更改你的账户出生日期,使年龄满 18 岁以上,或将账户加入到家庭中。\n你可以点击右上角帮助按钮进行求助。 account.methods.microsoft.error.country_unavailable=你所在的国家或地区不受 Xbox Live 的支持。 -account.methods.microsoft.error.missing_xbox_account=请点击上方“创建档案”关联 Xbox 账户。\n你可以点击右上角帮助按钮进行求助。 -account.methods.microsoft.error.no_character=请确认你已经购买了 Minecraft: Java 版。\n若已购买,则可能未创建游戏档案。请点击上方“创建档案”以创建游戏档案。\n你可以点击右上角帮助按钮进行求助。 -account.methods.microsoft.error.banned=你的账户可能被 Xbox Live 封禁。\n你可以点击上方“检测账户是否被封禁”查看账户状态。 +account.methods.microsoft.error.missing_xbox_account=请点击 此处 关联 Xbox 账户。\n你可以点击右上角帮助按钮进行求助。 +account.methods.microsoft.error.no_character=请确认你已经购买了 Minecraft: Java 版。\n若已购买,则可能未创建游戏档案。请点击 此处 创建游戏档案。\n你可以点击右上角帮助按钮进行求助。 +account.methods.microsoft.error.banned=你的账户可能被 Xbox Live 封禁。\n你可以点击 此处 按钮查询账户封禁状态。 account.methods.microsoft.error.unknown=未知问题。错误码:%d。\n你可以点击右上角帮助按钮进行求助。 -account.methods.microsoft.error.wrong_verify_method=请在微软账户登录页面使用密码登录,不要使用验证码登录。\n你可以点击右上角帮助按钮进行求助。 +account.methods.microsoft.error.wrong_verify_method=登录失败。请在微软账户登录页面使用密码登录,不要使用其他登录方式。\n你可以点击右上角帮助按钮进行求助。 account.methods.microsoft.logging_in=登录中…… account.methods.microsoft.makegameidsettings=创建档案 / 编辑档案名称 -account.methods.microsoft.hint=你需要按照以下步骤添加账户:\n\ - \ 1. 点击“登录”按钮;\n\ - \ 2. 在弹出的网页中输入 HMCL 显示的代码,并点击“允许访问”;\n\ - \ 3. 按照网站的提示登录;\n\ - \ 4. 当网站提示“是否允许此应用访问你的信息?”时,请点击“接受”;\n\ - \ 5. 在网站提示“大功告成”后,等待账户完成添加即可。\n\ - 若网站提示“出现错误”或账户添加失败时,请按照以上步骤重新添加。\n\ - 若设备网络环境不佳,可能会导致网页加载缓慢甚至无法加载。请使用网络代理并重试。\n\ - 如遇到问题,你可以点击右上角帮助按钮进行求助。 -account.methods.microsoft.manual=你需要按照以下步骤添加:\n\ - \ 1. 点击“登录”按钮;\n\ - \ 2. 在弹出的网页中输入 %1$s (已自动复制),并点击“允许访问”;\n\ - \ 3. 按照网站的提示登录;\n\ - \ 4. 当网站提示“是否允许此应用访问你的信息?”时,请点击“接受”;\n\ - \ 5. 在网站提示“大功告成”后,等待账户完成添加即可。\n\ - 若网站未能显示,请手动在浏览器中打开:%2$s\n\ - 若网站提示“出现错误”或账户添加失败时,请按照以上步骤重新添加。\n\ - 若设备网络环境不佳,可能会导致网页加载缓慢甚至无法加载。请使用网络代理并重试。\n\ +account.methods.microsoft.hint=点击“登录”按钮开始添加微软账户。 +account.methods.microsoft.manual=请在弹出的网页中输入上方显示的代码以完成登录。\n\ + 若网站未能显示,请手动在浏览器中打开:%s\n\ + 若网络环境不佳,可能会导致网页加载缓慢甚至无法加载,请使用网络代理并重试。\n\ 如遇到问题,你可以点击右上角帮助按钮进行求助。 account.methods.microsoft.profile=编辑账户个人信息 account.methods.microsoft.purchase=购买 Minecraft account.methods.forgot_password=忘记密码 account.methods.ban_query=检测账户是否被封禁 -account.methods.microsoft.snapshot=你正在使用第三方提供的 HMCL。请下载官方版本来登录微软账户。 +account.methods.microsoft.snapshot=你正在使用第三方提供的 HMCL。请下载 官方版本 来登录微软账户。 +account.methods.microsoft.snapshot.tooltip=你正在使用第三方提供的 HMCL。请下载官方版本来刷新账户。 account.methods.microsoft.snapshot.website=官方网站 account.methods.offline=离线模式 account.methods.offline.name.special_characters=建议使用英文字符、数字以及下划线命名,且长度不超过 16 个字符 @@ -197,6 +186,7 @@ button.export=导出 button.no=否 button.ok=确定 button.ok.countdown=确定 (%d) +button.reset=重置 button.reveal_dir=打开文件夹 button.refresh=刷新 button.remove=删除 @@ -208,11 +198,19 @@ button.select_all=全选 button.view=查看 button.yes=是 -chat=官方群组 - color.recent=推荐 color.custom=自定义颜色 +contact=反馈 +contact.feedback=提交反馈 +contact.feedback.github=GitHub Issue +contact.feedback.github.statement=提交一个 GitHub Issue +contact.chat=官方群组 +contact.chat.qq_group=用户 QQ 群 +contact.chat.qq_group.statement=欢迎加入 HMCL 官方 QQ 群,加入后请遵守群规 +contact.chat.discord=Discord +contact.chat.discord.statement=欢迎加入 Discord 服务器,加入后请遵守讨论区规定 + crash.NoClassDefFound=请确认 Hello Minecraft! Launcher 本体是否完整,或更新你的 Java。\n你可以访问 https://docs.hmcl.net/help.html 页面寻求帮助。 crash.user_fault=你的系统或 Java 环境可能安装不当导致本软件崩溃,请检查你的 Java 环境或你的电脑。\n你可以访问 https://docs.hmcl.net/help.html 页面寻求帮助。 @@ -377,12 +375,14 @@ exception.access_denied=无法访问文件“%s”。HMCL 没有对该文件的 如遇到问题,你可以点击右上角帮助按钮进行求助。 exception.artifact_malformed=下载的文件无法通过校验。\n你可以点击右上角帮助按钮进行求助。 exception.ssl_handshake=无法建立 SSL 连接。当前 Java 缺少相关的 SSL 证书。你可以尝试使用其他 Java 启动 HMCL 再试。\n你可以点击右上角帮助按钮进行求助。 +exception.dns.pollution=无法建立 SSL 连接。可能是 DNS 解析有误。请尝试更换 DNS 服务器或使用代理服务。\n你可以点击右上角帮助按钮进行求助。 extension.bat=Windows 脚本 extension.mod=模组文件 extension.png=图片文件 extension.ps1=PowerShell 脚本 extension.sh=Bash 脚本 +extension.command=macOS Shell 脚本 fatal.create_hmcl_current_directory_failure=Hello Minecraft! Launcher 无法创建 HMCL 文件夹 (%s),请将 HMCL 移动至其他位置再启动。\n如遇到问题,你可以访问 https://docs.hmcl.net/help.html 页面寻求帮助。 fatal.javafx.incompatible=缺少 JavaFX 运行环境。\nHello Minecraft! Launcher 无法在低于 Java 11 的 Java 环境上自行补全 JavaFX 运行环境。请更新到 Java 11 或更高版本。\n你可以访问 https://docs.hmcl.net/help.html 页面寻求帮助。 @@ -407,15 +407,6 @@ fatal.unsupported_platform.loongarch=Hello Minecraft! Launcher 已为龙芯提 fatal.unsupported_platform.macos_arm64=Hello Minecraft! Launcher 已为 Apple Silicon 平台提供支持。使用 ARM 原生 Java 启动游戏以获得更流畅的游戏体验。\n如果你在游戏中遇到问题,使用 x86-64 架构的 Java 启动游戏可能有更好的兼容性。\n如遇到问题,你可以点击右上角帮助按钮进行求助。 fatal.unsupported_platform.windows_arm64=Hello Minecraft! Launcher 已为 Windows on Arm 平台提供原生支持。如果你在游戏中遇到问题,请尝试使用 x86 架构的 Java 启动游戏。\n如果你正在使用 高通 平台,你可能需要安装 OpenGL 兼容包 后才能进行游戏。点击链接前往 Microsoft Store 安装兼容包。\n如遇到问题,你可以点击右上角帮助按钮进行求助。 -feedback=反馈 -feedback.channel=反馈渠道 -feedback.discord=Discord -feedback.discord.statement=欢迎加入 Discord 服务器,加入后请遵守讨论区规定 -feedback.github=GitHub Issue -feedback.github.statement=提交一个 GitHub Issue -feedback.qq_group=HMCL 用户群 -feedback.qq_group.statement=欢迎加入 HMCL 用户群,加入后请遵守群规 - file=文件 folder.config=配置文件夹 @@ -424,7 +415,7 @@ folder.logs=日志文件夹 folder.mod=模组文件夹 folder.resourcepacks=资源包文件夹 folder.shaderpacks=光影包文件夹 -folder.saves=存档文件夹 +folder.saves=世界文件夹 folder.schematics=原理图文件夹 folder.screenshots=截图文件夹 folder.world=世界文件夹 @@ -434,14 +425,14 @@ game.crash.feedback=请不要将本界面截图或拍照给他人!如 game.crash.info=游戏信息 game.crash.reason=崩溃原因 game.crash.reason.analyzing=分析中…… -game.crash.reason.block=当前游戏由于某个方块不能正常工作,无法继续运行。\n你可以尝试使用 MCEdit 工具编辑存档删除该方块,或者直接删除对应的模组。\n方块类型:%1$s\n方块坐标:%2$s +game.crash.reason.block=当前游戏由于某个方块不能正常工作,无法继续运行。\n你可以尝试使用 MCEdit 工具编辑世界删除该方块,或者直接删除对应的模组。\n方块类型:%1$s\n方块坐标:%2$s game.crash.reason.bootstrap_failed=当前游戏由于模组“%1$s”出现问题,无法继续运行。\n你可以尝试删除或更新该模组以解决问题。 game.crash.reason.mixin_apply_mod_failed=当前游戏由于 Mixin 无法应用于“%1$s”模组,无法继续运行。\n你可以尝试删除或更新该模组以解决问题。 game.crash.reason.config=当前游戏由于无法解析模组配置文件,无法继续运行。\n无法解析模组“%1$s”的配置文件“%2$s”。 game.crash.reason.multiple=检测到多个原因:\n\n game.crash.reason.debug_crash=当前游戏由于手动触发崩溃,无法继续运行。\n事实上游戏并没有问题,问题都是你造成的! game.crash.reason.duplicated_mod=当前游戏由于模组“%1$s”重复安装,无法继续运行。\n%2$s\n每种模组只能安装一个,请你删除多余的模组再试。 -game.crash.reason.entity=当前游戏由于某个实体不能正常工作,无法继续运行。\n你可以尝试使用 MCEdit 工具编辑存档删除该实体,或者直接删除对应的模组。\n实体类型:%1$s\n实体坐标:%2$s +game.crash.reason.entity=当前游戏由于某个实体不能正常工作,无法继续运行。\n你可以尝试使用 MCEdit 工具编辑世界删除该实体,或者直接删除对应的模组。\n实体类型:%1$s\n实体坐标:%2$s game.crash.reason.fabric_version_0_12=Fabric Loader 0.12 及更高版本与当前已经安装的模组可能不兼容,你需要将 Fabric Loader 降级至 0.11.7。 game.crash.reason.fabric_warnings=Fabric 提供了一些警告信息:\n%1$s game.crash.reason.file_already_exists=当前游戏由于文件“%1$s”已经存在,无法继续运行。\n如果你认为这个文件可以删除,你可以在备份这个文件后尝试删除它,并重新启动游戏。 @@ -530,8 +521,10 @@ install.installer.cleanroom=Cleanroom install.installer.depend=需要先安装 %s install.installer.do_not_install=不安装 install.installer.fabric=Fabric +install.installer.legacyfabric=Legacy Fabric +install.installer.legacyfabric-api=Legacy Fabric API install.installer.fabric-api=Fabric API -install.installer.fabric-api.warning=警告:Fabric API 是一个模组,将会被安装到新游戏的模组文件夹。请你在安装游戏后不要修改当前游戏的“运行路径”设置。如果你在之后修改了相关设置,则需要重新安装 Fabric API。 +install.installer.fabric-quilt-api.warning=%1$s 是一个模组,将会被安装到新游戏的模组文件夹。请你在安装游戏后不要修改当前游戏的“版本隔离”设置。如果你在之后修改了相关设置,则需要重新安装 %1$s。 install.installer.forge=Forge install.installer.neoforge=NeoForge install.installer.game=Minecraft @@ -720,7 +713,7 @@ modpack.files.mods.voxelmods=VoxelMods 配置,如小地图 modpack.files.options_txt=游戏设置 modpack.files.optionsshaders_txt=光影设置 modpack.files.resourcepacks=资源包 (纹理包) -modpack.files.saves=游戏存档 +modpack.files.saves=游戏世界 modpack.files.scripts=MineTweaker 配置 modpack.files.servers_dat=多人游戏服务器列表 modpack.installing=安装整合包 @@ -866,7 +859,9 @@ mods.category=类别 mods.channel.alpha=快照版本 mods.channel.beta=测试版本 mods.channel.release=稳定版本 -mods.check_updates=检查模组更新 +mods.check_updates=模组更新检查 +mods.check_updates.button=检查更新 +mods.check_updates.confirm=更新 mods.check_updates.current_version=当前版本 mods.check_updates.empty=没有需要更新的模组 mods.check_updates.failed_check=检查更新失败 @@ -874,7 +869,6 @@ mods.check_updates.failed_download=部分文件下载失败 mods.check_updates.file=文件 mods.check_updates.source=来源 mods.check_updates.target_version=目标版本 -mods.check_updates.update=更新 mods.choose_mod=选择模组 mods.curseforge=CurseForge mods.dependency.embedded=内置的前置模组 (已经由作者打包在模组文件中,无需另外下载) @@ -906,6 +900,14 @@ mods.install=安装到当前实例 mods.save_as=下载到本地文件夹 mods.unknown=未知模组 +menu.undo=撤销 +menu.redo=重做 +menu.cut=剪切 +menu.copy=复制 +menu.paste=粘贴 +menu.deleteselection=删除 +menu.selectall=全选 + nbt.entries=%s 个条目 nbt.open.failed=打开文件失败 nbt.save.failed=保存文件失败 @@ -934,6 +936,13 @@ world.chunkbase.end_city=末地城地图 world.chunkbase.seed_map=种子地图 world.chunkbase.stronghold=要塞地图 world.chunkbase.nether_fortress=下界要塞地图 +world.duplicate=复制此世界 +world.duplicate.prompt=输入复制后的世界名称 +world.duplicate.failed.already_exists=文件夹已存在 +world.duplicate.failed.empty_name=名称不能为空 +world.duplicate.failed.invalid_name=名称中包含非法字符 +world.duplicate.failed=复制世界失败 +world.duplicate.success.toast=复制世界成功 world.datapack=数据包管理 world.datetime=上一次游戏时间: %s world.delete=删除此世界 @@ -946,10 +955,19 @@ world.export.location=保存到 world.export.wizard=导出世界“%s” world.extension=世界压缩包 world.game_version=游戏版本 +world.icon=世界图标 +world.icon.change=修改世界图标 +world.icon.change.fail.load.title=图片解析出错 +world.icon.change.fail.load.text=该图片似乎已损坏,HMCL 无法解析它 +world.icon.change.fail.not_64x64.title=图片大小错误 +world.icon.change.fail.not_64x64.text=该图片的分辨率为 %d×%d,而不是 64×64,请提供一张 64×64 分辨率的图片再次尝试 +world.icon.change.succeed.toast=世界图标修改成功 +world.icon.change.tip=请提供一张 64×64 PNG 格式的图片。错误分辨率的图片将无法被 Minecraft 解析。 +world.icon.choose.title=选择世界图标 world.import.already_exists=此世界已经存在 -world.import.choose=选择要导入的存档压缩包 +world.import.choose=选择要导入的世界压缩包 world.import.failed=无法导入此世界:%s -world.import.invalid=无法识别该存档压缩包 +world.import.invalid=无法识别该世界压缩包 world.info=世界信息 world.info.basic=基本信息 world.info.allow_cheats=允许命令(作弊) @@ -960,6 +978,7 @@ world.info.difficulty.peaceful=和平 world.info.difficulty.easy=简单 world.info.difficulty.normal=普通 world.info.difficulty.hard=困难 +world.info.difficulty_lock=锁定难度 world.info.failed=读取世界信息失败 world.info.game_version=游戏版本 world.info.last_played=上一次游戏时间 @@ -980,6 +999,7 @@ world.info.player.xp_level=经验等级 world.info.random_seed=种子 world.info.time=游戏内时间 world.info.time.format=%s 天 +world.load.fail=世界加载失败 world.locked=使用中 world.locked.failed=该世界正在使用中,请关闭游戏后重试。 world.manage=世界管理 @@ -1009,6 +1029,11 @@ repositories.chooser=缺少 JavaFX 运行环境。HMCL 需要 JavaFX 才能正 repositories.chooser.title=选择 JavaFX 下载源 resourcepack=资源包 +resourcepack.add=添加资源包 +resourcepack.manage=资源包管理 +resourcepack.download=下载资源包 +resourcepack.add.failed=添加资源包失败 +resourcepack.delete.failed=删除资源包失败 resourcepack.download.title=资源包下载 - %1s reveal.in_file_manager=在文件管理器中查看 @@ -1069,6 +1094,7 @@ settings.advanced.custom_commands.hint=自定义命令被调用时将包含如 \ · $INST_LITELOADER: 若安装了 LiteLoader,将会存在本环境变量;\n\ \ · $INST_OPTIFINE: 若安装了 OptiFine,将会存在本环境变量;\n\ \ · $INST_FABRIC: 若安装了 Fabric,将会存在本环境变量;\n\ + \ · $INST_LEGACYFABRIC: 若安装了 Legacy Fabric,将会存在本环境变量;\n\ \ · $INST_QUILT: 若安装了 Quilt,将会存在本环境变量。 settings.advanced.dont_check_game_completeness=不检查游戏完整性 settings.advanced.dont_check_jvm_validity=不检查 Java 虚拟机与游戏的兼容性 @@ -1142,14 +1168,18 @@ settings.game.java_directory.invalid=Java 路径不正确 settings.game.java_directory.version=指定 Java 版本 settings.game.java_directory.template=%s (%s) settings.game.management=管理 -settings.game.working_directory=版本隔离 (建议使用模组时选择“各实例独立”。改后需移动存档、模组等相关游戏文件) +settings.game.working_directory=版本隔离 (建议使用模组时选择“各实例独立”。改后需移动世界、模组等相关游戏文件) settings.game.working_directory.choose=选择运行文件夹 -settings.game.working_directory.hint=在“版本隔离”中选择“各实例独立”使当前实例独立存放设置、存档、模组等数据。使用模组时建议启用此选项以避免不同实例模组冲突。修改此选项后需自行移动存档等文件。 +settings.game.working_directory.hint=在“版本隔离”中选择“各实例独立”使当前实例独立存放设置、世界、模组等数据。使用模组时建议启用此选项以避免不同实例模组冲突。修改此选项后需自行移动世界等文件。 settings.icon=游戏图标 settings.launcher=启动器设置 settings.launcher.appearance=外观 +settings.launcher.brightness=主题模式 +settings.launcher.brightness.auto=跟随系统设置 +settings.launcher.brightness.dark=深色模式 +settings.launcher.brightness.light=浅色模式 settings.launcher.common_path.tooltip=启动器将所有游戏资源及依赖库文件存放于此集中管理。如果游戏文件夹内有现成的将不会使用公共库文件。 settings.launcher.debug=调试 settings.launcher.disable_auto_game_options=不自动切换游戏语言 @@ -1161,12 +1191,12 @@ settings.launcher.download_source=下载源 settings.launcher.download_source.auto=自动选择下载源 settings.launcher.enable_game_list=在主页内显示版本列表 settings.launcher.font=字体 -settings.launcher.font.anti_aliasing=抗锯齿 (重启后生效) +settings.launcher.font.anti_aliasing=抗锯齿 settings.launcher.font.anti_aliasing.auto=自动 settings.launcher.font.anti_aliasing.gray=灰度 settings.launcher.font.anti_aliasing.lcd=子像素 settings.launcher.general=通用 -settings.launcher.language=语言 (重启后生效) +settings.launcher.language=语言 settings.launcher.launcher_log.export=导出启动器日志 settings.launcher.launcher_log.export.failed=无法导出日志 settings.launcher.launcher_log.export.success=日志已保存到“%s” @@ -1183,9 +1213,9 @@ settings.launcher.proxy.password=密码 settings.launcher.proxy.port=端口 settings.launcher.proxy.socks=SOCKS settings.launcher.proxy.username=账户 -settings.launcher.theme=主题 +settings.launcher.theme=主题色 settings.launcher.title_transparent=标题栏透明 -settings.launcher.turn_off_animations=关闭动画 (重启后生效) +settings.launcher.turn_off_animations=关闭动画 settings.launcher.version_list_source=版本列表源 settings.launcher.background.settings.opacity=不透明度 @@ -1197,9 +1227,10 @@ settings.memory.allocate.manual.exceeded=游戏分配 %1$.1f GiB (设备仅 %3$. settings.memory.auto_allocate=自动分配内存 settings.memory.lower_bound=最低内存分配 settings.memory.unit.mib=MiB -settings.memory.used_per_total=设备中已使用 %1$.1f GiB / 设备总内存 %2$.1f GiB +settings.memory.used_per_total=已使用 %1$.1f GiB / 总内存 %2$.1f GiB settings.physical_memory=物理内存大小 settings.show_log=查看日志 +settings.enable_debug_log_output=输出调试日志 settings.tabs.installers=自动安装 settings.take_effect_after_restart=重启后生效 settings.type=实例游戏设置类型 @@ -1210,6 +1241,8 @@ settings.type.special.enable=启用实例特定游戏设置 (不影响其他游 settings.type.special.edit=编辑实例特定游戏设置 settings.type.special.edit.hint=当前游戏实例“%s”启用了“实例特定游戏设置”,因此本页面选项不对该实例生效。点击链接前往该实例的“游戏设置”页。 +shaderpack.download.title=光影下载 - %1s + sponsor=赞助 sponsor.bmclapi=国内下载源由 BMCLAPI 提供高速下载服务。BMCLAPI 为公益服务,赞助 BMCLAPI 可以帮助作者更好地提供稳定高速的下载服务。[点击此处查阅详细信息] sponsor.hmcl=Hello Minecraft! Launcher 是一个免费、自由、开放源代码的 Minecraft 启动器。[点击此处查阅详细信息] @@ -1218,13 +1251,17 @@ system.architecture=架构 system.operating_system=操作系统 terracotta=多人联机 -terracotta.easytier=关于 EasyTier terracotta.terracotta=Terracotta | 陶瓦联机 terracotta.status=联机大厅 terracotta.back=退出 terracotta.feedback.title=填写反馈表 terracotta.feedback.desc=在 HMCL 更新联机核心时,我们欢迎您用 10 秒时间填写联机质量反馈收集表。 terracotta.sudo_installing=HMCL 需要验证您的密码才能安装联机核心 +terracotta.difficulty.easiest=当前网络状态极好:稍等一下就成功! +terracotta.difficulty.simple=当前网络状态较好:建立连接需要一段时间…… +terracotta.difficulty.medium=当前网络状态中等:已启用抗干扰备用线路,连接可能失败 +terracotta.difficulty.tough=当前网络状态极差:已启用抗干扰备用线路,连接可能失败 +terracotta.difficulty.estimate_only=连接成功率由房主和房客的 NAT 类型推算得到,仅供参考。 terracotta.from_local.title=联机核心第三方下载渠道 terracotta.from_local.desc=在部分地区,HMCL 内置的默认下载渠道可能不稳定或连接缓慢 terracotta.from_local.guide=您应当下载名为 %s 的联机核心包。下载完成后,请将文件拖入当前界面来安装。 @@ -1255,7 +1292,7 @@ terracotta.status.waiting.guest.desc=输入房主提供的邀请码加入游戏 terracotta.status.waiting.guest.prompt.title=请输入房主提供的邀请码 terracotta.status.waiting.guest.prompt.invalid=邀请码错误 terracotta.status.scanning=正在扫描局域网世界 -terracotta.status.scanning.desc=请启动游戏,进入单人存档,按下 ESC 键,选择对局域网开放,点击创建局域网世界。 +terracotta.status.scanning.desc=请启动游戏,进入单人世界,按下 ESC 键,选择对局域网开放,点击创建局域网世界。 terracotta.status.scanning.back=这将同时停止扫描局域网世界。 terracotta.status.host_starting=正在启动房间 terracotta.status.host_starting.back=这将会取消创建房间。 @@ -1276,7 +1313,7 @@ terracotta.status.exception.desc.ping_host_fail=加入房间失败:房间已 terracotta.status.exception.desc.ping_host_rst=房间连接断开:房间已关闭或网络不稳定 terracotta.status.exception.desc.guest_et_crash=加入房间失败:EasyTier 已崩溃,请向开发者反馈该问题 terracotta.status.exception.desc.host_et_crash=创建房间失败:EasyTier 已崩溃,请向开发者反馈该问题 -terracotta.status.exception.desc.ping_server_rst=房间已关闭:您已退出游戏存档,房间已自动关闭 +terracotta.status.exception.desc.ping_server_rst=房间已关闭:您已退出游戏世界,房间已自动关闭 terracotta.status.exception.desc.scaffolding_invalid_response=协议错误:房主发送了错误的响应数据,请向开发者反馈该问题 terracotta.status.fatal.retry=重试 terracotta.status.fatal.network=未能下载联机核心。请检查网络连接,然后再试一次 @@ -1292,6 +1329,7 @@ terracotta.unsupported=多人联机功能尚不支持当前平台。 terracotta.unsupported.os.windows.old=多人联机功能需要 Windows 10 或更高版本。请更新系统。 terracotta.unsupported.arch.32bit=多人联机功能不支持 32 位系统。请更新至 64 位系统。 terracotta.unsupported.arch.loongarch64_ow=多人联机功能不支持 Linux LoongArch64 旧世界发行版,请更新至新世界发行版 (如 AOSC OC)。 +terracotta.unsupported.region=多人联机功能目前仅为中国内地用户提供服务,在您所在地区可能不可用。 unofficial.hint=你正在使用非官方构建的 HMCL。我们无法保证其安全性,请注意甄别。 @@ -1320,7 +1358,7 @@ update.latest=当前版本为最新版本 update.no_browser=无法打开浏览器。网址已经复制到剪贴板,你可以手动粘贴网址打开页面。 update.tooltip=更新 update.preview=提前预览 HMCL 版本 -update.preview.tooltip=启用此选项,你将可以提前获取 HMCL 的新版本,以便在正式发布前进行测试。 +update.preview.subtitle=启用此选项,你将可以提前获取 HMCL 的新版本,以便在正式发布前进行测试。 version=游戏 version.name=游戏实例名称 @@ -1341,6 +1379,7 @@ version.game.support_status.unsupported=不支持 version.game.support_status.untested=未经测试 version.game.type=版本类型 version.launch=启动游戏 +version.launch_and_enter_world=进入世界 version.launch.empty=开始游戏 version.launch.empty.installing=安装游戏 version.launch.empty.tooltip=安装并启动最新正式版游戏 @@ -1354,7 +1393,7 @@ version.manage=实例列表 version.manage.clean=清理游戏文件夹 version.manage.clean.tooltip=清理“logs”和“crash-reports”文件夹 version.manage.duplicate=复制游戏实例 -version.manage.duplicate.duplicate_save=复制存档 +version.manage.duplicate.duplicate_save=复制世界 version.manage.duplicate.prompt=请输入新游戏名称 version.manage.duplicate.confirm=新的游戏实例将复制该实例文件夹 (".minecraft/versions/<实例名>") 下的文件,并带有独立的运行文件夹和设置。 version.manage.manage=实例管理 @@ -1362,7 +1401,7 @@ version.manage.manage.title=实例管理 - %1s version.manage.redownload_assets_index=更新游戏资源文件 version.manage.remove=删除该实例 version.manage.remove.confirm.trash=真的要删除实例“%s”吗?你可以在系统的回收站中还原“%s”文件夹来找回该实例。 -version.manage.remove.confirm.independent=由于该游戏启用了“(全局/实例特定) 游戏设置 → 版本隔离 → 各实例独立”选项,删除该实例将导致该游戏的存档等数据一同被删除!真的要删除实例“%s”吗? +version.manage.remove.confirm.independent=由于该游戏启用了“(全局/实例特定) 游戏设置 → 版本隔离 → 各实例独立”选项,删除该实例将导致该游戏的世界等数据一同被删除!真的要删除实例“%s”吗? version.manage.remove.failed=删除实例失败。可能文件被占用。 version.manage.remove_assets=删除所有游戏资源文件 version.manage.remove_libraries=删除所有库文件 diff --git a/HMCL/src/main/resources/assets/terracotta.json b/HMCL/src/main/resources/assets/terracotta.json index dd23c1b544..4074b39960 100644 --- a/HMCL/src/main/resources/assets/terracotta.json +++ b/HMCL/src/main/resources/assets/terracotta.json @@ -1,29 +1,73 @@ { - "version_legacy": "0\\.3\\.([89]-rc\\.([0-9]|10)|10|11|12)", - "version_recent": [ - "0.3.12" - ], - "version_latest": "0.3.13", - "classifiers": { - "freebsd-x86_64": "sha256:0713e54ee552496416bda9d9e814e33a8950ca8f321f5b3c6dd2e07e79b0e3af", - "linux-arm64": "sha256:61affc46035337c182adeca3671b4cf4cc59c7b4e73039899f35416f7d00ad94", - "linux-x86_64": "sha256:9399e1627b77d518950e66d944c9a4b70c20d2e13ca2c0e2fed0ded637e7ae06", - "linux-loongarch64": "sha256:f3eb4c40dfccc25b5b355298c776abe3d399afb57a2af38803dd78089f0c182e", - "linux-riscv64": "sha256:26f95f8b5f83746c9cf9a8362ce0ef793ede8515897a1ba15e5e6f93c3d39533", - "macos-arm64": "sha256:35ba271c7dc924e91c2fdd8c1cabeff2ce3d060836748a7a07162b0a5900e8d5", - "macos-arm64.pkg": "sha256:90a613ec69f28713fe06188247c57b7cc91743c95112de5aed85ea252103beaa", - "macos-x86_64": "sha256:45b420b15a32d5450794a9776cf45a217871cf4333b29b65a35d7358c806b5b1", - "macos-x86_64.pkg": "sha256:587623becb3593ccb5fe542a201a67ab3a4029dfa847fcef758faff7ba6d38d5", - "windows-arm64.exe": "sha256:bd4e1acf2f304761cdabddd9ade94d046534f4c024bc3026ac98e6be58c2bc22", - "windows-x86_64.exe": "sha256:b89599bbcc92b00222cfc6f2e5ef636b7daf192c96efba1049a892e6cb59ee70" + "__comment__": "THIS FILE IS MACHINE GENERATED! DO NOT EDIT!", + "version_latest": "0.4.1", + "packages": { + "windows-x86_64": { + "hash": "4693fec29bb54a0bb1a1a8a263a935f1673b8f76e84d125974cf259f1912a195beab4dfd04c23cae592cf71b116e82ecd48828b1445ab75c980c8cd79c777d21", + "files": { + "VCRUNTIME140.DLL": "3d4b24061f72c0e957c7b04a0c4098c94c8f1afb4a7e159850b9939c7210d73398be6f27b5ab85073b4e8c999816e7804fef0f6115c39cd061f4aaeb4dcda8cf", + "terracotta-0.4.1-windows-x86_64.exe": "3d29f7deb61b8b87e14032baecad71042340f6259d7ff2822336129714470e1a951cd96ee5d6a8c9312e7cf1bb9bb9d1cbf757cfa44d2b2f214362c32ea03b5b" + } + }, + "windows-arm64": { + "hash": "6c68da53142cc92598a9d3b803f110c77927ee7b481e2f623dfab17cd97fee4a58378e796030529713d3e394355135cc01d5f5d86cef7dbd31bbf8e913993d4c", + "files": { + "VCRUNTIME140.DLL": "5cb5ce114614101d260f4754c09e8a0dd57e4da885ebb96b91e274326f3e1dd95ed0ade9f542f1922fad0ed025e88a1f368e791e1d01fae69718f0ec3c7b98c8", + "terracotta-0.4.1-windows-arm64.exe": "d2cf0f29aac752d322e594da6144bbe2874aabde52b5890a6f04a33b77a377464cbf3367e40de6b046c7226517f48e23fd40e611198fcaa1f30503c36d99b20c" + } + }, + "macos-x86_64": { + "hash": "c247ab9023cf47231f3a056ddf70fe823e504c19ce241bf264c0a3cf2981c044b236dc239f430afb2541445d1b61d18898f8af5e1c063f31fb952bdfbea4aff5", + "files": { + "terracotta-0.4.1-macos-x86_64": "dd2cde70a950849498425b8b00c17fb80edb8dd9bc0312da4885d97a28a4959d87782bd2202ef7f71bdbf2a92609089585c9e3faf24c10df6150e221e111a764", + "terracotta-0.4.1-macos-x86_64.pkg": "6057d5b4ea93da45796277a20ddaea7850e8c66326ded769f20fff74e132b453d6d615d937fc8f1d62b0b36b26961df5afb76535aecf85f25d2160889225ac6d" + } + }, + "macos-arm64": { + "hash": "0ddf48a44ea2563c37707caea8381ad3c69da73dd443d07dd98fe630f4e24ccda6f1fcdc9207a66bf61758b887a7b47186883ccd12bcb6b68387d8d756873f44", + "files": { + "terracotta-0.4.1-macos-arm64": "ddc335ee082b4c0323314d482933810fc2d377cfc131a9aa978b54f245f1bed8973139edf7cf94f7ae6c04660dfe18d275b1a81638781e482b4ff585f351eed9", + "terracotta-0.4.1-macos-arm64.pkg": "5cbb41a450f080234b66fa264351bd594e3f6ef1599c96f277baa308e34dd26caefa3a34b3d65e972bc20868f2d4a661e8b3910d4b0311a374c6ac680bdccf8f" + } + }, + "linux-x86_64": { + "hash": "c00b0622203c1610a8e72bde5230fca6fe56cf1f45dc9bc7e857350f3a050d442c814084758090799b9a5b875730fa666539ee75bec706c05d9338ea208301eb", + "files": { + "terracotta-0.4.1-linux-x86_64": "e53a9d8ec085ef7a7816b3da374e5c88fced2cf8319d447be3226422d01f9d7ee2019e403eafe21082135050652b2788b7d9520cc432c8d08931930b99595ed7" + } + }, + "linux-arm64": { + "hash": "4d661e1123ca050980960fe2d32b8d93a2c3ba807748569a45f16050fb1b52089bfc64c2dd64aba652dfed0e4ac3fba1ff9518cc3f95598c99fc71d93552b589", + "files": { + "terracotta-0.4.1-linux-arm64": "d4ccf6ff8c2fac060fecaa3c8368677f9342e283f2435493b3e259e922ee0bb0b08f15a292bf91898800a462336c64d0dee7b73f03c1752e5b0988a05adb3b52" + } + }, + "linux-loongarch64": { + "hash": "8297bb37617e9e7ce282fc553c5b14c84a900bcff4d025be31fd4a4da8b3943d040afc6143aa17de9a88e5fa29af7254d38db8ae6418ee539c2301632448da09", + "files": { + "terracotta-0.4.1-linux-loongarch64": "ffdf7582d095307b91ddfc3e5d0daa78d434e4e34916d0cdf1520ae74b188fe5a48307047bf2da9a526eb725fe80cf230a93001bc8199d236b9cf28a1beaa6e9" + } + }, + "linux-riscv64": { + "hash": "092f863885205107525e7ccb0e18977f6fd3018910ca5819772ec741dd8cffee52cc352a44b928bd2ba99ab881adaadff9e3bf4bf283f7384a35fea14becb0b4", + "files": { + "terracotta-0.4.1-linux-riscv64": "a6e5d70ddc433bf804764b69e8c204a6a428ece22b9d8ab713ed339fb81bfa1d29daeb6bdfd62c85ff193396315f96172f4a28925e5a4efc45f7d6fa868782a9" + } + }, + "freebsd-x86_64": { + "hash": "288bd7a97b2e2c5fb3c7d8107ed1311bc881fb74cf92da40b6c84156239d0832d2306b74b1e93a683188a5db896a6506326c6a7a4ac0ab2e798fa4f1f00787f0", + "files": { + "terracotta-0.4.1-freebsd-x86_64": "c90e7db3c5e5cc8d53d8d10a8bf88899e2c418758c14c3d14bc24efc32a711f76882bb5b5a49db5b0256ead4f22b666139bd2a212bf09036d7e7e64ddec4ec3c" + } + } }, "downloads": [ - "https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}" + "https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz" ], "downloads_CN": [ - "https://gitee.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}", - "https://cnb.cool/HMCL-Terracotta/Terracotta/-/releases/download/v${version}/terracotta-${version}-${classifier}", - "https://alist.8mi.tech/d/mirror/HMCL-Terracotta/Auto/v${version}/terracotta-${version}-${classifier}" + "https://gitee.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz", + "https://cnb.cool/HMCL-Terracotta/Terracotta/-/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz", + "https://alist.8mi.tech/d/mirror/HMCL-Terracotta/Auto/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz" ], "links": [ { @@ -40,7 +84,7 @@ "zh": "QQ 群", "zh-Hant": "QQ 群" }, - "link": "https://qm.qq.com/cgi-bin/qm/qr?k=nIf5u5xQ3LXEP4ZEmLQtfjtpppjgHfI5&jump_from=webapi&authKey=sXStlPuGzhD1JyAhyExd2OwjzZkRf3x7bAEb/j1xNX1wrQcDdg71qPrhumIm6pyf" + "link": "https://qm.qq.com/cgi-bin/qm/qr?k\u003dnIf5u5xQ3LXEP4ZEmLQtfjtpppjgHfI5\u0026jump_from\u003dwebapi\u0026authKey\u003dsXStlPuGzhD1JyAhyExd2OwjzZkRf3x7bAEb/j1xNX1wrQcDdg71qPrhumIm6pyf" } ] } \ No newline at end of file diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/setting/ThemeColorTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/setting/ThemeColorTest.java new file mode 100644 index 0000000000..dfb2ce3092 --- /dev/null +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/ThemeColorTest.java @@ -0,0 +1,44 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import javafx.scene.paint.Color; +import org.jackhuang.hmcl.theme.ThemeColor; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/// @author Glavo +public final class ThemeColorTest { + + @Test + public void testOf() { + assertEquals(new ThemeColor("#AABBCC", Color.web("#AABBCC")), ThemeColor.of("#AABBCC")); + assertEquals(new ThemeColor("blue", Color.web("#5C6BC0")), ThemeColor.of("blue")); + assertEquals(new ThemeColor("darker_blue", Color.web("#283593")), ThemeColor.of("darker_blue")); + assertEquals(new ThemeColor("green", Color.web("#43A047")), ThemeColor.of("green")); + assertEquals(new ThemeColor("orange", Color.web("#E67E22")), ThemeColor.of("orange")); + assertEquals(new ThemeColor("purple", Color.web("#9C27B0")), ThemeColor.of("purple")); + assertEquals(new ThemeColor("red", Color.web("#B71C1C")), ThemeColor.of("red")); + + assertNull(ThemeColor.of((String) null)); + assertNull(ThemeColor.of("")); + assertNull(ThemeColor.of("unknown")); + } +} diff --git a/HMCL/terracotta-template.json b/HMCL/terracotta-template.json new file mode 100644 index 0000000000..d3a183da27 --- /dev/null +++ b/HMCL/terracotta-template.json @@ -0,0 +1,35 @@ +{ + "__comment__": "Run upgradeTerracottaConfig task with Gradle to resolve this template.", + + "version_latest": "@script_generated", + + "packages": "@script_generated", + + "downloads": [ + "https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz" + ], + "downloads_CN": [ + "https://gitee.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz", + "https://cnb.cool/HMCL-Terracotta/Terracotta/-/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz", + "https://alist.8mi.tech/d/mirror/HMCL-Terracotta/Auto/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz" + ], + + "links": [ + { + "desc": { + "default": "GitHub Release", + "zh": "GitHub 发布页", + "zh-Hant": "GitHub 發布頁" + }, + "link": "https://github.com/burningtnt/Terracotta/releases/tag/v${version}" + }, + { + "desc": { + "default": "Tencent QQ Group", + "zh": "QQ 群", + "zh-Hant": "QQ 群" + }, + "link": "https://qm.qq.com/cgi-bin/qm/qr?k=nIf5u5xQ3LXEP4ZEmLQtfjtpppjgHfI5&jump_from=webapi&authKey=sXStlPuGzhD1JyAhyExd2OwjzZkRf3x7bAEb/j1xNX1wrQcDdg71qPrhumIm6pyf" + } + ] +} \ No newline at end of file diff --git a/HMCLBoot/build.gradle.kts b/HMCLBoot/build.gradle.kts index 37332a9dde..f1a0541b8c 100644 --- a/HMCLBoot/build.gradle.kts +++ b/HMCLBoot/build.gradle.kts @@ -2,6 +2,10 @@ plugins { id("java-library") } -tasks.withType { +tasks.compileJava { options.release.set(8) } + +tasks.compileTestJava { + options.release.set(17) +} diff --git a/HMCLBoot/src/main/java/org/jackhuang/hmcl/Main.java b/HMCLBoot/src/main/java/org/jackhuang/hmcl/Main.java index 74eefc86b2..457dbe8148 100644 --- a/HMCLBoot/src/main/java/org/jackhuang/hmcl/Main.java +++ b/HMCLBoot/src/main/java/org/jackhuang/hmcl/Main.java @@ -102,7 +102,19 @@ static void showErrorAndExit(String[] args) { System.exit(1); } + private static void checkDirectoryPath() { + String currentDir = System.getProperty("user.dir", ""); + if (currentDir.contains("!")) { + SwingUtils.initLookAndFeel(); + System.err.println("The current working path contains an exclamation mark: " + currentDir); + // No Chinese translation because both Swing and JavaFX cannot render Chinese character properly when exclamation mark exists in the path. + SwingUtils.showErrorDialog("Exclamation mark(!) is not allowed in the path where HMCL is in.\n" + "The path is " + currentDir); + System.exit(1); + } + } + public static void main(String[] args) throws Throwable { + checkDirectoryPath(); if (getJavaFeatureVersion(System.getProperty("java.version")) >= MINIMUM_JAVA_VERSION) { EntryPoint.main(args); } else { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java index e6955b745f..7e93fa9019 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java @@ -103,10 +103,14 @@ protected boolean loadAuthlibInjector(Skin skin) { return skin != null && skin.getType() != Skin.Type.DEFAULT; } + public AuthInfo logInWithoutSkin() throws AuthenticationException { + // Using "legacy" user type here because "mojang" user type may cause "invalid session token" or "disconnected" when connecting to a game server. + return new AuthInfo(username, uuid, UUIDTypeAdapter.fromUUID(UUID.randomUUID()), AuthInfo.USER_TYPE_MSA, "{}"); + } + @Override public AuthInfo logIn() throws AuthenticationException { - // Using "legacy" user type here because "mojang" user type may cause "invalid session token" or "disconnected" when connecting to a game server. - AuthInfo authInfo = new AuthInfo(username, uuid, UUIDTypeAdapter.fromUUID(UUID.randomUUID()), AuthInfo.USER_TYPE_MSA, "{}"); + AuthInfo authInfo = logInWithoutSkin(); if (loadAuthlibInjector(skin)) { CompletableFuture artifactTask = CompletableFuture.supplyAsync(() -> { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AdaptedDownloadProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AdaptedDownloadProvider.java deleted file mode 100644 index d899df35b8..0000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AdaptedDownloadProvider.java +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.download; - -import org.jetbrains.annotations.Unmodifiable; - -import java.net.URI; -import java.util.List; -import java.util.stream.Collectors; - -/** - * The download provider that changes the real download source in need. - * - * @author huangyuhui - */ -public class AdaptedDownloadProvider implements DownloadProvider { - - private @Unmodifiable List downloadProviderCandidates; - - public void setDownloadProviderCandidates(List downloadProviderCandidates) { - this.downloadProviderCandidates = List.copyOf(downloadProviderCandidates); - } - - public DownloadProvider getPreferredDownloadProvider() { - List d = downloadProviderCandidates; - if (d == null || d.isEmpty()) { - throw new IllegalStateException("No download provider candidate"); - } - return d.get(0); - } - - @Override - public String getVersionListURL() { - return getPreferredDownloadProvider().getVersionListURL(); - } - - @Override - public String getAssetBaseURL() { - return getPreferredDownloadProvider().getAssetBaseURL(); - } - - @Override - public String injectURL(String baseURL) { - return getPreferredDownloadProvider().injectURL(baseURL); - } - - @Override - public List getAssetObjectCandidates(String assetObjectLocation) { - return downloadProviderCandidates.stream() - .flatMap(d -> d.getAssetObjectCandidates(assetObjectLocation).stream()) - .collect(Collectors.toList()); - } - - @Override - public List injectURLWithCandidates(String baseURL) { - return downloadProviderCandidates.stream() - .flatMap(d -> d.injectURLWithCandidates(baseURL).stream()) - .collect(Collectors.toList()); - } - - @Override - public List injectURLsWithCandidates(List urls) { - return downloadProviderCandidates.stream() - .flatMap(d -> d.injectURLsWithCandidates(urls).stream()) - .collect(Collectors.toList()); - } - - @Override - public VersionList getVersionListById(String id) { - return getPreferredDownloadProvider().getVersionListById(id); - } - - @Override - public int getConcurrency() { - return getPreferredDownloadProvider().getConcurrency(); - } -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AutoDownloadProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AutoDownloadProvider.java index d886dec4fb..78513ad59e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AutoDownloadProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AutoDownloadProvider.java @@ -18,60 +18,99 @@ package org.jackhuang.hmcl.download; import java.net.URI; +import java.util.LinkedHashSet; import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.Function; -/** - * Official Download Provider fetches version list from Mojang and - * download files from mcbbs. - * - * @author huangyuhui - */ -public class AutoDownloadProvider implements DownloadProvider { - private final DownloadProvider versionListProvider; - private final DownloadProvider fileProvider; +/// @author huangyuhui +public final class AutoDownloadProvider implements DownloadProvider { + private final List versionListProviders; + private final List fileProviders; + private final ConcurrentMap> versionLists = new ConcurrentHashMap<>(); + + public AutoDownloadProvider( + List versionListProviders, + List fileProviders) { + if (versionListProviders == null || versionListProviders.isEmpty()) { + throw new IllegalArgumentException("versionListProviders must not be null or empty"); + } + + if (fileProviders == null || fileProviders.isEmpty()) { + throw new IllegalArgumentException("fileProviders must not be null or empty"); + } - public AutoDownloadProvider(DownloadProvider versionListProvider, DownloadProvider fileProvider) { - this.versionListProvider = versionListProvider; - this.fileProvider = fileProvider; + this.versionListProviders = versionListProviders; + this.fileProviders = fileProviders; } - @Override - public String getVersionListURL() { - return versionListProvider.getVersionListURL(); + public AutoDownloadProvider(DownloadProvider... downloadProviderCandidate) { + if (downloadProviderCandidate.length == 0) { + throw new IllegalArgumentException("Download provider must have at least one download provider"); + } + + this.versionListProviders = List.of(downloadProviderCandidate); + this.fileProviders = versionListProviders; + } + + private DownloadProvider getPreferredDownloadProvider() { + return fileProviders.get(0); + } + + private static List getAll( + List providers, + Function> function) { + LinkedHashSet result = new LinkedHashSet<>(); + for (DownloadProvider provider : providers) { + result.addAll(function.apply(provider)); + } + return List.copyOf(result); } @Override - public String getAssetBaseURL() { - return fileProvider.getAssetBaseURL(); + public List getVersionListURLs() { + return getAll(versionListProviders, DownloadProvider::getVersionListURLs); } @Override public String injectURL(String baseURL) { - return fileProvider.injectURL(baseURL); + return getPreferredDownloadProvider().injectURL(baseURL); } @Override public List getAssetObjectCandidates(String assetObjectLocation) { - return fileProvider.getAssetObjectCandidates(assetObjectLocation); + return getAll(fileProviders, provider -> provider.getAssetObjectCandidates(assetObjectLocation)); } @Override public List injectURLWithCandidates(String baseURL) { - return fileProvider.injectURLWithCandidates(baseURL); + return getAll(fileProviders, provider -> provider.injectURLWithCandidates(baseURL)); } @Override public List injectURLsWithCandidates(List urls) { - return fileProvider.injectURLsWithCandidates(urls); + return getAll(fileProviders, provider -> provider.injectURLsWithCandidates(urls)); } @Override public VersionList getVersionListById(String id) { - return versionListProvider.getVersionListById(id); + return versionLists.computeIfAbsent(id, value -> { + VersionList[] lists = new VersionList[versionListProviders.size()]; + for (int i = 0; i < versionListProviders.size(); i++) { + lists[i] = versionListProviders.get(i).getVersionListById(value); + } + return new MultipleSourceVersionList(lists); + }); } @Override public int getConcurrency() { - return fileProvider.getConcurrency(); + return getPreferredDownloadProvider().getConcurrency(); + } + + @Override + public String toString() { + return "AutoDownloadProvider[versionListProviders=%s, fileProviders=%s]".formatted(versionListProviders, fileProviders); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/BMCLAPIDownloadProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/BMCLAPIDownloadProvider.java index 77a347b618..7042d32279 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/BMCLAPIDownloadProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/BMCLAPIDownloadProvider.java @@ -22,13 +22,17 @@ import org.jackhuang.hmcl.download.fabric.FabricVersionList; import org.jackhuang.hmcl.download.forge.ForgeBMCLVersionList; import org.jackhuang.hmcl.download.game.GameVersionList; +import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricAPIVersionList; +import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricVersionList; import org.jackhuang.hmcl.download.liteloader.LiteLoaderBMCLVersionList; import org.jackhuang.hmcl.download.neoforge.NeoForgeBMCLVersionList; import org.jackhuang.hmcl.download.optifine.OptiFineBMCLVersionList; import org.jackhuang.hmcl.download.quilt.QuiltAPIVersionList; import org.jackhuang.hmcl.download.quilt.QuiltVersionList; import org.jackhuang.hmcl.util.Pair; +import org.jackhuang.hmcl.util.io.NetworkUtils; +import java.net.URI; import java.util.Arrays; import java.util.List; @@ -45,6 +49,8 @@ public final class BMCLAPIDownloadProvider implements DownloadProvider { private final FabricAPIVersionList fabricApi; private final ForgeBMCLVersionList forge; private final CleanroomVersionList cleanroom; + private final LegacyFabricVersionList legacyFabric; + private final LegacyFabricAPIVersionList legacyFabricApi; private final NeoForgeBMCLVersionList neoforge; private final LiteLoaderBMCLVersionList liteLoader; private final OptiFineBMCLVersionList optifine; @@ -64,6 +70,9 @@ public BMCLAPIDownloadProvider(String apiRoot) { this.optifine = new OptiFineBMCLVersionList(apiRoot); this.quilt = new QuiltVersionList(this); this.quiltApi = new QuiltAPIVersionList(this); + this.legacyFabric = new LegacyFabricVersionList(this); + this.legacyFabricApi = new LegacyFabricAPIVersionList(this); + this.replacement = Arrays.asList( pair("https://bmclapi2.bangbang93.com", apiRoot), pair("https://launchermeta.mojang.com", apiRoot), @@ -82,8 +91,8 @@ public BMCLAPIDownloadProvider(String apiRoot) { pair("https://authlib-injector.yushi.moe", apiRoot + "/mirrors/authlib-injector"), pair("https://repo1.maven.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"), pair("https://repo.maven.apache.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"), - pair("https://hmcl-dev.github.io/metadata/cleanroom", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom"), - pair("https://hmcl-dev.github.io/metadata/fmllibs", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/fmllibs"), + pair("https://hmcl.glavo.site/metadata/cleanroom", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom"), + pair("https://hmcl.glavo.site/metadata/fmllibs", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/fmllibs"), pair("https://zkitefly.github.io/unlisted-versions-of-minecraft", "https://alist.8mi.tech/d/mirror/unlisted-versions-of-minecraft/Auto") // // https://github.com/mcmod-info-mirror/mcim-rust-api // pair("https://api.modrinth.com", "https://mod.mcimirror.top/modrinth"), @@ -99,41 +108,32 @@ public String getApiRoot() { } @Override - public String getVersionListURL() { - return apiRoot + "/mc/game/version_manifest.json"; + public List getVersionListURLs() { + return List.of(URI.create(apiRoot + "/mc/game/version_manifest.json")); } @Override - public String getAssetBaseURL() { - return apiRoot + "/assets/"; + public List getAssetObjectCandidates(String assetObjectLocation) { + return List.of(NetworkUtils.toURI(apiRoot + "/assets/" + assetObjectLocation)); } @Override public VersionList getVersionListById(String id) { - switch (id) { - case "game": - return game; - case "fabric": - return fabric; - case "fabric-api": - return fabricApi; - case "forge": - return forge; - case "cleanroom": - return cleanroom; - case "neoforge": - return neoforge; - case "liteloader": - return liteLoader; - case "optifine": - return optifine; - case "quilt": - return quilt; - case "quilt-api": - return quiltApi; - default: - throw new IllegalArgumentException("Unrecognized version list id: " + id); - } + return switch (id) { + case "game" -> game; + case "fabric" -> fabric; + case "fabric-api" -> fabricApi; + case "forge" -> forge; + case "cleanroom" -> cleanroom; + case "neoforge" -> neoforge; + case "liteloader" -> liteLoader; + case "optifine" -> optifine; + case "quilt" -> quilt; + case "quilt-api" -> quiltApi; + case "legacyfabric" -> legacyFabric; + case "legacyfabric-api" -> legacyFabricApi; + default -> throw new IllegalArgumentException("Unrecognized version list id: " + id); + }; } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/BalancedDownloadProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/BalancedDownloadProvider.java deleted file mode 100644 index 32afd93b31..0000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/BalancedDownloadProvider.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2021 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.download; - -import java.util.HashMap; -import java.util.Map; - -/** - * Official Download Provider fetches version list from Mojang and - * download files from mcbbs. - * - * @author huangyuhui - */ -public final class BalancedDownloadProvider implements DownloadProvider { - private final DownloadProvider[] candidates; - private final Map> versionLists = new HashMap<>(); - - public BalancedDownloadProvider(DownloadProvider... candidates) { - this.candidates = candidates; - } - - @Override - public String getVersionListURL() { - throw new UnsupportedOperationException(); - } - - @Override - public String getAssetBaseURL() { - throw new UnsupportedOperationException(); - } - - @Override - public String injectURL(String baseURL) { - throw new UnsupportedOperationException(); - } - - @Override - public VersionList getVersionListById(String id) { - return versionLists.computeIfAbsent(id, value -> { - VersionList[] lists = new VersionList[candidates.length]; - for (int i = 0; i < candidates.length; i++) { - lists[i] = candidates[i].getVersionListById(value); - } - return new MultipleSourceVersionList(lists); - }); - } - - @Override - public int getConcurrency() { - throw new UnsupportedOperationException(); - } -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProvider.java index 4be18b8812..0c6117e491 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProvider.java @@ -20,64 +20,55 @@ import org.jackhuang.hmcl.util.io.NetworkUtils; import java.net.URI; +import java.util.LinkedHashSet; import java.util.List; -import java.util.stream.Collectors; -/** - * The service provider that provides Minecraft online file downloads. - * - * @author huangyuhui - */ +/// The service provider that provides Minecraft online file downloads. +/// +/// @author huangyuhui public interface DownloadProvider { - String getVersionListURL(); + List getVersionListURLs(); - String getAssetBaseURL(); - - default List getAssetObjectCandidates(String assetObjectLocation) { - return List.of(NetworkUtils.toURI(getAssetBaseURL() + assetObjectLocation)); - } + List getAssetObjectCandidates(String assetObjectLocation); - /** - * Inject into original URL provided by Mojang and Forge. - * - * Since there are many provided URLs that are written in JSONs and are unmodifiable, - * this method provides a way to change them. - * - * @param baseURL original URL provided by Mojang and Forge. - * @return the URL that is equivalent to [baseURL], but belongs to your own service provider. - */ + /// Inject into original URL provided by Mojang and Forge. + /// + /// Since there are many provided URLs that are written in JSONs and are unmodifiable, + /// this method provides a way to change them. + /// + /// @param baseURL original URL provided by Mojang and Forge. + /// @return the URL that is equivalent to `baseURL``, but belongs to your own service provider. String injectURL(String baseURL); - /** - * Inject into original URL provided by Mojang and Forge. - * - * Since there are many provided URLs that are written in JSONs and are unmodifiable, - * this method provides a way to change them. - * - * @param baseURL original URL provided by Mojang and Forge. - * @return the URL that is equivalent to [baseURL], but belongs to your own service provider. - */ + /// Inject into original URL provided by Mojang and Forge. + /// + /// Since there are many provided URLs that are written in JSONs and are unmodifiable, + /// this method provides a way to change them. + /// + /// @param baseURL original URL provided by Mojang and Forge. + /// @return the URL that is equivalent to `baseURL`, but belongs to your own service provider. default List injectURLWithCandidates(String baseURL) { return List.of(NetworkUtils.toURI(injectURL(baseURL))); } default List injectURLsWithCandidates(List urls) { - return urls.stream().flatMap(url -> injectURLWithCandidates(url).stream()).collect(Collectors.toList()); + LinkedHashSet result = new LinkedHashSet<>(); + for (String url : urls) { + result.addAll(injectURLWithCandidates(url)); + } + return List.copyOf(result); } - /** - * the specific version list that this download provider provides. i.e. "fabric", "forge", "liteloader", "game", "optifine" - * - * @param id the id of specific version list that this download provider provides. i.e. "fabric", "forge", "liteloader", "game", "optifine" - * @return the version list - * @throws IllegalArgumentException if the version list does not exist - */ + /// the specific version list that this download provider provides. i.e. "fabric", "forge", "liteloader", "game", "optifine" + /// + /// @param id the id of specific version list that this download provider provides. i.e. "fabric", "forge", "liteloader", "game", "optifine" + /// @return the version list + /// @throws IllegalArgumentException if the version list does not exist VersionList getVersionListById(String id); - /** - * The maximum download concurrency that this download provider supports. - * @return the maximum download concurrency. - */ + /// The maximum download concurrency that this download provider supports. + /// + /// @return the maximum download concurrency. int getConcurrency(); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProviderWrapper.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProviderWrapper.java index 286f177900..770edbc310 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProviderWrapper.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProviderWrapper.java @@ -17,6 +17,8 @@ */ package org.jackhuang.hmcl.download; +import org.jackhuang.hmcl.task.Task; + import java.net.URI; import java.util.List; import java.util.Objects; @@ -26,7 +28,7 @@ */ public final class DownloadProviderWrapper implements DownloadProvider { - private DownloadProvider provider; + private volatile DownloadProvider provider; public DownloadProviderWrapper(DownloadProvider provider) { this.provider = provider; @@ -46,13 +48,8 @@ public List getAssetObjectCandidates(String assetObjectLocation) { } @Override - public String getVersionListURL() { - return getProvider().getVersionListURL(); - } - - @Override - public String getAssetBaseURL() { - return getProvider().getAssetBaseURL(); + public List getVersionListURLs() { + return getProvider().getVersionListURLs(); } @Override @@ -72,11 +69,41 @@ public List injectURLsWithCandidates(List urls) { @Override public VersionList getVersionListById(String id) { - return getProvider().getVersionListById(id); + + return new VersionList<>() { + @Override + public boolean hasType() { + return getProvider().getVersionListById(id).hasType(); + } + + @Override + public Task refreshAsync() { + throw new UnsupportedOperationException(); + } + + @Override + public Task refreshAsync(String gameVersion) { + return getProvider().getVersionListById(id).refreshAsync(gameVersion) + .thenComposeAsync(() -> { + lock.writeLock().lock(); + try { + versions.putAll(gameVersion, getProvider().getVersionListById(id).getVersions(gameVersion)); + } finally { + lock.writeLock().unlock(); + } + return null; + }); + } + }; } @Override public int getConcurrency() { return getProvider().getConcurrency(); } + + @Override + public String toString() { + return "DownloadProviderWrapper[provider=%s]".formatted(provider); + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LibraryAnalyzer.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LibraryAnalyzer.java index edbb735efa..f0c1b62ae1 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LibraryAnalyzer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LibraryAnalyzer.java @@ -189,7 +189,35 @@ public Set getModLoaders() { public enum LibraryType { MINECRAFT(true, "game", "^$", "^$", null), - FABRIC(true, "fabric", "net\\.fabricmc", "fabric-loader", ModLoaderType.FABRIC), + LEGACY_FABRIC(true, "legacyfabric", "net\\.fabricmc", "fabric-loader", ModLoaderType.LEGACY_FABRIC) { + @Override + protected boolean matchLibrary(Library library, List libraries) { + if (!super.matchLibrary(library, libraries)) { + return false; + } + for (Library l : libraries) { + if ("net.legacyfabric".equals(l.getGroupId())) { + return true; + } + } + return false; + } + }, + LEGACY_FABRIC_API(false, "legacyfabric-api", "net\\.legacyfabric", "legacyfabric-api", null), + FABRIC(true, "fabric", "net\\.fabricmc", "fabric-loader", ModLoaderType.FABRIC) { + @Override + protected boolean matchLibrary(Library library, List libraries) { + if (!super.matchLibrary(library, libraries)) { + return false; + } + for (Library l : libraries) { + if ("net.legacyfabric".equals(l.getGroupId())) { + return false; + } + } + return true; + } + }, FABRIC_API(true, "fabric-api", "net\\.fabricmc", "fabric-api", null), FORGE(true, "forge", "net\\.minecraftforge", "(forge|fmlloader)", ModLoaderType.FORGE) { private final Pattern FORGE_VERSION_MATCHER = Pattern.compile("^([0-9.]+)-(?[0-9.]+)(-([0-9.]+))?$"); @@ -278,6 +306,7 @@ private String scanVersion(Version version) { private final ModLoaderType modLoaderType; private static final Map PATCH_ID_MAP = new HashMap<>(); + static { for (LibraryType type : values()) { PATCH_ID_MAP.put(type.getPatchId(), type); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MojangDownloadProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MojangDownloadProvider.java index 2c5a5dba40..716acbcc39 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MojangDownloadProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MojangDownloadProvider.java @@ -22,11 +22,17 @@ import org.jackhuang.hmcl.download.fabric.FabricVersionList; import org.jackhuang.hmcl.download.forge.ForgeVersionList; import org.jackhuang.hmcl.download.game.GameVersionList; +import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricAPIVersionList; +import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricVersionList; import org.jackhuang.hmcl.download.liteloader.LiteLoaderVersionList; import org.jackhuang.hmcl.download.neoforge.NeoForgeOfficialVersionList; import org.jackhuang.hmcl.download.optifine.OptiFineBMCLVersionList; import org.jackhuang.hmcl.download.quilt.QuiltAPIVersionList; import org.jackhuang.hmcl.download.quilt.QuiltVersionList; +import org.jackhuang.hmcl.util.io.NetworkUtils; + +import java.net.URI; +import java.util.List; /** * @author huangyuhui @@ -43,6 +49,8 @@ public class MojangDownloadProvider implements DownloadProvider { private final OptiFineBMCLVersionList optifine; private final QuiltVersionList quilt; private final QuiltAPIVersionList quiltApi; + private final LegacyFabricVersionList legacyFabric; + private final LegacyFabricAPIVersionList legacyFabricApi; public MojangDownloadProvider() { // If there is no official download channel available, fallback to BMCLAPI. @@ -58,44 +66,37 @@ public MojangDownloadProvider() { this.optifine = new OptiFineBMCLVersionList(apiRoot); this.quilt = new QuiltVersionList(this); this.quiltApi = new QuiltAPIVersionList(this); + this.legacyFabric = new LegacyFabricVersionList(this); + this.legacyFabricApi = new LegacyFabricAPIVersionList(this); } @Override - public String getVersionListURL() { - return "https://piston-meta.mojang.com/mc/game/version_manifest.json"; + public List getVersionListURLs() { + return List.of(URI.create("https://piston-meta.mojang.com/mc/game/version_manifest.json")); } @Override - public String getAssetBaseURL() { - return "https://resources.download.minecraft.net/"; + public List getAssetObjectCandidates(String assetObjectLocation) { + return List.of(NetworkUtils.toURI("https://resources.download.minecraft.net/" + assetObjectLocation)); } @Override public VersionList getVersionListById(String id) { - switch (id) { - case "game": - return game; - case "fabric": - return fabric; - case "fabric-api": - return fabricApi; - case "forge": - return forge; - case "cleanroom": - return cleanroom; - case "neoforge": - return neoforge; - case "liteloader": - return liteLoader; - case "optifine": - return optifine; - case "quilt": - return quilt; - case "quilt-api": - return quiltApi; - default: - throw new IllegalArgumentException("Unrecognized version list id: " + id); - } + return switch (id) { + case "game" -> game; + case "fabric" -> fabric; + case "fabric-api" -> fabricApi; + case "forge" -> forge; + case "cleanroom" -> cleanroom; + case "neoforge" -> neoforge; + case "liteloader" -> liteLoader; + case "optifine" -> optifine; + case "quilt" -> quilt; + case "quilt-api" -> quiltApi; + case "legacyfabric" -> legacyFabric; + case "legacyfabric-api" -> legacyFabricApi; + default -> throw new IllegalArgumentException("Unrecognized version list id: " + id); + }; } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MultipleSourceVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MultipleSourceVersionList.java index b6ca45a468..4a77cc5136 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MultipleSourceVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MultipleSourceVersionList.java @@ -40,11 +40,6 @@ public boolean hasType() { return hasType; } - @Override - public Task loadAsync() { - throw new UnsupportedOperationException("MultipleSourceVersionList does not support loading the entire remote version list."); - } - @Override public Task refreshAsync() { throw new UnsupportedOperationException("MultipleSourceVersionList does not support loading the entire remote version list."); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/VersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/VersionList.java index 5519b8663d..0ac125384c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/VersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/VersionList.java @@ -71,17 +71,6 @@ public Task refreshAsync(String gameVersion) { return refreshAsync(); } - public Task loadAsync() { - return Task.composeAsync(() -> { - lock.readLock().lock(); - try { - return isLoaded() ? null : refreshAsync(); - } finally { - lock.readLock().unlock(); - } - }); - } - public Task loadAsync(String gameVersion) { return Task.composeAsync(() -> { lock.readLock().lock(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomInstallTask.java index 9d9baf2524..15bd9e8016 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomInstallTask.java @@ -92,7 +92,10 @@ public boolean doPostExecute() { @Override public void postExecute() throws Exception { - Files.deleteIfExists(installer); + if (remote != null) { + Files.deleteIfExists(installer); + } + setResult(task.getResult()); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomVersionList.java index cb5fc5c39b..6495a130bc 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomVersionList.java @@ -27,8 +27,8 @@ public final class CleanroomVersionList extends VersionList { private final DownloadProvider downloadProvider; - private static final String LOADER_LIST_URL = "https://hmcl-dev.github.io/metadata/cleanroom/index.json"; - private static final String INSTALLER_URL = "https://hmcl-dev.github.io/metadata/cleanroom/files/cleanroom-%s-installer.jar"; + private static final String LOADER_LIST_URL = "https://hmcl.glavo.site/metadata/cleanroom/index.json"; + private static final String INSTALLER_URL = "https://hmcl.glavo.site/metadata/cleanroom/files/cleanroom-%s-installer.jar"; public CleanroomVersionList(DownloadProvider downloadProvider) { this.downloadProvider = downloadProvider; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIInstallTask.java index 90c5f388a3..e74c8bf621 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIInstallTask.java @@ -59,7 +59,7 @@ public boolean isRelyingOnDependencies() { public void execute() throws IOException { dependencies.add(new FileDownloadTask( remote.getVersion().getFile().getUrl(), - dependencyManager.getGameRepository().getRunDirectory(version.getId()).resolve("mods").resolve("fabric-api-" + remote.getVersion().getVersion() + ".jar"), + dependencyManager.getGameRepository().getModsDirectory(version.getId()).resolve("fabric-api-" + remote.getVersion().getVersion() + ".jar"), remote.getVersion().getFile().getIntegrityCheck()) ); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricInstallTask.java index 458d8dd9b4..b21586201a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricInstallTask.java @@ -28,6 +28,7 @@ import org.jackhuang.hmcl.game.Version; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.gson.JsonSerializable; import org.jackhuang.hmcl.util.gson.JsonUtils; import java.io.IOException; @@ -127,6 +128,7 @@ private Version getPatch(FabricInfo fabricInfo, String gameVersion, String loade return new Version(LibraryAnalyzer.LibraryType.FABRIC.getPatchId(), loaderVersion, Version.PRIORITY_LOADER, arguments, mainClass, libraries); } + @JsonSerializable public static class FabricInfo { private final LoaderInfo loader; private final IntermediaryInfo intermediary; @@ -151,6 +153,7 @@ public JsonObject getLauncherMeta() { } } + @JsonSerializable public static class LoaderInfo { private final String separator; private final int build; @@ -187,6 +190,7 @@ public boolean isStable() { } } + @JsonSerializable public static class IntermediaryInfo { private final String maven; private final String version; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeBMCLVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeBMCLVersionList.java index 1c1782dfed..14dbcaae3d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeBMCLVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeBMCLVersionList.java @@ -56,11 +56,6 @@ public boolean hasType() { return false; } - @Override - public Task loadAsync() { - throw new UnsupportedOperationException("ForgeBMCLVersionList does not support loading the entire Forge remote version list."); - } - @Override public Task refreshAsync() { throw new UnsupportedOperationException("ForgeBMCLVersionList does not support loading the entire Forge remote version list."); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeInstallTask.java index 11b410069d..922fb75f2a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeInstallTask.java @@ -177,6 +177,6 @@ public static Task install(DefaultDependencyManager dependencyManager, } private static String modifyVersion(String gameVersion, String version) { - return removeSuffix(removePrefix(removeSuffix(removePrefix(version.replace(gameVersion, "").trim(), "-"), "-"), "_"), "_"); + return removePrefix(removeSuffix(removePrefix(removeSuffix(removePrefix(version.replace(gameVersion, "").trim(), "-"), "-"), "_"), "_"), "forge-"); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeVersionList.java index cf58b60cfe..9d0941b2b2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeVersionList.java @@ -96,5 +96,5 @@ public Task refreshAsync() { }); } - public static final URI FORGE_LIST = URI.create("https://hmcl-dev.github.io/metadata/forge/"); + public static final URI FORGE_LIST = URI.create("https://hmcl.glavo.site/metadata/forge/"); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameLibrariesTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameLibrariesTask.java index 700c474289..eda938b350 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameLibrariesTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameLibrariesTask.java @@ -149,7 +149,7 @@ public void execute() throws IOException { Path file = libDir.resolve(fmlLib.name); if (shouldDownloadFMLLib(fmlLib, file)) { List uris = dependencyManager.getDownloadProvider() - .injectURLWithCandidates(fmlLib.getDownloadURI()); + .injectURLWithCandidates(fmlLib.downloadUrl()); dependencies.add(new FileDownloadTask(uris, file) .withCounter("hmcl.install.libraries")); } @@ -193,9 +193,12 @@ public void execute() throws IOException { if (forgeVersion.startsWith("7.8.1.")) { return List.of( new FMLLib("argo-small-3.2.jar", "58912ea2858d168c50781f956fa5b59f0f7c6b51"), - new FMLLib("guava-14.0-rc3.jar", "931ae21fa8014c3ce686aaa621eae565fefb1a6a"), - new FMLLib("asm-all-4.1.jar", "054986e962b88d8660ae4566475658469595ef58"), - new FMLLib("bcprov-jdk15on-148.jar", "960dea7c9181ba0b17e8bab0c06a43f0a5f04e65"), + new FMLLib("guava-14.0-rc3.jar", "931ae21fa8014c3ce686aaa621eae565fefb1a6a", + "https://repo1.maven.org/maven2/com/google/guava/guava/14.0-rc3/guava-14.0-rc3.jar"), + new FMLLib("asm-all-4.1.jar", "054986e962b88d8660ae4566475658469595ef58", + "https://repo1.maven.org/maven2/org/ow2/asm/asm-all/4.1/asm-all-4.1.jar"), + new FMLLib("bcprov-jdk15on-148.jar", "960dea7c9181ba0b17e8bab0c06a43f0a5f04e65", + "https://repo1.maven.org/maven2/org/bouncycastle/bcprov-jdk15on/1.48/bcprov-jdk15on-1.48.jar"), new FMLLib("deobfuscation_data_1.5.2.zip", "446e55cd986582c70fcf12cb27bc00114c5adfd9"), new FMLLib("scala-library.jar", "458d046151ad179c85429ed7420ffb1eaf6ddf85") ); @@ -204,9 +207,9 @@ public void execute() throws IOException { return null; } - private record FMLLib(String name, String sha1) { - public String getDownloadURI() { - return "https://hmcl-dev.github.io/metadata/fmllibs/" + name; + private record FMLLib(String name, String sha1, String downloadUrl) { + FMLLib(String name, String sha1) { + this(name, sha1, "https://hmcl.glavo.site/metadata/fmllibs/" + name); } } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVersionList.java index 97690772c2..cf8428b804 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVersionList.java @@ -53,7 +53,7 @@ protected Collection getVersionsImpl(String gameVersion) { @Override public Task refreshAsync() { - return new GetTask(downloadProvider.getVersionListURL()).thenGetJsonAsync(GameRemoteVersions.class) + return new GetTask(downloadProvider.getVersionListURLs()).thenGetJsonAsync(GameRemoteVersions.class) .thenAcceptAsync(root -> { GameRemoteVersions unlistedVersions = null; @@ -91,4 +91,9 @@ public Task refreshAsync() { } }); } + + @Override + public String toString() { + return "GameVersionList[downloadProvider=%s]".formatted(downloadProvider); + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/java/mojang/MojangJavaDownloadTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/java/mojang/MojangJavaDownloadTask.java index eea3f18f2e..a44a68e811 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/java/mojang/MojangJavaDownloadTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/java/mojang/MojangJavaDownloadTask.java @@ -21,7 +21,7 @@ import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.game.DownloadInfo; import org.jackhuang.hmcl.game.GameJavaVersion; -import org.jackhuang.hmcl.java.*; +import org.jackhuang.hmcl.java.JavaInfo; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Task; @@ -59,11 +59,11 @@ public MojangJavaDownloadTask(DownloadProvider downloadProvider, Path target, Ga MojangJavaDownloads allDownloads = JsonUtils.fromNonNullJson(javaDownloadsJson, MojangJavaDownloads.class); Map> osDownloads = allDownloads.getDownloads().get(platform); - if (osDownloads == null || !osDownloads.containsKey(javaVersion.getComponent())) + if (osDownloads == null || !osDownloads.containsKey(javaVersion.component())) throw new UnsupportedPlatformException("Unsupported platform: " + platform); - List candidates = osDownloads.get(javaVersion.getComponent()); + List candidates = osDownloads.get(javaVersion.component()); for (MojangJavaDownloads.JavaDownload download : candidates) { - if (JavaInfo.parseVersion(download.getVersion().getName()) >= javaVersion.getMajorVersion()) { + if (JavaInfo.parseVersion(download.getVersion().getName()) >= javaVersion.majorVersion()) { this.download = download; return new GetTask(downloadProvider.injectURLWithCandidates(download.getManifest().getUrl())); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/java/mojang/MojangJavaRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/java/mojang/MojangJavaRemoteVersion.java index ed8fece9a7..5e1a3c23bf 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/java/mojang/MojangJavaRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/java/mojang/MojangJavaRemoteVersion.java @@ -36,7 +36,7 @@ public GameJavaVersion getGameJavaVersion() { @Override public int getJdkVersion() { - return gameJavaVersion.getMajorVersion(); + return gameJavaVersion.majorVersion(); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIInstallTask.java new file mode 100644 index 0000000000..1a9c2de6a0 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIInstallTask.java @@ -0,0 +1,61 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.download.legacyfabric; + +import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.game.Version; +import org.jackhuang.hmcl.task.FileDownloadTask; +import org.jackhuang.hmcl.task.Task; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public final class LegacyFabricAPIInstallTask extends Task { + + private final DefaultDependencyManager dependencyManager; + private final Version version; + private final LegacyFabricAPIRemoteVersion remote; + private final List> dependencies = new ArrayList<>(1); + + public LegacyFabricAPIInstallTask(DefaultDependencyManager dependencyManager, Version version, LegacyFabricAPIRemoteVersion remoteVersion) { + this.dependencyManager = dependencyManager; + this.version = version; + this.remote = remoteVersion; + } + + @Override + public Collection> getDependencies() { + return dependencies; + } + + @Override + public boolean isRelyingOnDependencies() { + return false; + } + + @Override + public void execute() throws IOException { + dependencies.add(new FileDownloadTask( + remote.getVersion().getFile().getUrl(), + dependencyManager.getGameRepository().getModsDirectory(version.getId()).resolve("legacy-fabric-api-" + remote.getVersion().getVersion() + ".jar"), + remote.getVersion().getFile().getIntegrityCheck()) + ); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIRemoteVersion.java new file mode 100644 index 0000000000..eb21c07aab --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIRemoteVersion.java @@ -0,0 +1,67 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2022 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.download.legacyfabric; + +import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.Version; +import org.jackhuang.hmcl.mod.RemoteMod; +import org.jackhuang.hmcl.task.Task; + +import java.time.Instant; +import java.util.List; + +public class LegacyFabricAPIRemoteVersion extends RemoteVersion { + private final String fullVersion; + private final RemoteMod.Version version; + + /** + * Constructor. + * + * @param gameVersion the Minecraft version that this remote version suits. + * @param selfVersion the version string of the remote version. + * @param urls the installer or universal jar original URL. + */ + LegacyFabricAPIRemoteVersion(String gameVersion, String selfVersion, String fullVersion, Instant datePublished, RemoteMod.Version version, List urls) { + super(LibraryAnalyzer.LibraryType.LEGACY_FABRIC_API.getPatchId(), gameVersion, selfVersion, datePublished, urls); + + this.fullVersion = fullVersion; + this.version = version; + } + + @Override + public String getFullVersion() { + return fullVersion; + } + + public RemoteMod.Version getVersion() { + return version; + } + + @Override + public Task getInstallTask(DefaultDependencyManager dependencyManager, Version baseVersion) { + return new LegacyFabricAPIInstallTask(dependencyManager, baseVersion, this); + } + + @Override + public int compareTo(RemoteVersion o) { + if (!(o instanceof LegacyFabricAPIRemoteVersion)) return 0; + return -this.getReleaseDate().compareTo(o.getReleaseDate()); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIVersionList.java new file mode 100644 index 0000000000..da21df24a5 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIVersionList.java @@ -0,0 +1,53 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2022 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.download.legacyfabric; + +import org.jackhuang.hmcl.download.DownloadProvider; +import org.jackhuang.hmcl.download.VersionList; +import org.jackhuang.hmcl.mod.RemoteMod; +import org.jackhuang.hmcl.mod.modrinth.ModrinthRemoteModRepository; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.Lang; + +import java.util.Collections; + +public class LegacyFabricAPIVersionList extends VersionList { + + private final DownloadProvider downloadProvider; + + public LegacyFabricAPIVersionList(DownloadProvider downloadProvider) { + this.downloadProvider = downloadProvider; + } + + @Override + public boolean hasType() { + return false; + } + + @Override + public Task refreshAsync() { + return Task.runAsync(() -> { + for (RemoteMod.Version modVersion : Lang.toIterable(ModrinthRemoteModRepository.MODS.getRemoteVersionsById("legacy-fabric-api"))) { + for (String gameVersion : modVersion.getGameVersions()) { + versions.put(gameVersion, new LegacyFabricAPIRemoteVersion(gameVersion, modVersion.getVersion(), modVersion.getName(), modVersion.getDatePublished(), modVersion, + Collections.singletonList(modVersion.getFile().getUrl()))); + } + } + }); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricInstallTask.java new file mode 100644 index 0000000000..7ef8cd1227 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricInstallTask.java @@ -0,0 +1,130 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2022 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.download.legacyfabric; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.download.UnsupportedInstallationException; +import org.jackhuang.hmcl.download.fabric.FabricInstallTask; +import org.jackhuang.hmcl.game.Arguments; +import org.jackhuang.hmcl.game.Artifact; +import org.jackhuang.hmcl.game.Library; +import org.jackhuang.hmcl.game.Version; +import org.jackhuang.hmcl.task.GetTask; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.gson.JsonUtils; + +import java.util.*; + +import static org.jackhuang.hmcl.download.UnsupportedInstallationException.FABRIC_NOT_COMPATIBLE_WITH_FORGE; + +public final class LegacyFabricInstallTask extends Task { + + private final DefaultDependencyManager dependencyManager; + private final Version version; + private final LegacyFabricRemoteVersion remote; + private final GetTask launchMetaTask; + private final List> dependencies = new ArrayList<>(1); + + public LegacyFabricInstallTask(DefaultDependencyManager dependencyManager, Version version, LegacyFabricRemoteVersion remoteVersion) { + this.dependencyManager = dependencyManager; + this.version = version; + this.remote = remoteVersion; + + launchMetaTask = new GetTask(dependencyManager.getDownloadProvider().injectURLsWithCandidates(remoteVersion.getUrls())); + launchMetaTask.setCacheRepository(dependencyManager.getCacheRepository()); + } + + @Override + public boolean doPreExecute() { + return true; + } + + @Override + public void preExecute() throws Exception { + if (!Objects.equals("net.minecraft.client.main.Main", version.resolve(dependencyManager.getGameRepository()).getMainClass())) + throw new UnsupportedInstallationException(FABRIC_NOT_COMPATIBLE_WITH_FORGE); + } + + @Override + public Collection> getDependents() { + return Collections.singleton(launchMetaTask); + } + + @Override + public Collection> getDependencies() { + return dependencies; + } + + @Override + public boolean isRelyingOnDependencies() { + return false; + } + + @Override + public void execute() { + setResult(getPatch(JsonUtils.GSON.fromJson(launchMetaTask.getResult(), FabricInstallTask.FabricInfo.class), remote.getGameVersion(), remote.getSelfVersion())); + + dependencies.add(dependencyManager.checkLibraryCompletionAsync(getResult(), true)); + } + + private Version getPatch(FabricInstallTask.FabricInfo legacyFabricInfo, String gameVersion, String loaderVersion) { + JsonObject launcherMeta = legacyFabricInfo.getLauncherMeta(); + Arguments arguments = new Arguments(); + + String mainClass; + if (!launcherMeta.get("mainClass").isJsonObject()) { + mainClass = launcherMeta.get("mainClass").getAsString(); + } else { + mainClass = launcherMeta.get("mainClass").getAsJsonObject().get("client").getAsString(); + } + + if (launcherMeta.has("launchwrapper")) { + String clientTweaker = launcherMeta.get("launchwrapper").getAsJsonObject().get("tweakers").getAsJsonObject().get("client").getAsJsonArray().get(0).getAsString(); + arguments = arguments.addGameArguments("--tweakClass", clientTweaker); + } + + JsonObject librariesObject = launcherMeta.getAsJsonObject("libraries"); + List libraries = new ArrayList<>(); + + // "common, server" is hard coded in fabric installer. + // Don't know the purpose of ignoring client libraries. + for (String side : new String[]{"common", "server"}) { + for (JsonElement element : librariesObject.getAsJsonArray(side)) { + libraries.add(JsonUtils.GSON.fromJson(element, Library.class)); + } + } + + // libraries.add(new Library(Artifact.fromDescriptor(legacyFabricInfo.hashed.maven), getMavenRepositoryByGroup(legacyFabricInfo.hashed.maven), null)); + libraries.add(new Library(Artifact.fromDescriptor(legacyFabricInfo.getIntermediary().getMaven()), getMavenRepositoryByGroup(legacyFabricInfo.getIntermediary().getMaven()), null)); + libraries.add(new Library(Artifact.fromDescriptor(legacyFabricInfo.getLoader().getMaven()), getMavenRepositoryByGroup(legacyFabricInfo.getLoader().getMaven()), null)); + + return new Version(LibraryAnalyzer.LibraryType.LEGACY_FABRIC.getPatchId(), loaderVersion, Version.PRIORITY_LOADER, arguments, mainClass, libraries); + } + + private static String getMavenRepositoryByGroup(String maven) { + Artifact artifact = Artifact.fromDescriptor(maven); + return switch (artifact.getGroup()) { + case "net.fabricmc" -> "https://maven.fabricmc.net/"; + case "net.legacyfabric" -> "https://maven.legacyfabric.net/"; + default -> "https://maven.fabricmc.net/"; + }; + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricRemoteVersion.java new file mode 100644 index 0000000000..cd29f0fddd --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricRemoteVersion.java @@ -0,0 +1,44 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2022 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.download.legacyfabric; + +import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.Version; +import org.jackhuang.hmcl.task.Task; + +import java.util.List; + +public class LegacyFabricRemoteVersion extends RemoteVersion { + /** + * Constructor. + * + * @param gameVersion the Minecraft version that this remote version suits. + * @param selfVersion the version string of the remote version. + * @param urls the installer or universal jar original URL. + */ + LegacyFabricRemoteVersion(String gameVersion, String selfVersion, List urls) { + super(LibraryAnalyzer.LibraryType.LEGACY_FABRIC.getPatchId(), gameVersion, selfVersion, null, urls); + } + + @Override + public Task getInstallTask(DefaultDependencyManager dependencyManager, Version baseVersion) { + return new LegacyFabricInstallTask(dependencyManager, baseVersion, this); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricVersionList.java new file mode 100644 index 0000000000..5533e9e5ff --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricVersionList.java @@ -0,0 +1,106 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2022 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.download.legacyfabric; + +import org.jackhuang.hmcl.download.DownloadProvider; +import org.jackhuang.hmcl.download.VersionList; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.NetworkUtils; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import static org.jackhuang.hmcl.util.gson.JsonUtils.listTypeOf; + +public final class LegacyFabricVersionList extends VersionList { + private final DownloadProvider downloadProvider; + + public LegacyFabricVersionList(DownloadProvider downloadProvider) { + this.downloadProvider = downloadProvider; + } + + @Override + public boolean hasType() { + return false; + } + + @Override + public Task refreshAsync() { + return Task.runAsync(() -> { + List gameVersions = getGameVersions(GAME_META_URL); + List loaderVersions = getGameVersions(LOADER_META_URL); + + lock.writeLock().lock(); + + try { + for (String gameVersion : gameVersions) + for (String loaderVersion : loaderVersions) + versions.put(gameVersion, new LegacyFabricRemoteVersion(gameVersion, loaderVersion, + Collections.singletonList(getLaunchMetaUrl(gameVersion, loaderVersion)))); + } finally { + lock.writeLock().unlock(); + } + }); + } + + private static final String LOADER_META_URL = "https://meta.legacyfabric.net/v2/versions/loader"; + private static final String GAME_META_URL = "https://meta.legacyfabric.net/v2/versions/game"; + + private List getGameVersions(String metaUrl) throws IOException { + String json = NetworkUtils.doGet(downloadProvider.injectURLWithCandidates(metaUrl)); + return JsonUtils.GSON.fromJson(json, listTypeOf(GameVersion.class)) + .stream().map(GameVersion::getVersion).collect(Collectors.toList()); + } + + private static String getLaunchMetaUrl(String gameVersion, String loaderVersion) { + return String.format("https://meta.legacyfabric.net/v2/versions/loader/%s/%s", gameVersion, loaderVersion); + } + + private static class GameVersion { + private final String version; + private final String maven; + private final boolean stable; + + public GameVersion() { + this("", null, false); + } + + public GameVersion(String version, String maven, boolean stable) { + this.version = version; + this.maven = maven; + this.stable = stable; + } + + public String getVersion() { + return version; + } + + @Nullable + public String getMaven() { + return maven; + } + + public boolean isStable() { + return stable; + } + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderBMCLVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderBMCLVersionList.java index d8d5d04b74..c80aa47a95 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderBMCLVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderBMCLVersionList.java @@ -22,6 +22,7 @@ import org.jackhuang.hmcl.download.VersionList; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.NetworkUtils; import java.util.Collections; @@ -60,17 +61,21 @@ public Task refreshAsync() { @Override public Task refreshAsync(String gameVersion) { - return new GetTask(NetworkUtils.withQuery(downloadProvider.injectURLWithCandidates("https://bmclapi2.bangbang93.com/liteloader/list"), Map.of("mcversion", gameVersion))) - .thenGetJsonAsync(LiteLoaderBMCLVersion.class) + return new GetTask( + NetworkUtils.withQuery(downloadProvider.getApiRoot() + "/liteloader/list", Map.of( + "mcversion", gameVersion + ))) + .thenApplyAsync(json -> JsonUtils.fromMaybeMalformedJson(json, LiteLoaderBMCLVersion.class)) .thenAcceptAsync(v -> { lock.writeLock().lock(); try { versions.clear(); - + if (v == null) + return; versions.put(gameVersion, new LiteLoaderRemoteVersion( gameVersion, v.version, RemoteVersion.Type.UNCATEGORIZED, Collections.singletonList(NetworkUtils.withQuery( - downloadProvider.injectURL("https://bmclapi2.bangbang93.com/liteloader/download"), + downloadProvider.getApiRoot() + "/liteloader/download", Collections.singletonMap("version", v.version) )), v.build.getTweakClass(), v.build.getLibraries() diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderVersionList.java index 42b9dd1176..dffb8cd2eb 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderVersionList.java @@ -52,7 +52,7 @@ public boolean hasType() { @Override public Task refreshAsync(String gameVersion) { - return new GetTask(downloadProvider.injectURL(LITELOADER_LIST)) + return new GetTask(downloadProvider.injectURLWithCandidates(LITELOADER_LIST)) .thenGetJsonAsync(LiteLoaderVersionsRoot.class) .thenAcceptAsync(root -> { LiteLoaderGameVersions versions = root.getVersions().get(gameVersion); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeBMCLVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeBMCLVersionList.java index ab0e7ace44..d0466fd520 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeBMCLVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeBMCLVersionList.java @@ -45,11 +45,6 @@ public boolean hasType() { return true; } - @Override - public Task loadAsync() { - throw new UnsupportedOperationException("NeoForgeBMCLVersionList does not support loading the entire NeoForge remote version list."); - } - @Override public Task refreshAsync() { throw new UnsupportedOperationException("NeoForgeBMCLVersionList does not support loading the entire NeoForge remote version list."); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOfficialVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOfficialVersionList.java index 2ebfc03d1e..2e471621e1 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOfficialVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOfficialVersionList.java @@ -24,7 +24,6 @@ public boolean hasType() { } private static final String OLD_URL = "https://maven.neoforged.net/api/maven/versions/releases/net/neoforged/forge"; - private static final String META_URL = "https://maven.neoforged.net/api/maven/versions/releases/net/neoforged/neoforge"; @Override @@ -38,8 +37,8 @@ public Optional getVersion(String gameVersion, String rem @Override public Task refreshAsync() { return Task.allOf( - new GetTask(downloadProvider.injectURL(OLD_URL)).thenGetJsonAsync(OfficialAPIResult.class), - new GetTask(downloadProvider.injectURL(META_URL)).thenGetJsonAsync(OfficialAPIResult.class) + new GetTask(downloadProvider.injectURLWithCandidates(OLD_URL)).thenGetJsonAsync(OfficialAPIResult.class), + new GetTask(downloadProvider.injectURLWithCandidates(META_URL)).thenGetJsonAsync(OfficialAPIResult.class) ).thenAcceptAsync(results -> { lock.writeLock().lock(); @@ -64,7 +63,13 @@ public Task refreshAsync() { if (majorVersion == 0) { // Snapshot version. mcVersion = version.substring(si1 + 1, si2); } else { - mcVersion = "1." + version.substring(0, Integer.parseInt(version.substring(si1 + 1, si2)) == 0 ? si1 : si2); + String ver = version.substring(0, Integer.parseInt(version.substring(si1 + 1, si2)) == 0 ? si1 : si2); + if (majorVersion >= 26) { + int separator = version.indexOf('+'); + mcVersion = separator < 0 ? ver : ver + "-" + version.substring(separator + 1); + } else { + mcVersion = "1." + ver; + } } } catch (RuntimeException e) { LOG.warning(String.format("Cannot parse NeoForge version %s for cracking its mc version.", version), e); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeRemoteVersion.java index bca39f2957..d1312489ed 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeRemoteVersion.java @@ -19,7 +19,7 @@ public Task getInstallTask(DefaultDependencyManager dependencyManager, } private static Type getType(String version) { - return version.contains("beta") ? Type.SNAPSHOT : Type.RELEASE; + return version.contains("beta") || version.contains("alpha") ? Type.SNAPSHOT : Type.RELEASE; } public static String normalize(String version) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineBMCLVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineBMCLVersionList.java index 7d8dd0f1d0..608a1ca047 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineBMCLVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineBMCLVersionList.java @@ -80,7 +80,7 @@ public Task refreshAsync() { Set duplicates = new HashSet<>(); for (OptiFineVersion element : root) { String version = element.getType() + "_" + element.getPatch(); - String mirror = "https://bmclapi2.bangbang93.com/optifine/" + toLookupVersion(element.getGameVersion()) + "/" + element.getType() + "/" + element.getPatch(); + String mirror = apiRoot + "/optifine/" + toLookupVersion(element.getGameVersion()) + "/" + element.getType() + "/" + element.getPatch(); if (!duplicates.add(mirror)) continue; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIInstallTask.java index b0d9eb2bf9..5f06cccba5 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIInstallTask.java @@ -59,7 +59,7 @@ public boolean isRelyingOnDependencies() { public void execute() throws IOException { dependencies.add(new FileDownloadTask( remote.getVersion().getFile().getUrl(), - dependencyManager.getGameRepository().getRunDirectory(version.getId()).resolve("mods").resolve("quilt-api-" + remote.getVersion().getVersion() + ".jar"), + dependencyManager.getGameRepository().getModsDirectory(version.getId()).resolve("quilt-api-" + remote.getVersion().getVersion() + ".jar"), remote.getVersion().getFile().getIntegrityCheck()) ); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltInstallTask.java index c0bd512907..01642cecf0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltInstallTask.java @@ -28,6 +28,7 @@ import org.jackhuang.hmcl.game.Version; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.gson.JsonSerializable; import org.jackhuang.hmcl.util.gson.JsonUtils; import java.util.*; @@ -135,6 +136,7 @@ private static String getMavenRepositoryByGroup(String maven) { } } + @JsonSerializable public static class QuiltInfo { private final LoaderInfo loader; private final IntermediaryInfo hashed; @@ -165,6 +167,7 @@ public JsonObject getLauncherMeta() { } } + @JsonSerializable public static class LoaderInfo { private final String separator; private final int build; @@ -201,6 +204,7 @@ public boolean isStable() { } } + @JsonSerializable public static class IntermediaryInfo { private final String maven; private final String version; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java index df8f453268..42d5fcafec 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -564,4 +564,8 @@ public String toString() { .append("baseDirectory", baseDirectory) .toString(); } + + public Path getResourcepacksDirectory(String id) { + return getRunDirectory(id).resolve("resourcepacks"); + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameJavaVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameJavaVersion.java index e4a4f6417f..f3d1dfc567 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameJavaVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameJavaVersion.java @@ -17,14 +17,19 @@ */ package org.jackhuang.hmcl.game; +import org.jackhuang.hmcl.util.gson.JsonSerializable; import org.jackhuang.hmcl.util.platform.Architecture; import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jackhuang.hmcl.util.platform.Platform; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; -import java.util.*; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; -public final class GameJavaVersion { +@JsonSerializable +public record GameJavaVersion(String component, int majorVersion) { + public static final GameJavaVersion JAVA_25 = new GameJavaVersion("java-runtime-epsilon", 25); public static final GameJavaVersion JAVA_21 = new GameJavaVersion("java-runtime-delta", 21); public static final GameJavaVersion JAVA_17 = new GameJavaVersion("java-runtime-beta", 17); public static final GameJavaVersion JAVA_16 = new GameJavaVersion("java-runtime-alpha", 16); @@ -33,6 +38,8 @@ public final class GameJavaVersion { public static final GameJavaVersion LATEST = JAVA_21; public static GameJavaVersion getMinimumJavaVersion(GameVersionNumber gameVersion) { + if (gameVersion.compareTo("26.1") >= 0) + return JAVA_25; if (gameVersion.compareTo("1.20.5") >= 0) return JAVA_21; if (gameVersion.compareTo("1.18") >= 0) @@ -45,18 +52,14 @@ public static GameJavaVersion getMinimumJavaVersion(GameVersionNumber gameVersio } public static GameJavaVersion get(int major) { - switch (major) { - case 8: - return JAVA_8; - case 16: - return JAVA_16; - case 17: - return JAVA_17; - case 21: - return JAVA_21; - default: - return null; - } + return switch (major) { + case 8 -> JAVA_8; + case 16 -> JAVA_16; + case 17 -> JAVA_17; + case 21 -> JAVA_21; + case 25 -> JAVA_25; + default -> null; + }; } public static List getSupportedVersions(Platform platform) { @@ -74,49 +77,26 @@ public static List getSupportedVersions(Platform platform) { case WINDOWS: case LINUX: case MACOS: - return Arrays.asList(JAVA_8, JAVA_16, JAVA_17, JAVA_21); + return Arrays.asList(JAVA_8, JAVA_16, JAVA_17, JAVA_21, JAVA_25); } } else if (architecture == Architecture.ARM64) { switch (operatingSystem) { case WINDOWS: case MACOS: - return Arrays.asList(JAVA_17, JAVA_21); + return Arrays.asList(JAVA_17, JAVA_21, JAVA_25); } } return Collections.emptyList(); } - private final String component; - private final int majorVersion; - - public GameJavaVersion() { - this("", 0); - } - - public GameJavaVersion(String component, int majorVersion) { - this.component = component; - this.majorVersion = majorVersion; - } - - public String getComponent() { - return component; - } - - public int getMajorVersion() { - return majorVersion; - } - @Override public int hashCode() { - return getMajorVersion(); + return majorVersion(); } @Override public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof GameJavaVersion)) return false; - GameJavaVersion that = (GameJavaVersion) o; - return majorVersion == that.majorVersion; + return this == o || o instanceof GameJavaVersion that && this.majorVersion == that.majorVersion; } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/JavaVersionConstraint.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/JavaVersionConstraint.java index 8e745d982f..3d0ebd2487 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/JavaVersionConstraint.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/JavaVersionConstraint.java @@ -18,9 +18,9 @@ package org.jackhuang.hmcl.game; import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.platform.Architecture; -import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jackhuang.hmcl.util.versioning.VersionNumber; @@ -43,7 +43,7 @@ protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nul @Override public boolean checkJava(GameVersionNumber gameVersionNumber, Version version, JavaRuntime java) { GameJavaVersion minimumJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersionNumber); - return minimumJavaVersion == null || java.getParsedVersion() >= minimumJavaVersion.getMajorVersion(); + return minimumJavaVersion == null || java.getParsedVersion() >= minimumJavaVersion.majorVersion(); } }, // Minecraft with suggested java version recorded in game json is restrictedly constrained. @@ -59,10 +59,10 @@ protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nul @Override public VersionRange getJavaVersionRange(Version version) { String javaVersion; - if (Objects.requireNonNull(version.getJavaVersion()).getMajorVersion() >= 9) { - javaVersion = "" + version.getJavaVersion().getMajorVersion(); + if (Objects.requireNonNull(version.getJavaVersion()).majorVersion() >= 9) { + javaVersion = "" + version.getJavaVersion().majorVersion(); } else { - javaVersion = "1." + version.getJavaVersion().getMajorVersion(); + javaVersion = "1." + version.getJavaVersion().majorVersion(); } return VersionNumber.atLeast(javaVersion); } @@ -247,9 +247,9 @@ protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nul return true; } - String versionNumber = gameJavaVersion.getMajorVersion() >= 9 - ? String.valueOf(gameJavaVersion.getMajorVersion()) - : "1." + gameJavaVersion.getMajorVersion(); + String versionNumber = gameJavaVersion.majorVersion() >= 9 + ? String.valueOf(gameJavaVersion.majorVersion()) + : "1." + gameJavaVersion.majorVersion(); VersionRange range = getJavaVersionRange(version); VersionNumber maximum = range.getMaximum(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchOptions.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchOptions.java index 93d9a41b46..52c660061d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchOptions.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchOptions.java @@ -21,7 +21,6 @@ import org.jetbrains.annotations.NotNull; import java.io.Serializable; -import java.net.Proxy; import java.nio.file.Path; import java.util.*; @@ -47,13 +46,9 @@ public class LaunchOptions implements Serializable { private Integer width; private Integer height; private boolean fullscreen; - private String serverIp; + private QuickPlayOption quickPlayOption; private String wrapper; - private Proxy.Type proxyType; - private String proxyHost; - private int proxyPort; - private String proxyUser; - private String proxyPass; + private ProxyOption proxyOption; private boolean noGeneratedJVMArgs; private boolean noGeneratedOptimizingJVMArgs; private String preLaunchCommand; @@ -64,6 +59,7 @@ public class LaunchOptions implements Serializable { private Renderer renderer = Renderer.DEFAULT; private boolean useNativeGLFW; private boolean useNativeOpenAL; + private boolean enableDebugLogOutput; private boolean daemon; /** @@ -180,11 +176,11 @@ public boolean isFullscreen() { return fullscreen; } - /** - * The server ip that will connect to when enter game main menu. - */ - public String getServerIp() { - return serverIp; + /// The quick play option. + /// + /// @see Quick Play - Minecraft Wiki + public QuickPlayOption getQuickPlayOption() { + return quickPlayOption; } /** @@ -194,30 +190,8 @@ public String getWrapper() { return wrapper; } - public Proxy.Type getProxyType() { - return proxyType; - } - - public String getProxyHost() { - return proxyHost; - } - - public int getProxyPort() { - return proxyPort; - } - - /** - * The user name of the proxy, optional. - */ - public String getProxyUser() { - return proxyUser; - } - - /** - * The password of the proxy, optional - */ - public String getProxyPass() { - return proxyPass; + public ProxyOption getProxyOption() { + return proxyOption; } /** @@ -282,6 +256,10 @@ public boolean isUseNativeOpenAL() { return useNativeOpenAL; } + public boolean isEnableDebugLogOutput() { + return enableDebugLogOutput; + } + /** * Will launcher keeps alive after game launched or not. */ @@ -407,8 +385,8 @@ public Builder setFullscreen(boolean fullscreen) { return this; } - public Builder setServerIp(String serverIp) { - options.serverIp = serverIp; + public Builder setQuickPlayOption(QuickPlayOption quickPlayOption) { + options.quickPlayOption = quickPlayOption; return this; } @@ -417,28 +395,8 @@ public Builder setWrapper(String wrapper) { return this; } - public Builder setProxyType(Proxy.Type proxyType) { - options.proxyType = proxyType; - return this; - } - - public Builder setProxyHost(String proxyHost) { - options.proxyHost = proxyHost; - return this; - } - - public Builder setProxyPort(int proxyPort) { - options.proxyPort = proxyPort; - return this; - } - - public Builder setProxyUser(String proxyUser) { - options.proxyUser = proxyUser; - return this; - } - - public Builder setProxyPass(String proxyPass) { - options.proxyPass = proxyPass; + public Builder setProxyOption(ProxyOption proxyOption) { + options.proxyOption = proxyOption; return this; } @@ -497,5 +455,9 @@ public Builder setDaemon(boolean daemon) { return this; } + public Builder setEnableDebugLogOutput(boolean u) { + options.enableDebugLogOutput = u; + return this; + } } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/ProxyOption.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/ProxyOption.java new file mode 100644 index 0000000000..6121c79ecd --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/ProxyOption.java @@ -0,0 +1,63 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import org.jackhuang.hmcl.util.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/// @author Glavo +public sealed interface ProxyOption { + final class Direct implements ProxyOption { + public static final Direct INSTANCE = new Direct(); + + private Direct() { + } + } + + final class Default implements ProxyOption { + public static final Default INSTANCE = new Default(); + + private Default() { + } + } + + record Http(@NotNull String host, int port, @Nullable String username, + @Nullable String password) implements ProxyOption { + public Http { + if (StringUtils.isBlank(host)) { + throw new IllegalArgumentException("Host cannot be blank"); + } + if (port < 0 || port > 0xFFFF) { + throw new IllegalArgumentException("Illegal port: " + port); + } + } + } + + record Socks(@NotNull String host, int port, @Nullable String username, + @Nullable String password) implements ProxyOption { + public Socks { + if (StringUtils.isBlank(host)) { + throw new IllegalArgumentException("Host cannot be blank"); + } + if (port < 0 || port > 0xFFFF) { + throw new IllegalArgumentException("Illegal port: " + port); + } + } + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/QuickPlayOption.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/QuickPlayOption.java new file mode 100644 index 0000000000..f2d9cc061b --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/QuickPlayOption.java @@ -0,0 +1,32 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +/// The quick play option. +/// +/// @see Quick Play - Minecraft Wiki +public sealed interface QuickPlayOption { + record SinglePlayer(String worldFolderName) implements QuickPlayOption { + } + + record MultiPlayer(String serverIP) implements QuickPlayOption { + } + + record Realm(String realmID) implements QuickPlayOption { + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index 8d21dc25d7..24943caf07 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -18,12 +18,10 @@ package org.jackhuang.hmcl.game; import com.github.steveice10.opennbt.NBTIO; -import com.github.steveice10.opennbt.tag.builtin.CompoundTag; -import com.github.steveice10.opennbt.tag.builtin.LongTag; -import com.github.steveice10.opennbt.tag.builtin.StringTag; -import com.github.steveice10.opennbt.tag.builtin.Tag; +import com.github.steveice10.opennbt.tag.builtin.*; import javafx.scene.image.Image; import org.jackhuang.hmcl.util.io.*; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.Nullable; import java.io.IOException; @@ -47,13 +45,10 @@ public final class World { private final Path file; private String fileName; - private String worldName; - private String gameVersion; - private long lastPlayed; + private CompoundTag levelData; private Image icon; - private Long seed; - private boolean largeBiomes; private boolean isLocked; + private Path levelDataPath; public World(Path file) throws IOException { this.file = file; @@ -66,24 +61,6 @@ else if (Files.isRegularFile(file)) throw new IOException("Path " + file + " cannot be recognized as a Minecraft world"); } - private void loadFromDirectory() throws IOException { - fileName = FileUtils.getName(file); - Path levelDat = file.resolve("level.dat"); - loadWorldInfo(levelDat); - isLocked = isLocked(getSessionLockFile()); - - Path iconFile = file.resolve("icon.png"); - if (Files.isRegularFile(iconFile)) { - try (InputStream inputStream = Files.newInputStream(iconFile)) { - icon = new Image(inputStream, 64, 64, true, false); - if (icon.isError()) - throw icon.getException(); - } catch (Exception e) { - LOG.warning("Failed to load world icon", e); - } - } - } - public Path getFile() { return file; } @@ -93,7 +70,16 @@ public String getFileName() { } public String getWorldName() { - return worldName; + CompoundTag data = levelData.get("Data"); + StringTag levelNameTag = data.get("LevelName"); + return levelNameTag.getValue(); + } + + public void setWorldName(String worldName) throws IOException { + if (levelData.get("Data") instanceof CompoundTag data && data.get("LevelName") instanceof StringTag levelNameTag) { + levelNameTag.setValue(worldName); + writeLevelDat(levelData); + } } public Path getLevelDatFile() { @@ -104,20 +90,53 @@ public Path getSessionLockFile() { return file.resolve("session.lock"); } + public CompoundTag getLevelData() { + return levelData; + } + public long getLastPlayed() { - return lastPlayed; + CompoundTag data = levelData.get("Data"); + LongTag lastPlayedTag = data.get("LastPlayed"); + return lastPlayedTag.getValue(); } - public String getGameVersion() { - return gameVersion; + public @Nullable GameVersionNumber getGameVersion() { + if (levelData.get("Data") instanceof CompoundTag data && + data.get("Version") instanceof CompoundTag versionTag && + versionTag.get("Name") instanceof StringTag nameTag) { + return GameVersionNumber.asGameVersion(nameTag.getValue()); + } + return null; } public @Nullable Long getSeed() { - return seed; + CompoundTag data = levelData.get("Data"); + if (data.get("WorldGenSettings") instanceof CompoundTag worldGenSettingsTag && worldGenSettingsTag.get("seed") instanceof LongTag seedTag) { //Valid after 1.16 + return seedTag.getValue(); + } else if (data.get("RandomSeed") instanceof LongTag seedTag) { //Valid before 1.16 + return seedTag.getValue(); + } + return null; } public boolean isLargeBiomes() { - return largeBiomes; + CompoundTag data = levelData.get("Data"); + if (data.get("generatorName") instanceof StringTag generatorNameTag) { //Valid before 1.16 + return "largeBiomes".equals(generatorNameTag.getValue()); + } else { + if (data.get("WorldGenSettings") instanceof CompoundTag worldGenSettingsTag + && worldGenSettingsTag.get("dimensions") instanceof CompoundTag dimensionsTag + && dimensionsTag.get("minecraft:overworld") instanceof CompoundTag overworldTag + && overworldTag.get("generator") instanceof CompoundTag generatorTag) { + if (generatorTag.get("biome_source") instanceof CompoundTag biomeSourceTag + && biomeSourceTag.get("large_biomes") instanceof ByteTag largeBiomesTag) { //Valid between 1.16 and 1.16.2 + return largeBiomesTag.getValue() == (byte) 1; + } else if (generatorTag.get("settings") instanceof StringTag settingsTag) { //Valid after 1.16.2 + return "minecraft:large_biomes".equals(settingsTag.getValue()); + } + } + return false; + } } public Image getIcon() { @@ -128,12 +147,38 @@ public boolean isLocked() { return isLocked; } + private void loadFromDirectory() throws IOException { + fileName = FileUtils.getName(file); + Path levelDat = file.resolve("level.dat"); + if (!Files.exists(levelDat)) { // version 20w14infinite + levelDat = file.resolve("special_level.dat"); + } + loadAndCheckLevelDat(levelDat); + this.levelDataPath = levelDat; + isLocked = isLocked(getSessionLockFile()); + + Path iconFile = file.resolve("icon.png"); + if (Files.isRegularFile(iconFile)) { + try (InputStream inputStream = Files.newInputStream(iconFile)) { + icon = new Image(inputStream, 64, 64, true, false); + if (icon.isError()) + throw icon.getException(); + } catch (Exception e) { + LOG.warning("Failed to load world icon", e); + } + } + } + private void loadFromZipImpl(Path root) throws IOException { Path levelDat = root.resolve("level.dat"); - if (!Files.exists(levelDat)) - throw new IOException("Not a valid world zip file since level.dat cannot be found."); + if (!Files.exists(levelDat)) { //version 20w14infinite + levelDat = root.resolve("special_level.dat"); + } + if (!Files.exists(levelDat)) { + throw new IOException("Not a valid world zip file since level.dat or special_level.dat cannot be found."); + } - loadWorldInfo(levelDat); + loadAndCheckLevelDat(levelDat); Path iconFile = root.resolve("icon.png"); if (Files.isRegularFile(iconFile)) { @@ -166,50 +211,22 @@ private void loadFromZip() throws IOException { } } - private void loadWorldInfo(Path levelDat) throws IOException { - CompoundTag nbt = parseLevelDat(levelDat); - - CompoundTag data = nbt.get("Data"); + private void loadAndCheckLevelDat(Path levelDat) throws IOException { + this.levelData = parseLevelDat(levelDat); + CompoundTag data = levelData.get("Data"); if (data == null) throw new IOException("level.dat missing Data"); - if (data.get("LevelName") instanceof StringTag) - worldName = data.get("LevelName").getValue(); - else + if (!(data.get("LevelName") instanceof StringTag)) throw new IOException("level.dat missing LevelName"); - if (data.get("LastPlayed") instanceof LongTag) - lastPlayed = data.get("LastPlayed").getValue(); - else + if (!(data.get("LastPlayed") instanceof LongTag)) throw new IOException("level.dat missing LastPlayed"); + } - gameVersion = null; - if (data.get("Version") instanceof CompoundTag) { - CompoundTag version = data.get("Version"); - - if (version.get("Name") instanceof StringTag) - gameVersion = version.get("Name").getValue(); - } - - Tag worldGenSettings = data.get("WorldGenSettings"); - if (worldGenSettings instanceof CompoundTag) { - Tag seedTag = ((CompoundTag) worldGenSettings).get("seed"); - if (seedTag instanceof LongTag) { - seed = ((LongTag) seedTag).getValue(); - } - } - if (seed == null) { - Tag seedTag = data.get("RandomSeed"); - if (seedTag instanceof LongTag) { - seed = ((LongTag) seedTag).getValue(); - } - } - - // FIXME: Only work for 1.15 and below - if (data.get("generatorName") instanceof StringTag) { - largeBiomes = "largeBiomes".equals(data.get("generatorName").getValue()); - } else { - largeBiomes = false; + public void reloadLevelDat() throws IOException { + if (levelDataPath != null) { + loadAndCheckLevelDat(this.levelDataPath); } } @@ -218,10 +235,9 @@ public void rename(String newName) throws IOException { throw new IOException("Not a valid world directory"); // Change the name recorded in level.dat - CompoundTag nbt = readLevelDat(); - CompoundTag data = nbt.get("Data"); + CompoundTag data = levelData.get("Data"); data.put(new StringTag("LevelName", newName)); - writeLevelDat(nbt); + writeLevelDat(levelData); // then change the folder's name Files.move(file, file.resolveSibling(newName)); @@ -282,11 +298,19 @@ public void delete() throws IOException { FileUtils.forceDelete(file); } - public CompoundTag readLevelDat() throws IOException { - if (!Files.isDirectory(file)) + public void copy(String newName) throws IOException { + if (!Files.isDirectory(file)) { throw new IOException("Not a valid world directory"); + } + + if (isLocked()) { + throw new WorldLockedException("The world " + getFile() + " has been locked"); + } - return parseLevelDat(getLevelDatFile()); + Path newPath = file.resolveSibling(newName); + FileUtils.copyDirectory(file, newPath, path -> !path.contains("session.lock")); + World newWorld = new World(newPath); + newWorld.rename(newName); } public FileChannel lock() throws WorldLockedException { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java index 09489492b4..e8e1d92c69 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -30,7 +30,6 @@ import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import java.io.*; -import java.net.Proxy; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -73,14 +72,14 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { // res.add("cmd", "/C", "start", "unused title", "/B", "/high"); } else if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() || OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { - res.add("nice", "-n", "-5"); + res.addAll("nice", "-n", "-5"); } break; case ABOVE_NORMAL: if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { // res.add("cmd", "/C", "start", "unused title", "/B", "/abovenormal"); } else if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() || OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { - res.add("nice", "-n", "-1"); + res.addAll("nice", "-n", "-1"); } break; case NORMAL: @@ -90,14 +89,14 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { // res.add("cmd", "/C", "start", "unused title", "/B", "/belownormal"); } else if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() || OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { - res.add("nice", "-n", "1"); + res.addAll("nice", "-n", "1"); } break; case LOW: if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { // res.add("cmd", "/C", "start", "unused title", "/B", "/low"); } else if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() || OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { - res.add("nice", "-n", "5"); + res.addAll("nice", "-n", "5"); } break; } @@ -149,7 +148,7 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { res.addDefault("-Dcom.sun.jndi.cosnaming.object.trustURLCodebase=", "false"); String formatMsgNoLookups = res.addDefault("-Dlog4j2.formatMsgNoLookups=", "true"); - if (!"-Dlog4j2.formatMsgNoLookups=false".equals(formatMsgNoLookups) && isUsingLog4j()) { + if (isUsingLog4j() && (options.isEnableDebugLogOutput() || !"-Dlog4j2.formatMsgNoLookups=false".equals(formatMsgNoLookups))) { res.addDefault("-Dlog4j.configurationFile=", FileUtils.getAbsolutePath(getLog4jConfigurationFile())); } @@ -170,22 +169,36 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS) res.addDefault("-Duser.home=", options.getGameDir().toAbsolutePath().getParent().toString()); - Proxy.Type proxyType = options.getProxyType(); - if (proxyType == null) { - res.addDefault("-Djava.net.useSystemProxies", "true"); - } else { - String proxyHost = options.getProxyHost(); - int proxyPort = options.getProxyPort(); - - if (StringUtils.isNotBlank(proxyHost) && proxyPort >= 0 && proxyPort <= 0xFFFF) { - if (proxyType == Proxy.Type.HTTP) { - res.addDefault("-Dhttp.proxyHost=", proxyHost); - res.addDefault("-Dhttp.proxyPort=", String.valueOf(proxyPort)); - res.addDefault("-Dhttps.proxyHost=", proxyHost); - res.addDefault("-Dhttps.proxyPort=", String.valueOf(proxyPort)); - } else if (proxyType == Proxy.Type.SOCKS) { - res.addDefault("-DsocksProxyHost=", proxyHost); - res.addDefault("-DsocksProxyPort=", String.valueOf(proxyPort)); + boolean addProxyOptions = res.noneMatch(arg -> + arg.startsWith("-Djava.net.useSystemProxies=") + || arg.startsWith("-Dhttp.proxy") + || arg.startsWith("-Dhttps.proxy") + || arg.startsWith("-DsocksProxy") + || arg.startsWith("-Djava.net.socks.") + ); + + if (addProxyOptions) { + if (options.getProxyOption() == null || options.getProxyOption() == ProxyOption.Default.INSTANCE) { + res.add("-Djava.net.useSystemProxies=true"); + } else if (options.getProxyOption() instanceof ProxyOption.Http httpProxy) { + res.add("-Dhttp.proxyHost=" + httpProxy.host()); + res.add("-Dhttp.proxyPort=" + httpProxy.port()); + res.add("-Dhttps.proxyHost=" + httpProxy.host()); + res.add("-Dhttps.proxyPort=" + httpProxy.port()); + + if (StringUtils.isNotBlank(httpProxy.username())) { + res.add("-Dhttp.proxyUser=" + httpProxy.username()); + res.add("-Dhttp.proxyPassword=" + Objects.requireNonNullElse(httpProxy.password(), "")); + res.add("-Dhttps.proxyUser=" + httpProxy.username()); + res.add("-Dhttps.proxyPassword=" + Objects.requireNonNullElse(httpProxy.password(), "")); + } + } else if (options.getProxyOption() instanceof ProxyOption.Socks socksProxy) { + res.add("-DsocksProxyHost=" + socksProxy.host()); + res.add("-DsocksProxyPort=" + socksProxy.port()); + + if (StringUtils.isNotBlank(socksProxy.username())) { + res.add("-Djava.net.socks.username=" + socksProxy.username()); + res.add("-Djava.net.socks.password=" + Objects.requireNonNullElse(socksProxy.password(), "")); } } } @@ -230,7 +243,7 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { } } - if (is64bit && javaVersion == 25) { + if (is64bit && (javaVersion >= 25 && javaVersion <= 26)) { res.addUnstableDefault("UseCompactObjectHeaders", true); } @@ -304,43 +317,47 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { if (argumentsFromAuthInfo != null && argumentsFromAuthInfo.getGame() != null && !argumentsFromAuthInfo.getGame().isEmpty()) res.addAll(Arguments.parseArguments(argumentsFromAuthInfo.getGame(), configuration, features)); - if (StringUtils.isNotBlank(options.getServerIp())) { - String address = options.getServerIp(); + if (options.getQuickPlayOption() instanceof QuickPlayOption.MultiPlayer multiPlayer) { + String address = multiPlayer.serverIP(); try { ServerAddress parsed = ServerAddress.parse(address); - if (GameVersionNumber.asGameVersion(gameVersion).compareTo("1.20") < 0) { + if (GameVersionNumber.asGameVersion(gameVersion).isAtLeast("1.20", "23w14a")) { + res.add("--quickPlayMultiplayer"); + res.add(parsed.getPort() >= 0 ? address : parsed.getHost() + ":25565"); + } else { res.add("--server"); res.add(parsed.getHost()); res.add("--port"); res.add(parsed.getPort() >= 0 ? String.valueOf(parsed.getPort()) : "25565"); - } else { - res.add("--quickPlayMultiplayer"); - res.add(parsed.getPort() < 0 ? address + ":25565" : address); } } catch (IllegalArgumentException e) { LOG.warning("Invalid server address: " + address, e); } + } else if (options.getQuickPlayOption() instanceof QuickPlayOption.SinglePlayer singlePlayer + && GameVersionNumber.asGameVersion(gameVersion).isAtLeast("1.20", "23w14a")) { + res.add("--quickPlaySingleplayer"); + res.add(singlePlayer.worldFolderName()); + } else if (options.getQuickPlayOption() instanceof QuickPlayOption.Realm realm + && GameVersionNumber.asGameVersion(gameVersion).isAtLeast("1.20", "23w14a")) { + res.add("--quickPlayRealms"); + res.add(realm.realmID()); } if (options.isFullscreen()) res.add("--fullscreen"); - if (options.getProxyType() == Proxy.Type.SOCKS) { - String proxyHost = options.getProxyHost(); - int proxyPort = options.getProxyPort(); - - if (StringUtils.isNotBlank(proxyHost) && proxyPort >= 0 && proxyPort <= 0xFFFF) { - res.add("--proxyHost"); - res.add(proxyHost); - res.add("--proxyPort"); - res.add(String.valueOf(proxyPort)); - if (StringUtils.isNotBlank(options.getProxyUser()) && StringUtils.isNotBlank(options.getProxyPass())) { - res.add("--proxyUser"); - res.add(options.getProxyUser()); - res.add("--proxyPass"); - res.add(options.getProxyPass()); - } + // https://github.com/HMCL-dev/HMCL/issues/774 + if (options.getProxyOption() instanceof ProxyOption.Socks socksProxy) { + res.add("--proxyHost"); + res.add(socksProxy.host()); + res.add("--proxyPort"); + res.add(String.valueOf(socksProxy.port())); + if (StringUtils.isNotBlank(socksProxy.username())) { + res.add("--proxyUser"); + res.add(socksProxy.username()); + res.add("--proxyPass"); + res.add(Objects.requireNonNullElse(socksProxy.password(), "")); } } @@ -389,9 +406,12 @@ public void decompressNatives(Path destination) throws NotDecompressingNativesEx for (Library library : version.getLibraries()) if (library.isNative()) new Unzipper(repository.getLibraryFile(version, library), destination) - .setFilter((zipEntry, isDirectory, destFile, path) -> { - if (!isDirectory && Files.isRegularFile(destFile) && Files.size(destFile) == Files.size(zipEntry)) + .setFilter((zipEntry, destFile, relativePath) -> { + if (!zipEntry.isDirectory() && !zipEntry.isUnixSymlink() + && Files.isRegularFile(destFile) + && zipEntry.getSize() == Files.size(destFile)) { return false; + } String ext = FileUtils.getExtension(destFile); if (ext.equals("sha1") || ext.equals("git")) return false; @@ -403,7 +423,7 @@ public void decompressNatives(Path destination) throws NotDecompressingNativesEx return false; } - return library.getExtract().shouldExtract(path); + return library.getExtract().shouldExtract(relativePath); }) .setReplaceExistentFile(false).unzip(); } catch (IOException e) { @@ -421,14 +441,24 @@ public Path getLog4jConfigurationFile() { public void extractLog4jConfigurationFile() throws IOException { Path targetFile = getLog4jConfigurationFile(); - InputStream source; + + String sourcePath; + if (GameVersionNumber.asGameVersion(repository.getGameVersion(version)).compareTo("1.12") < 0) { - source = DefaultLauncher.class.getResourceAsStream("/assets/game/log4j2-1.7.xml"); + if (options.isEnableDebugLogOutput()) { + sourcePath = "/assets/game/log4j2-1.7-debug.xml"; + } else { + sourcePath = "/assets/game/log4j2-1.7.xml"; + } } else { - source = DefaultLauncher.class.getResourceAsStream("/assets/game/log4j2-1.12.xml"); + if (options.isEnableDebugLogOutput()) { + sourcePath = "/assets/game/log4j2-1.12-debug.xml"; + } else { + sourcePath = "/assets/game/log4j2-1.12.xml"; + } } - try (InputStream input = source) { + try (InputStream input = DefaultLauncher.class.getResourceAsStream(sourcePath)) { Files.copy(input, targetFile, StandardCopyOption.REPLACE_EXISTING); } } @@ -579,6 +609,9 @@ private Map getEnvVars() { if (analyzer.has(LibraryAnalyzer.LibraryType.QUILT)) { env.put("INST_QUILT", "1"); } + if (analyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC)) { + env.put("INST_LEGACYFABRIC", "1"); + } env.putAll(options.getEnvironmentVariables()); @@ -609,8 +642,8 @@ public void makeLaunchScript(Path scriptFile) throws IOException { if (!usePowerShell) { if (isWindows && !scriptExtension.equals("bat")) throw new IllegalArgumentException("The extension of " + scriptFile + " is not 'bat' or 'ps1' in Windows"); - else if (!isWindows && !scriptExtension.equals("sh")) - throw new IllegalArgumentException("The extension of " + scriptFile + " is not 'sh' or 'ps1' in macOS/Linux"); + else if (!isWindows && !(scriptExtension.equals("sh") || scriptExtension.equals("command"))) + throw new IllegalArgumentException("The extension of " + scriptFile + " is not 'sh', 'ps1' or 'command' in macOS/Linux"); } final Command commandLine = generateCommandLine(nativeFolder); @@ -704,7 +737,7 @@ else if (!isWindows && !scriptExtension.equals("sh")) writer.newLine(); } writer.newLine(); - writer.write(new CommandBuilder().add("cd", "/D", FileUtils.getAbsolutePath(repository.getRunDirectory(version.getId()))).toString()); + writer.write(new CommandBuilder().addAll("cd", "/D", FileUtils.getAbsolutePath(repository.getRunDirectory(version.getId()))).toString()); } else { writer.write("#!/usr/bin/env bash"); writer.newLine(); @@ -713,10 +746,10 @@ else if (!isWindows && !scriptExtension.equals("sh")) writer.newLine(); } if (commandLine.tempNativeFolder != null) { - writer.write(new CommandBuilder().add("ln", "-s", FileUtils.getAbsolutePath(nativeFolder), commandLine.tempNativeFolder.toString()).toString()); + writer.write(new CommandBuilder().addAll("ln", "-s", FileUtils.getAbsolutePath(nativeFolder), commandLine.tempNativeFolder.toString()).toString()); writer.newLine(); } - writer.write(new CommandBuilder().add("cd", FileUtils.getAbsolutePath(repository.getRunDirectory(version.getId()))).toString()); + writer.write(new CommandBuilder().addAll("cd", FileUtils.getAbsolutePath(repository.getRunDirectory(version.getId()))).toString()); } writer.newLine(); if (StringUtils.isNotBlank(options.getPreLaunchCommand())) { @@ -736,7 +769,7 @@ else if (!isWindows && !scriptExtension.equals("sh")) writer.newLine(); } if (commandLine.tempNativeFolder != null) { - writer.write(new CommandBuilder().add("rm", commandLine.tempNativeFolder.toString()).toString()); + writer.write(new CommandBuilder().addAll("rm", commandLine.tempNativeFolder.toString()).toString()); writer.newLine(); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/Datapack.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/Datapack.java index 09093ac9b1..48081fe486 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/Datapack.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/Datapack.java @@ -219,7 +219,7 @@ private Optional loadSinglePackFromZipFile(Path path) { private Optional parsePack(Path datapackPath, boolean isDirectory, String name, Path mcmetaPath) { try { PackMcMeta mcMeta = JsonUtils.fromNonNullJson(Files.readString(mcmetaPath), PackMcMeta.class); - return Optional.of(new Pack(datapackPath, isDirectory, name, mcMeta.getPackInfo().getDescription(), this)); + return Optional.of(new Pack(datapackPath, isDirectory, name, mcMeta.pack().description(), this)); } catch (JsonParseException e) { LOG.warning("Invalid pack.mcmeta format in " + datapackPath, e); } catch (IOException e) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/LocalModFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/LocalModFile.java index 5cb1a4403f..aa12e7302b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/LocalModFile.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/LocalModFile.java @@ -23,8 +23,11 @@ import java.io.IOException; import java.nio.file.Path; -import java.util.*; -import java.util.stream.Collectors; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Optional; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -182,9 +185,9 @@ public ModUpdate checkUpdates(String gameVersion, RemoteModRepository repository .filter(version -> version.getLoaders().contains(getModLoaderType())) .filter(version -> version.getDatePublished().compareTo(currentVersion.get().getDatePublished()) > 0) .sorted(Comparator.comparing(RemoteMod.Version::getDatePublished).reversed()) - .collect(Collectors.toList()); + .toList(); if (remoteVersions.isEmpty()) return null; - return new ModUpdate(this, currentVersion.get(), remoteVersions); + return new ModUpdate(this, currentVersion.get(), remoteVersions.get(0)); } @Override @@ -205,12 +208,12 @@ public int hashCode() { public static class ModUpdate { private final LocalModFile localModFile; private final RemoteMod.Version currentVersion; - private final List candidates; + private final RemoteMod.Version candidate; - public ModUpdate(LocalModFile localModFile, RemoteMod.Version currentVersion, List candidates) { + public ModUpdate(LocalModFile localModFile, RemoteMod.Version currentVersion, RemoteMod.Version candidate) { this.localModFile = localModFile; this.currentVersion = currentVersion; - this.candidates = candidates; + this.candidate = candidate; } public LocalModFile getLocalMod() { @@ -221,8 +224,8 @@ public RemoteMod.Version getCurrentVersion() { return currentVersion; } - public List getCandidates() { - return candidates; + public RemoteMod.Version getCandidate() { + return candidate; } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/MinecraftInstanceTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/MinecraftInstanceTask.java index d4bd61cacf..88d8dc8d03 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/MinecraftInstanceTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/MinecraftInstanceTask.java @@ -17,19 +17,22 @@ */ package org.jackhuang.hmcl.mod; +import kala.compress.archivers.zip.ZipArchiveEntry; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.DigestUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.CompressingUtils; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.tree.ArchiveFileTree; +import org.jackhuang.hmcl.util.tree.ZipFileTree; -import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.Charset; import java.nio.file.*; -import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.List; +import java.util.Map; public final class MinecraftInstanceTask extends Task> { @@ -53,26 +56,42 @@ public MinecraftInstanceTask(Path zipFile, Charset encoding, List subDir this.version = version; } + private static void getOverrides(List overrides, + ZipFileTree tree, + ArchiveFileTree.Dir dir, + List names) throws IOException { + String prefix = String.join("/", names); + if (!prefix.isEmpty()) + prefix = prefix + "/"; + + for (Map.Entry entry : dir.getFiles().entrySet()) { + String hash; + try (InputStream input = tree.getInputStream(entry.getValue())) { + hash = DigestUtils.digestToString("SHA-1", input); + } + overrides.add(new ModpackConfiguration.FileInformation(prefix + entry.getKey(), hash)); + } + + for (ArchiveFileTree.Dir subDir : dir.getSubDirs().values()) { + names.add(subDir.getName()); + getOverrides(overrides, tree, subDir, names); + names.remove(names.size() - 1); + } + } + @Override public void execute() throws Exception { List overrides = new ArrayList<>(); - try (FileSystem fs = CompressingUtils.readonly(zipFile).setEncoding(encoding).build()) { + try (var tree = new ZipFileTree(CompressingUtils.openZipFileWithPossibleEncoding(zipFile, encoding))) { for (String subDirectory : subDirectories) { - Path root = fs.getPath(subDirectory); - - if (Files.exists(root)) - Files.walkFileTree(root, new SimpleFileVisitor<>() { - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { - String relativePath = root.relativize(file).normalize().toString().replace(File.separatorChar, '/'); - overrides.add(new ModpackConfiguration.FileInformation(relativePath, DigestUtils.digestToString("SHA-1", file))); - return FileVisitResult.CONTINUE; - } - }); + ArchiveFileTree.Dir root = tree.getDirectory(subDirectory); + if (root == null) + continue; + var names = new ArrayList(); + getOverrides(overrides, tree, root, names); } } - ModpackConfiguration configuration = new ModpackConfiguration<>(manifest, type, name, version, overrides); Files.createDirectories(jsonFile.getParent()); JsonUtils.writeToJsonFile(jsonFile, configuration); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModLoaderType.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModLoaderType.java index f4b8feacd8..c1205803a6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModLoaderType.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModLoaderType.java @@ -25,5 +25,6 @@ public enum ModLoaderType { FABRIC, QUILT, LITE_LOADER, - PACK; + LEGACY_FABRIC, + PACK } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModManager.java index 5296be3afc..65fd5af71f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModManager.java @@ -25,7 +25,7 @@ import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.CompressingUtils; import org.jackhuang.hmcl.util.io.FileUtils; -import org.jackhuang.hmcl.util.versioning.VersionNumber; +import org.jackhuang.hmcl.util.tree.ZipFileTree; import org.jetbrains.annotations.Unmodifiable; import java.io.IOException; @@ -38,7 +38,7 @@ public final class ModManager { @FunctionalInterface private interface ModMetadataReader { - LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException, JsonParseException; + LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException; } private static final Map>> READERS; @@ -125,10 +125,10 @@ private void addModInfo(Path file) { LocalModFile modInfo = null; List exceptions = new ArrayList<>(); - try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(file)) { + try (ZipFileTree tree = CompressingUtils.openZipTree(file)) { for (ModMetadataReader reader : supportedReaders) { try { - modInfo = reader.fromFile(this, file, fs); + modInfo = reader.fromFile(this, file, tree); break; } catch (Exception e) { exceptions.add(e); @@ -138,7 +138,7 @@ private void addModInfo(Path file) { if (modInfo == null) { for (ModMetadataReader reader : unsupportedReaders) { try { - modInfo = reader.fromFile(this, file, fs); + modInfo = reader.fromFile(this, file, tree); break; } catch (Exception ignored) { } @@ -176,11 +176,13 @@ public void refreshMods() throws IOException { analyzer = LibraryAnalyzer.analyze(getRepository().getResolvedPreservingPatchesVersion(id), null); + boolean supportSubfolders = analyzer.has(LibraryAnalyzer.LibraryType.FORGE) + || analyzer.has(LibraryAnalyzer.LibraryType.QUILT); + if (Files.isDirectory(getModsDirectory())) { try (DirectoryStream modsDirectoryStream = Files.newDirectoryStream(getModsDirectory())) { for (Path subitem : modsDirectoryStream) { - if (Files.isDirectory(subitem) && VersionNumber.isIntVersionNumber(FileUtils.getName(subitem))) { - // If the folder name is game version, forge will search mod in this subdirectory + if (supportSubfolders && Files.isDirectory(subitem)) { try (DirectoryStream subitemDirectoryStream = Files.newDirectoryStream(subitem)) { for (Path subsubitem : subitemDirectoryStream) { addModInfo(subsubitem); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/Modpack.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/Modpack.java index a82c83e9bb..dd05bea8ec 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/Modpack.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/Modpack.java @@ -114,7 +114,7 @@ public Modpack setManifest(ModpackManifest manifest) { return this; } - public abstract Task getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name); + public abstract Task getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name, String iconUrl); public static boolean acceptFile(String path, List blackList, List whiteList) { if (path.isEmpty()) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModpackInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModpackInstallTask.java index b23606db65..5e49194bce 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModpackInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModpackInstallTask.java @@ -73,22 +73,22 @@ public void execute() throws Exception { .setTerminateIfSubDirectoryNotExists() .setReplaceExistentFile(true) .setEncoding(charset) - .setFilter((destPath, isDirectory, zipEntry, entryPath) -> { - if (isDirectory) return true; - if (!callback.test(entryPath)) return false; - entries.add(entryPath); + .setFilter((zipEntry, destFile, relativePath) -> { + if (zipEntry.isDirectory()) return true; + if (!callback.test(relativePath)) return false; + entries.add(relativePath); - if (!files.containsKey(entryPath)) { + if (!files.containsKey(relativePath)) { // If old modpack does not have this entry, add this entry or override the file that user added. return true; - } else if (!Files.exists(destPath)) { + } else if (!Files.exists(destFile)) { // If both old and new modpacks have this entry, but the file is deleted by user, leave it missing. return false; } else { // If both old and new modpacks have this entry, and user has modified this file, // we will not replace it since this modified file is what user expects. - String fileHash = DigestUtils.digestToString("SHA-1", destPath); - String oldHash = files.get(entryPath).getHash(); + String fileHash = DigestUtils.digestToString("SHA-1", destFile); + String oldHash = files.get(relativePath).getHash(); return Objects.equals(oldHash, fileHash); } }).unzip(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteModRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteModRepository.java index 5f74ba7d49..4dcd467fe6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteModRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteModRepository.java @@ -32,6 +32,7 @@ enum Type { MOD, MODPACK, RESOURCE_PACK, + SHADER_PACK, WORLD, CUSTOMIZATION } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseForgeRemoteModRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseForgeRemoteModRepository.java index f884334c8e..62ed344418 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseForgeRemoteModRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseForgeRemoteModRepository.java @@ -36,6 +36,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.*; +import java.util.concurrent.Semaphore; import java.util.stream.Stream; import static org.jackhuang.hmcl.util.Lang.mapOf; @@ -46,6 +47,7 @@ public final class CurseForgeRemoteModRepository implements RemoteModRepository private static final String PREFIX = "https://api.curseforge.com"; private static final String apiKey = System.getProperty("hmcl.curseforge.apikey", JarUtils.getAttribute("hmcl.curseforge.apikey", "")); + private static final Semaphore SEMAPHORE = new Semaphore(16); private static final int WORD_PERFECT_MATCH_WEIGHT = 5; @@ -110,46 +112,55 @@ private int calculateTotalPages(Response> response, int pageSiz @Override public SearchResult search(DownloadProvider downloadProvider, String gameVersion, @Nullable RemoteModRepository.Category category, int pageOffset, int pageSize, String searchFilter, SortType sortType, SortOrder sortOrder) throws IOException { - int categoryId = 0; - if (category != null && category.getSelf() instanceof CurseAddon.Category) { - categoryId = ((CurseAddon.Category) category.getSelf()).getId(); - } - Response> response = withApiKey(HttpRequest.GET(downloadProvider.injectURL(NetworkUtils.withQuery(PREFIX + "/v1/mods/search", mapOf( - pair("gameId", "432"), - pair("classId", Integer.toString(section)), - pair("categoryId", Integer.toString(categoryId)), - pair("gameVersion", gameVersion), - pair("searchFilter", searchFilter), - pair("sortField", Integer.toString(toModsSearchSortField(sortType))), - pair("sortOrder", toSortOrder(sortOrder)), - pair("index", Integer.toString(pageOffset * pageSize)), - pair("pageSize", Integer.toString(pageSize))))))) - .getJson(Response.typeOf(listTypeOf(CurseAddon.class))); - if (searchFilter.isEmpty()) { - return new SearchResult(response.getData().stream().map(CurseAddon::toMod), calculateTotalPages(response, pageSize)); - } + SEMAPHORE.acquireUninterruptibly(); + try { + int categoryId = 0; + if (category != null && category.getSelf() instanceof CurseAddon.Category) { + categoryId = ((CurseAddon.Category) category.getSelf()).getId(); + } - // https://github.com/HMCL-dev/HMCL/issues/1549 - String lowerCaseSearchFilter = searchFilter.toLowerCase(Locale.ROOT); - Map searchFilterWords = new HashMap<>(); - for (String s : StringUtils.tokenize(lowerCaseSearchFilter)) { - searchFilterWords.put(s, searchFilterWords.getOrDefault(s, 0) + 1); - } + var query = new LinkedHashMap(); + query.put("gameId", "432"); + query.put("classId", Integer.toString(section)); + if (categoryId != 0) + query.put("categoryId", Integer.toString(categoryId)); + query.put("gameVersion", gameVersion); + query.put("searchFilter", searchFilter); + query.put("sortField", Integer.toString(toModsSearchSortField(sortType))); + query.put("sortOrder", toSortOrder(sortOrder)); + query.put("index", Integer.toString(pageOffset * pageSize)); + query.put("pageSize", Integer.toString(pageSize)); + + Response> response = withApiKey(HttpRequest.GET(downloadProvider.injectURL(NetworkUtils.withQuery(PREFIX + "/v1/mods/search", query)))) + .getJson(Response.typeOf(listTypeOf(CurseAddon.class))); + if (searchFilter.isEmpty()) { + return new SearchResult(response.getData().stream().map(CurseAddon::toMod), calculateTotalPages(response, pageSize)); + } - StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator(); + // https://github.com/HMCL-dev/HMCL/issues/1549 + String lowerCaseSearchFilter = searchFilter.toLowerCase(Locale.ROOT); + Map searchFilterWords = new HashMap<>(); + for (String s : StringUtils.tokenize(lowerCaseSearchFilter)) { + searchFilterWords.put(s, searchFilterWords.getOrDefault(s, 0) + 1); + } - return new SearchResult(response.getData().stream().map(CurseAddon::toMod).map(remoteMod -> { - String lowerCaseResult = remoteMod.getTitle().toLowerCase(Locale.ROOT); - int diff = levCalculator.calc(lowerCaseSearchFilter, lowerCaseResult); + StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator(); - for (String s : StringUtils.tokenize(lowerCaseResult)) { - if (searchFilterWords.containsKey(s)) { - diff -= WORD_PERFECT_MATCH_WEIGHT * searchFilterWords.get(s) * s.length(); + return new SearchResult(response.getData().stream().map(CurseAddon::toMod).map(remoteMod -> { + String lowerCaseResult = remoteMod.getTitle().toLowerCase(Locale.ROOT); + int diff = levCalculator.calc(lowerCaseSearchFilter, lowerCaseResult); + + for (String s : StringUtils.tokenize(lowerCaseResult)) { + if (searchFilterWords.containsKey(s)) { + diff -= WORD_PERFECT_MATCH_WEIGHT * searchFilterWords.get(s) * s.length(); + } } - } - return pair(remoteMod, diff); - }).sorted(Comparator.comparingInt(Pair::getValue)).map(Pair::getKey), response.getData().stream().map(CurseAddon::toMod), calculateTotalPages(response, pageSize)); + return pair(remoteMod, diff); + }).sorted(Comparator.comparingInt(Pair::getValue)).map(Pair::getKey), response.getData().stream().map(CurseAddon::toMod), calculateTotalPages(response, pageSize)); + } finally { + SEMAPHORE.release(); + } } @Override @@ -169,49 +180,73 @@ public Optional getRemoteVersionByLocalFile(LocalModFile loca } long hash = Integer.toUnsignedLong(MurmurHash2.hash32(baos.toByteArray(), baos.size(), 1)); - - Response response = withApiKey(HttpRequest.POST(PREFIX + "/v1/fingerprints/432")) - .json(mapOf(pair("fingerprints", Collections.singletonList(hash)))) - .getJson(Response.typeOf(FingerprintMatchesResult.class)); - - if (response.getData().getExactMatches() == null || response.getData().getExactMatches().isEmpty()) { + if (hash == 811513880) { // Workaround for https://github.com/HMCL-dev/HMCL/issues/4597 return Optional.empty(); } - return Optional.of(response.getData().getExactMatches().get(0).getFile().toVersion()); + SEMAPHORE.acquireUninterruptibly(); + try { + Response response = withApiKey(HttpRequest.POST(PREFIX + "/v1/fingerprints/432")) + .json(mapOf(pair("fingerprints", Collections.singletonList(hash)))) + .getJson(Response.typeOf(FingerprintMatchesResult.class)); + + if (response.getData().getExactMatches() == null || response.getData().getExactMatches().isEmpty()) { + return Optional.empty(); + } + + return Optional.of(response.getData().getExactMatches().get(0).getFile().toVersion()); + } finally { + SEMAPHORE.release(); + } } @Override public RemoteMod getModById(String id) throws IOException { - Response response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id)) - .getJson(Response.typeOf(CurseAddon.class)); - return response.data.toMod(); + SEMAPHORE.acquireUninterruptibly(); + try { + Response response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id)) + .getJson(Response.typeOf(CurseAddon.class)); + return response.data.toMod(); + } finally { + SEMAPHORE.release(); + } } @Override public RemoteMod.File getModFile(String modId, String fileId) throws IOException { - Response response = withApiKey(HttpRequest.GET(String.format("%s/v1/mods/%s/files/%s", PREFIX, modId, fileId))) - .getJson(Response.typeOf(CurseAddon.LatestFile.class)); - return response.getData().toVersion().getFile(); + SEMAPHORE.acquireUninterruptibly(); + try { + Response response = withApiKey(HttpRequest.GET(String.format("%s/v1/mods/%s/files/%s", PREFIX, modId, fileId))) + .getJson(Response.typeOf(CurseAddon.LatestFile.class)); + return response.getData().toVersion().getFile(); + } finally { + SEMAPHORE.release(); + } } @Override public Stream getRemoteVersionsById(String id) throws IOException { - Response> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id + "/files", - pair("pageSize", "10000"))) - .getJson(Response.typeOf(listTypeOf(CurseAddon.LatestFile.class))); - return response.getData().stream().map(CurseAddon.LatestFile::toVersion); - } - - public List getCategoriesImpl() throws IOException { - Response> categories = withApiKey(HttpRequest.GET(PREFIX + "/v1/categories", pair("gameId", "432"))) - .getJson(Response.typeOf(listTypeOf(CurseAddon.Category.class))); - return reorganizeCategories(categories.getData(), section); + SEMAPHORE.acquireUninterruptibly(); + try { + Response> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id + "/files", + pair("pageSize", "10000"))) + .getJson(Response.typeOf(listTypeOf(CurseAddon.LatestFile.class))); + return response.getData().stream().map(CurseAddon.LatestFile::toVersion); + } finally { + SEMAPHORE.release(); + } } @Override public Stream getCategories() throws IOException { - return getCategoriesImpl().stream().map(CurseAddon.Category::toCategory); + SEMAPHORE.acquireUninterruptibly(); + try { + Response> categories = withApiKey(HttpRequest.GET(PREFIX + "/v1/categories", pair("gameId", "432"))) + .getJson(Response.typeOf(listTypeOf(CurseAddon.Category.class))); + return reorganizeCategories(categories.getData(), section).stream().map(CurseAddon.Category::toCategory); + } finally { + SEMAPHORE.release(); + } } private List reorganizeCategories(List categories, int rootId) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseModpackProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseModpackProvider.java index 20a1435b14..0337754726 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseModpackProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseModpackProvider.java @@ -68,7 +68,7 @@ public Modpack readManifest(ZipArchiveReader zip, Path file, Charset encoding) t return new Modpack(manifest.getName(), manifest.getAuthor(), manifest.getVersion(), manifest.getMinecraft().getGameVersion(), description, encoding, manifest) { @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name) { + public Task getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name, String iconUrl) { return new CurseInstallTask(dependencyManager, zipFile, this, manifest, name); } }; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackExportTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackExportTask.java index 2429b10737..3386951788 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackExportTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackExportTask.java @@ -109,6 +109,8 @@ public void execute() throws Exception { addons.add(new McbbsModpackManifest.Addon(FABRIC.getPatchId(), fabricVersion))); analyzer.getVersion(QUILT).ifPresent(quiltVersion -> addons.add(new McbbsModpackManifest.Addon(QUILT.getPatchId(), quiltVersion))); + analyzer.getVersion(LEGACY_FABRIC).ifPresent(legacyfabricVersion -> + addons.add(new McbbsModpackManifest.Addon(LEGACY_FABRIC.getPatchId(), legacyfabricVersion))); List libraries = new ArrayList<>(); // TODO libraries diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackManifest.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackManifest.java index 8a36877662..403a30ff74 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackManifest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackManifest.java @@ -424,7 +424,7 @@ public Modpack toModpack(Charset encoding) throws IOException { .orElseThrow(() -> new IOException("Cannot find game version")).getVersion(); return new Modpack(name, author, version, gameVersion, description, encoding, this) { @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name) { + public Task getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name, String iconUrl) { return new McbbsModpackLocalInstallTask(dependencyManager, zipFile, this, McbbsModpackManifest.this, name); } }; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/FabricModMetadata.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/FabricModMetadata.java index b69fd8755a..dd9b6c4d8a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/FabricModMetadata.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/FabricModMetadata.java @@ -19,16 +19,16 @@ import com.google.gson.*; import com.google.gson.annotations.JsonAdapter; +import kala.compress.archivers.zip.ZipArchiveEntry; import org.jackhuang.hmcl.mod.LocalModFile; import org.jackhuang.hmcl.mod.ModLoaderType; import org.jackhuang.hmcl.mod.ModManager; import org.jackhuang.hmcl.util.Immutable; import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.tree.ZipFileTree; import java.io.IOException; import java.lang.reflect.Type; -import java.nio.file.FileSystem; -import java.nio.file.Files; import java.nio.file.Path; import java.util.Collections; import java.util.List; @@ -59,11 +59,11 @@ public FabricModMetadata(String id, String name, String version, String icon, St this.contact = contact; } - public static LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException, JsonParseException { - Path mcmod = fs.getPath("fabric.mod.json"); - if (Files.notExists(mcmod)) + public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException { + ZipArchiveEntry mcmod = tree.getEntry("fabric.mod.json"); + if (mcmod == null) throw new IOException("File " + modFile + " is not a Fabric mod."); - FabricModMetadata metadata = JsonUtils.fromNonNullJson(Files.readString(mcmod), FabricModMetadata.class); + FabricModMetadata metadata = JsonUtils.fromNonNullJsonFully(tree.getInputStream(mcmod), FabricModMetadata.class); String authors = metadata.authors == null ? "" : metadata.authors.stream().map(author -> author.name).collect(Collectors.joining(", ")); return new LocalModFile(modManager, modManager.getLocalMod(metadata.id, ModLoaderType.FABRIC), modFile, metadata.name, new LocalModFile.Description(metadata.description), authors, metadata.version, "", metadata.contact != null ? metadata.contact.getOrDefault("homepage", "") : "", metadata.icon); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/ForgeNewModMetadata.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/ForgeNewModMetadata.java index a6c24ce709..9a0f815de5 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/ForgeNewModMetadata.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/ForgeNewModMetadata.java @@ -1,7 +1,14 @@ package org.jackhuang.hmcl.mod.modinfo; +import com.google.gson.JsonArray; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; +import com.google.gson.annotations.JsonAdapter; import com.moandjiezana.toml.Toml; +import kala.compress.archivers.zip.ZipArchiveEntry; import org.jackhuang.hmcl.mod.LocalModFile; import org.jackhuang.hmcl.mod.ModLoaderType; import org.jackhuang.hmcl.mod.ModManager; @@ -10,13 +17,17 @@ import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.gson.Validation; import org.jackhuang.hmcl.util.io.CompressingUtils; +import org.jackhuang.hmcl.util.tree.ZipFileTree; import java.io.IOException; import java.io.InputStream; -import java.nio.file.*; +import java.lang.reflect.Type; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.StringJoiner; import java.util.jar.Attributes; import java.util.jar.Manifest; @@ -68,6 +79,7 @@ public static class Mod { private final String displayName; private final String side; private final String displayURL; + @JsonAdapter(AuthorDeserializer.class) private final String authors; private final String description; @@ -112,35 +124,58 @@ public String getAuthors() { public String getDescription() { return description; } + + static final class AuthorDeserializer implements JsonDeserializer { + @Override + public String deserialize(JsonElement authors, Type type, JsonDeserializationContext context) throws JsonParseException { + if (authors == null || authors.isJsonNull()) { + return null; + } else if (authors instanceof JsonPrimitive primitive) { + return primitive.getAsString(); + } else if (authors instanceof JsonArray array) { + var joiner = new StringJoiner(", "); + for (int i = 0; i < array.size(); i++) { + if (!(array.get(i) instanceof JsonPrimitive element)) { + return authors.toString(); + } + + joiner.add(element.getAsString()); + } + return joiner.toString(); + } + + return authors.toString(); + } + } } - public static LocalModFile fromForgeFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException { - return fromFile(modManager, modFile, fs, ModLoaderType.FORGE); + public static LocalModFile fromForgeFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException { + return fromFile(modManager, modFile, tree, ModLoaderType.FORGE); } - public static LocalModFile fromNeoForgeFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException { - return fromFile(modManager, modFile, fs, ModLoaderType.NEO_FORGED); + public static LocalModFile fromNeoForgeFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException { + return fromFile(modManager, modFile, tree, ModLoaderType.NEO_FORGED); } - private static LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs, ModLoaderType modLoaderType) throws IOException { + private static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree, ModLoaderType modLoaderType) throws IOException { if (modLoaderType != ModLoaderType.FORGE && modLoaderType != ModLoaderType.NEO_FORGED) { throw new IOException("Invalid mod loader: " + modLoaderType); } if (modLoaderType == ModLoaderType.NEO_FORGED) { try { - return fromFile0("META-INF/neoforge.mods.toml", modLoaderType, modManager, modFile, fs); + return fromFile0("META-INF/neoforge.mods.toml", modLoaderType, modManager, modFile, tree); } catch (Exception ignored) { } } try { - return fromFile0("META-INF/mods.toml", modLoaderType, modManager, modFile, fs); + return fromFile0("META-INF/mods.toml", modLoaderType, modManager, modFile, tree); } catch (Exception ignored) { } try { - return fromEmbeddedMod(modManager, modFile, fs, modLoaderType); + return fromEmbeddedMod(modManager, modFile, tree, modLoaderType); } catch (Exception ignored) { } @@ -152,19 +187,19 @@ private static LocalModFile fromFile0( ModLoaderType modLoaderType, ModManager modManager, Path modFile, - FileSystem fs) throws IOException, JsonParseException { - Path modToml = fs.getPath(tomlPath); - if (Files.notExists(modToml)) + ZipFileTree tree) throws IOException, JsonParseException { + ZipArchiveEntry modToml = tree.getEntry(tomlPath); + if (modToml == null) throw new IOException("File " + modFile + " is not a Forge 1.13+ or NeoForge mod."); - Toml toml = new Toml().read(Files.readString(modToml)); + Toml toml = new Toml().read(tree.readTextEntry(modToml)); ForgeNewModMetadata metadata = toml.to(ForgeNewModMetadata.class); if (metadata == null || metadata.getMods().isEmpty()) throw new IOException("Mod " + modFile + " `mods.toml` is malformed.."); Mod mod = metadata.getMods().get(0); - Path manifestMF = fs.getPath("META-INF/MANIFEST.MF"); + ZipArchiveEntry manifestMF = tree.getEntry("META-INF/MANIFEST.MF"); String jarVersion = ""; - if (Files.exists(manifestMF)) { - try (InputStream is = Files.newInputStream(manifestMF)) { + if (manifestMF != null) { + try (InputStream is = tree.getInputStream(manifestMF)) { Manifest manifest = new Manifest(is); jarVersion = manifest.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_VERSION); } catch (IOException e) { @@ -180,30 +215,30 @@ private static LocalModFile fromFile0( metadata.getLogoFile()); } - private static LocalModFile fromEmbeddedMod(ModManager modManager, Path modFile, FileSystem fs, ModLoaderType modLoaderType) throws IOException { - Path manifestFile = fs.getPath("META-INF/MANIFEST.MF"); - if (!Files.isRegularFile(manifestFile)) - throw new IOException("Missing MANIFEST.MF in file " + manifestFile); + private static LocalModFile fromEmbeddedMod(ModManager modManager, Path modFile, ZipFileTree tree, ModLoaderType modLoaderType) throws IOException { + ZipArchiveEntry manifestFile = tree.getEntry("META-INF/MANIFEST.MF"); + if (manifestFile == null) + throw new IOException("Missing MANIFEST.MF in file " + modFile); Manifest manifest; - try (InputStream input = Files.newInputStream(manifestFile)) { + try (InputStream input = tree.getInputStream(manifestFile)) { manifest = new Manifest(input); } - List embeddedModFiles = List.of(); + List embeddedModFiles = List.of(); String embeddedDependenciesMod = manifest.getMainAttributes().getValue("Embedded-Dependencies-Mod"); if (embeddedDependenciesMod != null) { - Path embeddedModFile = fs.getPath(embeddedDependenciesMod); - if (!Files.isRegularFile(embeddedModFile)) { - LOG.warning("Missing embedded-dependencies-mod: " + embeddedModFile); + ZipArchiveEntry embeddedModFile = tree.getEntry(embeddedDependenciesMod); + if (embeddedModFile == null) { + LOG.warning("Missing embedded-dependencies-mod: " + embeddedDependenciesMod); throw new IOException(); } embeddedModFiles = List.of(embeddedModFile); } else { - Path jarInJarMetadata = fs.getPath("META-INF/jarjar/metadata.json"); - if (Files.isRegularFile(jarInJarMetadata)) { - JarInJarMetadata metadata = JsonUtils.fromJsonFile(jarInJarMetadata, JarInJarMetadata.class); + ZipArchiveEntry jarInJarMetadata = tree.getEntry("META-INF/jarjar/metadata.json"); + if (jarInJarMetadata != null) { + JarInJarMetadata metadata = JsonUtils.fromJsonFully(tree.getInputStream(jarInJarMetadata), JarInJarMetadata.class); if (metadata == null) throw new IOException("Invalid metadata file: " + jarInJarMetadata); @@ -211,11 +246,11 @@ private static LocalModFile fromEmbeddedMod(ModManager modManager, Path modFile, embeddedModFiles = new ArrayList<>(); for (EmbeddedJarMetadata jar : metadata.jars) { - Path path = fs.getPath(jar.path); - if (Files.isRegularFile(path)) { + ZipArchiveEntry path = tree.getEntry(jar.path); + if (path != null) { embeddedModFiles.add(path); } else { - LOG.warning("Missing embedded-dependencies-mod: " + path); + LOG.warning("Missing embedded-dependencies-mod: " + jar.path); } } } @@ -227,10 +262,10 @@ private static LocalModFile fromEmbeddedMod(ModManager modManager, Path modFile, Path tempFile = Files.createTempFile("hmcl-", ".zip"); try { - for (Path embeddedModFile : embeddedModFiles) { - Files.copy(embeddedModFile, tempFile, StandardCopyOption.REPLACE_EXISTING); - try (FileSystem embeddedFs = CompressingUtils.createReadOnlyZipFileSystem(tempFile)) { - return fromFile(modManager, modFile, embeddedFs, modLoaderType); + for (ZipArchiveEntry embeddedModFile : embeddedModFiles) { + tree.extractTo(embeddedModFile, tempFile); + try (ZipFileTree embeddedTree = CompressingUtils.openZipTree(tempFile)) { + return fromFile(modManager, modFile, embeddedTree, modLoaderType); } catch (Exception ignored) { } } @@ -242,7 +277,12 @@ private static LocalModFile fromEmbeddedMod(ModManager modManager, Path modFile, } private static ModLoaderType analyzeLoader(Toml toml, String modID, ModLoaderType loader) throws IOException { - List> dependencies = toml.getList("dependencies." + modID); + List> dependencies = null; + try { + dependencies = toml.getList("dependencies." + modID); + } catch (ClassCastException ignored) { // https://github.com/HMCL-dev/HMCL/issues/5068 + } + if (dependencies == null) { try { dependencies = toml.getList("dependencies"); // ??? I have no idea why some of the Forge mods use [[dependencies]] diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/ForgeOldModMetadata.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/ForgeOldModMetadata.java index 5deee4f9ee..53c6b09252 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/ForgeOldModMetadata.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/ForgeOldModMetadata.java @@ -21,16 +21,16 @@ import com.google.gson.annotations.SerializedName; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonToken; +import kala.compress.archivers.zip.ZipArchiveEntry; import org.jackhuang.hmcl.mod.LocalModFile; import org.jackhuang.hmcl.mod.ModLoaderType; import org.jackhuang.hmcl.mod.ModManager; import org.jackhuang.hmcl.util.Immutable; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.tree.ZipFileTree; import java.io.IOException; -import java.nio.file.FileSystem; -import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -123,14 +123,14 @@ public String[] getAuthors() { return authors; } - public static LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException, JsonParseException { - Path mcmod = fs.getPath("mcmod.info"); - if (Files.notExists(mcmod)) + public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException { + ZipArchiveEntry mcmod = tree.getEntry("mcmod.info"); + if (mcmod == null) throw new IOException("File " + modFile + " is not a Forge mod."); List modList; - try (var reader = Files.newBufferedReader(mcmod); + try (var reader = tree.getBufferedReader(mcmod); var jsonReader = new JsonReader(reader)) { JsonToken firstToken = jsonReader.peek(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/LiteModMetadata.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/LiteModMetadata.java index d29f43dfcd..8a59a711be 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/LiteModMetadata.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/LiteModMetadata.java @@ -18,17 +18,16 @@ package org.jackhuang.hmcl.mod.modinfo; import com.google.gson.JsonParseException; +import kala.compress.archivers.zip.ZipArchiveEntry; import org.jackhuang.hmcl.mod.LocalModFile; import org.jackhuang.hmcl.mod.ModLoaderType; import org.jackhuang.hmcl.mod.ModManager; import org.jackhuang.hmcl.util.Immutable; import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.tree.ZipFileTree; import java.io.IOException; -import java.nio.file.FileSystem; import java.nio.file.Path; -import java.util.zip.ZipEntry; -import java.util.zip.ZipFile; /** * @@ -109,18 +108,16 @@ public String getCheckUpdateUrl() { public String getUpdateURI() { return updateURI; } - - public static LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException, JsonParseException { - try (ZipFile zipFile = new ZipFile(modFile.toFile())) { - ZipEntry entry = zipFile.getEntry("litemod.json"); - if (entry == null) - throw new IOException("File " + modFile + "is not a LiteLoader mod."); - LiteModMetadata metadata = JsonUtils.fromJsonFully(zipFile.getInputStream(entry), LiteModMetadata.class); - if (metadata == null) - throw new IOException("Mod " + modFile + " `litemod.json` is malformed."); - return new LocalModFile(modManager, modManager.getLocalMod(metadata.getName(), ModLoaderType.LITE_LOADER), modFile, metadata.getName(), new LocalModFile.Description(metadata.getDescription()), metadata.getAuthor(), - metadata.getVersion(), metadata.getGameVersion(), metadata.getUpdateURI(), ""); - } + + public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException { + ZipArchiveEntry entry = tree.getEntry("litemod.json"); + if (entry == null) + throw new IOException("File " + modFile + " is not a LiteLoader mod."); + LiteModMetadata metadata = JsonUtils.fromJsonFully(tree.getInputStream(entry), LiteModMetadata.class); + if (metadata == null) + throw new IOException("Mod " + modFile + " `litemod.json` is malformed."); + return new LocalModFile(modManager, modManager.getLocalMod(metadata.getName(), ModLoaderType.LITE_LOADER), modFile, metadata.getName(), new LocalModFile.Description(metadata.getDescription()), metadata.getAuthor(), + metadata.getVersion(), metadata.getGameVersion(), metadata.getUpdateURI(), ""); } - + } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/PackMcMeta.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/PackMcMeta.java index 3e9c6091cc..024a0102ee 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/PackMcMeta.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/PackMcMeta.java @@ -20,44 +20,28 @@ import com.google.gson.*; import com.google.gson.annotations.JsonAdapter; import com.google.gson.annotations.SerializedName; +import kala.compress.archivers.zip.ZipArchiveEntry; import org.jackhuang.hmcl.mod.LocalModFile; import org.jackhuang.hmcl.mod.ModLoaderType; import org.jackhuang.hmcl.mod.ModManager; -import org.jackhuang.hmcl.util.Immutable; import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.gson.JsonSerializable; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.gson.Validation; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.tree.ZipFileTree; import java.io.IOException; import java.lang.reflect.Type; -import java.nio.file.FileSystem; -import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -@Immutable -public class PackMcMeta implements Validation { - @SerializedName("pack") - private final PackInfo pack; - - public PackMcMeta() { - this(new PackInfo()); - } - - public PackMcMeta(PackInfo packInfo) { - this.pack = packInfo; - } - - public PackInfo getPackInfo() { - return pack; - } - +@JsonSerializable +public record PackMcMeta(@SerializedName("pack") PackInfo pack) implements Validation { @Override public void validate() throws JsonParseException { if (pack == null) @@ -65,29 +49,10 @@ public void validate() throws JsonParseException { } @JsonAdapter(PackInfoDeserializer.class) - public static class PackInfo { - @SerializedName("pack_format") - private final int packFormat; - - @SerializedName("min_format") - private final PackVersion minPackVersion; - @SerializedName("max_format") - private final PackVersion maxPackVersion; - - @SerializedName("description") - private final LocalModFile.Description description; - - public PackInfo() { - this(0, PackVersion.UNSPECIFIED, PackVersion.UNSPECIFIED, new LocalModFile.Description(Collections.emptyList())); - } - - public PackInfo(int packFormat, PackVersion minPackVersion, PackVersion maxPackVersion, LocalModFile.Description description) { - this.packFormat = packFormat; - this.minPackVersion = minPackVersion; - this.maxPackVersion = maxPackVersion; - this.description = description; - } - + public record PackInfo(@SerializedName("pack_format") int packFormat, + @SerializedName("min_format") PackVersion minPackVersion, + @SerializedName("max_format") PackVersion maxPackVersion, + @SerializedName("description") LocalModFile.Description description) { public PackVersion getEffectiveMinVersion() { return !minPackVersion.isUnspecified() ? minPackVersion : new PackVersion(packFormat, 0); } @@ -95,10 +60,6 @@ public PackVersion getEffectiveMinVersion() { public PackVersion getEffectiveMaxVersion() { return !maxPackVersion.isUnspecified() ? maxPackVersion : new PackVersion(packFormat, 0); } - - public LocalModFile.Description getDescription() { - return description; - } } public record PackVersion(int majorVersion, int minorVersion) implements Comparable { @@ -148,7 +109,7 @@ public static PackVersion fromJson(JsonElement element) throws JsonParseExceptio } } - public static class PackInfoDeserializer implements JsonDeserializer { + public static final class PackInfoDeserializer implements JsonDeserializer { private List pairToPart(List> lists, String color) { List parts = new ArrayList<>(); @@ -222,11 +183,11 @@ public PackInfo deserialize(JsonElement json, Type typeOfT, JsonDeserializationC } } - public static LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException, JsonParseException { - Path mcmod = fs.getPath("pack.mcmeta"); - if (Files.notExists(mcmod)) + public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException { + ZipArchiveEntry mcmod = tree.getEntry("pack.mcmeta"); + if (mcmod == null) throw new IOException("File " + modFile + " is not a resource pack."); - PackMcMeta metadata = JsonUtils.fromNonNullJson(Files.readString(mcmod), PackMcMeta.class); + PackMcMeta metadata = JsonUtils.fromNonNullJsonFully(tree.getInputStream(mcmod), PackMcMeta.class); return new LocalModFile( modManager, modManager.getLocalMod(FileUtils.getNameWithoutExtension(modFile), ModLoaderType.PACK), diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/QuiltModMetadata.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/QuiltModMetadata.java index d8f2bb4f4a..b9e7ebd56b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/QuiltModMetadata.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/QuiltModMetadata.java @@ -2,15 +2,15 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParseException; +import kala.compress.archivers.zip.ZipArchiveEntry; import org.jackhuang.hmcl.mod.LocalModFile; import org.jackhuang.hmcl.mod.ModLoaderType; import org.jackhuang.hmcl.mod.ModManager; import org.jackhuang.hmcl.util.Immutable; import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.tree.ZipFileTree; import java.io.IOException; -import java.nio.file.FileSystem; -import java.nio.file.Files; import java.nio.file.Path; import java.util.Optional; import java.util.stream.Collectors; @@ -53,13 +53,13 @@ public QuiltModMetadata(int schemaVersion, QuiltLoader quiltLoader) { this.quilt_loader = quiltLoader; } - public static LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException, JsonParseException { - Path path = fs.getPath("quilt.mod.json"); - if (Files.notExists(path)) { + public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException { + ZipArchiveEntry path = tree.getEntry("quilt.mod.json"); + if (path == null) { throw new IOException("File " + modFile + " is not a Quilt mod."); } - QuiltModMetadata root = JsonUtils.fromNonNullJson(Files.readString(path), QuiltModMetadata.class); + QuiltModMetadata root = JsonUtils.fromNonNullJsonFully(tree.getInputStream(path), QuiltModMetadata.class); if (root.schema_version != 1) { throw new IOException("File " + modFile + " is not a supported Quilt mod."); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthInstallTask.java index b018680063..5a557008e6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthInstallTask.java @@ -22,15 +22,23 @@ import org.jackhuang.hmcl.download.GameBuilder; import org.jackhuang.hmcl.game.DefaultGameRepository; import org.jackhuang.hmcl.mod.*; +import org.jackhuang.hmcl.task.CacheFileTask; import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.io.NetworkUtils; import java.io.IOException; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.util.*; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + public class ModrinthInstallTask extends Task { + private static final Set SUPPORTED_ICON_EXTS = Set.of("png", "jpg", "jpeg", "bmp", "gif", "webp", "apng"); private final DefaultDependencyManager dependencyManager; private final DefaultGameRepository repository; @@ -38,17 +46,21 @@ public class ModrinthInstallTask extends Task { private final Modpack modpack; private final ModrinthManifest manifest; private final String name; + private final String iconUrl; private final Path run; private final ModpackConfiguration config; + private String iconExt; + private Task downloadIconTask; private final List> dependents = new ArrayList<>(4); private final List> dependencies = new ArrayList<>(1); - public ModrinthInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, Modpack modpack, ModrinthManifest manifest, String name) { + public ModrinthInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, Modpack modpack, ModrinthManifest manifest, String name, String iconUrl) { this.dependencyManager = dependencyManager; this.zipFile = zipFile; this.modpack = modpack; this.manifest = manifest; this.name = name; + this.iconUrl = iconUrl; this.repository = dependencyManager.getGameRepository(); this.run = repository.getRunDirectory(name); @@ -65,6 +77,8 @@ public ModrinthInstallTask(DefaultDependencyManager dependencyManager, Path zipF builder.version("forge", modLoader.getValue()); break; case "neoforge": + // https://github.com/HMCL-dev/HMCL/pull/5170 + case "neo-forge": builder.version("neoforge", modLoader.getValue()); break; case "fabric-loader": @@ -104,6 +118,14 @@ public ModrinthInstallTask(DefaultDependencyManager dependencyManager, Path zipF dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), subDirectories, any -> true, config).withStage("hmcl.modpack")); dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), subDirectories, manifest, ModrinthModpackProvider.INSTANCE, manifest.getName(), manifest.getVersionId(), repository.getModpackConfiguration(name)).withStage("hmcl.modpack")); + URI iconUri = NetworkUtils.toURIOrNull(iconUrl); + if (iconUri != null) { + String ext = FileUtils.getExtension(StringUtils.substringAfter(iconUri.getPath(), '/')).toLowerCase(Locale.ROOT); + if (SUPPORTED_ICON_EXTS.contains(ext)) { + iconExt = ext; + dependents.add(downloadIconTask = new CacheFileTask(iconUrl)); + } + } dependencies.add(new ModrinthCompletionTask(dependencyManager, name, manifest)); } @@ -133,5 +155,13 @@ public void execute() throws Exception { Path root = repository.getVersionRoot(name); Files.createDirectories(root); JsonUtils.writeToJsonFile(root.resolve("modrinth.index.json"), manifest); + + if (iconExt != null) { + try { + Files.copy(downloadIconTask.getResult(), root.resolve("icon." + iconExt)); + } catch (Exception e) { + LOG.warning("Failed to copy modpack icon", e); + } + } } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthModpackProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthModpackProvider.java index 77e154bdd9..c8adc3da62 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthModpackProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthModpackProvider.java @@ -50,7 +50,7 @@ public Task createUpdateTask(DefaultDependencyManager dependencyManager, Stri if (!(modpack.getManifest() instanceof ModrinthManifest modrinthManifest)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); - return new ModpackUpdateTask(dependencyManager.getGameRepository(), name, new ModrinthInstallTask(dependencyManager, zipFile, modpack, modrinthManifest, name)); + return new ModpackUpdateTask(dependencyManager.getGameRepository(), name, new ModrinthInstallTask(dependencyManager, zipFile, modpack, modrinthManifest, name, null)); } @Override @@ -58,8 +58,8 @@ public Modpack readManifest(ZipArchiveReader zip, Path file, Charset encoding) t ModrinthManifest manifest = JsonUtils.fromNonNullJson(CompressingUtils.readTextZipEntry(zip, "modrinth.index.json"), ModrinthManifest.class); return new Modpack(manifest.getName(), "", manifest.getVersionId(), manifest.getGameVersion(), manifest.getSummary(), encoding, manifest) { @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name) { - return new ModrinthInstallTask(dependencyManager, zipFile, this, manifest, name); + public Task getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name, String iconUrl) { + return new ModrinthInstallTask(dependencyManager, zipFile, this, manifest, name, iconUrl); } }; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthRemoteModRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthRemoteModRepository.java index 4b60bf3cdd..3cfa7c255c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthRemoteModRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthRemoteModRepository.java @@ -24,7 +24,11 @@ import org.jackhuang.hmcl.mod.ModLoaderType; import org.jackhuang.hmcl.mod.RemoteMod; import org.jackhuang.hmcl.mod.RemoteModRepository; -import org.jackhuang.hmcl.util.*; +import org.jackhuang.hmcl.util.DigestUtils; +import org.jackhuang.hmcl.util.Immutable; +import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.Pair; +import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.HttpRequest; import org.jackhuang.hmcl.util.io.NetworkUtils; @@ -35,7 +39,14 @@ import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.time.Instant; -import java.util.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.Semaphore; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -49,6 +60,8 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository { public static final ModrinthRemoteModRepository RESOURCE_PACKS = new ModrinthRemoteModRepository("resourcepack"); public static final ModrinthRemoteModRepository SHADER_PACKS = new ModrinthRemoteModRepository("shader"); + private static final Semaphore SEMAPHORE = new Semaphore(16); + private static final String PREFIX = "https://api.modrinth.com"; private final String projectType; @@ -81,30 +94,36 @@ private static String convertSortType(SortType sortType) { @Override public SearchResult search(DownloadProvider downloadProvider, String gameVersion, @Nullable RemoteModRepository.Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException { - List> facets = new ArrayList<>(); - facets.add(Collections.singletonList("project_type:" + projectType)); - if (StringUtils.isNotBlank(gameVersion)) { - facets.add(Collections.singletonList("versions:" + gameVersion)); - } - if (category != null && StringUtils.isNotBlank(category.getId())) { - facets.add(Collections.singletonList("categories:" + category.getId())); - } - Map query = mapOf( - pair("query", searchFilter), - pair("facets", JsonUtils.UGLY_GSON.toJson(facets)), - pair("offset", Integer.toString(pageOffset * pageSize)), - pair("limit", Integer.toString(pageSize)), - pair("index", convertSortType(sort)) - ); - Response response = HttpRequest.GET(downloadProvider.injectURL(NetworkUtils.withQuery(PREFIX + "/v2/search", query))) - .getJson(Response.typeOf(ProjectSearchResult.class)); - return new SearchResult(response.getHits().stream().map(ProjectSearchResult::toMod), (int) Math.ceil((double) response.totalHits / pageSize)); + SEMAPHORE.acquireUninterruptibly(); + try { + List> facets = new ArrayList<>(); + facets.add(Collections.singletonList("project_type:" + projectType)); + if (StringUtils.isNotBlank(gameVersion)) { + facets.add(Collections.singletonList("versions:" + gameVersion)); + } + if (category != null && StringUtils.isNotBlank(category.getId())) { + facets.add(Collections.singletonList("categories:" + category.getId())); + } + Map query = mapOf( + pair("query", searchFilter), + pair("facets", JsonUtils.UGLY_GSON.toJson(facets)), + pair("offset", Integer.toString(pageOffset * pageSize)), + pair("limit", Integer.toString(pageSize)), + pair("index", convertSortType(sort)) + ); + Response response = HttpRequest.GET(downloadProvider.injectURL(NetworkUtils.withQuery(PREFIX + "/v2/search", query))) + .getJson(Response.typeOf(ProjectSearchResult.class)); + return new SearchResult(response.getHits().stream().map(ProjectSearchResult::toMod), (int) Math.ceil((double) response.totalHits / pageSize)); + } finally { + SEMAPHORE.release(); + } } @Override public Optional getRemoteVersionByLocalFile(LocalModFile localModFile, Path file) throws IOException { String sha1 = DigestUtils.digestToString("SHA-1", file); + SEMAPHORE.acquireUninterruptibly(); try { ProjectVersion mod = HttpRequest.GET(PREFIX + "/v2/version_file/" + sha1, pair("algorithm", "sha1")) @@ -118,14 +137,21 @@ public Optional getRemoteVersionByLocalFile(LocalModFile loca } } catch (NoSuchFileException e) { return Optional.empty(); + } finally { + SEMAPHORE.release(); } } @Override public RemoteMod getModById(String id) throws IOException { - id = StringUtils.removePrefix(id, "local-"); - Project project = HttpRequest.GET(PREFIX + "/v2/project/" + id).getJson(Project.class); - return project.toMod(); + SEMAPHORE.acquireUninterruptibly(); + try { + id = StringUtils.removePrefix(id, "local-"); + Project project = HttpRequest.GET(PREFIX + "/v2/project/" + id).getJson(Project.class); + return project.toMod(); + } finally { + SEMAPHORE.release(); + } } @Override @@ -135,20 +161,28 @@ public RemoteMod.File getModFile(String modId, String fileId) throws IOException @Override public Stream getRemoteVersionsById(String id) throws IOException { - id = StringUtils.removePrefix(id, "local-"); - List versions = HttpRequest.GET(PREFIX + "/v2/project/" + id + "/version") - .getJson(listTypeOf(ProjectVersion.class)); - return versions.stream().map(ProjectVersion::toVersion).flatMap(Lang::toStream); - } - - public List getCategoriesImpl() throws IOException { - List categories = HttpRequest.GET(PREFIX + "/v2/tag/category").getJson(listTypeOf(Category.class)); - return categories.stream().filter(category -> category.getProjectType().equals(projectType)).collect(Collectors.toList()); + SEMAPHORE.acquireUninterruptibly(); + try { + id = StringUtils.removePrefix(id, "local-"); + List versions = HttpRequest.GET(PREFIX + "/v2/project/" + id + "/version?include_changelog=false") + .getJson(listTypeOf(ProjectVersion.class)); + return versions.stream().map(ProjectVersion::toVersion).flatMap(Lang::toStream); + } finally { + SEMAPHORE.release(); + } } @Override public Stream getCategories() throws IOException { - return getCategoriesImpl().stream().map(Category::toCategory); + SEMAPHORE.acquireUninterruptibly(); + try { + List categories = HttpRequest.GET(PREFIX + "/v2/tag/category").getJson(listTypeOf(Category.class)); + return categories.stream() + .filter(category -> category.getProjectType().equals(projectType)) + .map(Category::toCategory); + } finally { + SEMAPHORE.release(); + } } public static class Category { @@ -160,7 +194,7 @@ public static class Category { private final String projectType; public Category() { - this("","",""); + this("", "", ""); } public Category(String icon, String name, String projectType) { @@ -576,6 +610,9 @@ public static class ProjectSearchResult implements RemoteMod.IMod { private final List categories; + @SerializedName("display_categories") + private final List displayCategories; + @SerializedName("project_type") private final String projectType; @@ -600,11 +637,12 @@ public static class ProjectSearchResult implements RemoteMod.IMod { @SerializedName("latest_version") private final String latestVersion; - public ProjectSearchResult(String slug, String title, String description, List categories, String projectType, int downloads, String iconUrl, String projectId, String author, List versions, Instant dateCreated, Instant dateModified, String latestVersion) { + public ProjectSearchResult(String slug, String title, String description, List categories, List displayCategories, String projectType, int downloads, String iconUrl, String projectId, String author, List versions, Instant dateCreated, Instant dateModified, String latestVersion) { this.slug = slug; this.title = title; this.description = description; this.categories = categories; + this.displayCategories = displayCategories; this.projectType = projectType; this.downloads = downloads; this.iconUrl = iconUrl; @@ -632,6 +670,10 @@ public List getCategories() { return categories; } + public List getDisplayCategories() { + return displayCategories; + } + public String getProjectType() { return projectType; } @@ -691,7 +733,7 @@ public RemoteMod toMod() { author, title, description, - categories, + displayCategories, String.format("https://modrinth.com/%s/%s", projectType, projectId), iconUrl, this diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/multimc/MultiMCModpackProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/multimc/MultiMCModpackProvider.java index aff3d11c06..1bb7c8e939 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/multimc/MultiMCModpackProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/multimc/MultiMCModpackProvider.java @@ -85,7 +85,7 @@ public Modpack readManifest(ZipArchiveReader modpackFile, Path modpackPath, Char MultiMCInstanceConfiguration cfg = new MultiMCInstanceConfiguration(name, instanceStream, manifest); return new Modpack(cfg.getName(), "", "", cfg.getGameVersion(), cfg.getNotes(), encoding, cfg) { @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name) { + public Task getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name, String iconUrl) { return new MultiMCModpackInstallTask(dependencyManager, zipFile, this, cfg, name); } }; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/server/ServerModpackManifest.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/server/ServerModpackManifest.java index bf3c30545b..b8075c09f0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/server/ServerModpackManifest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/server/ServerModpackManifest.java @@ -126,7 +126,7 @@ public Modpack toModpack(Charset encoding) throws IOException { .orElseThrow(() -> new IOException("Cannot find game version")).getVersion(); return new Modpack(name, author, version, gameVersion, description, encoding, this) { @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name) { + public Task getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name, String iconUrl) { return new ServerModpackLocalInstallTask(dependencyManager, zipFile, this, ServerModpackManifest.this, name); } }; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackFile.java new file mode 100644 index 0000000000..6bfea93923 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackFile.java @@ -0,0 +1,30 @@ +package org.jackhuang.hmcl.resourcepack; + +import org.jackhuang.hmcl.mod.LocalModFile; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; + +public interface ResourcepackFile { + @Nullable + LocalModFile.Description getDescription(); + + String getName(); + + Path getPath(); + + byte @Nullable [] getIcon(); + + static ResourcepackFile parse(Path path) throws IOException { + String fileName = path.getFileName().toString(); + if (Files.isRegularFile(path) && fileName.toLowerCase(Locale.ROOT).endsWith(".zip")) { + return new ResourcepackZipFile(path); + } else if (Files.isDirectory(path) && Files.exists(path.resolve("pack.mcmeta"))) { + return new ResourcepackFolder(path); + } + return null; + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackFolder.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackFolder.java new file mode 100644 index 0000000000..14b5d3c637 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackFolder.java @@ -0,0 +1,60 @@ +package org.jackhuang.hmcl.resourcepack; + +import org.jackhuang.hmcl.mod.LocalModFile; +import org.jackhuang.hmcl.mod.modinfo.PackMcMeta; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public final class ResourcepackFolder implements ResourcepackFile { + private final Path path; + private final LocalModFile.Description description; + private final byte @Nullable [] icon; + + public ResourcepackFolder(Path path) { + this.path = path; + + LocalModFile.Description description = null; + try { + description = JsonUtils.fromJsonFile(path.resolve("pack.mcmeta"), PackMcMeta.class).pack().description(); + } catch (Exception e) { + LOG.warning("Failed to parse resourcepack meta", e); + } + + byte[] icon; + try { + icon = Files.readAllBytes(path.resolve("pack.png")); + } catch (IOException e) { + icon = null; + LOG.warning("Failed to read resourcepack icon", e); + } + this.icon = icon; + + this.description = description; + } + + @Override + public String getName() { + return path.getFileName().toString(); + } + + @Override + public Path getPath() { + return path; + } + + @Override + public LocalModFile.Description getDescription() { + return description; + } + + @Override + public byte @Nullable [] getIcon() { + return icon; + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackZipFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackZipFile.java new file mode 100644 index 0000000000..8f35f933bc --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackZipFile.java @@ -0,0 +1,72 @@ +package org.jackhuang.hmcl.resourcepack; + +import org.jackhuang.hmcl.mod.LocalModFile; +import org.jackhuang.hmcl.mod.modinfo.PackMcMeta; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.CompressingUtils; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.tree.ZipFileTree; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public final class ResourcepackZipFile implements ResourcepackFile { + private final Path path; + private final byte @Nullable [] icon; + private final String name; + private final LocalModFile.Description description; + + public ResourcepackZipFile(Path path) throws IOException { + this.path = path; + LocalModFile.Description description = null; + + byte[] icon = null; + + try (var zipFileTree = new ZipFileTree(CompressingUtils.openZipFile(path))) { + try { + description = JsonUtils.fromNonNullJson(zipFileTree.readTextEntry("/pack.mcmeta"), PackMcMeta.class).pack().description(); + } catch (Exception e) { + LOG.warning("Failed to parse resourcepack meta", e); + } + + var iconEntry = zipFileTree.getEntry("/pack.png"); + if (iconEntry != null) { + try (InputStream is = zipFileTree.getInputStream(iconEntry)) { + icon = is.readAllBytes(); + } catch (Exception e) { + LOG.warning("Failed to load resourcepack icon", e); + } + } + } + + this.icon = icon; + this.description = description; + + name = FileUtils.getNameWithoutExtension(path); + } + + @Override + public String getName() { + return name; + } + + @Override + public Path getPath() { + return path; + } + + @Override + public LocalModFile.Description getDescription() { + return description; + } + + @Override + public byte @Nullable [] getIcon() { + return icon; + } +} + diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/FetchTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/FetchTask.java index 675820e9e8..380e0d4518 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/FetchTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/FetchTask.java @@ -185,9 +185,11 @@ private void downloadHttp(URI uri, boolean checkETag) throws DownloadException, } } - ArrayList exceptions = null; + ArrayList exceptions = null; - for (int retryTime = 0; retryTime < retry; retryTime++) { + // If loading the cache fails, the cache should not be loaded again. + boolean useCachedResult = true; + for (int retryTime = 0, retryLimit = retry; retryTime < retryLimit; retryTime++) { if (isCancelled()) { throw new InterruptedException(); } @@ -204,12 +206,14 @@ private void downloadHttp(URI uri, boolean checkETag) throws DownloadException, LinkedHashMap headers = new LinkedHashMap<>(); headers.put("accept-encoding", "gzip"); - if (checkETag) + if (useCachedResult && checkETag) headers.putAll(repository.injectConnection(uri)); do { - HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(currentURI); - requestBuilder.timeout(Duration.ofMillis(NetworkUtils.TIME_OUT)); + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(currentURI) + .timeout(Duration.ofMillis(NetworkUtils.TIME_OUT)) + .header("User-Agent", Holder.USER_AGENT); + headers.forEach(requestBuilder::header); response = Holder.HTTP_CLIENT.send(requestBuilder.build(), BODY_HANDLER); @@ -248,7 +252,7 @@ private void downloadHttp(URI uri, boolean checkETag) throws DownloadException, } while (true); int responseCode = response.statusCode(); - if (responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) { + if (useCachedResult && responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) { // Handle cache try { Path cache = repository.getCachedRemoteFile(currentURI, false); @@ -260,9 +264,10 @@ private void downloadHttp(URI uri, boolean checkETag) throws DownloadException, } catch (IOException e) { LOG.warning("Unable to use cached file, redownload " + NetworkUtils.dropQuery(uri), e); repository.removeRemoteEntry(currentURI); + useCachedResult = false; // Now we must reconnect the server since 304 may result in empty content, // if we want to redownload the file, we must reconnect the server without etag settings. - retryTime--; + retryLimit++; continue; } } else if (responseCode / 100 == 4) { @@ -279,10 +284,12 @@ private void downloadHttp(URI uri, boolean checkETag) throws DownloadException, contentLength, contentEncoding); return; + } catch (InterruptedException e) { + throw e; } catch (FileNotFoundException ex) { LOG.warning("Failed to download " + uri + ", not found" + (redirects == null ? "" : ", redirects: " + redirects), ex); throw toDownloadException(uri, ex, exceptions); // we will not try this URL again - } catch (IOException ex) { + } catch (Exception ex) { if (exceptions == null) exceptions = new ArrayList<>(); @@ -296,7 +303,7 @@ private void downloadHttp(URI uri, boolean checkETag) throws DownloadException, } private void downloadNotHttp(URI uri) throws DownloadException, InterruptedException { - ArrayList exceptions = null; + ArrayList exceptions = null; for (int retryTime = 0; retryTime < retry; retryTime++) { if (isCancelled()) { throw new InterruptedException(); @@ -312,11 +319,13 @@ private void downloadNotHttp(URI uri) throws DownloadException, InterruptedExcep conn.getContentLengthLong(), ContentEncoding.fromConnection(conn)); return; + } catch (InterruptedException e) { + throw e; } catch (FileNotFoundException ex) { LOG.warning("Failed to download " + uri + ", not found", ex); throw toDownloadException(uri, ex, exceptions); // we will not try this URL again - } catch (IOException ex) { + } catch (Exception ex) { if (exceptions == null) exceptions = new ArrayList<>(); @@ -328,7 +337,7 @@ private void downloadNotHttp(URI uri) throws DownloadException, InterruptedExcep throw toDownloadException(uri, null, exceptions); } - private static DownloadException toDownloadException(URI uri, @Nullable IOException last, @Nullable ArrayList exceptions) { + private static DownloadException toDownloadException(URI uri, @Nullable Exception last, @Nullable ArrayList exceptions) { if (exceptions == null || exceptions.isEmpty()) { return new DownloadException(uri, last != null ? last @@ -337,7 +346,7 @@ private static DownloadException toDownloadException(URI uri, @Nullable IOExcept if (last == null) last = exceptions.remove(exceptions.size() - 1); - for (IOException e : exceptions) { + for (Exception e : exceptions) { last.addSuppressed(e); } return new DownloadException(uri, last); @@ -500,11 +509,22 @@ public static int getDownloadExecutorConcurrency() { return downloadExecutorConcurrency; } + private static volatile boolean initialized = false; + + public static void notifyInitialized() { + initialized = true; + } + /// Ensure that [#HTTP_CLIENT] is initialized after ProxyManager has been initialized. private static final class Holder { private static final HttpClient HTTP_CLIENT; + private static final String USER_AGENT = System.getProperty("http.agent", "HMCL"); static { + if (!initialized) { + throw new AssertionError("FetchTask.Holder accessed before ProxyManager initialization."); + } + boolean useHttp2 = !"false".equalsIgnoreCase(System.getProperty("hmcl.http2")); HTTP_CLIENT = HttpClient.newBuilder() diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/FileDownloadTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/FileDownloadTask.java index b3b4d8dc1d..0237a3c7e4 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/FileDownloadTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/FileDownloadTask.java @@ -193,7 +193,7 @@ protected Context getContext(HttpResponse response, boolean checkETag, String if (integrityCheck != null) { algorithm = integrityCheck.getAlgorithm(); checksum = integrityCheck.getChecksum(); - } else if (bmclapiHash != null) { + } else if (bmclapiHash != null && DigestUtils.isSha1Digest(bmclapiHash)) { algorithm = "SHA-1"; checksum = bmclapiHash; } else { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/FutureCallback.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/FutureCallback.java index 579c1ee0ea..4abad2ac11 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/FutureCallback.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/FutureCallback.java @@ -17,18 +17,23 @@ */ package org.jackhuang.hmcl.util; -import java.util.function.Consumer; - @FunctionalInterface public interface FutureCallback { - /** - * Callback of future, called after future finishes. - * This callback gives the feedback whether the result of future is acceptable or not, - * if not, giving the reason, and future will be relaunched when necessary. - * @param result result of the future - * @param resolve accept the result - * @param reject reject the result with failure reason - */ - void call(T result, Runnable resolve, Consumer reject); + /// Callback of future, called after future finishes. + /// This callback gives the feedback whether the result of future is acceptable or not, + /// if not, giving the reason, and future will be relaunched when necessary. + /// + /// @param result result of the future + /// @param handler handler to accept or reject the result + void call(T result, ResultHandler handler); + + interface ResultHandler { + + /// Accept the result. + void resolve(); + + /// Reject the result with given reason. + void reject(String reason); + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Log4jLevel.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Log4jLevel.java index 99a154bc65..f3abac83eb 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Log4jLevel.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Log4jLevel.java @@ -17,8 +17,6 @@ */ package org.jackhuang.hmcl.util; -import javafx.scene.paint.Color; - import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -27,30 +25,24 @@ * @author huangyuhui */ public enum Log4jLevel { - FATAL(1, Color.web("#F7A699")), - ERROR(2, Color.web("#FFCCBB")), - WARN(3, Color.web("#FFEECC")), - INFO(4, Color.web("#FBFBFB")), - DEBUG(5, Color.web("#EEE9E0")), - TRACE(6, Color.BLUE), - ALL(2147483647, Color.BLACK); + FATAL(1), + ERROR(2), + WARN(3), + INFO(4), + DEBUG(5), + TRACE(6), + ALL(2147483647); private final int level; - private final Color color; - Log4jLevel(int level, Color color) { + Log4jLevel(int level) { this.level = level; - this.color = color; } public int getLevel() { return level; } - public Color getColor() { - return color; - } - public boolean lessOrEqual(Log4jLevel level) { return this.level <= level.level; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MurmurHash2.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MurmurHash2.java index 59f233a312..5538217ec1 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MurmurHash2.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MurmurHash2.java @@ -82,7 +82,7 @@ public static int hash32(final byte[] data, final int length, final int seed) { // body for (int i = 0; i < nblocks; i++) { final int index = (i << 2); - int k = getLittleEndianInt(data, index); + int k = ByteArray.getIntLE(data, index); k *= M32; k ^= k >>> R32; k *= M32; @@ -191,7 +191,7 @@ public static long hash64(final byte[] data, final int length, final int seed) { // body for (int i = 0; i < nblocks; i++) { final int index = (i << 3); - long k = getLittleEndianLong(data, index); + long k = ByteArray.getLongLE(data, index); k *= M64; k ^= k >>> R64; @@ -294,36 +294,4 @@ public static long hash64(final String text) { public static long hash64(final String text, final int from, final int length) { return hash64(text.substring(from, from + length)); } - - /** - * Gets the little-endian int from 4 bytes starting at the specified index. - * - * @param data The data - * @param index The index - * @return The little-endian int - */ - private static int getLittleEndianInt(final byte[] data, final int index) { - return ((data[index] & 0xff)) | - ((data[index + 1] & 0xff) << 8) | - ((data[index + 2] & 0xff) << 16) | - ((data[index + 3] & 0xff) << 24); - } - - /** - * Gets the little-endian long from 8 bytes starting at the specified index. - * - * @param data The data - * @param index The index - * @return The little-endian long - */ - private static long getLittleEndianLong(final byte[] data, final int index) { - return (((long) data[index] & 0xff)) | - (((long) data[index + 1] & 0xff) << 8) | - (((long) data[index + 2] & 0xff) << 16) | - (((long) data[index + 3] & 0xff) << 24) | - (((long) data[index + 4] & 0xff) << 32) | - (((long) data[index + 5] & 0xff) << 40) | - (((long) data[index + 6] & 0xff) << 48) | - (((long) data[index + 7] & 0xff) << 56); - } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/CompressingUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/CompressingUtils.java index aca03c64e4..d4875d909c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/CompressingUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/CompressingUtils.java @@ -21,6 +21,7 @@ import kala.compress.archivers.zip.ZipArchiveReader; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jackhuang.hmcl.util.tree.ZipFileTree; import java.io.IOException; import java.nio.ByteBuffer; @@ -127,13 +128,33 @@ public static Charset findSuitableEncoding(ZipArchiveReader zipFile) throws IOEx throw new IOException("Cannot find suitable encoding for the zip."); } + public static ZipFileTree openZipTree(Path zipFile) throws IOException { + return new ZipFileTree(openZipFile(zipFile)); + } + public static ZipArchiveReader openZipFile(Path zipFile) throws IOException { + return openZipFileWithPossibleEncoding(zipFile, StandardCharsets.UTF_8); + } + + public static ZipArchiveReader openZipFile(Path zipFile, Charset charset) throws IOException { + return new ZipArchiveReader(zipFile, charset); + } + + public static ZipArchiveReader openZipFileWithPossibleEncoding(Path zipFile, Charset possibleEncoding) throws IOException { + if (possibleEncoding == null) + possibleEncoding = StandardCharsets.UTF_8; + ZipArchiveReader zipReader = new ZipArchiveReader(Files.newByteChannel(zipFile)); + Charset suitableEncoding; try { - suitableEncoding = findSuitableEncoding(zipReader); - if (suitableEncoding == StandardCharsets.UTF_8) - return zipReader; + if (possibleEncoding != StandardCharsets.UTF_8 && CompressingUtils.testEncoding(zipReader, possibleEncoding)) { + suitableEncoding = possibleEncoding; + } else { + suitableEncoding = CompressingUtils.findSuitableEncoding(zipReader); + if (suitableEncoding == StandardCharsets.UTF_8) + return zipReader; + } } catch (Throwable e) { IOUtils.closeQuietly(zipReader, e); throw e; @@ -143,10 +164,6 @@ public static ZipArchiveReader openZipFile(Path zipFile) throws IOException { return new ZipArchiveReader(Files.newByteChannel(zipFile), suitableEncoding); } - public static ZipArchiveReader openZipFile(Path zipFile, Charset charset) throws IOException { - return new ZipArchiveReader(zipFile, charset); - } - public static final class Builder { private boolean autoDetectEncoding = false; private Charset encoding = StandardCharsets.UTF_8; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java index 0a7043f236..97290f6280 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java @@ -177,6 +177,7 @@ public static boolean isNameValid(OperatingSystem os, String name) { if (!Character.isValidCodePoint(codePoint) || Character.isISOControl(codePoint) || codePoint == '/' || codePoint == '\0' + || codePoint == ':' // Unicode replacement character || codePoint == 0xfffd // Not Unicode character @@ -185,7 +186,7 @@ public static boolean isNameValid(OperatingSystem os, String name) { // https://learn.microsoft.com/windows/win32/fileio/naming-a-file if (os == OperatingSystem.WINDOWS && - (ch == '<' || ch == '>' || ch == ':' || ch == '"' || ch == '\\' || ch == '|' || ch == '?' || ch == '*')) { + (ch == '<' || ch == '>' || ch == '"' || ch == '\\' || ch == '|' || ch == '?' || ch == '*')) { return false; } } @@ -526,4 +527,25 @@ public static void saveSafely(Path file, ExceptionalConsumer parsePosixFilePermission(int unixMode) { + EnumSet permissions = EnumSet.noneOf(PosixFilePermission.class); + + // Owner permissions + if ((unixMode & 0400) != 0) permissions.add(PosixFilePermission.OWNER_READ); + if ((unixMode & 0200) != 0) permissions.add(PosixFilePermission.OWNER_WRITE); + if ((unixMode & 0100) != 0) permissions.add(PosixFilePermission.OWNER_EXECUTE); + + // Group permissions + if ((unixMode & 0040) != 0) permissions.add(PosixFilePermission.GROUP_READ); + if ((unixMode & 0020) != 0) permissions.add(PosixFilePermission.GROUP_WRITE); + if ((unixMode & 0010) != 0) permissions.add(PosixFilePermission.GROUP_EXECUTE); + + // Others permissions + if ((unixMode & 0004) != 0) permissions.add(PosixFilePermission.OTHERS_READ); + if ((unixMode & 0002) != 0) permissions.add(PosixFilePermission.OTHERS_WRITE); + if ((unixMode & 0001) != 0) permissions.add(PosixFilePermission.OTHERS_EXECUTE); + + return permissions; + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/NetworkUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/NetworkUtils.java index c0aac47d59..830a852c37 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/NetworkUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/NetworkUtils.java @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.StringUtils; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.io.*; import java.net.*; @@ -419,6 +420,15 @@ public static String decodeURL(String toDecode) { public static @NotNull URI toURI(@NotNull URL url) { return toURI(url.toExternalForm()); } - // ==== + public static @Nullable URI toURIOrNull(String uri) { + if (StringUtils.isNotBlank(uri)) { + try { + return toURI(uri); + } catch (Exception ignored) { + } + } + + return null; + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/Unzipper.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/Unzipper.java index b78fc52b03..79b23b9e2c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/Unzipper.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/Unzipper.java @@ -17,57 +17,54 @@ */ package org.jackhuang.hmcl.util.io; +import kala.compress.archivers.zip.ZipArchiveEntry; +import kala.compress.archivers.zip.ZipArchiveReader; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.platform.OperatingSystem; + import java.io.IOException; +import java.io.InputStream; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.*; -import java.nio.file.attribute.BasicFileAttributes; public final class Unzipper { private final Path zipFile, dest; private boolean replaceExistentFile = false; private boolean terminateIfSubDirectoryNotExists = false; private String subDirectory = "/"; - private FileFilter filter = null; + private EntryFilter filter; private Charset encoding = StandardCharsets.UTF_8; - /** - * Decompress the given zip file to a directory. - * - * @param zipFile the input zip file to be uncompressed - * @param destDir the dest directory to hold uncompressed files - */ + /// Decompress the given zip file to a directory. + /// + /// @param zipFile the input zip file to be uncompressed + /// @param destDir the dest directory to hold uncompressed files public Unzipper(Path zipFile, Path destDir) { this.zipFile = zipFile; this.dest = destDir; } - /** - * True if replace the existent files in destination directory, - * otherwise those conflict files will be ignored. - */ + /// True if replace the existent files in destination directory, + /// otherwise those conflict files will be ignored. public Unzipper setReplaceExistentFile(boolean replaceExistentFile) { this.replaceExistentFile = replaceExistentFile; return this; } - /** - * Will be called for every entry in the zip file. - * Callback returns false if you want leave the specific file uncompressed. - */ - public Unzipper setFilter(FileFilter filter) { + /// Will be called for every entry in the zip file. + /// Callback returns false if you want leave the specific file uncompressed. + public Unzipper setFilter(EntryFilter filter) { this.filter = filter; return this; } - /** - * Will only uncompress files in the "subDirectory", their path will be also affected. - * - * For example, if you set subDirectory to /META-INF, files in /META-INF/ will be - * uncompressed to the destination directory without creating META-INF folder. - * - * Default value: "/" - */ + /// Will only uncompress files in the "subDirectory", their path will be also affected. + /// + /// For example, if you set subDirectory to /META-INF, files in /META-INF/ will be + /// uncompressed to the destination directory without creating META-INF folder. + /// + /// Default value: "/" public Unzipper setSubDirectory(String subDirectory) { this.subDirectory = FileUtils.normalizePath(subDirectory); return this; @@ -83,53 +80,86 @@ public Unzipper setTerminateIfSubDirectoryNotExists() { return this; } - /** - * Decompress the given zip file to a directory. - * - * @throws IOException if zip file is malformed or filesystem error. - */ + /// Decompress the given zip file to a directory. + /// + /// @throws IOException if zip file is malformed or filesystem error. public void unzip() throws IOException { - Files.createDirectories(dest); - try (FileSystem fs = CompressingUtils.readonly(zipFile).setEncoding(encoding).setAutoDetectEncoding(true).build()) { - Path root = fs.getPath(subDirectory); - if (!root.isAbsolute() || (subDirectory.length() > 1 && subDirectory.endsWith("/"))) - throw new IllegalArgumentException("Subdirectory for unzipper must be absolute"); - - if (terminateIfSubDirectoryNotExists && Files.notExists(root)) - return; - - Files.walkFileTree(root, new SimpleFileVisitor() { - @Override - public FileVisitResult visitFile(Path file, - BasicFileAttributes attrs) throws IOException { - String relativePath = root.relativize(file).toString(); - Path destFile = dest.resolve(relativePath); - if (filter != null && !filter.accept(file, false, destFile, relativePath)) - return FileVisitResult.CONTINUE; - try { - Files.copy(file, destFile, replaceExistentFile ? new CopyOption[]{StandardCopyOption.REPLACE_EXISTING} : new CopyOption[]{}); - } catch (FileAlreadyExistsException e) { + Path destDir = this.dest.toAbsolutePath().normalize(); + Files.createDirectories(destDir); + + CopyOption[] copyOptions = replaceExistentFile + ? new CopyOption[]{StandardCopyOption.REPLACE_EXISTING} + : new CopyOption[]{}; + + long entryCount = 0L; + try (ZipArchiveReader reader = CompressingUtils.openZipFileWithPossibleEncoding(zipFile, encoding)) { + String pathPrefix = StringUtils.addSuffix(subDirectory, "/"); + + for (ZipArchiveEntry entry : reader.getEntries()) { + String normalizedPath = FileUtils.normalizePath(entry.getName()); + if (!normalizedPath.startsWith(pathPrefix)) { + continue; + } + + String relativePath = normalizedPath.substring(pathPrefix.length()); + Path destFile = destDir.resolve(relativePath).toAbsolutePath().normalize(); + if (!destFile.startsWith(destDir)) { + throw new IOException("Zip entry is trying to write outside of the destination directory: " + entry.getName()); + } + + if (filter != null && !filter.accept(entry, destFile, relativePath)) { + continue; + } + + entryCount++; + + if (entry.isDirectory()) { + Files.createDirectories(destFile); + } else { + Files.createDirectories(destFile.getParent()); + if (entry.isUnixSymlink()) { + String linkTarget = reader.getUnixSymlink(entry); if (replaceExistentFile) - throw e; + Files.deleteIfExists(destFile); + + Path targetPath; + try { + targetPath = Path.of(linkTarget); + } catch (InvalidPathException e) { + throw new IOException("Zip entry has an invalid symlink target: " + entry.getName(), e); + } + + if (!destFile.getParent().resolve(targetPath).toAbsolutePath().normalize().startsWith(destDir)) { + throw new IOException("Zip entry is trying to create a symlink outside of the destination directory: " + entry.getName()); + } + + try { + Files.createSymbolicLink(destFile, targetPath); + } catch (FileAlreadyExistsException ignored) { + } + } else { + try (InputStream input = reader.getInputStream(entry)) { + Files.copy(input, destFile, copyOptions); + } catch (FileAlreadyExistsException e) { + if (replaceExistentFile) + throw e; + } + + if (entry.getUnixMode() != 0 && OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS) { + Files.setPosixFilePermissions(destFile, FileUtils.parsePosixFilePermission(entry.getUnixMode())); + } } - return FileVisitResult.CONTINUE; } + } - @Override - public FileVisitResult preVisitDirectory(Path dir, - BasicFileAttributes attrs) throws IOException { - String relativePath = root.relativize(dir).toString(); - Path dirToCreate = dest.resolve(relativePath); - if (filter != null && !filter.accept(dir, true, dirToCreate, relativePath)) - return FileVisitResult.SKIP_SUBTREE; - Files.createDirectories(dirToCreate); - return FileVisitResult.CONTINUE; - } - }); + if (entryCount == 0 && !"/".equals(subDirectory) && !terminateIfSubDirectoryNotExists) { + throw new NoSuchFileException("Subdirectory " + subDirectory + " does not exist in the zip file."); + } } } - public interface FileFilter { - boolean accept(Path zipEntry, boolean isDirectory, Path destFile, String entryPath) throws IOException; + @FunctionalInterface + public interface EntryFilter { + boolean accept(ZipArchiveEntry zipArchiveEntry, Path destFile, String relativePath) throws IOException; } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/Zipper.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/Zipper.java index 51e8220c9f..80e2bfd359 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/Zipper.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/Zipper.java @@ -25,6 +25,8 @@ import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; +import java.util.HashSet; +import java.util.Set; import java.util.stream.Stream; import java.util.zip.ZipEntry; import java.util.zip.ZipException; @@ -39,13 +41,15 @@ public final class Zipper implements Closeable { private final ZipOutputStream zos; private final byte[] buffer = new byte[IOUtils.DEFAULT_BUFFER_SIZE]; + private final Set entryNames; public Zipper(Path zipFile) throws IOException { - this(zipFile, StandardCharsets.UTF_8); + this(zipFile, false); } - public Zipper(Path zipFile, Charset encoding) throws IOException { - this.zos = new ZipOutputStream(Files.newOutputStream(zipFile), encoding); + public Zipper(Path zipFile, boolean allowDuplicateEntry) throws IOException { + this.zos = new ZipOutputStream(Files.newOutputStream(zipFile), StandardCharsets.UTF_8); + this.entryNames = allowDuplicateEntry ? new HashSet<>() : null; } private static String normalize(String path) { @@ -57,6 +61,20 @@ private static String normalize(String path) { return path; } + private ZipEntry newEntry(String name) throws IOException { + if (entryNames == null || name.endsWith("/") || entryNames.add(name)) + return new ZipEntry(name); + + for (int i = 1; i < 10; i++) { + String newName = name + "." + i; + if (entryNames.add(newName)) { + return new ZipEntry(newName); + } + } + + throw new ZipException("duplicate entry: " + name); + } + private static String resolve(String dir, String file) { if (dir.isEmpty()) return file; if (file.isEmpty()) return dir; @@ -71,7 +89,7 @@ public void close() throws IOException { /** * Compress all the files in sourceDir * - * @param source the file in basePath to be compressed + * @param source the file in basePath to be compressed * @param targetDir the path of the directory in this zip file. */ public void putDirectory(Path source, String targetDir) throws IOException { @@ -81,9 +99,9 @@ public void putDirectory(Path source, String targetDir) throws IOException { /** * Compress all the files in sourceDir * - * @param source the file in basePath to be compressed + * @param source the file in basePath to be compressed * @param targetDir the path of the directory in this zip file. - * @param filter returns false if you do not want that file or directory + * @param filter returns false if you do not want that file or directory */ public void putDirectory(Path source, String targetDir, ExceptionalPredicate filter) throws IOException { String root = normalize(targetDir); @@ -118,16 +136,12 @@ public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) th }); } - public void putFile(File file, String path) throws IOException { - putFile(file.toPath(), path); - } - public void putFile(Path file, String path) throws IOException { path = normalize(path); BasicFileAttributes attrs = Files.readAttributes(file, BasicFileAttributes.class); - ZipEntry entry = new ZipEntry(attrs.isDirectory() ? path + "/" : path); + ZipEntry entry = newEntry(attrs.isDirectory() ? path + "/" : path); entry.setCreationTime(attrs.creationTime()); entry.setLastAccessTime(attrs.lastAccessTime()); entry.setLastModifiedTime(attrs.lastModifiedTime()); @@ -149,13 +163,13 @@ public void putFile(Path file, String path) throws IOException { } public void putStream(InputStream in, String path) throws IOException { - zos.putNextEntry(new ZipEntry(normalize(path))); + zos.putNextEntry(newEntry(normalize(path))); IOUtils.copyTo(in, zos, buffer); zos.closeEntry(); } public OutputStream putStream(String path) throws IOException { - zos.putNextEntry(new ZipEntry(normalize(path))); + zos.putNextEntry(newEntry(normalize(path))); return new OutputStream() { public void write(int b) throws IOException { zos.write(b); @@ -180,7 +194,7 @@ public void close() throws IOException { } public void putLines(Stream lines, String path) throws IOException { - zos.putNextEntry(new ZipEntry(normalize(path))); + zos.putNextEntry(newEntry(normalize(path))); try { BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(zos)); @@ -205,7 +219,7 @@ public void putTextFile(String text, String path) throws IOException { } public void putTextFile(String text, Charset encoding, String path) throws IOException { - zos.putNextEntry(new ZipEntry(normalize(path))); + zos.putNextEntry(newEntry(normalize(path))); zos.write(text.getBytes(encoding)); zos.closeEntry(); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/CommandBuilder.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/CommandBuilder.java index 4fd7662b3f..5d0ab0fc34 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/CommandBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/CommandBuilder.java @@ -48,13 +48,24 @@ private String parse(String s) { } } + /** + * Parsing will ignore your manual escaping + * + * @param arg command + * @return this + */ + public CommandBuilder add(String arg) { + raw.add(new Item(arg, true)); + return this; + } + /** * Parsing will ignore your manual escaping * * @param args commands * @return this */ - public CommandBuilder add(String... args) { + public CommandBuilder addAll(String... args) { for (String s : args) raw.add(new Item(s, true)); return this; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ManagedProcess.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ManagedProcess.java index a179a74fe3..858d14e9f3 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ManagedProcess.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ManagedProcess.java @@ -113,16 +113,19 @@ public Map getProperties() { */ public List getLines(Predicate lineFilter) { lock.lock(); - - if (lineFilter == null) - return List.copyOf(lines); - - ArrayList res = new ArrayList<>(); - for (String line : this.lines) { - if (lineFilter.test(line)) - res.add(line); + try { + if (lineFilter == null) + return List.copyOf(lines); + + ArrayList res = new ArrayList<>(); + for (String line : this.lines) { + if (lineFilter.test(line)) + res.add(line); + } + return Collections.unmodifiableList(res); + } finally { + lock.unlock(); } - return Collections.unmodifiableList(res); } public void addLine(String line) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/SystemUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/SystemUtils.java index 3ea8b867ad..2675d10469 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/SystemUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/SystemUtils.java @@ -81,13 +81,8 @@ public static String run(String... command) throws Exception { } public static T run(List command, ExceptionalFunction convert) throws Exception { - File nul = OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS - ? new File("NUL") - : new File("/dev/null"); - Process process = new ProcessBuilder(command) - .redirectInput(nul) - .redirectError(nul) + .redirectError(ProcessBuilder.Redirect.DISCARD) .start(); try { InputStream inputStream = process.getInputStream(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/windows/Dwmapi.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/windows/Dwmapi.java new file mode 100644 index 0000000000..6d5de237f7 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/windows/Dwmapi.java @@ -0,0 +1,32 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util.platform.windows; + +import com.sun.jna.PointerType; +import com.sun.jna.win32.StdCallLibrary; +import org.jackhuang.hmcl.util.platform.NativeUtils; + +/// @author Glavo +public interface Dwmapi extends StdCallLibrary { + Dwmapi INSTANCE = NativeUtils.USE_JNA && com.sun.jna.Platform.isWindows() + ? NativeUtils.load("dwmapi", Dwmapi.class) + : null; + + /// @see DwmSetWindowAttribute function + int DwmSetWindowAttribute(WinTypes.HANDLE hwnd, int dwAttribute, PointerType pvAttribute, int cbAttribute); +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/windows/WinConstants.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/windows/WinConstants.java index e76a6c38d3..01888d0308 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/windows/WinConstants.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/windows/WinConstants.java @@ -96,4 +96,8 @@ public interface WinConstants { int RelationNumaNodeEx = 6; int RelationProcessorModule = 7; int RelationAll = 0xffff; + + // https://learn.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute + int DWMWA_USE_IMMERSIVE_DARK_MODE = 20; + } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/windows/WinTypes.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/windows/WinTypes.java index 1f639842a1..8c0e43ae47 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/windows/WinTypes.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/windows/WinTypes.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.util.platform.windows; import com.sun.jna.*; +import com.sun.jna.ptr.ByReference; import com.sun.jna.ptr.LongByReference; import java.util.Arrays; @@ -27,6 +28,97 @@ * @author Glavo */ public interface WinTypes { + + /// @see Windows Data Types + final class BOOL extends IntegerType { + + public static final int SIZE = 4; + + public BOOL() { + this(0); + } + + public BOOL(boolean value) { + this(value ? 1L : 0L); + } + + public BOOL(long value) { + super(SIZE, value, false); + assert value == 0 || value == 1; + } + + public boolean booleanValue() { + return this.intValue() > 0; + } + + @Override + public String toString() { + return Boolean.toString(booleanValue()); + } + + } + + /// @see Windows Data Types + final class BOOLByReference extends ByReference { + + public BOOLByReference() { + this(new BOOL(0)); + } + + public BOOLByReference(BOOL value) { + super(BOOL.SIZE); + setValue(value); + } + + public void setValue(BOOL value) { + getPointer().setInt(0, value.intValue()); + } + + public BOOL getValue() { + return new BOOL(getPointer().getInt(0)); + } + } + + /// @see Windows Data Types + final class HANDLE extends PointerType { + public static final long INVALID_VALUE = Native.POINTER_SIZE == 8 ? -1 : 0xFFFFFFFFL; + + public static final HANDLE INVALID = new HANDLE(Pointer.createConstant(INVALID_VALUE)); + + private boolean immutable; + + public HANDLE() { + } + + public HANDLE(Pointer p) { + setPointer(p); + immutable = true; + } + + @Override + public Object fromNative(Object nativeValue, FromNativeContext context) { + Object o = super.fromNative(nativeValue, context); + if (INVALID.equals(o)) { + return INVALID; + } + return o; + } + + @Override + public void setPointer(Pointer p) { + if (immutable) { + throw new UnsupportedOperationException("immutable reference"); + } + + super.setPointer(p); + } + + @Override + public String toString() { + return String.valueOf(getPointer()); + } + } + /** * @see OSVERSIONINFOEXW structure */ diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/tree/ArchiveFileTree.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/tree/ArchiveFileTree.java index 741621c70c..f96b898c42 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/tree/ArchiveFileTree.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/tree/ArchiveFileTree.java @@ -23,10 +23,7 @@ import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.UnmodifiableView; -import java.io.Closeable; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; +import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -72,11 +69,8 @@ public Dir getRoot() { public @Nullable E getEntry(@NotNull String entryPath) { Dir dir = root; - String fileName; if (entryPath.indexOf('/') < 0) { - fileName = entryPath; - if (fileName.isEmpty()) - return root.getEntry(); + return dir.getFiles().get(entryPath); } else { String[] path = entryPath.split("/"); if (path.length == 0) @@ -91,14 +85,25 @@ public Dir getRoot() { return null; } - fileName = path[path.length - 1]; - E entry = dir.getFiles().get(fileName); - if (entry != null) - return entry; + String fileName = path[path.length - 1]; + return dir.getFiles().get(fileName); } + } - Dir subDir = dir.getSubDirs().get(fileName); - return subDir != null ? subDir.getEntry() : null; + public @Nullable Dir getDirectory(@NotNull String dirPath) { + Dir dir = root; + if (dirPath.isEmpty()) { + return dir; + } + String[] path = dirPath.split("/"); + for (String item : path) { + if (item.isEmpty()) + continue; + dir = dir.getSubDirs().get(item); + if (dir == null) + return null; + } + return dir; } protected void addEntry(E entry) throws IOException { @@ -152,6 +157,17 @@ protected void addEntry(E entry) throws IOException { return getInputStream(entry); } + public BufferedReader getBufferedReader(@NotNull E entry) throws IOException { + return new BufferedReader(new InputStreamReader(getInputStream(entry), StandardCharsets.UTF_8)); + } + + public @NotNull BufferedReader getBufferedReader(String entryPath) throws IOException { + E entry = getEntry(entryPath); + if (entry == null) + throw new FileNotFoundException("Entry not found: " + entryPath); + return getBufferedReader(entry); + } + public byte[] readBinaryEntry(@NotNull E entry) throws IOException { try (InputStream input = getInputStream(entry)) { return input.readAllBytes(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/versioning/GameVersionNumber.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/versioning/GameVersionNumber.java index 40f4f8193c..6139652c8d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/versioning/GameVersionNumber.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/versioning/GameVersionNumber.java @@ -17,7 +17,7 @@ */ package org.jackhuang.hmcl.util.versioning; -import org.intellij.lang.annotations.MagicConstant; +import org.jackhuang.hmcl.util.ToStringBuilder; import org.jetbrains.annotations.NotNull; import java.io.BufferedReader; @@ -28,6 +28,8 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + /** * @author Glavo */ @@ -38,6 +40,10 @@ public static String[] getDefaultGameVersions() { } public static GameVersionNumber asGameVersion(String version) { + GameVersionNumber versionNumber = Versions.SPECIALS.get(version); + if (versionNumber != null) + return versionNumber; + try { if (!version.isEmpty()) { char ch = version.charAt(0); @@ -53,20 +59,15 @@ public static GameVersionNumber asGameVersion(String version) { if (version.equals("0.0")) return Release.ZERO; - if (version.startsWith("1.")) - return Release.parse(version); + if (version.length() >= 6 && version.charAt(2) == 'w') + return LegacySnapshot.parse(version); - if (version.length() == 6 && version.charAt(2) == 'w') - return Snapshot.parse(version); + return Release.parse(version); } - } catch (IllegalArgumentException ignore) { + } catch (Throwable ignore) { } - Special special = Versions.SPECIALS.get(version); - if (special == null) { - special = new Special(version); - } - return special; + return new Special(version, version); } public static GameVersionNumber asGameVersion(Optional version) { @@ -94,17 +95,22 @@ public static VersionRange atMost(String maximum) { } final String value; + final String normalized; - GameVersionNumber(String value) { + GameVersionNumber(String value, String normalized) { this.value = value; + this.normalized = normalized; } public boolean isAprilFools() { - if (this instanceof Special && !value.endsWith("_unobfuscated")) - return true; + if (this instanceof Special) { + String normalizedVersion = this.toNormalizedString(); + return !normalizedVersion.startsWith("1.") && !normalizedVersion.equals("13w12~") + || normalizedVersion.equals("1.RV-Pre1"); + } - if (this instanceof Snapshot snapshot) { - return snapshot.intValue == Snapshot.toInt(15, 14, 'a'); + if (this instanceof LegacySnapshot snapshot) { + return snapshot.intValue == LegacySnapshot.toInt(15, 14, 'a', false); } return false; @@ -144,10 +150,10 @@ public boolean isAtLeast(@NotNull String releaseVersion, @NotNull String snapsho /// /// ```java /// GameVersionNumber.asVersion("...").isAtLeast("1.13", "17w43a"); - /// ``` + ///``` /// /// @param strictReleaseVersion When `strictReleaseVersion` is `false`, `releaseVersion` is considered less than - /// its corresponding pre/rc versions. + /// its corresponding pre/rc versions. public boolean isAtLeast(@NotNull String releaseVersion, @NotNull String snapshotVersion, boolean strictReleaseVersion) { if (this instanceof Release self) { Release other; @@ -159,17 +165,35 @@ public boolean isAtLeast(@NotNull String releaseVersion, @NotNull String snapsho return self.compareToRelease(other) >= 0; } else { - return this.compareTo(Snapshot.parse(snapshotVersion)) >= 0; + return this.compareTo(LegacySnapshot.parse(snapshotVersion)) >= 0; } } + public String toNormalizedString() { + return normalized; + } + @Override public String toString() { return value; } + protected ToStringBuilder buildDebugString() { + return new ToStringBuilder(this) + .append("value", value) + .append("normalized", normalized) + .append("type", getType()); + } + + public final String toDebugString() { + return buildDebugString().toString(); + } + public static final class Old extends GameVersionNumber { static Old parse(String value) { + if (value.isEmpty()) + throw new IllegalArgumentException("Empty old version number"); + Type type; int prefixLength = 1; switch (value.charAt(0)) { @@ -215,7 +239,7 @@ static Old parse(String value) { final VersionNumber versionNumber; private Old(String value, Type type, VersionNumber versionNumber) { - super(value); + super(value, value); this.type = type; this.versionNumber = versionNumber; } @@ -231,83 +255,141 @@ int compareToImpl(@NotNull GameVersionNumber other) { } @Override - public boolean equals(Object o) { - if (this == o) return true; - return o instanceof Old other && type == other.type && this.versionNumber.compareTo(other.versionNumber) == 0; + public int hashCode() { + return Objects.hash(type, versionNumber); } @Override - public int hashCode() { - return Objects.hash(type, versionNumber.hashCode()); + public boolean equals(Object o) { + return o instanceof Old that + && this.type == that.type + && this.versionNumber.equals(that.versionNumber); } } public static final class Release extends GameVersionNumber { + private static final int MINIMUM_YEAR_MAJOR_VERSION = 25; + + public enum ReleaseType { + UNKNOWN(""), + SNAPSHOT("-snapshot-"), + PRE_RELEASE("-pre"), + RELEASE_CANDIDATE("-rc"), + GA(""); + private final String infix; + + ReleaseType(String infix) { + this.infix = infix; + } + } - private static final Pattern PATTERN = Pattern.compile("1\\.(?[0-9]+)(\\.(?[0-9]+))?((?(-[a-zA-Z]+| Pre-Release ))(?.+))?"); + public enum Additional { + NONE(""), UNOBFUSCATED("_unobfuscated"); + private final String suffix; - public static final int TYPE_GA = Integer.MAX_VALUE; + Additional(String suffix) { + this.suffix = suffix; + } + } - public static final int TYPE_UNKNOWN = 0; - public static final int TYPE_EXP = 1; - public static final int TYPE_PRE = 2; - public static final int TYPE_RC = 3; + static final Release ZERO = new Release( + "0.0", "0.0", + 0, 0, 0, + ReleaseType.UNKNOWN, VersionNumber.ZERO, Additional.NONE + ); - static final Release ZERO = new Release("0.0", 0, 0, 0, TYPE_GA, VersionNumber.ZERO); + private static final Pattern VERSION_PATTERN = Pattern.compile("(?(?1|[1-9]\\d+)\\.(?\\d+)(\\.(?[0-9]+))?)(?.*)"); static Release parse(String value) { - Matcher matcher = PATTERN.matcher(value); + Matcher matcher = VERSION_PATTERN.matcher(value); if (!matcher.matches()) { throw new IllegalArgumentException(value); } + int major = Integer.parseInt(matcher.group("major")); + if (major != 1 && major < MINIMUM_YEAR_MAJOR_VERSION) + throw new IllegalArgumentException(value); + int minor = Integer.parseInt(matcher.group("minor")); String patchString = matcher.group("patch"); int patch = patchString != null ? Integer.parseInt(patchString) : 0; - String eaTypeString = matcher.group("eaType"); - int eaType; - if (eaTypeString == null) { - eaType = TYPE_GA; - } else if ("-pre".equals(eaTypeString) || " Pre-Release ".equals(eaTypeString)) { - eaType = TYPE_PRE; - } else if ("-rc".equals(eaTypeString)) { - eaType = TYPE_RC; - } else if ("-exp".equals(eaTypeString)) { - eaType = TYPE_EXP; - } else { - eaType = TYPE_UNKNOWN; - } + String suffix = matcher.group("suffix"); - String eaVersionString = matcher.group("eaVersion"); - VersionNumber eaVersion = eaVersionString != null ? VersionNumber.asVersion(eaVersionString) : VersionNumber.ZERO; + ReleaseType releaseType; + VersionNumber eaVersion; + Additional additional = Additional.NONE; + boolean needNormalize = false; - return new Release(value, 1, minor, patch, eaType, eaVersion); - } + if (suffix.endsWith("_unobfuscated")) { + suffix = suffix.substring(0, suffix.length() - "_unobfuscated".length()); + additional = Additional.UNOBFUSCATED; + } else if (suffix.endsWith(" Unobfuscated")) { + needNormalize = true; + suffix = suffix.substring(0, suffix.length() - " Unobfuscated".length()); + additional = Additional.UNOBFUSCATED; + } - private static int getNumberLength(String value, int offset) { - int current = offset; - while (current < value.length()) { - char ch = value.charAt(current); - if (ch < '0' || ch > '9') - break; + if (suffix.isEmpty()) { + releaseType = ReleaseType.GA; + eaVersion = VersionNumber.ZERO; + } else if (suffix.startsWith("-snapshot-")) { + releaseType = ReleaseType.SNAPSHOT; + eaVersion = VersionNumber.asVersion(suffix.substring("-snapshot-".length())); + } else if (suffix.startsWith(" Snapshot ")) { + needNormalize = true; + releaseType = ReleaseType.SNAPSHOT; + eaVersion = VersionNumber.asVersion(suffix.substring(" Snapshot ".length())); + } else if (suffix.startsWith("-pre")) { + releaseType = ReleaseType.PRE_RELEASE; + eaVersion = VersionNumber.asVersion(suffix.substring("-pre".length())); + } else if (suffix.startsWith(" Pre-Release ")) { + needNormalize = true; + releaseType = ReleaseType.PRE_RELEASE; + eaVersion = VersionNumber.asVersion(suffix.substring(" Pre-Release ".length())); + } else if (suffix.startsWith("-rc")) { + releaseType = ReleaseType.RELEASE_CANDIDATE; + eaVersion = VersionNumber.asVersion(suffix.substring("-rc".length())); + } else if (suffix.startsWith(" Release Candidate ")) { + needNormalize = true; + releaseType = ReleaseType.RELEASE_CANDIDATE; + eaVersion = VersionNumber.asVersion(suffix.substring(" Release Candidate ".length())); + } else { + throw new IllegalArgumentException(value); + } - current++; + String normalized; + if (needNormalize) { + StringBuilder builder = new StringBuilder(value.length()); + builder.append(matcher.group("prefix")); + if (releaseType != ReleaseType.GA) { + builder.append(releaseType.infix); + builder.append(eaVersion); + } + builder.append(additional.suffix); + normalized = builder.toString(); + } else { + normalized = value; } - return current - offset; + return new Release(value, normalized, major, minor, patch, releaseType, eaVersion, additional); } - /// Quickly parses a simple format (`1\.[0-9]+(\.[0-9]+)?`) release version. - /// The returned [#eaType] will be set to [#TYPE_UNKNOWN], meaning it will be less than all pre/rc and official versions of this version. + /// Quickly parses a simple format (`[1-9][0-9]+\.[0-9]+(\.[0-9]+)?`) release version. + /// The returned [#eaType] will be set to [ReleaseType#UNKNOWN], meaning it will be less than all pre/rc and official versions of this version. /// /// @see GameVersionNumber#isAtLeast(String, String) static Release parseSimple(String value) { - if (!value.startsWith("1.")) + int majorLength = getNumberLength(value, 0); + if (majorLength == 0 || value.length() < majorLength + 2 || value.charAt(majorLength) != '.') throw new IllegalArgumentException(value); - final int minorOffset = 2; + int major = Integer.parseInt(value.substring(0, majorLength)); + if (major != 1 && major < MINIMUM_YEAR_MAJOR_VERSION) + throw new IllegalArgumentException(value); + + final int minorOffset = majorLength + 1; int minorLength = getNumberLength(value, minorOffset); if (minorLength == 0) @@ -326,27 +408,41 @@ static Release parseSimple(String value) { patch = Integer.parseInt(value.substring(patchOffset)); } - return new Release(value, 1, minor, patch, TYPE_UNKNOWN, VersionNumber.ZERO); + return new Release(value, value, major, minor, patch, ReleaseType.UNKNOWN, VersionNumber.ZERO, Additional.NONE); } catch (NumberFormatException e) { throw new IllegalArgumentException(value); } } + private static int getNumberLength(String value, int offset) { + int current = offset; + while (current < value.length()) { + char ch = value.charAt(current); + if (ch < '0' || ch > '9') + break; + + current++; + } + + return current - offset; + } + private final int major; private final int minor; private final int patch; - @MagicConstant(intValues = {TYPE_GA, TYPE_UNKNOWN, TYPE_EXP, TYPE_PRE, TYPE_RC}) - private final int eaType; + private final ReleaseType eaType; private final VersionNumber eaVersion; + private final Additional additional; - Release(String value, int major, int minor, int patch, int eaType, VersionNumber eaVersion) { - super(value); + Release(String value, String normalized, int major, int minor, int patch, ReleaseType eaType, VersionNumber eaVersion, Additional additional) { + super(value, normalized); this.major = major; this.minor = minor; this.patch = patch; this.eaType = eaType; this.eaVersion = eaVersion; + this.additional = additional; } @Override @@ -367,35 +463,45 @@ int compareToRelease(Release other) { if (c != 0) return c; - c = Integer.compare(this.eaType, other.eaType); + c = this.eaType.compareTo(other.eaType); if (c != 0) return c; - return this.eaVersion.compareTo(other.eaVersion); - } + c = this.eaVersion.compareTo(other.eaVersion); + if (c != 0) + return c; - int compareToSnapshot(Snapshot other) { - int idx = Arrays.binarySearch(Versions.SNAPSHOT_INTS, other.intValue); - if (idx >= 0) - return this.compareToRelease(Versions.SNAPSHOT_PREV[idx]) <= 0 ? -1 : 1; + return this.additional.compareTo(other.additional); + } - idx = -(idx + 1); - if (idx == Versions.SNAPSHOT_INTS.length) + int compareToSnapshot(LegacySnapshot other) { + if (major == 0) { return -1; + } else if (major == 1) { + int idx = Arrays.binarySearch(Versions.SNAPSHOT_INTS, other.intValue); + if (idx >= 0) + return this.compareToRelease(Versions.SNAPSHOT_PREV[idx]) <= 0 ? -1 : 1; - return this.compareToRelease(Versions.SNAPSHOT_PREV[idx]) <= 0 ? -1 : 1; + idx = -(idx + 1); + if (idx == Versions.SNAPSHOT_INTS.length) + return -1; + + return this.compareToRelease(Versions.SNAPSHOT_PREV[idx]) <= 0 ? -1 : 1; + } else { + return 1; + } } @Override int compareToImpl(@NotNull GameVersionNumber other) { - if (other instanceof Release) - return compareToRelease((Release) other); + if (other instanceof Release release) + return compareToRelease(release); - if (other instanceof Snapshot) - return compareToSnapshot((Snapshot) other); + if (other instanceof LegacySnapshot snapshot) + return compareToSnapshot(snapshot); - if (other instanceof Special) - return -((Special) other).compareToReleaseOrSnapshot(this); + if (other instanceof Special special) + return -special.compareToReleaseOrSnapshot(this); throw new AssertionError(other.getClass()); } @@ -412,7 +518,7 @@ public int getPatch() { return patch; } - public int getEaType() { + public ReleaseType getEaType() { return eaType; } @@ -420,28 +526,65 @@ public VersionNumber getEaVersion() { return eaVersion; } + public Additional getAdditional() { + return additional; + } + @Override public int hashCode() { - return Objects.hash(major, minor, patch, eaType, eaVersion); + return Objects.hash(major, minor, patch, eaType, eaVersion, additional); } @Override public boolean equals(Object o) { - if (this == o) return true; - return o instanceof Release other - && major == other.major - && minor == other.minor - && patch == other.patch - && eaType == other.eaType - && eaVersion.equals(other.eaVersion); + return o instanceof Release that + && this.major == that.major + && this.minor == that.minor + && this.patch == that.patch + && this.eaType == that.eaType + && this.eaVersion.equals(that.eaVersion) + && this.additional == that.additional; + } + + @Override + protected ToStringBuilder buildDebugString() { + return super.buildDebugString() + .append("major", major) + .append("minor", minor) + .append("patch", patch) + .append("eaType", eaType) + .append("eaVersion", eaVersion) + .append("additional", additional); } } - public static final class Snapshot extends GameVersionNumber { - static Snapshot parse(String value) { - if (value.length() != 6 || value.charAt(2) != 'w') + /// Legacy snapshot version numbers like `25w46a`. + public static final class LegacySnapshot extends GameVersionNumber { + static LegacySnapshot parse(String value) { + if (value.length() < 6 || value.charAt(2) != 'w') throw new IllegalArgumentException(value); + int prefixLength; + boolean unobfuscated; + String normalized; + if (value.endsWith("_unobfuscated")) { + prefixLength = value.length() - "_unobfuscated".length(); + unobfuscated = true; + normalized = value; + } else if (value.endsWith(" Unobfuscated")) { + prefixLength = value.length() - " Unobfuscated".length(); + unobfuscated = true; + normalized = value.substring(0, prefixLength) + "_unobfuscated"; + } else { + prefixLength = value.length(); + unobfuscated = false; + normalized = value; + } + + if (prefixLength != 6) { + throw new IllegalArgumentException(value); + } + int year; int week; try { @@ -452,21 +595,21 @@ static Snapshot parse(String value) { } char suffix = value.charAt(5); - if ((suffix < 'a' || suffix > 'z') && suffix != '~') + if (suffix < 'a' || suffix > 'z') throw new IllegalArgumentException(value); - return new Snapshot(value, year, week, suffix); + return new LegacySnapshot(value, normalized, year, week, suffix, unobfuscated); } - static int toInt(int year, int week, char suffix) { - return (year << 16) | (week << 8) | suffix; + static int toInt(int year, int week, char suffix, boolean unobfuscated) { + return (year << 24) | (week << 16) | (suffix << 8) | (unobfuscated ? 1 : 0); } final int intValue; - Snapshot(String value, int year, int week, char suffix) { - super(value); - this.intValue = toInt(year, week, suffix); + LegacySnapshot(String value, String normalized, int year, int week, char suffix, boolean unobfuscated) { + super(value, normalized); + this.intValue = toInt(year, week, suffix, unobfuscated); } @Override @@ -476,40 +619,52 @@ Type getType() { @Override int compareToImpl(@NotNull GameVersionNumber other) { - if (other instanceof Release) - return -((Release) other).compareToSnapshot(this); + if (other instanceof Release otherRelease) + return -otherRelease.compareToSnapshot(this); - if (other instanceof Snapshot) - return Integer.compare(this.intValue, ((Snapshot) other).intValue); + if (other instanceof LegacySnapshot otherSnapshot) + return Integer.compare(this.intValue, otherSnapshot.intValue); - if (other instanceof Special) - return -((Special) other).compareToReleaseOrSnapshot(this); + if (other instanceof Special otherSpecial) + return -otherSpecial.compareToReleaseOrSnapshot(this); throw new AssertionError(other.getClass()); } public int getYear() { - return (intValue >> 16) & 0xff; + return (intValue >> 24) & 0xff; } public int getWeek() { - return (intValue >> 8) & 0xff; + return (intValue >> 16) & 0xff; } public char getSuffix() { - return (char) (intValue & 0xff); + return (char) ((intValue >> 8) & 0xff); } - @Override - public boolean equals(Object o) { - if (this == o) return true; - return o instanceof Snapshot other && this.intValue == other.intValue; + public boolean isUnobfuscated() { + return (intValue & 0b00000001) != 0; } @Override public int hashCode() { return intValue; } + + @Override + public boolean equals(Object o) { + return o instanceof LegacySnapshot that && this.intValue == that.intValue; + } + + @Override + protected ToStringBuilder buildDebugString() { + return super.buildDebugString() + .append("year", getYear()) + .append("week", getWeek()) + .append("suffix", getSuffix()) + .append("unobfuscated", isUnobfuscated()); + } } public static final class Special extends GameVersionNumber { @@ -517,8 +672,8 @@ public static final class Special extends GameVersionNumber { private GameVersionNumber prev; - Special(String value) { - super(value); + Special(String value, String normalized) { + super(value, normalized); } @Override @@ -534,13 +689,13 @@ VersionNumber asVersionNumber() { if (versionNumber != null) return versionNumber; - return versionNumber = VersionNumber.asVersion(value); + return versionNumber = VersionNumber.asVersion(normalized); } GameVersionNumber getPrevNormalVersion() { GameVersionNumber v = prev; - while (v instanceof Special) { - v = ((Special) v).prev; + while (v instanceof Special special) { + v = special.prev; } if (v == null) throw new AssertionError("version: " + value); @@ -567,7 +722,7 @@ int compareToSpecial(Special other) { if (other.isUnknown()) return -1; - if (this.value.equals(other.value)) + if (this.normalized.equals(other.normalized)) return 0; int c = this.getPrevNormalVersion().compareTo(other.getPrevNormalVersion()); @@ -575,11 +730,11 @@ int compareToSpecial(Special other) { return c; GameVersionNumber v = prev; - while (v instanceof Special) { + while (v instanceof Special special) { if (v == other) return 1; - v = ((Special) v).prev; + v = special.prev; } return -1; @@ -587,27 +742,23 @@ int compareToSpecial(Special other) { @Override int compareToImpl(@NotNull GameVersionNumber o) { - if (o instanceof Release) + if (o instanceof Release || o instanceof LegacySnapshot) return compareToReleaseOrSnapshot(o); - if (o instanceof Snapshot) - return compareToReleaseOrSnapshot(o); - - if (o instanceof Special) - return compareToSpecial((Special) o); + if (o instanceof Special special) + return compareToSpecial(special); throw new AssertionError(o.getClass()); } @Override - public int hashCode() { - return value.hashCode(); + public boolean equals(Object o) { + return o instanceof Special that && this.normalized.equals(that.normalized); } @Override - public boolean equals(Object o) { - if (this == o) return true; - return o instanceof Special other && this.value.equals(other.value); + public int hashCode() { + return normalized.hashCode(); } } @@ -621,10 +772,11 @@ static final class Versions { static { ArrayDeque defaultGameVersions = new ArrayDeque<>(64); - List snapshots = new ArrayList<>(1024); + List snapshots = new ArrayList<>(1024); List snapshotPrev = new ArrayList<>(1024); - try (BufferedReader reader = new BufferedReader(new InputStreamReader(GameVersionNumber.class.getResourceAsStream("/assets/game/versions.txt"), StandardCharsets.US_ASCII))) { + //noinspection DataFlowIssue + try (var reader = new BufferedReader(new InputStreamReader(GameVersionNumber.class.getResourceAsStream("/assets/game/versions.txt"), StandardCharsets.US_ASCII))) { Release currentRelease = null; GameVersionNumber prev = null; @@ -637,13 +789,14 @@ static final class Versions { if (currentRelease == null) currentRelease = (Release) version; - if (version instanceof Snapshot snapshot) { + if (version instanceof LegacySnapshot snapshot) { snapshots.add(snapshot); snapshotPrev.add(currentRelease); - } else if (version instanceof Release) { - currentRelease = (Release) version; + } else if (version instanceof Release release) { + currentRelease = release; - if (currentRelease.eaType == Release.TYPE_GA) { + if (currentRelease.eaType == Release.ReleaseType.GA + && currentRelease.additional == Release.Additional.NONE) { defaultGameVersions.addFirst(currentRelease.value); } } else if (version instanceof Special special) { @@ -658,6 +811,36 @@ static final class Versions { throw new AssertionError(e); } + //noinspection DataFlowIssue + try (var reader = new BufferedReader(new InputStreamReader(GameVersionNumber.class.getResourceAsStream("/assets/game/version-alias.csv"), StandardCharsets.US_ASCII))) { + for (String line; (line = reader.readLine()) != null; ) { + if (line.isEmpty()) + continue; + + String[] parts = line.split(","); + if (parts.length < 2) { + LOG.warning("Invalid line: " + line); + continue; + } + + String normalized = parts[0]; + Special normalizedVersion = SPECIALS.get(normalized); + if (normalizedVersion == null) { + LOG.warning("Unknown special version: " + normalized); + continue; + } + + for (int i = 1; i < parts.length; i++) { + String version = parts[i]; + Special versionNumber = new Special(version, normalized); + versionNumber.prev = normalizedVersion.prev; + SPECIALS.put(version, versionNumber); + } + } + } catch (IOException e) { + throw new AssertionError(e); + } + DEFAULT_GAME_VERSIONS = defaultGameVersions.toArray(new String[0]); SNAPSHOT_INTS = new int[snapshots.size()]; diff --git a/HMCLCore/src/main/resources/assets/game/log4j2-1.12-debug.xml b/HMCLCore/src/main/resources/assets/game/log4j2-1.12-debug.xml new file mode 100644 index 0000000000..088bccaba7 --- /dev/null +++ b/HMCLCore/src/main/resources/assets/game/log4j2-1.12-debug.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/HMCLCore/src/main/resources/assets/game/log4j2-1.7-debug.xml b/HMCLCore/src/main/resources/assets/game/log4j2-1.7-debug.xml new file mode 100644 index 0000000000..7fabc697da --- /dev/null +++ b/HMCLCore/src/main/resources/assets/game/log4j2-1.7-debug.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/HMCLCore/src/main/resources/assets/game/unlisted-versions.json b/HMCLCore/src/main/resources/assets/game/unlisted-versions.json index 2086ead2c3..4de59be586 100644 --- a/HMCLCore/src/main/resources/assets/game/unlisted-versions.json +++ b/HMCLCore/src/main/resources/assets/game/unlisted-versions.json @@ -1,5 +1,61 @@ { "versions": [ + { + "id": "1.21.11_unobfuscated", + "type": "unobfuscated", + "url": "https://piston-meta.mojang.com/v1/packages/327be7759157b04495c591dbb721875e341877af/1.21.11_unobfuscated.json", + "time": "2025-12-09T12:43:15+00:00", + "releaseTime": "2025-12-09T12:43:15+00:00" + }, + { + "id": "1.21.11-rc2_unobfuscated", + "type": "unobfuscated", + "url": "https://piston-meta.mojang.com/v1/packages/9282a3fb154d2a425086c62c11827281308bf93b/1.21.11-rc2_unobfuscated.json", + "time": "2025-12-05T11:57:45+00:00", + "releaseTime": "2025-12-05T11:57:45+00:00" + }, + { + "id": "1.21.11-rc1_unobfuscated", + "type": "unobfuscated", + "url": "https://piston-meta.mojang.com/v1/packages/5d3ee0ef1f0251cf7e073354ca9e085a884a643d/1.21.11-rc1_unobfuscated.json", + "time": "2025-12-04T15:56:55+00:00", + "releaseTime": "2025-12-04T15:56:55+00:00" + }, + { + "id": "1.21.11-pre5_unobfuscated", + "type": "unobfuscated", + "url": "https://piston-meta.mojang.com/v1/packages/1028441ca6d288bbf2103e773196bf524f7260fd/1.21.11-pre5_unobfuscated.json", + "time": "2025-12-03T13:34:06+00:00", + "releaseTime": "2025-12-03T13:34:06+00:00" + }, + { + "id": "1.21.11-pre4_unobfuscated", + "type": "unobfuscated", + "url": "https://piston-meta.mojang.com/v1/packages/410ce37a2506adcfd54ef7d89168cfbe89cac4cb/1.21.11-pre4_unobfuscated.json", + "time": "2025-12-01T13:40:12+00:00", + "releaseTime": "2025-12-01T13:40:12+00:00" + }, + { + "id": "1.21.11-pre3_unobfuscated", + "type": "unobfuscated", + "url": "https://piston-meta.mojang.com/v1/packages/579bf3428f72b5ea04883d202e4831bfdcb2aa8d/1.21.11-pre3_unobfuscated.json", + "time": "2025-11-25T14:14:30+00:00", + "releaseTime": "2025-11-25T14:14:30+00:00" + }, + { + "id": "1.21.11-pre2_unobfuscated", + "type": "unobfuscated", + "url": "https://piston-meta.mojang.com/v1/packages/2955ce0af0512fdfe53ff0740b017344acf6f397/1.21.11-pre2_unobfuscated.json", + "time": "2025-11-21T12:07:21+00:00", + "releaseTime": "2025-11-21T12:07:21+00:00" + }, + { + "id": "1.21.11-pre1_unobfuscated", + "type": "unobfuscated", + "url": "https://piston-meta.mojang.com/v1/packages/9c267f8dda2728bae55201a753cdd07b584709f1/1.21.11-pre1_unobfuscated.json", + "time": "2025-11-19T08:30:46+00:00", + "releaseTime": "2025-11-19T08:30:46+00:00" + }, { "id": "25w46a_unobfuscated", "type": "unobfuscated", @@ -71,63 +127,63 @@ "releaseTime": "2021-07-13T12:54:19+00:00" }, { - "id": "1_16_combat-6", + "id": "1.16_combat-6", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-6/1_16_combat-6.json", "time": "2020-08-26T06:24:28+00:00", "releaseTime": "2020-08-26T06:24:28+00:00" }, { - "id": "1_16_combat-5", + "id": "1.16_combat-5", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-5/1_16_combat-5.json", "time": "2020-08-21T09:23:13+00:00", "releaseTime": "2020-08-21T09:23:13+00:00" }, { - "id": "1_16_combat-4", + "id": "1.16_combat-4", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-4/1_16_combat-4.json", "time": "2020-08-19T11:14:58+00:00", "releaseTime": "2020-08-19T11:14:58+00:00" }, { - "id": "1_16_combat-3", + "id": "1.16_combat-3", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-3/1_16_combat-3.json", "time": "2020-08-14T09:02:15+00:00", "releaseTime": "2020-08-14T09:02:15+00:00" }, { - "id": "1_16_combat-2", + "id": "1.16_combat-2", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-2/1_16_combat-2.json", "time": "2020-08-13T11:56:30+00:00", "releaseTime": "2020-08-13T11:56:30+00:00" }, { - "id": "1_16_combat-1", + "id": "1.16_combat-1", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-1/1_16_combat-1.json", "time": "2020-08-12T14:07:25+00:00", "releaseTime": "2020-08-12T14:07:25+00:00" }, { - "id": "1_16_combat-0", + "id": "1.16_combat-0", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-0/1_16_combat-0.json", "time": "2020-08-07T10:44:47+00:00", "releaseTime": "2020-08-07T10:44:47+00:00" }, { - "id": "1_15_combat-6", + "id": "1.15_combat-6", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_15_combat-6/1_15_combat-6.json", "time": "2020-01-15T09:46:35+00:00", "releaseTime": "2020-01-15T09:46:35+00:00" }, { - "id": "1_15_combat-1", + "id": "1.15_combat-1", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_15_combat-1/1_15_combat-1.json", "time": "2019-11-29T15:41:39+00:00", diff --git a/HMCLCore/src/main/resources/assets/game/version-alias.csv b/HMCLCore/src/main/resources/assets/game/version-alias.csv new file mode 100644 index 0000000000..5750f988d6 --- /dev/null +++ b/HMCLCore/src/main/resources/assets/game/version-alias.csv @@ -0,0 +1,22 @@ +1.14_combat-212796,1.14.3 - Combat Test +1.14_combat-0,Combat Test 2 +1.14_combat-3,Combat Test 3 +1.15_combat-1,Combat Test 4 +1.15_combat-6,Combat Test 5 +1.16_combat-0,Combat Test 6 +1.16_combat-1,Combat Test 7 +1.16_combat-2,Combat Test 7b +1.16_combat-3,Combat Test 7c +1.16_combat-4,Combat Test 8 +1.16_combat-5,Combat Test 8b +1.16_combat-6,Combat Test 8c +1.18_experimental-snapshot-1,1.18 Experimental Snapshot 1,1.18 experimental snapshot 1 +1.18_experimental-snapshot-2,1.18 Experimental Snapshot 2,1.18 experimental snapshot 2 +1.18_experimental-snapshot-3,1.18 Experimental Snapshot 3,1.18 experimental snapshot 3 +1.18_experimental-snapshot-4,1.18 Experimental Snapshot 4,1.18 experimental snapshot 4 +1.18_experimental-snapshot-5,1.18 Experimental Snapshot 5,1.18 experimental snapshot 5 +1.18_experimental-snapshot-6,1.18 Experimental Snapshot 6,1.18 experimental snapshot 6 +1.18_experimental-snapshot-7,1.18 Experimental Snapshot 7,1.18 experimental snapshot 7 +1.19_deep_dark_experimental_snapshot-1,Deep Dark Experimental Snapshot 1 +20w14infinite,20w14~ +22w13oneBlockAtATime,22w13oneblockatatime \ No newline at end of file diff --git a/HMCLCore/src/main/resources/assets/game/versions.txt b/HMCLCore/src/main/resources/assets/game/versions.txt index 6f1e2d319a..2170f1419a 100644 --- a/HMCLCore/src/main/resources/assets/game/versions.txt +++ b/HMCLCore/src/main/resources/assets/game/versions.txt @@ -452,24 +452,25 @@ 3D Shareware v1.34 19w14a 19w14b -1.14 Pre-Release 1 -1.14 Pre-Release 2 -1.14 Pre-Release 3 -1.14 Pre-Release 4 -1.14 Pre-Release 5 +1.14-pre1 +1.14-pre2 +1.14-pre3 +1.14-pre4 +1.14-pre5 1.14 -1.14.1 Pre-Release 1 -1.14.1 Pre-Release 2 +1.14.1-pre1 +1.14.1-pre2 1.14.1 -1.14.2 Pre-Release 1 -1.14.2 Pre-Release 2 -1.14.2 Pre-Release 3 -1.14.2 Pre-Release 4 +1.14.2-pre1 +1.14.2-pre2 +1.14.2-pre3 +1.14.2-pre4 1.14.2 1.14.3-pre1 1.14.3-pre2 1.14.3-pre3 1.14.3-pre4 +1.14_combat-212796 1.14.3 1.14.4-pre1 1.14.4-pre2 @@ -479,6 +480,8 @@ 1.14.4-pre6 1.14.4-pre7 1.14.4 +1.14_combat-0 +1.14_combat-3 19w34a 19w35a 19w36a @@ -497,6 +500,7 @@ 1.15-pre1 1.15-pre2 1.15-pre3 +1.15_combat-1 1.15-pre4 1.15-pre5 1.15-pre6 @@ -506,6 +510,7 @@ 1.15.1 1.15.2-pre1 1.15.2-pre2 +1.15_combat-6 1.15.2 20w06a 20w07a @@ -545,9 +550,16 @@ 1.16.2-pre1 1.16.2-pre2 1.16.2-pre3 +1.16_combat-0 1.16.2-rc1 1.16.2-rc2 1.16.2 +1.16_combat-1 +1.16_combat-2 +1.16_combat-3 +1.16_combat-4 +1.16_combat-5 +1.16_combat-6 1.16.3-rc1 1.16.3 1.16.4-pre1 @@ -592,6 +604,13 @@ 1.17.1-rc1 1.17.1-rc2 1.17.1 +1.18_experimental-snapshot-1 +1.18_experimental-snapshot-2 +1.18_experimental-snapshot-3 +1.18_experimental-snapshot-4 +1.18_experimental-snapshot-5 +1.18_experimental-snapshot-6 +1.18_experimental-snapshot-7 21w37a 21w38a 21w39a @@ -622,12 +641,13 @@ 22w05a 22w06a 22w07a +1.19_deep_dark_experimental_snapshot-1 1.18.2-pre1 1.18.2-pre2 1.18.2-pre3 1.18.2-rc1 1.18.2 -22w13oneblockatatime +22w13oneBlockAtATime 22w11a 22w12a 22w13a @@ -856,4 +876,20 @@ 25w45a 25w45a_unobfuscated 25w46a -25w46a_unobfuscated \ No newline at end of file +25w46a_unobfuscated +1.21.11-pre1 +1.21.11-pre1_unobfuscated +1.21.11-pre2 +1.21.11-pre2_unobfuscated +1.21.11-pre3 +1.21.11-pre3_unobfuscated +1.21.11-pre4 +1.21.11-pre4_unobfuscated +1.21.11-pre5 +1.21.11-pre5_unobfuscated +1.21.11-rc1 +1.21.11-rc1_unobfuscated +1.21.11-rc2 +1.21.11-rc2_unobfuscated +1.21.11 +1.21.11_unobfuscated diff --git a/HMCLCore/src/main/resources/assets/platform/amdgpu.ids b/HMCLCore/src/main/resources/assets/platform/amdgpu.ids index 22875d9793..34d7d43a82 100644 --- a/HMCLCore/src/main/resources/assets/platform/amdgpu.ids +++ b/HMCLCore/src/main/resources/assets/platform/amdgpu.ids @@ -1,4 +1,4 @@ -# https://gitlab.freedesktop.org/mesa/libdrm/-/blob/7d43d9b6fe5df4b234937fce9e51adb1b327ded1/data/amdgpu.ids +# https://gitlab.freedesktop.org/mesa/libdrm/-/blob/35a21916c8f621b35a67f3c5994280ae59a4981a/data/amdgpu.ids # # List of AMDGPU IDs # @@ -559,6 +559,7 @@ 7448, 00, AMD Radeon Pro W7900 7449, 00, AMD Radeon Pro W7800 48GB 744A, 00, AMD Radeon Pro W7900 Dual Slot +744B, 00, AMD Radeon Pro W7900D 744C, C8, AMD Radeon RX 7900 XTX 744C, CC, AMD Radeon RX 7900 XT 744C, CE, AMD Radeon RX 7900 GRE @@ -569,6 +570,7 @@ 7470, 00, AMD Radeon Pro W7700 747E, C8, AMD Radeon RX 7800 XT 747E, D8, AMD Radeon RX 7800M +747E, DB, AMD Radeon RX 7700 747E, FF, AMD Radeon RX 7700 XT 7480, 00, AMD Radeon Pro W7600 7480, C0, AMD Radeon RX 7600 XT @@ -586,6 +588,7 @@ 74A1, 00, AMD Instinct MI300X 74A2, 00, AMD Instinct MI308X 74A5, 00, AMD Instinct MI325X +74A8, 00, AMD Instinct MI308X HF 74A9, 00, AMD Instinct MI300X HF 74B5, 00, AMD Instinct MI300X VF 74B6, 00, AMD Instinct MI308X @@ -594,6 +597,12 @@ 7550, C2, AMD Radeon RX 9070 GRE 7550, C3, AMD Radeon RX 9070 7551, C0, AMD Radeon AI PRO R9700 +7590, C0, AMD Radeon RX 9060 XT +7590, C7, AMD Radeon RX 9060 +75A0, C0, AMD Instinct MI350X +75A3, C0, AMD Instinct MI355X +75B0, C0, AMD Instinct MI350X VF +75B3, C0, AMD Instinct MI355X VF 9830, 00, AMD Radeon HD 8400 / R3 Series 9831, 00, AMD Radeon HD 8400E 9832, 00, AMD Radeon HD 8330 @@ -689,4 +698,4 @@ 98E4, E9, AMD Radeon R4 Graphics 98E4, EA, AMD Radeon R4 Graphics 98E4, EB, AMD Radeon R3 Graphics -98E4, EB, AMD Radeon R4 Graphics +98E4, EB, AMD Radeon R4 Graphics \ No newline at end of file diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/io/FileUtilsTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/io/FileUtilsTest.java index f8d8244852..ffe4f90e92 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/io/FileUtilsTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/io/FileUtilsTest.java @@ -52,6 +52,7 @@ public void testIsNameValid(OperatingSystem os) { assertFalse(FileUtils.isNameValid(os, "a\uD83Db")); assertFalse(FileUtils.isNameValid(os, "a\uDE00b")); assertFalse(FileUtils.isNameValid(os, "a\uDE00\uD83Db")); + assertFalse(FileUtils.isNameValid(os, "f:oo")); // Platform-specific tests boolean isWindows = os == OperatingSystem.WINDOWS; @@ -62,7 +63,6 @@ public void testIsNameValid(OperatingSystem os) { assertEquals(isNotWindows, FileUtils.isNameValid(os, "foo ")); assertEquals(isNotWindows, FileUtils.isNameValid(os, "foo")); - assertEquals(isNotWindows, FileUtils.isNameValid(os, "f:oo")); assertEquals(isNotWindows, FileUtils.isNameValid(os, "f?oo")); assertEquals(isNotWindows, FileUtils.isNameValid(os, "f*oo")); assertEquals(isNotWindows, FileUtils.isNameValid(os, "f\\oo")); diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/versioning/GameVersionNumberTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/versioning/GameVersionNumberTest.java index 865f71754d..8a702e765f 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/versioning/GameVersionNumberTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/versioning/GameVersionNumberTest.java @@ -25,6 +25,7 @@ import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.util.*; +import java.util.function.Supplier; import static org.jackhuang.hmcl.util.versioning.GameVersionNumber.asGameVersion; import static org.junit.jupiter.api.Assertions.*; @@ -34,6 +35,8 @@ */ public final class GameVersionNumberTest { + //region Helpers + private static List readVersions() { List versions = new ArrayList<>(); @@ -48,18 +51,8 @@ private static List readVersions() { return versions; } - @Test - public void testSortVersions() { - List versions = readVersions(); - List copied = new ArrayList<>(versions); - Collections.shuffle(copied, new Random(0)); - copied.sort(Comparator.comparing(GameVersionNumber::asGameVersion)); - - assertIterableEquals(versions, copied); - } - - private static String errorMessage(String version1, String version2) { - return String.format("version1=%s, version2=%s", version1, version2); + private static Supplier errorMessage(GameVersionNumber version1, GameVersionNumber version2) { + return () -> "version1=%s, version2=%s".formatted(version1.toDebugString(), version2.toDebugString()); } private static void assertGameVersionEquals(String version) { @@ -67,25 +60,42 @@ private static void assertGameVersionEquals(String version) { } private static void assertGameVersionEquals(String version1, String version2) { - assertEquals(0, asGameVersion(version1).compareTo(version2), errorMessage(version1, version2)); - assertEquals(asGameVersion(version1), asGameVersion(version2), errorMessage(version1, version2)); - } - - private static String toString(GameVersionNumber gameVersionNumber) { - return gameVersionNumber.getClass().getSimpleName(); + GameVersionNumber gameVersion1 = asGameVersion(version1); + GameVersionNumber gameVersion2 = asGameVersion(version2); + assertEquals(0, gameVersion1.compareTo(gameVersion2), errorMessage(gameVersion1, gameVersion2)); + assertEquals(0, gameVersion2.compareTo(gameVersion1), errorMessage(gameVersion1, gameVersion2)); + assertEquals(gameVersion1, gameVersion2, errorMessage(gameVersion1, gameVersion2)); + assertEquals(gameVersion2, gameVersion1, errorMessage(gameVersion1, gameVersion2)); + assertEquals(gameVersion1.hashCode(), gameVersion2.hashCode(), errorMessage(gameVersion1, gameVersion2)); } private static void assertOrder(String... versions) { + var gameVersionNumbers = new GameVersionNumber[versions.length]; + for (int i = 0; i < versions.length; i++) { + gameVersionNumbers[i] = asGameVersion(versions[i]); + } + for (int i = 0; i < versions.length - 1; i++) { - GameVersionNumber version1 = asGameVersion(versions[i]); + GameVersionNumber version1 = gameVersionNumbers[i]; + + for (int j = 0; j < i; j++) { + GameVersionNumber version2 = gameVersionNumbers[j]; + + assertTrue(version1.compareTo(version2) > 0, errorMessage(version1, version2)); + assertTrue(version2.compareTo(version1) < 0, errorMessage(version1, version2)); + assertNotEquals(version1, version2, errorMessage(version1, version2)); + assertNotEquals(version2, version1, errorMessage(version1, version2)); + } assertGameVersionEquals(versions[i]); for (int j = i + 1; j < versions.length; j++) { - GameVersionNumber version2 = asGameVersion(versions[j]); + GameVersionNumber version2 = gameVersionNumbers[j]; - assertEquals(-1, version1.compareTo(version2), String.format("version1=%s (%s), version2=%s (%s)", versions[i], toString(version1), versions[j], toString(version2))); - assertEquals(1, version2.compareTo(version1), String.format("version1=%s (%s), version2=%s (%s)", versions[i], toString(version1), versions[j], toString(version2))); + assertTrue(version1.compareTo(version2) < 0, errorMessage(version1, version2)); + assertTrue(version2.compareTo(version1) > 0, errorMessage(version1, version2)); + assertNotEquals(version1, version2, errorMessage(version1, version2)); + assertNotEquals(version2, version1, errorMessage(version1, version2)); } } @@ -100,6 +110,8 @@ private void assertOldVersion(String oldVersion, GameVersionNumber.Type type, St assertEquals(VersionNumber.asVersion(versionNumber), old.versionNumber); } + //endregion Helpers + private static boolean isAprilFools(String version) { return asGameVersion(version).isAprilFools(); } @@ -124,6 +136,31 @@ public void testIsAprilFools() { assertFalse(isAprilFools("25w45a_unobfuscated")); } + @Test + public void testSortVersions() { + List versions = readVersions(); + + { + List copied = new ArrayList<>(versions); + copied.sort(Comparator.comparing(GameVersionNumber::asGameVersion)); + assertIterableEquals(versions, copied); + } + + { + List copied = new ArrayList<>(versions); + Collections.reverse(copied); + copied.sort(Comparator.comparing(GameVersionNumber::asGameVersion)); + assertIterableEquals(versions, copied); + } + + for (int randomSeed = 0; randomSeed < 5; randomSeed++) { + List copied = new ArrayList<>(versions); + Collections.shuffle(copied, new Random(randomSeed)); + copied.sort(Comparator.comparing(GameVersionNumber::asGameVersion)); + assertIterableEquals(versions, copied); + } + } + @Test public void testParseOld() { assertOldVersion("rd-132211", GameVersionNumber.Type.PRE_CLASSIC, "132211"); @@ -136,36 +173,96 @@ public void testParseOld() { assertOldVersion("a1.0.13_01-1", GameVersionNumber.Type.ALPHA, "1.0.13_01-1"); assertOldVersion("b1.0", GameVersionNumber.Type.BETA, "1.0"); assertOldVersion("b1.0_01", GameVersionNumber.Type.BETA, "1.0_01"); + assertOldVersion("b1.6-tb3", GameVersionNumber.Type.BETA, "1.6-tb3"); assertOldVersion("b1.8-pre1-2", GameVersionNumber.Type.BETA, "1.8-pre1-2"); assertOldVersion("b1.9-pre1", GameVersionNumber.Type.BETA, "1.9-pre1"); + + assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.Old.parse("")); + assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.Old.parse("1.21")); + assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.Old.parse("r-132211")); + assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.Old.parse("rd-")); + assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.Old.parse("rd-a")); + assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.Old.parse("i-20100223")); + assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.Old.parse("in-")); + assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.Old.parse("in-a")); + assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.Old.parse("inf-")); + assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.Old.parse("inf-a")); + } + + private static void testParseLegacySnapshot(int year, int week, char suffix) { + String raw = "%02dw%02d%s".formatted(year, week, suffix); + var rawVersion = (GameVersionNumber.LegacySnapshot) asGameVersion(raw); + assertInstanceOf(GameVersionNumber.LegacySnapshot.class, rawVersion); + assertEquals(raw, rawVersion.toString()); + assertEquals(raw, rawVersion.toNormalizedString()); + assertEquals(year, rawVersion.getYear()); + assertEquals(week, rawVersion.getWeek()); + assertEquals(suffix, rawVersion.getSuffix()); + assertFalse(rawVersion.isUnobfuscated()); + + var unobfuscated = raw + "_unobfuscated"; + var unobfuscatedVersion = (GameVersionNumber.LegacySnapshot) asGameVersion(unobfuscated); + assertInstanceOf(GameVersionNumber.LegacySnapshot.class, rawVersion); + assertEquals(unobfuscated, unobfuscatedVersion.toString()); + assertEquals(unobfuscated, unobfuscatedVersion.toNormalizedString()); + assertEquals(year, unobfuscatedVersion.getYear()); + assertEquals(week, unobfuscatedVersion.getWeek()); + assertEquals(suffix, unobfuscatedVersion.getSuffix()); + assertTrue(unobfuscatedVersion.isUnobfuscated()); + + var unobfuscated2 = raw + " Unobfuscated"; + var unobfuscatedVersion2 = (GameVersionNumber.LegacySnapshot) asGameVersion(unobfuscated2); + assertInstanceOf(GameVersionNumber.LegacySnapshot.class, rawVersion); + assertEquals(unobfuscated2, unobfuscatedVersion2.toString()); + assertEquals(unobfuscated, unobfuscatedVersion2.toNormalizedString()); + assertEquals(year, unobfuscatedVersion2.getYear()); + assertEquals(week, unobfuscatedVersion2.getWeek()); + assertEquals(suffix, unobfuscatedVersion2.getSuffix()); + assertTrue(unobfuscatedVersion2.isUnobfuscated()); } @Test public void testParseNew() { List versions = readVersions(); for (String version : versions) { - assertFalse(asGameVersion(version) instanceof GameVersionNumber.Old, "version=" + version); + GameVersionNumber gameVersion = asGameVersion(version); + assertFalse(gameVersion instanceof GameVersionNumber.Old, "version=" + gameVersion.toDebugString()); } + + testParseLegacySnapshot(25, 46, 'a'); + + assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.Release.parse("2.1")); + assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.LegacySnapshot.parse("1.0")); + assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.LegacySnapshot.parse("1.100.1")); + assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.LegacySnapshot.parse("aawbba")); + assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.LegacySnapshot.parse("13w12A")); + assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.LegacySnapshot.parse("13w12~")); } - private static void assertSimpleReleaseVersion(String simpleReleaseVersion, int minor, int patch) { + private static void assertSimpleReleaseVersion(String simpleReleaseVersion, int major, int minor, int patch) { GameVersionNumber.Release release = GameVersionNumber.Release.parseSimple(simpleReleaseVersion); assertAll("Assert Simple Release Version " + simpleReleaseVersion, - () -> assertEquals(1, release.getMajor()), + () -> assertEquals(major, release.getMajor()), () -> assertEquals(minor, release.getMinor()), () -> assertEquals(patch, release.getPatch()), - () -> assertEquals(GameVersionNumber.Release.TYPE_UNKNOWN, release.getEaType()), + () -> assertEquals(GameVersionNumber.Release.ReleaseType.UNKNOWN, release.getEaType()), () -> assertEquals(VersionNumber.ZERO, release.getEaVersion()) ); } @Test public void testParseSimpleRelease() { - assertSimpleReleaseVersion("1.0", 0, 0); - assertSimpleReleaseVersion("1.13", 13, 0); - assertSimpleReleaseVersion("1.21.8", 21, 8); - + assertSimpleReleaseVersion("1.0", 1, 0, 0); + assertSimpleReleaseVersion("1.13", 1, 13, 0); + assertSimpleReleaseVersion("1.21.8", 1, 21, 8); + assertSimpleReleaseVersion("26.1", 26, 1, 0); + assertSimpleReleaseVersion("26.1.1", 26, 1, 1); + + assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.Release.parseSimple("26")); + assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.Release.parseSimple("24.0.0")); + assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.Release.parseSimple("24.0")); assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.Release.parseSimple("2.0")); + assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.Release.parseSimple("1")); assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.Release.parseSimple("1..0")); assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.Release.parseSimple("1.0.")); assertThrows(IllegalArgumentException.class, () -> GameVersionNumber.Release.parseSimple("1.a")); @@ -186,13 +283,13 @@ public void testCompareRelease() { "0.0", "1.0", "1.99", - "1.99.1-unknown1", "1.99.1-pre1", "1.99.1 Pre-Release 2", "1.99.1-rc1", "1.99.1", "1.100", - "1.100.1" + "1.100.1", + "26.1" ); } @@ -202,7 +299,6 @@ public void testCompareSnapshot() { "90w01a", "90w01b", "90w01e", - "90w01~", "90w02a" ); } @@ -257,6 +353,7 @@ public void testCompareMix() { "1.14", "1.15.2", "20w06a", + "20w13b", "20w14infinite", "20w22a", "1.16-pre1", @@ -273,10 +370,21 @@ public void testCompareMix() { "24w13a", "24w14potato", "24w14a", - "25w45a", - "25w45a_unobfuscated", - "Unknown", - "100.0" + "25w46a", + "25w46a_unobfuscated", + "1.21.11-pre1", + "1.21.11-pre1_unobfuscated", + "1.21.11-pre2", + "1.21.11-pre2_unobfuscated", + "99w99a", + "26.1-snapshot-1", + "26.1-snapshot-2", + "26.1", + "26.2-snapshot-1", + "26.2-snapshot-2", + "26.2", + "100.0", + "Unknown" ); } @@ -312,26 +420,86 @@ public void testCompareUnknown() { ); } + private static void assertNormalized(String normalized, String version) { + assertGameVersionEquals(version); + assertGameVersionEquals(normalized, version); + assertEquals(normalized, asGameVersion(version).toNormalizedString()); + } + + @Test + public void testToNormalizedString() { + for (String version : readVersions()) { + assertNormalized(version, version); + } + + assertNormalized("26.1-snapshot-1", "26.1 Snapshot 1"); + assertNormalized("1.21.11-pre3", "1.21.11 Pre-Release 3"); + assertNormalized("1.21.11-pre3_unobfuscated", "1.21.11 Pre-Release 3 Unobfuscated"); + assertNormalized("1.21.11-pre3_unobfuscated", "1.21.11-pre3 Unobfuscated"); + assertNormalized("1.21.11-rc1", "1.21.11 Release Candidate 1"); + assertNormalized("1.21.11-rc1_unobfuscated", "1.21.11 Release Candidate 1 Unobfuscated"); + assertNormalized("1.14_combat-212796", "1.14.3 - Combat Test"); + assertNormalized("1.14_combat-0", "Combat Test 2"); + assertNormalized("1.14_combat-3", "Combat Test 3"); + assertNormalized("1.15_combat-1", "Combat Test 4"); + assertNormalized("1.15_combat-6", "Combat Test 5"); + assertNormalized("1.16_combat-0", "Combat Test 6"); + assertNormalized("1.16_combat-1", "Combat Test 7"); + assertNormalized("1.16_combat-2", "Combat Test 7b"); + assertNormalized("1.16_combat-3", "Combat Test 7c"); + assertNormalized("1.16_combat-4", "Combat Test 8"); + assertNormalized("1.16_combat-5", "Combat Test 8b"); + assertNormalized("1.16_combat-6", "Combat Test 8c"); + assertNormalized("1.18_experimental-snapshot-1", "1.18 Experimental Snapshot 1"); + assertNormalized("1.18_experimental-snapshot-2", "1.18 experimental snapshot 2"); + assertNormalized("1.18_experimental-snapshot-3", "1.18 experimental snapshot 3"); + assertNormalized("1.18_experimental-snapshot-4", "1.18 experimental snapshot 4"); + assertNormalized("1.18_experimental-snapshot-5", "1.18 experimental snapshot 5"); + assertNormalized("1.18_experimental-snapshot-6", "1.18 experimental snapshot 6"); + assertNormalized("1.18_experimental-snapshot-7", "1.18 experimental snapshot 7"); + assertNormalized("1.19_deep_dark_experimental_snapshot-1", "Deep Dark Experimental Snapshot 1"); + assertNormalized("20w14infinite", "20w14~"); + assertNormalized("22w13oneBlockAtATime", "22w13oneblockatatime"); + } + @Test public void isAtLeast() { - assertTrue(asGameVersion("1.13").isAtLeast("1.13", "17w43a")); - assertTrue(asGameVersion("1.13.1").isAtLeast("1.13", "17w43a")); - assertTrue(asGameVersion("1.14").isAtLeast("1.13", "17w43a")); - assertTrue(asGameVersion("1.13-rc1").isAtLeast("1.13", "17w43a")); - assertTrue(asGameVersion("1.13-pre1").isAtLeast("1.13", "17w43a")); - assertTrue(asGameVersion("17w43a").isAtLeast("1.13", "17w43a")); - assertTrue(asGameVersion("17w43b").isAtLeast("1.13", "17w43a")); - assertTrue(asGameVersion("17w45a").isAtLeast("1.13", "17w43a")); - - assertFalse(asGameVersion("17w31a").isAtLeast("1.13", "17w43a")); - assertFalse(asGameVersion("1.12").isAtLeast("1.13", "17w43a")); - assertFalse(asGameVersion("1.12.2").isAtLeast("1.13", "17w43a")); - assertFalse(asGameVersion("1.12.2-pre1").isAtLeast("1.13", "17w43a")); - assertFalse(asGameVersion("rd-132211").isAtLeast("1.13", "17w43a")); - assertFalse(asGameVersion("a1.0.6").isAtLeast("1.13", "17w43a")); - - assertThrows(IllegalArgumentException.class, () -> asGameVersion("1.13").isAtLeast("17w43a", "17w43a")); - assertThrows(IllegalArgumentException.class, () -> asGameVersion("17w43a").isAtLeast("1.13", "1.13")); - assertThrows(IllegalArgumentException.class, () -> asGameVersion("17w43a").isAtLeast("1.13", "22w13oneblockatatime")); + assertTrue(asGameVersion("1.13").isAtLeast("1.13", "17w43a", true)); + assertTrue(asGameVersion("1.13").isAtLeast("1.13", "17w43a", false)); + assertTrue(asGameVersion("1.13.1").isAtLeast("1.13", "17w43a", true)); + assertTrue(asGameVersion("1.13.1").isAtLeast("1.13", "17w43a", false)); + assertTrue(asGameVersion("1.14").isAtLeast("1.13", "17w43a", true)); + assertTrue(asGameVersion("1.14").isAtLeast("1.13", "17w43a", false)); + assertTrue(asGameVersion("1.13-rc1").isAtLeast("1.13", "17w43a", false)); + assertTrue(asGameVersion("1.13-pre1").isAtLeast("1.13", "17w43a", false)); + assertTrue(asGameVersion("17w43a").isAtLeast("1.13", "17w43a", true)); + assertTrue(asGameVersion("17w43a").isAtLeast("1.13", "17w43a", false)); + assertTrue(asGameVersion("17w43b").isAtLeast("1.13", "17w43a", true)); + assertTrue(asGameVersion("17w43b").isAtLeast("1.13", "17w43a", false)); + assertTrue(asGameVersion("17w45a").isAtLeast("1.13", "17w43a", true)); + assertTrue(asGameVersion("17w45a").isAtLeast("1.13", "17w43a", false)); + + + assertFalse(asGameVersion("1.13-rc1").isAtLeast("1.13", "17w43a", true)); + assertFalse(asGameVersion("1.13-pre1").isAtLeast("1.13", "17w43a", true)); + assertFalse(asGameVersion("17w31a").isAtLeast("1.13", "17w43a", true)); + assertFalse(asGameVersion("17w31a").isAtLeast("1.13", "17w43a", false)); + assertFalse(asGameVersion("1.12").isAtLeast("1.13", "17w43a", true)); + assertFalse(asGameVersion("1.12").isAtLeast("1.13", "17w43a", false)); + assertFalse(asGameVersion("1.12.2").isAtLeast("1.13", "17w43a", true)); + assertFalse(asGameVersion("1.12.2").isAtLeast("1.13", "17w43a", false)); + assertFalse(asGameVersion("1.12.2-pre1").isAtLeast("1.13", "17w43a", true)); + assertFalse(asGameVersion("1.12.2-pre1").isAtLeast("1.13", "17w43a", false)); + assertFalse(asGameVersion("rd-132211").isAtLeast("1.13", "17w43a", true)); + assertFalse(asGameVersion("rd-132211").isAtLeast("1.13", "17w43a", false)); + assertFalse(asGameVersion("a1.0.6").isAtLeast("1.13", "17w43a", true)); + assertFalse(asGameVersion("a1.0.6").isAtLeast("1.13", "17w43a", false)); + + assertThrows(IllegalArgumentException.class, () -> asGameVersion("1.13").isAtLeast("17w43a", "17w43a", true)); + assertThrows(IllegalArgumentException.class, () -> asGameVersion("1.13").isAtLeast("17w43a", "17w43a", false)); + assertThrows(IllegalArgumentException.class, () -> asGameVersion("17w43a").isAtLeast("1.13", "1.13", true)); + assertThrows(IllegalArgumentException.class, () -> asGameVersion("17w43a").isAtLeast("1.13", "1.13", false)); + assertThrows(IllegalArgumentException.class, () -> asGameVersion("17w43a").isAtLeast("1.13", "22w13oneblockatatime", true)); + assertThrows(IllegalArgumentException.class, () -> asGameVersion("17w43a").isAtLeast("1.13", "22w13oneblockatatime", false)); } } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index f0b6737ceb..789dca8a86 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -10,6 +10,7 @@ repositories { dependencies { implementation(libs.gson) implementation(libs.jna) + implementation(libs.kala.compress.tar) } java { diff --git a/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/TerracottaConfigUpgradeTask.java b/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/TerracottaConfigUpgradeTask.java new file mode 100644 index 0000000000..7a035963ac --- /dev/null +++ b/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/TerracottaConfigUpgradeTask.java @@ -0,0 +1,167 @@ +package org.jackhuang.hmcl.gradle; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.google.gson.annotations.SerializedName; +import kala.compress.archivers.tar.TarArchiveEntry; +import kala.compress.archivers.tar.TarArchiveReader; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.zip.GZIPInputStream; + +public abstract class TerracottaConfigUpgradeTask extends DefaultTask { + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + @Input + public abstract ListProperty<@NotNull String> getClassifiers(); + + @Input + public abstract Property<@NotNull String> getVersion(); + + @Input + public abstract Property<@NotNull String> getDownloadURL(); + + @InputFile + public abstract RegularFileProperty getTemplateFile(); + + @OutputFile + public abstract RegularFileProperty getOutputFile(); + + @TaskAction + public void run() throws Exception { + JsonObject config = GSON.fromJson( + Files.readString(getTemplateFile().get().getAsFile().toPath(), StandardCharsets.UTF_8), + JsonObject.class + ); + + Map files = new LinkedHashMap<>(); + HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.ALWAYS).build(); + try { + List>> tasks = new ArrayList<>(); + for (String classifier : getClassifiers().get()) { + Path path = Files.createTempFile("terracotta-bundle-", ".tar.gz"); + String url = getDownloadURL().get().replace("${classifier}", classifier).replace("${version}", getVersion().get()); + files.put(classifier, path); + + tasks.add(client.sendAsync( + HttpRequest.newBuilder().GET().uri(URI.create(url)).build(), + HttpResponse.BodyHandlers.ofFile(path) + )); + } + + for (CompletableFuture> task : tasks) { + HttpResponse response = task.get(); + if (response.statusCode() != 200) { + throw new IOException(String.format("Unable to request %s: %d", response.uri(), response.statusCode())); + } + } + } finally { + if (client instanceof AutoCloseable) { // Since Java21, HttpClient implements AutoCloseable: https://bugs.openjdk.org/browse/JDK-8304165 + ((AutoCloseable) client).close(); + } + } + + Map bundles = new LinkedHashMap<>(); + MessageDigest digest = MessageDigest.getInstance("SHA-512"); + HexFormat hexFormat = HexFormat.of(); + + for (Map.Entry entry : files.entrySet()) { + String classifier = entry.getKey(); + Path bundle = entry.getValue(); + Path decompressedBundle = Files.createTempFile("terracotta-bundle-", ".tar"); + try (InputStream is = new GZIPInputStream(new DigestInputStream(Files.newInputStream(bundle), digest)); + OutputStream os = Files.newOutputStream(decompressedBundle)) { + is.transferTo(os); + } + + String bundleHash = hexFormat.formatHex(digest.digest()); + + Map bundleContents = new LinkedHashMap<>(); + try (TarArchiveReader reader = new TarArchiveReader(decompressedBundle)) { + List entries = new ArrayList<>(reader.getEntries()); + entries.sort(Comparator.comparing(TarArchiveEntry::getName)); + + for (TarArchiveEntry archiveEntry : entries) { + String[] split = archiveEntry.getName().split("/", 2); + if (split.length != 1) { + throw new IllegalStateException( + String.format("Illegal bundle %s: files (%s) in sub directories are unsupported.", classifier, archiveEntry.getName()) + ); + } + String name = split[0]; + + try (InputStream is = new DigestInputStream(reader.getInputStream(archiveEntry), digest)) { + is.transferTo(OutputStream.nullOutputStream()); + } + String hash = hexFormat.formatHex(digest.digest()); + + bundleContents.put(name, hash); + } + } + + bundles.put(classifier, new Bundle(bundleHash, bundleContents)); + + Files.delete(bundle); + Files.delete(decompressedBundle); + } + + config.add("__comment__", new JsonPrimitive("THIS FILE IS MACHINE GENERATED! DO NOT EDIT!")); + config.add("version_latest", new JsonPrimitive(getVersion().get())); + config.add("packages", GSON.toJsonTree(bundles)); + + Files.writeString(getOutputFile().get().getAsFile().toPath(), GSON.toJson(config), StandardCharsets.UTF_8); + } + + public void checkValid() throws IOException { + Path output = getOutputFile().get().getAsFile().toPath(); + if (Files.isReadable(output)) { + String version = GSON.fromJson(Files.readString(output, StandardCharsets.UTF_8), JsonObject.class) + .get("version_latest").getAsJsonPrimitive().getAsString(); + if (Objects.equals(version, getVersion().get())) { + return; + } + } + + throw new GradleException(String.format("Terracotta config isn't up-to-date! " + + "You might have just edited the version number in libs.version.toml. " + + "Please run task %s to resolve the new config.", getPath())); + } + + private record Bundle( + @SerializedName("hash") String hash, + @SerializedName("files") Map files + ) { + } +} diff --git a/config/project.properties b/config/project.properties index 022d8fd39b..e62f674dd3 100644 --- a/config/project.properties +++ b/config/project.properties @@ -15,4 +15,4 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # -versionRoot=3.8 +versionRoot=3.11 diff --git a/docs/Building.md b/docs/Building.md deleted file mode 100644 index 12536a2438..0000000000 --- a/docs/Building.md +++ /dev/null @@ -1,56 +0,0 @@ -# Build Guide - - -**English** | [中文](Building_zh.md) - - -## Requirements - -To build the HMCL launcher, you need to install JDK 17 (or higher). You can download it here: [Download Liberica JDK](https://bell-sw.com/pages/downloads/#jdk-25-lts). - -After installing the JDK, make sure the `JAVA_HOME` environment variable points to the required JDK directory. -You can check the JDK version that `JAVA_HOME` points to like this: - -

-Windows - -PowerShell: -``` -PS > & "$env:JAVA_HOME/bin/java.exe" -version -openjdk version "25" 2025-09-16 LTS -OpenJDK Runtime Environment (build 25+37-LTS) -OpenJDK 64-Bit Server VM (build 25+37-LTS, mixed mode, sharing) -``` - -
- -
-Linux/macOS/FreeBSD - -``` -> $JAVA_HOME/bin/java -version -openjdk version "25" 2025-09-16 LTS -OpenJDK Runtime Environment (build 25+37-LTS) -OpenJDK 64-Bit Server VM (build 25+37-LTS, mixed mode, sharing) -``` - -
- -## Get HMCL Source Code - -- You can get the latest source code via [Git](https://git-scm.com/downloads): - ```shell - git clone https://github.com/HMCL-dev/HMCL.git - cd HMCL - ``` -- You can manually download a specific version of the source code from the [GitHub Release page](https://github.com/HMCL-dev/HMCL/releases). - -## Build HMCL - -To build HMCL, switch to the root directory of the HMCL project and run the following command: - -```shell -./gradlew clean makeExecutables -``` - -The built HMCL program files are located in the `HMCL/build/libs` subdirectory under the project root. diff --git a/docs/Building_zh.md b/docs/Building_zh.md deleted file mode 100644 index 58d9a264b7..0000000000 --- a/docs/Building_zh.md +++ /dev/null @@ -1,56 +0,0 @@ -# 构建指南 - - -[English](Building.md) | **中文** - - -## 环境需求 - -构建 HMCL 启动器需要安装 JDK 17 (或更高版本)。你可以从此处下载它: [Download Liberica JDK](https://bell-sw.com/pages/downloads/#jdk-25-lts)。 - -在安装 JDK 后,请确保 `JAVA_HOME` 环境变量指向符合需求的 JDK 目录。 -你可以这样查看 `JAVA_HOME` 指向的 JDK 版本: - -
-Windows - -PowerShell: -``` -PS > & "$env:JAVA_HOME/bin/java.exe" -version -openjdk version "25" 2025-09-16 LTS -OpenJDK Runtime Environment (build 25+37-LTS) -OpenJDK 64-Bit Server VM (build 25+37-LTS, mixed mode, sharing) -``` - -
- -
-Linux/macOS/FreeBSD - -``` -> $JAVA_HOME/bin/java -version -openjdk version "25" 2025-09-16 LTS -OpenJDK Runtime Environment (build 25+37-LTS) -OpenJDK 64-Bit Server VM (build 25+37-LTS, mixed mode, sharing) -``` - -
- -## 获取 HMCL 源码 - -- 通过 [Git](https://git-scm.com/downloads) 可以获取最新源码: - ```shell - git clone https://github.com/HMCL-dev/HMCL.git - cd HMCL - ``` -- 从 [GitHub Release 页面](https://github.com/HMCL-dev/HMCL/releases)可以手动下载特定版本的源码。 - -## 构建 HMCL - -想要构建 HMCL,请切换到 HMCL 项目的根目录下,并执行以下命令: - -```shell -./gradlew clean makeExecutables -``` - -构建出的 HMCL 程序文件位于根目录下的 `HMCL/build/libs` 子目录中。 diff --git a/docs/Contributing.md b/docs/Contributing.md new file mode 100644 index 0000000000..5c008ba48d --- /dev/null +++ b/docs/Contributing.md @@ -0,0 +1,93 @@ +# Contributing Guide + + +**English** | [中文](Contributing_zh.md) + + +## Build HMCL + +### Requirements + +To build the HMCL launcher, you need to install JDK 17 (or higher). You can download it here: [Download Liberica JDK](https://bell-sw.com/pages/downloads/#jdk-25-lts). + +After installing the JDK, make sure the `JAVA_HOME` environment variable points to the required JDK directory. +You can check the JDK version that `JAVA_HOME` points to like this: + +
+Windows + +PowerShell: + +``` +PS > & "$env:JAVA_HOME/bin/java.exe" -version +openjdk version "25" 2025-09-16 LTS +OpenJDK Runtime Environment (build 25+37-LTS) +OpenJDK 64-Bit Server VM (build 25+37-LTS, mixed mode, sharing) +``` + +
+ +
+Linux/macOS/FreeBSD + +``` +> $JAVA_HOME/bin/java -version +openjdk version "25" 2025-09-16 LTS +OpenJDK Runtime Environment (build 25+37-LTS) +OpenJDK 64-Bit Server VM (build 25+37-LTS, mixed mode, sharing) +``` + +
+ +### Get HMCL Source Code + +- You can get the latest source code via [Git](https://git-scm.com/downloads): + ```shell + git clone https://github.com/HMCL-dev/HMCL.git + cd HMCL + ``` +- You can manually download a specific version of the source code from the [GitHub Release page](https://github.com/HMCL-dev/HMCL/releases). + +### Build HMCL + +To build HMCL, switch to the root directory of the HMCL project and run the following command: + +```shell +./gradlew clean makeExecutables +``` + +The built HMCL program files are located in the `HMCL/build/libs` subdirectory under the project root. + +## Debug Options + +> [!WARNING] +> This document describes HMCL's internal features, which we do not guarantee to be stable and may be modified or removed at any time. +> +> Please use these features with caution, as improper use may cause HMCL to behave abnormally or even crash. + +HMCL provides a series of debug options to control the behavior of the launcher. + +These options can be specified via environment variables or JVM parameters. If both are present, JVM parameters will override the environment variable settings. + +| Environment Variable | JVM Parameter | Function | Default Value | Additional Notes | +|-----------------------------|----------------------------------------------|-----------------------------------------------------------|-------------------------------------------------------------------------------------------------------------|---------------------------| +| `HMCL_JAVA_HOME` | | Specifies the Java used to launch HMCL | | Only effective for exe/sh | +| `HMCL_JAVA_OPTS` | | Specifies the default JVM parameters when launching HMCL | | Only effective for exe/sh | +| `HMCL_FORCE_GPU` | | Specifies whether to force GPU-accelerated rendering | `false` | | +| `HMCL_ANIMATION_FRAME_RATE` | | Specifies the animation frame rate of HMCL | `60` | | +| `HMCL_LANGUAGE` | | Specifies the default language of HMCL | Uses the system default language | | +| | `-Dhmcl.dir=` | Specifies the current data folder of HMCL | `./.hmcl` | | +| | `-Dhmcl.home=` | Specifies the user data folder of HMCL | Windows: `%APPDATA\.hmcl`
Linux/BSD: `$XDG_DATA_HOME/hmcl`
macOS: `~Library/Application Support/hmcl` | | +| | `-Dhmcl.self_integrity_check.disable=true` | Disables self-integrity checks during updates | | | +| | `-Dhmcl.bmclapi.override=` | Specifies the API Root for BMCLAPI | `https://bmclapi2.bangbang93.com` | | +| | `-Dhmcl.discoapi.override=` | Specifies the API Root for foojay Disco API | `https://api.foojay.io/disco/v3.0` | | +| `HMCL_FONT` | `-Dhmcl.font.override=` | Specifies the default font for HMCL | Uses the system default font | | +| | `-Dhmcl.update_source.override=` | Specifies the update source for HMCL | `https://hmcl.huangyuhui.net/api/update_link` | | +| | `-Dhmcl.authlibinjector.location=` | Specifies the location of the authlib-injector JAR file | Uses the built-in authlib-injector | | +| | `-Dhmcl.openjfx.repo=` | Adds a custom Maven repository for downloading OpenJFX | | | +| | `-Dhmcl.native.encoding=` | Specifies the native encoding | Uses the system's native encoding | | +| | `-Dhmcl.microsoft.auth.id=` | Specifies the Microsoft OAuth App ID | Uses the built-in Microsoft OAuth App ID | | +| | `-Dhmcl.microsoft.auth.secret=` | Specifies the Microsoft OAuth App Secret | Uses the built-in Microsoft OAuth App Secret | | +| | `-Dhmcl.curseforge.apikey=` | Specifies the CurseForge API key | Uses the built-in CurseForge API key | | +| | `-Dhmcl.native.backend=` | Specifies the native backend used by HMCL | `auto` | | +| | `-Dhmcl.hardware.fastfetch=` | Specifies whether to use fastfetch for hardware detection | `true` | | diff --git a/docs/Debug_zh.md b/docs/Contributing_zh.md similarity index 80% rename from docs/Debug_zh.md rename to docs/Contributing_zh.md index d82e95bb96..7e284ead48 100644 --- a/docs/Debug_zh.md +++ b/docs/Contributing_zh.md @@ -1,4 +1,63 @@ -# 调试选项 +# 贡献指南 + + +[English](Contributing.md) | **中文** + + +## 构建 HMCL + +### 环境需求 + +构建 HMCL 启动器需要安装 JDK 17 (或更高版本)。你可以从此处下载它: [Download Liberica JDK](https://bell-sw.com/pages/downloads/#jdk-25-lts)。 + +在安装 JDK 后,请确保 `JAVA_HOME` 环境变量指向符合需求的 JDK 目录。 +你可以这样查看 `JAVA_HOME` 指向的 JDK 版本: + +
+Windows + +PowerShell: +``` +PS > & "$env:JAVA_HOME/bin/java.exe" -version +openjdk version "25" 2025-09-16 LTS +OpenJDK Runtime Environment (build 25+37-LTS) +OpenJDK 64-Bit Server VM (build 25+37-LTS, mixed mode, sharing) +``` + +
+ +
+Linux/macOS/FreeBSD + +``` +> $JAVA_HOME/bin/java -version +openjdk version "25" 2025-09-16 LTS +OpenJDK Runtime Environment (build 25+37-LTS) +OpenJDK 64-Bit Server VM (build 25+37-LTS, mixed mode, sharing) +``` + +
+ +### 获取 HMCL 源码 + +- 通过 [Git](https://git-scm.com/downloads) 可以获取最新源码: + ```shell + git clone https://github.com/HMCL-dev/HMCL.git + cd HMCL + ``` +- 从 [GitHub Release 页面](https://github.com/HMCL-dev/HMCL/releases)可以手动下载特定版本的源码。 + +### 构建 HMCL + +想要构建 HMCL,请切换到 HMCL 项目的根目录下,并执行以下命令: + +```shell +./gradlew clean makeExecutables +``` + +构建出的 HMCL 程序文件位于根目录下的 `HMCL/build/libs` 子目录中。 + +## 调试选项 > [!WARNING] > 本文介绍的是 HMCL 的内部功能,我们不保证这些功能的稳定性,并且随时可能修改或删除这些功能。 @@ -32,9 +91,3 @@ HMCL 提供了一系列调试选项,用于控制启动器的行为。 | | `-Dhmcl.native.backend=` | 指定HMCL使用的本机后端 | `auto` | | | `-Dhmcl.hardware.fastfetch=` | 指定是否使用 fastfetch 检测硬件信息 | `true` | - - - - - - diff --git a/docs/PLATFORM.md b/docs/PLATFORM.md index 36bc5fcf2b..09e4563ffd 100644 --- a/docs/PLATFORM.md +++ b/docs/PLATFORM.md @@ -119,13 +119,13 @@ Legend: | | Windows | Linux | macOS | FreeBSD | |-----------------------------|:--------------------------------------------------|:---------------------------|:------------------------------------------------------------------------|:----------------------------| -| x86-64 | ✅️ | ✅️ | ✅️ | 👌 (Minecraft 1.13~1.21.10) | +| x86-64 | ✅️ | ✅️ | ✅️ | 👌 (Minecraft 1.13~1.21.11) | | x86 | ✅️ (~1.20.4) | ✅️ (~1.20.4) | / | / | -| ARM64 | 👌 (Minecraft 1.8~1.18.2)
✅ (Minecraft 1.19+) | 👌 (Minecraft 1.8~1.21.10) | 👌 (Minecraft 1.6~1.18.2)
✅ (Minecraft 1.19+)
✅ (use Rosetta 2) | ❔ | +| ARM64 | 👌 (Minecraft 1.8~1.18.2)
✅ (Minecraft 1.19+) | 👌 (Minecraft 1.8~1.21.11) | 👌 (Minecraft 1.6~1.18.2)
✅ (Minecraft 1.19+)
✅ (use Rosetta 2) | ❔ | | ARM32 | /️ | 👌 (Minecraft 1.8~1.20.1) | / | / | | MIPS64el | / | 👌 (Minecraft 1.8~1.20.1) | / | / | | RISC-V 64 | / | 👌 (Minecraft 1.13~1.21.5) | / | / | -| LoongArch64 (New World) | / | 👌 (Minecraft 1.6~1.21.10) | / | / | +| LoongArch64 (New World) | / | 👌 (Minecraft 1.6~1.21.11) | / | / | | LoongArch64 (Old World) | / | 👌 (Minecraft 1.6~1.20.1) | / | / | | PowerPC-64 (Little-Endian) | / | ❔ | / | / | | S390x | / | ❔ | / | / | diff --git a/docs/PLATFORM_zh.md b/docs/PLATFORM_zh.md index 7131a20486..e6900a657f 100644 --- a/docs/PLATFORM_zh.md +++ b/docs/PLATFORM_zh.md @@ -126,13 +126,13 @@ | | Windows | Linux | macOS | FreeBSD | |-----------------------------|:--------------------------------------------------|:---------------------------|:------------------------------------------------------------------------|:----------------------------| -| x86-64 | ✅️ | ✅️ | ✅️ | 👌 (Minecraft 1.13~1.21.10) | +| x86-64 | ✅️ | ✅️ | ✅️ | 👌 (Minecraft 1.13~1.21.11) | | x86 | ✅️ (~1.20.4) | ✅️ (~1.20.4) | / | / | -| ARM64 | 👌 (Minecraft 1.8~1.18.2)
✅ (Minecraft 1.19+) | 👌 (Minecraft 1.8~1.21.10) | 👌 (Minecraft 1.6~1.18.2)
✅ (Minecraft 1.19+)
✅ (使用 Rosetta 2) | ❔ | +| ARM64 | 👌 (Minecraft 1.8~1.18.2)
✅ (Minecraft 1.19+) | 👌 (Minecraft 1.8~1.21.11) | 👌 (Minecraft 1.6~1.18.2)
✅ (Minecraft 1.19+)
✅ (使用 Rosetta 2) | ❔ | | ARM32 | /️ | 👌 (Minecraft 1.8~1.20.1) | / | / | | MIPS64el | / | 👌 (Minecraft 1.8~1.20.1) | / | / | | RISC-V 64 | / | 👌 (Minecraft 1.13~1.21.5) | / | / | -| LoongArch64 (新世界) | / | 👌 (Minecraft 1.6~1.21.10) | / | / | +| LoongArch64 (新世界) | / | 👌 (Minecraft 1.6~1.21.11) | / | / | | LoongArch64 (旧世界) | / | 👌 (Minecraft 1.6~1.20.1) | / | / | | PowerPC-64 (Little-Endian) | / | ❔ | / | / | | S390x | / | ❔ | / | / | diff --git a/docs/PLATFORM_zh_Hant.md b/docs/PLATFORM_zh_Hant.md index 37ca8c2430..b6108ba6f6 100644 --- a/docs/PLATFORM_zh_Hant.md +++ b/docs/PLATFORM_zh_Hant.md @@ -126,13 +126,13 @@ | | Windows | Linux | macOS | FreeBSD | |-----------------------------|:--------------------------------------------------|:---------------------------|:------------------------------------------------------------------------|:----------------------------| -| x86-64 | ✅️ | ✅️ | ✅️ | 👌 (Minecraft 1.13~1.21.10) | +| x86-64 | ✅️ | ✅️ | ✅️ | 👌 (Minecraft 1.13~1.21.11) | | x86 | ✅️ (~1.20.4) | ✅️ (~1.20.4) | / | / | -| ARM64 | 👌 (Minecraft 1.8~1.18.2)
✅ (Minecraft 1.19+) | 👌 (Minecraft 1.8~1.21.10) | 👌 (Minecraft 1.6~1.18.2)
✅ (Minecraft 1.19+)
✅ (使用 Rosetta 2) | ❔ | +| ARM64 | 👌 (Minecraft 1.8~1.18.2)
✅ (Minecraft 1.19+) | 👌 (Minecraft 1.8~1.21.11) | 👌 (Minecraft 1.6~1.18.2)
✅ (Minecraft 1.19+)
✅ (使用 Rosetta 2) | ❔ | | ARM32 | /️ | 👌 (Minecraft 1.8~1.20.1) | / | / | | MIPS64el | / | 👌 (Minecraft 1.8~1.20.1) | / | / | | RISC-V 64 | / | 👌 (Minecraft 1.13~1.21.5) | / | / | -| LoongArch64 (新世界) | / | 👌 (Minecraft 1.6~1.21.10) | / | / | +| LoongArch64 (新世界) | / | 👌 (Minecraft 1.6~1.21.11) | / | / | | LoongArch64 (舊世界) | / | 👌 (Minecraft 1.6~1.20.1) | / | / | | PowerPC-64 (Little-Endian) | / | ❔ | / | / | | S390x | / | ❔ | / | / | diff --git a/docs/README.md b/docs/README.md index 63a1c7f2a3..ecb7675127 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,13 +1,29 @@ -# Hello Minecraft! Launcher + + +
+ HMCL Logo +
+ +

Hello Minecraft! Launcher

+ -[![Downloads](https://img.shields.io/github/downloads/HMCL-dev/HMCL/total?label=Downloads&style=flat)](https://github.com/HMCL-dev/HMCL/releases) -![Stars](https://img.shields.io/github/stars/HMCL-dev/HMCL?style=flat) -[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U) -[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html) +
+ +[![GitHub](https://img.shields.io/badge/GitHub-repo-blue?style=flat-square&logo=github)](https://github.com/HMCL-dev/HMCL) +[![CNB](https://img.shields.io/badge/CNB-mirror-ff6200?style=flat-square&logo=cloudnativebuild)](https://cnb.cool/HMCL-dev/HMCL) +[![Gitee](https://img.shields.io/badge/Gitee-mirror-c71d23?style=flat-square&logo=gitee)](https://gitee.com/huanghongxun/HMCL) + +[![QQ Group](https://img.shields.io/badge/QQ-gray?style=flat-square&logo=qq&logoColor=ffffff)](https://docs.hmcl.net/groups.html) +[![Discord](https://img.shields.io/badge/Discord-gray?style=flat-square&logo=discord)](https://discord.gg/jVvC7HfM6U) +[![Bilibili](https://img.shields.io/badge/Bilibili-gray?style=flat-square&logo=bilibili)](https://space.bilibili.com/20314891) + +
+--- + **English** (**Standard**, [uʍoᗡ ǝpᴉsd∩](README_en_Qabs.md)) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) | [español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md) @@ -22,48 +38,40 @@ For systems and CPU architectures supported by HMCL, please refer to [this table ## Download -Download the latest version from the [official website](https://hmcl.huangyuhui.net/download). +You can download HMCL from the following sources: -You can also find the latest version of HMCL in [GitHub Releases](https://github.com/HMCL-dev/HMCL/releases). +- [HMCL Official Website](https://hmcl.huangyuhui.net/download) +- [GitHub Release](https://github.com/HMCL-dev/HMCL/releases) +- [CNB Release](https://cnb.cool/HMCL-dev/HMCL/-/releases) -Although not necessary, it is recommended only to download releases from the official websites listed above. +## Contributing -## License +HMCL is a community-driven open-source project, and everyone is welcome to contribute code or provide suggestions. -The software is distributed under [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) license with the following additional terms: +You can contribute to HMCL development in the following ways: -### Additional terms under GPLv3 Section 7 +- Report bugs or request features by [creating an issue](https://github.com/HMCL-dev/HMCL/issues/new/choose) on GitHub. +- Contribute code by forking the repository on GitHub and [submitting a pull request](https://github.com/HMCL-dev/HMCL/compare). -1. When you distribute a modified version of the software, you must change the software name or the version number in a reasonable way in order to distinguish it from the original version. (Under [GPLv3, 7(c)](https://github.com/HMCL-dev/HMCL/blob/11820e31a85d8989e41d97476712b07e7094b190/LICENSE#L372-L374)) +Before contributing, please read the [Contributing Guide](./Contributing.md), which includes the following: - The software name and the version number can be edited [here](https://github.com/HMCL-dev/HMCL/blob/javafx/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java#L33-L35). +- [How to build and run HMCL from source](./Contributing.md#build-hmcl) +- [Adjusting HMCL behavior using debug options](./Contributing.md#debug-options) -2. You must not remove the copyright declaration displayed in the software. (Under [GPLv3, 7(b)](https://github.com/HMCL-dev/HMCL/blob/11820e31a85d8989e41d97476712b07e7094b190/LICENSE#L368-L370)) +## Contributors -## Contribution +Since 2015, more than 110 contributors have participated in HMCL. Thank you for your hard work! -If you want to submit a pull request, here are some requirements: +[![Contributors](https://contrib.rocks/image?repo=HMCL-dev/HMCL)](https://github.com/HMCL-dev/HMCL/graphs/contributors) + +## License -* IDE: IntelliJ IDEA -* Compiler: Java 17+ +The software is distributed under [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) license with the following additional terms: -### Compilation +### Additional terms under GPLv3 Section 7 -See the [Build Guide](./Building.md) page. +1. When you distribute a modified version of the software, you must change the software name or the version number in a reasonable way in order to distinguish it from the original version. (Under [GPLv3, 7(c)](https://github.com/HMCL-dev/HMCL/blob/11820e31a85d8989e41d97476712b07e7094b190/LICENSE#L372-L374)) -## JVM Options (for debugging) + The software name and the version number can be edited [here](https://github.com/HMCL-dev/HMCL/blob/javafx/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java#L33-L35). -| Parameter | Description | -| -------------------------------------------- | --------------------------------------------------------------------------------------------- | -| `-Dhmcl.home=` | Override HMCL directory | -| `-Dhmcl.self_integrity_check.disable=true` | Bypass the self integrity check when checking for updates | -| `-Dhmcl.bmclapi.override=` | Override API Root of BMCLAPI download provider. Defaults to `https://bmclapi2.bangbang93.com` | -| `-Dhmcl.font.override=` | Override font family | -| `-Dhmcl.version.override=` | Override the version number | -| `-Dhmcl.update_source.override=` | Override the update source for HMCL itself | -| `-Dhmcl.authlibinjector.location=` | Use the specified authlib-injector (instead of downloading one) | -| `-Dhmcl.openjfx.repo=` | Add custom Maven repository for downloading OpenJFX | -| `-Dhmcl.native.encoding=` | Override the native encoding | -| `-Dhmcl.microsoft.auth.id=` | Override Microsoft OAuth App ID | -| `-Dhmcl.microsoft.auth.secret=` | Override Microsoft OAuth App Secret | -| `-Dhmcl.curseforge.apikey=` | Override CurseForge API Key | +2. You must not remove the copyright declaration displayed in the software. (Under [GPLv3, 7(b)](https://github.com/HMCL-dev/HMCL/blob/11820e31a85d8989e41d97476712b07e7094b190/LICENSE#L368-L370)) diff --git a/docs/README_en_Qabs.md b/docs/README_en_Qabs.md index eec53e812e..6fc21f3b6e 100644 --- a/docs/README_en_Qabs.md +++ b/docs/README_en_Qabs.md @@ -1,6 +1,8 @@ -# ɹǝɥɔunɐ˥ ¡ʇɟɐɹɔǝuᴉW ollǝH +
+ HMCL Logo +
-[![pɐoꞁuʍoᗡ](https://img.shields.io/github/downloads/HMCL-dev/HMCL/total?label=p%C9%90o%EA%9E%81u%CA%8Do%E1%97%A1&style=flat)](https://github.com/HMCL-dev/HMCL/releases) +

ɹǝɥɔunɐ˥ ¡ʇɟɐɹɔǝuᴉW ollǝH

diff --git a/docs/README_es.md b/docs/README_es.md index ecc86ede02..8725913df3 100644 --- a/docs/README_es.md +++ b/docs/README_es.md @@ -1,13 +1,28 @@ -# Hello Minecraft! Launcher + + +
+ HMCL Logo +
+ +

Hello Minecraft! Launcher

+ -[![Downloads](https://img.shields.io/github/downloads/HMCL-dev/HMCL/total?label=Downloads&style=flat)](https://github.com/HMCL-dev/HMCL/releases) -![Stars](https://img.shields.io/github/stars/HMCL-dev/HMCL?style=flat) -[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U) -[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html) +
+ +[![GitHub](https://img.shields.io/badge/GitHub-repo-blue?style=flat-square&logo=github)](https://github.com/HMCL-dev/HMCL) +[![CNB](https://img.shields.io/badge/CNB-mirror-ff6200?style=flat-square&logo=cloudnativebuild)](https://cnb.cool/HMCL-dev/HMCL) +[![Gitee](https://img.shields.io/badge/Gitee-mirror-c71d23?style=flat-square&logo=gitee)](https://gitee.com/huanghongxun/HMCL) + +[![QQ Group](https://img.shields.io/badge/QQ-gray?style=flat-square&logo=qq&logoColor=ffffff)](https://docs.hmcl.net/groups.html) +[![Discord](https://img.shields.io/badge/Discord-gray?style=flat-square&logo=discord)](https://discord.gg/jVvC7HfM6U) +[![Bilibili](https://img.shields.io/badge/Bilibili-gray?style=flat-square&logo=bilibili)](https://space.bilibili.com/20314891) + +
+--- English ([Standard](README.md), [uʍoᗡ ǝpᴉsd∩](README_en_Qabs.md)) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) | **español** | [русский](README_ru.md) | [українська](README_uk.md) @@ -32,31 +47,3 @@ Aunque no es necesario, se recomienda descargar las versiones solo de los sitios ## Licencia Consulta [README.md](README.md#license). - -## Contribución - -Si deseas enviar un pull request, aquí tienes algunos requisitos: - -* IDE: IntelliJ IDEA -* Compilador: Java 17+ - -### Compilación - -Consulta la página de la [Guía de compilación](./Building.md). - -## Opciones de JVM (para depuración) - -| Parámetro | Descripción | -|---------------------------------------------------|-----------------------------------------------------------------------------------------------------------------| -| `-Dhmcl.home=` | Sobrescribe el directorio de HMCL | -| `-Dhmcl.self_integrity_check.disable=true` | Omite la verificación de integridad propia al buscar actualizaciones | -| `-Dhmcl.bmclapi.override=` | Sobrescribe la raíz de la API del proveedor de descargas BMCLAPI. Por defecto `https://bmclapi2.bangbang93.com` | -| `-Dhmcl.font.override=` | Sobrescribe la familia de fuente | -| `-Dhmcl.version.override=` | Sobrescribe el número de versión | -| `-Dhmcl.update_source.override=` | Sobrescribe la fuente de actualizaciones de HMCL | -| `-Dhmcl.authlibinjector.location=` | Usa el authlib-injector especificado (en vez de descargar uno) | -| `-Dhmcl.openjfx.repo=` | Añade un repositorio Maven personalizado para descargar OpenJFX | -| `-Dhmcl.native.encoding=` | Sobrescribe la codificación nativa | -| `-Dhmcl.microsoft.auth.id=` | Sobrescribe el ID de la App OAuth de Microsoft | -| `-Dhmcl.microsoft.auth.secret=` | Sobrescribe el secreto de la App OAuth de Microsoft | -| `-Dhmcl.curseforge.apikey=` | Sobrescribe la clave API de CurseForge | diff --git a/docs/README_ja.md b/docs/README_ja.md index 787f8a9447..788e922378 100644 --- a/docs/README_ja.md +++ b/docs/README_ja.md @@ -1,13 +1,29 @@ -# Hello Minecraft! Launcher + + +
+ HMCL Logo +
+ +

Hello Minecraft! Launcher

+ -[![Downloads](https://img.shields.io/github/downloads/HMCL-dev/HMCL/total?label=Downloads&style=flat)](https://github.com/HMCL-dev/HMCL/releases) -![Stars](https://img.shields.io/github/stars/HMCL-dev/HMCL?style=flat) -[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U) -[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html) +
+ +[![GitHub](https://img.shields.io/badge/GitHub-repo-blue?style=flat-square&logo=github)](https://github.com/HMCL-dev/HMCL) +[![CNB](https://img.shields.io/badge/CNB-mirror-ff6200?style=flat-square&logo=cloudnativebuild)](https://cnb.cool/HMCL-dev/HMCL) +[![Gitee](https://img.shields.io/badge/Gitee-mirror-c71d23?style=flat-square&logo=gitee)](https://gitee.com/huanghongxun/HMCL) + +[![QQ Group](https://img.shields.io/badge/QQ-gray?style=flat-square&logo=qq&logoColor=ffffff)](https://docs.hmcl.net/groups.html) +[![Discord](https://img.shields.io/badge/Discord-gray?style=flat-square&logo=discord)](https://discord.gg/jVvC7HfM6U) +[![Bilibili](https://img.shields.io/badge/Bilibili-gray?style=flat-square&logo=bilibili)](https://space.bilibili.com/20314891) + +
+--- + English ([Standard](README.md), [uʍoᗡ ǝpᴉsd∩](README_en_Qabs.md)) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | **日本語** | [español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md) @@ -31,31 +47,3 @@ HMCLが対応しているシステムやCPUアーキテクチャについては ## ライセンス ライセンスについては [README.md](README.md#license) をご参照ください。 - -## コントリビューション - -プルリクエストを送信したい場合、以下の要件を満たしてください。 - -* IDE:IntelliJ IDEA -* コンパイラ:Java 17以上 - -### コンパイル方法 - -ビルド方法については、[ビルドガイド](./Building.md)ページをご覧ください。 - -## JVMオプション(デバッグ用) - -| パラメータ | 説明 | -|----------------------------------------------|-----------------------------------------------------------------------------| -| `-Dhmcl.home=` | HMCLディレクトリを上書きします | -| `-Dhmcl.self_integrity_check.disable=true` | アップデート時の自己整合性チェックをバイパスします | -| `-Dhmcl.bmclapi.override=` | BMCLAPIダウンロードプロバイダーのAPIルートを上書きします。デフォルトは`https://bmclapi2.bangbang93.com`です | -| `-Dhmcl.font.override=` | フォントファミリーを上書きします | -| `-Dhmcl.version.override=` | バージョン番号を上書きします | -| `-Dhmcl.update_source.override=` | HMCL本体のアップデートソースを上書きします | -| `-Dhmcl.authlibinjector.location=` | 指定したauthlib-injectorを使用します(ダウンロードせずに) | -| `-Dhmcl.openjfx.repo=` | OpenJFXダウンロード用のカスタムMavenリポジトリを追加します | -| `-Dhmcl.native.encoding=` | ネイティブエンコーディングを上書きします | -| `-Dhmcl.microsoft.auth.id=` | Microsoft OAuthアプリIDを上書きします | -| `-Dhmcl.microsoft.auth.secret=` | Microsoft OAuthアプリシークレットを上書きします | -| `-Dhmcl.curseforge.apikey=` | CurseForge APIキーを上書きします | diff --git a/docs/README_lzh.md b/docs/README_lzh.md index 4e0186f319..743905b676 100644 --- a/docs/README_lzh.md +++ b/docs/README_lzh.md @@ -1,13 +1,29 @@ -# Hello Minecraft! Launcher + + +
+ HMCL Logo +
+ +

Hello Minecraft! Launcher

+ -[![Downloads](https://img.shields.io/github/downloads/HMCL-dev/HMCL/total?label=Downloads&style=flat)](https://github.com/HMCL-dev/HMCL/releases) -![Stars](https://img.shields.io/github/stars/HMCL-dev/HMCL?style=flat) -[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U) -[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html) +
+ +[![GitHub](https://img.shields.io/badge/GitHub-repo-blue?style=flat-square&logo=github)](https://github.com/HMCL-dev/HMCL) +[![CNB](https://img.shields.io/badge/CNB-mirror-ff6200?style=flat-square&logo=cloudnativebuild)](https://cnb.cool/HMCL-dev/HMCL) +[![Gitee](https://img.shields.io/badge/Gitee-mirror-c71d23?style=flat-square&logo=gitee)](https://gitee.com/huanghongxun/HMCL) + +[![QQ Group](https://img.shields.io/badge/QQ-gray?style=flat-square&logo=qq&logoColor=ffffff)](https://docs.hmcl.net/groups.html) +[![Discord](https://img.shields.io/badge/Discord-gray?style=flat-square&logo=discord)](https://discord.gg/jVvC7HfM6U) +[![Bilibili](https://img.shields.io/badge/Bilibili-gray?style=flat-square&logo=bilibili)](https://space.bilibili.com/20314891) + +
+--- + English ([Standard](README.md), [uʍoᗡ ǝpᴉsd∩](README_en_Qabs.md)) | **中文** ([简体](README_zh.md), [繁體](README_zh_Hant.md), **文言**) | [日本語](README_ja.md) | [español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md) @@ -23,40 +39,33 @@ HMCL 跨域甚廣。無論 Windows、Linux、macOS、FreeBSD 諸常見械綱, ## 下載 -請自 [HMCL 官網](https://hmcl.huangyuhui.net/download) 取其最新版。 +君可自此數途引 HMCL: -亦可於 [GitHub Releases](https://github.com/HMCL-dev/HMCL/releases) 得其新者。 +- [HMCL 官方網站](https://hmcl.huangyuhui.net/download) +- [GitHub Release](https://github.com/HMCL-dev/HMCL/releases) +- [CNB Release](https://cnb.cool/HMCL-dev/HMCL/-/releases) -雖非強制,然猶勸自官網取之。 +## 與貢 -## 開源之約 +HMCL 為社群所驅之開源計畫,迎諸君貢獻其碼,或建言於此。 -詳見 [README_zh_Hant.md](README_zh_Hant.md#開源協議)。 +君可由此數途參與 HMCL 之開發: + +- 於 GitHub 上[創建 Issue](https://github.com/HMCL-dev/HMCL/issues/new/choose),以報告謬誤,或請求功能。 +- 於 GitHub 上 Fork 倉庫,並[提交 Pull Request](https://github.com/HMCL-dev/HMCL/compare),以貢獻其碼。 -## 貢獻 +參與貢獻之前,請閱[貢獻指南](./Contributing_zh.md),其中載有如下: -若欲獻 Pull Request,須遵下列: +- [如何自源碼構建並運行 HMCL](./Contributing_zh.md#构建-hmcl) +- [以調試選項調整 HMCL 之行為](./Contributing_zh.md#调试选项) -* IDE 用 IntelliJ IDEA -* 編譯器用爪哇十七以上 +## 貢獻者 -### 編造 +自公元二〇一五年以來,參與 HMCL 者已逾百十,謝其勤勞! -請觀[編造指南](./Building_zh.md)。 +[![Contributors](https://contrib.rocks/image?repo=HMCL-dev/HMCL)](https://github.com/HMCL-dev/HMCL/graphs/contributors) -## 爪哇虛機之通弦(以資勘誤) +## 開源之約 + +詳見 [README_zh_Hant.md](README_zh_Hant.md#開源協議)。 -| 參數 | 解釋 | -|----------------------------------------------|---------------------------------------------------------| -| `-Dhmcl.home=` | 易 HMCL 之用戶目錄 | -| `-Dhmcl.self_integrity_check.disable=true` | 檢查更新時不驗本體之全 | -| `-Dhmcl.bmclapi.override=` | 易 BMCLAPI 之 API 根,預設為 `https://bmclapi2.bangbang93.com` | -| `-Dhmcl.font.override=` | 易書體 | -| `-Dhmcl.version.override=` | 易版 | -| `-Dhmcl.update_source.override=` | 易 HMCL 之更新所 | -| `-Dhmcl.authlibinjector.location=` | 用所指之 authlib-injector,毋需下載 | -| `-Dhmcl.openjfx.repo=` | 增 OpenJFX 下載之自定 Maven 庫 | -| `-Dhmcl.native.encoding=` | 易本地編碼 | -| `-Dhmcl.microsoft.auth.id=` | 易 Microsoft OAuth 之 App ID | -| `-Dhmcl.microsoft.auth.secret=` | 易 Microsoft OAuth 之金鑰 | -| `-Dhmcl.curseforge.apikey=` | 易 CurseForge 之 API 金鑰 | diff --git a/docs/README_ru.md b/docs/README_ru.md index d843fc64da..bf36f99300 100644 --- a/docs/README_ru.md +++ b/docs/README_ru.md @@ -1,13 +1,29 @@ -# Hello Minecraft! Launcher + + +
+ HMCL Logo +
+ +

Hello Minecraft! Launcher

+ -[![Downloads](https://img.shields.io/github/downloads/HMCL-dev/HMCL/total?label=Downloads&style=flat)](https://github.com/HMCL-dev/HMCL/releases) -![Stars](https://img.shields.io/github/stars/HMCL-dev/HMCL?style=flat) -[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U) -[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html) +
+ +[![GitHub](https://img.shields.io/badge/GitHub-repo-blue?style=flat-square&logo=github)](https://github.com/HMCL-dev/HMCL) +[![CNB](https://img.shields.io/badge/CNB-mirror-ff6200?style=flat-square&logo=cloudnativebuild)](https://cnb.cool/HMCL-dev/HMCL) +[![Gitee](https://img.shields.io/badge/Gitee-mirror-c71d23?style=flat-square&logo=gitee)](https://gitee.com/huanghongxun/HMCL) + +[![QQ Group](https://img.shields.io/badge/QQ-gray?style=flat-square&logo=qq&logoColor=ffffff)](https://docs.hmcl.net/groups.html) +[![Discord](https://img.shields.io/badge/Discord-gray?style=flat-square&logo=discord)](https://discord.gg/jVvC7HfM6U) +[![Bilibili](https://img.shields.io/badge/Bilibili-gray?style=flat-square&logo=bilibili)](https://space.bilibili.com/20314891) + +
+--- + English ([Standard](README.md), [uʍoᗡ ǝpᴉsd∩](README_en_Qabs.md)) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) | [español](README_es.md) | **русский** | [українська](README_uk.md) @@ -31,31 +47,3 @@ HMCL обладает отличной кроссплатформенность ## Лицензия См. [README.md](README.md#license). - -## Вклад - -Если вы хотите отправить pull request, ознакомьтесь с требованиями: - -* IDE: IntelliJ IDEA -* Компилятор: Java 17+ - -### Сборка - -См. страницу [Руководство по сборке](./Building.md). - -## Параметры JVM (для отладки) - -| Параметр | Описание | -|-----------------------------------------------|---------------------------------------------------------------------------------------------------------------| -| `-Dhmcl.home=<путь>` | Переопределить директорию HMCL | -| `-Dhmcl.self_integrity_check.disable=true` | Отключить проверку целостности при проверке обновлений | -| `-Dhmcl.bmclapi.override=` | Переопределить корневой API-адрес провайдера загрузки BMCLAPI. По умолчанию `https://bmclapi2.bangbang93.com` | -| `-Dhmcl.font.override=<название шрифта>` | Переопределить семейство шрифтов | -| `-Dhmcl.version.override=<версия>` | Переопределить номер версии | -| `-Dhmcl.update_source.override=` | Переопределить источник обновлений для самого HMCL | -| `-Dhmcl.authlibinjector.location=<путь>` | Использовать указанный authlib-injector (вместо загрузки) | -| `-Dhmcl.openjfx.repo=` | Добавить пользовательский Maven-репозиторий для загрузки OpenJFX | -| `-Dhmcl.native.encoding=<кодировка>` | Переопределить нативную кодировку | -| `-Dhmcl.microsoft.auth.id=` | Переопределить Microsoft OAuth App ID | -| `-Dhmcl.microsoft.auth.secret=` | Переопределить Microsoft OAuth App Secret | -| `-Dhmcl.curseforge.apikey=` | Переопределить CurseForge API Key | diff --git a/docs/README_uk.md b/docs/README_uk.md index d6edb3982f..d2db47c44e 100644 --- a/docs/README_uk.md +++ b/docs/README_uk.md @@ -1,13 +1,29 @@ -# Hello Minecraft! Launcher + + +
+ HMCL Logo +
+ +

Hello Minecraft! Launcher

+ -[![Downloads](https://img.shields.io/github/downloads/HMCL-dev/HMCL/total?label=Downloads&style=flat)](https://github.com/HMCL-dev/HMCL/releases) -![Stars](https://img.shields.io/github/stars/HMCL-dev/HMCL?style=flat) -[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U) -[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html) +
+ +[![GitHub](https://img.shields.io/badge/GitHub-repo-blue?style=flat-square&logo=github)](https://github.com/HMCL-dev/HMCL) +[![CNB](https://img.shields.io/badge/CNB-mirror-ff6200?style=flat-square&logo=cloudnativebuild)](https://cnb.cool/HMCL-dev/HMCL) +[![Gitee](https://img.shields.io/badge/Gitee-mirror-c71d23?style=flat-square&logo=gitee)](https://gitee.com/huanghongxun/HMCL) + +[![QQ Group](https://img.shields.io/badge/QQ-gray?style=flat-square&logo=qq&logoColor=ffffff)](https://docs.hmcl.net/groups.html) +[![Discord](https://img.shields.io/badge/Discord-gray?style=flat-square&logo=discord)](https://discord.gg/jVvC7HfM6U) +[![Bilibili](https://img.shields.io/badge/Bilibili-gray?style=flat-square&logo=bilibili)](https://space.bilibili.com/20314891) + +
+--- + English ([Standard](README.md), [uʍoᗡ ǝpᴉsd∩](README_en_Qabs.md)) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) | [español](README_es.md) | [русский](README_ru.md) | **українська** @@ -38,24 +54,3 @@ HMCL має чудові кросплатформні можливості. Ві * IDE: IntelliJ IDEA * Компілятор: Java 17+ - -### Компіляція - -Дивіться сторінку [Посібник зі збірки](./Building.md). - -## JVM Options (for debugging) - -| Parameter | Description | -|----------------------------------------------|-----------------------------------------------------------------------------------------------| -| `-Dhmcl.home=` | Override HMCL directory | -| `-Dhmcl.self_integrity_check.disable=true` | Bypass the self integrity check when checking for updates | -| `-Dhmcl.bmclapi.override=` | Override API Root of BMCLAPI download provider. Defaults to `https://bmclapi2.bangbang93.com` | -| `-Dhmcl.font.override=` | Override font family | -| `-Dhmcl.version.override=` | Override the version number | -| `-Dhmcl.update_source.override=` | Override the update source for HMCL itself | -| `-Dhmcl.authlibinjector.location=` | Use the specified authlib-injector (instead of downloading one) | -| `-Dhmcl.openjfx.repo=` | Add custom Maven repository for downloading OpenJFX | -| `-Dhmcl.native.encoding=` | Override the native encoding | -| `-Dhmcl.microsoft.auth.id=` | Override Microsoft OAuth App ID | -| `-Dhmcl.microsoft.auth.secret=` | Override Microsoft OAuth App Secret | -| `-Dhmcl.curseforge.apikey=` | Override CurseForge API Key | diff --git a/docs/README_zh.md b/docs/README_zh.md index 072eb5de1b..390016a9c3 100644 --- a/docs/README_zh.md +++ b/docs/README_zh.md @@ -1,13 +1,29 @@ -# Hello Minecraft! Launcher + + +
+ HMCL Logo +
+ +

Hello Minecraft! Launcher

+ -[![Downloads](https://img.shields.io/github/downloads/HMCL-dev/HMCL/total?label=Downloads&style=flat)](https://github.com/HMCL-dev/HMCL/releases) -![Stars](https://img.shields.io/github/stars/HMCL-dev/HMCL?style=flat) -[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U) -[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html) +
+ +[![GitHub](https://img.shields.io/badge/GitHub-repo-blue?style=flat-square&logo=github)](https://github.com/HMCL-dev/HMCL) +[![CNB](https://img.shields.io/badge/CNB-mirror-ff6200?style=flat-square&logo=cloudnativebuild)](https://cnb.cool/HMCL-dev/HMCL) +[![Gitee](https://img.shields.io/badge/Gitee-mirror-c71d23?style=flat-square&logo=gitee)](https://gitee.com/huanghongxun/HMCL) + +[![QQ Group](https://img.shields.io/badge/QQ-gray?style=flat-square&logo=qq&logoColor=ffffff)](https://docs.hmcl.net/groups.html) +[![Discord](https://img.shields.io/badge/Discord-gray?style=flat-square&logo=discord)](https://discord.gg/jVvC7HfM6U) +[![Bilibili](https://img.shields.io/badge/Bilibili-gray?style=flat-square&logo=bilibili)](https://space.bilibili.com/20314891) + +
+--- + English ([Standard](README.md), [uʍoᗡ ǝpᴉsd∩](README_en_Qabs.md)) | **中文** (**简体**, [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) | [español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md) @@ -22,48 +38,41 @@ HMCL 有着强大的跨平台能力。它不仅支持 Windows、Linux、macOS、 ## 下载 -请从 [HMCL 官网](https://hmcl.huangyuhui.net/download) 下载最新版本的 HMCL。 +你可以从这些渠道下载 HMCL: -你也可以在 [GitHub Releases](https://github.com/HMCL-dev/HMCL/releases) 中下载最新版本的 HMCL。 +- [HMCL 官方网站](https://hmcl.huangyuhui.net/download) +- [GitHub Release](https://github.com/HMCL-dev/HMCL/releases) +- [CNB Release](https://cnb.cool/HMCL-dev/HMCL/-/releases) -虽然并不强制,但仍建议通过 HMCL 官网下载启动器。 +## 参与贡献 -## 开源协议 +HMCL 是一个社区驱动的开源项目,欢迎任何人参与贡献代码或提出建议。 -该程序在 [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) 开源协议下发布,同时附有以下附加条款。 +你可以通过以下方式参与 HMCL 的开发: -### 附加条款 (依据 GPLv3 开源协议第七条) +- 通过在 GitHub 上[创建 Issue](https://github.com/HMCL-dev/HMCL/issues/new/choose) 来报告 Bug 或提出功能请求。 +- 通过在 GitHub 上 Fork 仓库并[提交 Pull Request](https://github.com/HMCL-dev/HMCL/compare) 来贡献代码。 -1. 当你分发该程序的修改版本时,你必须以一种合理的方式修改该程序的名称或版本号,以示其与原始版本不同。(依据 [GPLv3, 7(c)](https://github.com/HMCL-dev/HMCL/blob/11820e31a85d8989e41d97476712b07e7094b190/LICENSE#L372-L374)) +在参与贡献前,请阅读[贡献指南](./Contributing_zh.md),其中包含以下内容: - 该程序的名称及版本号可在 [此处](https://github.com/HMCL-dev/HMCL/blob/javafx/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java#L33-L35) 修改。 +- [如何从源码构建并运行 HMCL](./Contributing_zh.md#构建-hmcl) +- [通过调试选项调整 HMCL 的行为](./Contributing_zh.md#调试选项) -2. 你不得移除该程序所显示的版权声明。(依据 [GPLv3, 7(b)](https://github.com/HMCL-dev/HMCL/blob/11820e31a85d8989e41d97476712b07e7094b190/LICENSE#L368-L370)) +## 贡献者 -## 贡献 +自 2015 年以来,HMCL 已经有超过 110 位贡献者参与其中,感谢他们的辛勤付出! -如果你想提交一个 Pull Request,必须遵守如下要求: +[![Contributors](https://contrib.rocks/image?repo=HMCL-dev/HMCL)](https://github.com/HMCL-dev/HMCL/graphs/contributors) -* IDE:IntelliJ IDEA -* 编译器:Java 17+ +## 开源协议 -### 构建 HMCL +该程序在 [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) 开源协议下发布,同时附有以下附加条款。 -参见[构建指南](./Building_zh.md)页面。 +### 附加条款 (依据 GPLv3 开源协议第七条) + +1. 当你分发该程序的修改版本时,你必须以一种合理的方式修改该程序的名称或版本号,以示其与原始版本不同。( + 依据 [GPLv3, 7(c)](https://github.com/HMCL-dev/HMCL/blob/11820e31a85d8989e41d97476712b07e7094b190/LICENSE#L372-L374)) -## JVM 选项 (用于调试) + 该程序的名称及版本号可在 [此处](https://github.com/HMCL-dev/HMCL/blob/javafx/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java#L33-L35) 修改。 -| 参数 | 简介 | -| -------------------------------------------- | -------------------------------------------------------------------- | -| `-Dhmcl.home=` | 覆盖 HMCL 数据文件夹 | -| `-Dhmcl.self_integrity_check.disable=true` | 检查更新时不检查本体完整性 | -| `-Dhmcl.bmclapi.override=` | 覆盖 BMCLAPI 的 API Root,默认值为 `https://bmclapi2.bangbang93.com` | -| `-Dhmcl.font.override=` | 覆盖字族 | -| `-Dhmcl.version.override=` | 覆盖版本号 | -| `-Dhmcl.update_source.override=` | 覆盖 HMCL 更新源 | -| `-Dhmcl.authlibinjector.location=` | 使用指定的 authlib-injector (而非下载一个) | -| `-Dhmcl.openjfx.repo=` | 添加用于下载 OpenJFX 的自定义 Maven 仓库 | -| `-Dhmcl.native.encoding=` | 覆盖原生编码 | -| `-Dhmcl.microsoft.auth.id=` | 覆盖 Microsoft OAuth App ID | -| `-Dhmcl.microsoft.auth.secret=` | 覆盖 Microsoft OAuth App 密钥 | -| `-Dhmcl.curseforge.apikey=` | 覆盖 CurseForge API 密钥 | +2. 你不得移除该程序所显示的版权声明。(依据 [GPLv3, 7(b)](https://github.com/HMCL-dev/HMCL/blob/11820e31a85d8989e41d97476712b07e7094b190/LICENSE#L368-L370)) diff --git a/docs/README_zh_Hant.md b/docs/README_zh_Hant.md index a73b3950c1..453262f79f 100644 --- a/docs/README_zh_Hant.md +++ b/docs/README_zh_Hant.md @@ -1,13 +1,29 @@ -# Hello Minecraft! Launcher + + +
+ HMCL Logo +
+ +

Hello Minecraft! Launcher

+ -[![Downloads](https://img.shields.io/github/downloads/HMCL-dev/HMCL/total?label=Downloads&style=flat)](https://github.com/HMCL-dev/HMCL/releases) -![Stars](https://img.shields.io/github/stars/HMCL-dev/HMCL?style=flat) -[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U) -[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html) +
+ +[![GitHub](https://img.shields.io/badge/GitHub-repo-blue?style=flat-square&logo=github)](https://github.com/HMCL-dev/HMCL) +[![CNB](https://img.shields.io/badge/CNB-mirror-ff6200?style=flat-square&logo=cloudnativebuild)](https://cnb.cool/HMCL-dev/HMCL) +[![Gitee](https://img.shields.io/badge/Gitee-mirror-c71d23?style=flat-square&logo=gitee)](https://gitee.com/huanghongxun/HMCL) + +[![QQ Group](https://img.shields.io/badge/QQ-gray?style=flat-square&logo=qq&logoColor=ffffff)](https://docs.hmcl.net/groups.html) +[![Discord](https://img.shields.io/badge/Discord-gray?style=flat-square&logo=discord)](https://discord.gg/jVvC7HfM6U) +[![Bilibili](https://img.shields.io/badge/Bilibili-gray?style=flat-square&logo=bilibili)](https://space.bilibili.com/20314891) + +
+--- + English ([Standard](README.md), [uʍoᗡ ǝpᴉsd∩](README_en_Qabs.md)) | **中文** ([简体](README_zh.md), **繁體**, [文言](README_lzh.md)) | [日本語](README_ja.md) | [español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md) @@ -22,48 +38,40 @@ HMCL 有著強大的跨平臺能力。它不僅支援 Windows、Linux、macOS、 ## 下載 -請從 [HMCL 官網](https://hmcl.huangyuhui.net/download) 下載最新版本的 HMCL。 +你可以從這些渠道下載 HMCL: -你也可以在 [GitHub Releases](https://github.com/HMCL-dev/HMCL/releases) 中下載最新版本的 HMCL。 +- [HMCL 官方網站](https://hmcl.huangyuhui.net/download) +- [GitHub Release](https://github.com/HMCL-dev/HMCL/releases) +- [CNB Release](https://cnb.cool/HMCL-dev/HMCL/-/releases) -雖然並不強制,但仍建議透過 HMCL 官網下載啟動器。 +## 參與貢獻 -## 開源協議 +HMCL 是一個社區驅動的開源項目,歡迎任何人參與貢獻程式碼或提出建議。 -該程式在 [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) 開源協議下發布,同時附有以下附加條款。 +你可以通過以下方式參與 HMCL 的開發: -### 附加條款 (依據 GPLv3 開源協議第七條) +- 通過在 GitHub 上[創建 Issue](https://github.com/HMCL-dev/HMCL/issues/new/choose) 來報告 Bug 或提出功能請求。 +- 通過在 GitHub 上 Fork 倉庫並[提交 Pull Request](https://github.com/HMCL-dev/HMCL/compare) 來貢獻程式碼。 -1. 當你分發該程式的修改版本時,你必須以一種合理的方式修改該程式的名稱或版本號,以示其與原始版本不同。(依據 [GPLv3, 7(c)](https://github.com/HMCL-dev/HMCL/blob/11820e31a85d8989e41d97476712b07e7094b190/LICENSE#L372-L374)) +在參與貢獻前,請閱讀[貢獻指南](./Contributing_zh.md),其中包含以下內容: - 該程式的名稱及版本號可在 [此處](https://github.com/HMCL-dev/HMCL/blob/javafx/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java#L33-L35) 修改。 +- [如何從原始碼構建並運行 HMCL](./Contributing_zh.md#构建-hmcl) +- [通過調試選項調整 HMCL 的行為](./Contributing_zh.md#调试选项) -2. 你不得移除該程式所顯示的版權宣告。(依據 [GPLv3, 7(b)](https://github.com/HMCL-dev/HMCL/blob/11820e31a85d8989e41d97476712b07e7094b190/LICENSE#L368-L370)) +## 貢獻者 -## 貢獻 +自 2015 年以來,HMCL 已經有超過 110 位貢獻者參與其中,感謝他們的辛勤付出! -如果你想提交一個 Pull Request,必須遵守如下要求: +[![Contributors](https://contrib.rocks/image?repo=HMCL-dev/HMCL)](https://github.com/HMCL-dev/HMCL/graphs/contributors) + +## 開源協議 -* IDE:IntelliJ IDEA -* 編譯器:Java 17+ +該程式在 [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) 開源協議下發布,同時附有以下附加條款。 -### 編譯 +### 附加條款 (依據 GPLv3 開源協議第七條) -參閱[構建指南](./Building_zh.md)頁面。 +1. 當你分發該程式的修改版本時,你必須以一種合理的方式修改該程式的名稱或版本號,以示其與原始版本不同。(依據 [GPLv3, 7(c)](https://github.com/HMCL-dev/HMCL/blob/11820e31a85d8989e41d97476712b07e7094b190/LICENSE#L372-L374)) -## JVM 選項 (用於除錯) + 該程式的名稱及版本號可在 [此處](https://github.com/HMCL-dev/HMCL/blob/javafx/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java#L33-L35) 修改。 -| 參數 | 簡介 | -| -------------------------------------------- | -------------------------------------------------------------------- | -| `-Dhmcl.home=` | 覆蓋 HMCL 使用者目錄 | -| `-Dhmcl.self_integrity_check.disable=true` | 檢查更新時不檢查本體完整性 | -| `-Dhmcl.bmclapi.override=` | 覆蓋 BMCLAPI 的 API Root,預設值為 `https://bmclapi2.bangbang93.com` | -| `-Dhmcl.font.override=` | 覆蓋字族 | -| `-Dhmcl.version.override=` | 覆蓋版本號 | -| `-Dhmcl.update_source.override=` | 覆蓋 HMCL 更新來源 | -| `-Dhmcl.authlibinjector.location=` | 使用指定的 authlib-injector (而非下載一個) | -| `-Dhmcl.openjfx.repo=` | 添加用於下載 OpenJFX 的自訂 Maven 倉庫 | -| `-Dhmcl.native.encoding=` | 覆蓋原生編碼 | -| `-Dhmcl.microsoft.auth.id=` | 覆蓋 Microsoft OAuth App ID | -| `-Dhmcl.microsoft.auth.secret=` | 覆蓋 Microsoft OAuth App 金鑰 | -| `-Dhmcl.curseforge.apikey=` | 覆蓋 CurseForge API 金鑰 | +2. 你不得移除該程式所顯示的版權宣告。(依據 [GPLv3, 7(b)](https://github.com/HMCL-dev/HMCL/blob/11820e31a85d8989e41d97476712b07e7094b190/LICENSE#L368-L370)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3df48fc2bb..0d1c8e544a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,10 +16,12 @@ twelvemonkeys = "3.12.0" jna = "5.18.1" pci-ids = "0.4.0" java-info = "1.0" -authlib-injector = "1.2.6" +authlib-injector = "1.2.7" +monet-fx = "0.4.0" +terracotta = "0.4.1" # testing -junit = "5.13.4" +junit = "6.0.1" jimfs = "1.3.0" # plugins @@ -46,6 +48,7 @@ jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" } pci-ids = { module = "org.glavo:pci-ids", version.ref = "pci-ids" } java-info = { module = "org.glavo:java-info", version.ref = "java-info" } authlib-injector = { module = "org.glavo.hmcl:authlib-injector", version.ref = "authlib-injector" } +monet-fx = { module = "org.glavo:MonetFX", version.ref = "monet-fx" } # testing junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } From 453417df109d29fc4f6737714ff4bd0749577092 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 17 Mar 2026 04:43:31 +0800 Subject: [PATCH 10/10] update --- .../util/platform/macos/MacOSHardwareDetector.java | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java index 46d89ae49f..776aba1aa7 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/macos/MacOSHardwareDetector.java @@ -167,7 +167,6 @@ public long getFreeMemorySize() { long pagesFree; long pagesInactive; long pagesSpeculative; - long pagesPurgeable; if (statistics != null) { Matcher matcher = PAGE_SIZE_PATTERN.matcher(statistics); @@ -201,14 +200,7 @@ public long getFreeMemorySize() { break vmStat; } - String pagesPurgeableStr = stats.get("Pages purgeable"); - if (pagesPurgeableStr != null && pagesPurgeableStr.endsWith(".")) { - pagesPurgeable = Long.parseUnsignedLong(pagesPurgeableStr, 0, pagesPurgeableStr.length() - 1, 10); - } else { - break vmStat; - } - - long available = (pagesFree + pagesSpeculative + pagesInactive + pagesPurgeable) * pageSize; + long available = (pagesFree + pagesSpeculative + pagesInactive) * pageSize; if (available > 0) { return available; }