Compare commits

...
Author SHA1 Message Date
layue13 c4240a24bf Merge pull request 'fix: harden MultiMC/authlib/IO trust boundaries (#8–#13)' (#14) from cursor/fix-open-security-issues-1729 into main
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Successful in 12m30s
fix: harden MultiMC/authlib/IO trust boundaries (#8–#13) (#14)
2026-11-09 21:19:54 +00:00
layue13 e81e974af7 Merge pull request 'ci: publish launcher to Gitea Release + tdlm-hmcl/latest for TDLMPack' (#7) from cursor/publish-gitea-artifacts-1729 into main
Mirror Repository / Mirror to CNB (push) Skipped
Publish TDLM launcher / publish (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Publish TDLM launcher / noop (push) Successful in 4s
Java CI / build (push) Canceled after 0s
ci: publish launcher to Gitea Release + tdlm-hmcl/latest for TDLMPack (#7)
2026-11-09 21:19:49 +00:00
layue13 551bd4cef2 fix: harden MultiMC/authlib/IO trust boundaries (#8–#13)
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Successful in 12m50s
- #8: do not import MultiMC PreLaunch/Wrapper/JVM overrides from packs
- #9: filter dangerous +jvmArgs from MultiMC patches / lwjgl3ify
- #10: cap HTTP body reads at 16 MiB
- #11: guard FileUtils.copyDirectory against path escape
- #12: allow only http/https/mailto in openLink
- #13: follow authlib api-location only on same-host HTTPS
2026-11-09 21:19:30 +00:00
layue13 95111d2f84 ci: stop caching entire ~/.gradle/caches (was ~4GB)
Java CI / build (pull_request) Successful in 13m11s
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
2026-11-09 21:19:21 +00:00
layue13 327c0ea940 ci: fix HMCL:test hang — install GTK for JavaFX under xvfb 2026-11-09 21:19:21 +00:00
layue13 aace7932e0 ci: quote publish-tdlm workflow_dispatch description
Unquoted "Example: ..." broke YAML parsing, so Gitea recorded push
runs with 0 jobs as failure.
2026-11-09 21:19:21 +00:00
layue13 93bb547a9e ci: add publish-tdlm push no-op to avoid empty failed runs
Gitea was marking publish-tdlm.yml push events as failure with 0 jobs.
Add an explicit push path filter + noop job; keep publish on dispatch.
2026-11-09 21:19:21 +00:00
layue13 0f29fafc0a ci: skip publish-tdlm job unless workflow_dispatch
Push events were recording empty failed runs (0 jobs) for this
dispatch-only workflow. Guard the job so push is a clean skip.
2026-11-09 21:19:21 +00:00
layue13 7f3b7cfe8a ci: align publish-tdlm action pins with gradle.yml
Use checkout/setup-java v6 and gradle-actions v5 so act resolves the
same nest-mirrored action tags as Java CI.
2026-11-09 21:19:21 +00:00
layue13 9de2d8eeef ci: align publish auth with TDLMPack / org secret pattern
Keep secrets.TDLM_PACK_TOKEN (same name as pack.yml); document that it
is currently a TDLMPack repo secret while MAVEN_* are org-level.
Fall back to github.token so release can proceed after merge without a
new HMCL-only secret when the pack token is promoted or unavailable.
2026-11-09 21:19:21 +00:00
layue13 6434573105 fix: point DEFAULT_CLIENT_FILE_API at packages.dandanton.com CDN
Use https://packages.dandanton.com/tdlm-client/latest (same relative
layout as Pack's tdlm-client/latest). Document TDLM_CLIENT_FILE_API
alignment and the Pack publish/mirror caveat.
2026-11-09 21:19:21 +00:00
layue13 3cbab39d16 ci: publish HMCL.exe/jar to Gitea Release and tdlm-hmcl/latest
Add a TDLMPack-oriented publish workflow (nest API, same pattern as
TDLMPack pack.yml) plus docs for the exact consume URLs. Pack builds
still do not Maven-resolve the launcher.
2026-11-09 21:19:21 +00:00
layue13 0d58eac860 Merge pull request 'ci: stop caching entire ~/.gradle/caches (was ~4GB)' (#17) from cursor/shrink-gradle-cache-4ee7 into main
Java CI / build (push) Canceled after 47s
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
ci: stop caching entire ~/.gradle/caches (was ~4GB) (#17)
2026-11-09 21:19:12 +00:00
layue13 82b0459f4d ci: stop caching entire ~/.gradle/caches (was ~4GB)
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Successful in 12m22s
Match ForestryMC/IC2: Gitea uses setup-gradle cache-provider external
with no whole-home actions/cache. Keep wrapper-only cache so restores
stay small on the Windows Docker runner.
2026-11-09 20:37:39 +00:00
layue13 b8305f85b0 Merge pull request 'ci: align Gradle/JDK caching with TDLM org workflows' (#16) from cursor/ci-cache-optimize-1729 into main
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Successful in 13m8s
ci: align Gradle/JDK caching with TDLM org workflows (#16)
2026-11-09 19:57:41 +00:00
layue13 b7e9d7fda4 ci: fix HMCL:test hang — install GTK for JavaFX under xvfb
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Successful in 14m4s
The apparent checkstyle stall was :HMCL:test stuck after
GtkPlatformFactory Internal Error (xvfb without libgtk). Install GTK/X11
deps, use software prism, and bound Platform.startup in test launchers.
2026-11-09 19:42:47 +00:00
layue13 a4e6ab3673 ci: bump setup-gradle to v6 and cache apt xvfb debs
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Canceled after 24m45s
Nest actions/gradle-actions@v5 rejects cache-provider; v6 supports it
(and defaults to enhanced — keep Gitea on external). Cache ~/apt-cache
so xvfb debs can skip apt-get update when Actions cache is available.
2026-11-09 18:27:47 +00:00
layue13 096146449d ci: align Gradle/JDK caching with TDLM org workflows
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Canceled after 1m55s
Match ForestryMC/IC2 setup-build: cache-jdk, and use setup-gradle
cache-provider basic on GitHub / external on Gitea (avoids ECONNREFUSED
to GitHub cache). Cache ~/.gradle caches+wrapper via actions/cache.
2026-11-09 17:31:17 +00:00
layue13 320c5644e9 Merge pull request 'ci: point setup-gradle at nest mirror actions/gradle-actions' (#15) from cursor/mirror-gradle-actions-1729 into main
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Canceled after 1h31m56s
ci: point setup-gradle at nest mirror actions/gradle-actions

Merged HMC-17.
2026-11-09 16:40:26 +00:00
layue13 5f61a0d05c ci: use nest mirror actions/gradle-actions for setup-gradle
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Canceled after 1h53m21s
act resolves uses: gradle/actions/* to git.nest…/gradle/actions, which
is not available. Sibling mirrors live under the actions org as
actions/gradle-actions (mirror of github.com/gradle/actions).
2026-11-09 16:20:00 +00:00
layue13 bdacd65473 Merge pull request 'chore: 移除误提交的 java/ 目录并忽略' (#6) from fix/remove-committed-java into main
Mirror Repository / Mirror to Gitee (push) Skipped
Mirror Repository / Mirror to CNB (push) Skipped
Java CI / build (push) Canceled after 0s
Reviewed-on: #6
2026-11-08 09:18:07 +00:00
layue13andClaude Sonnet 5.5 f176fbd1fd chore: remove the Java runtime that was committed by mistake and ignore /java/
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Canceled after 0s
A launcher run from the repository root downloads Mojang Java into java/, and it was
picked up by git add -A in e12c7e53f. The files stay in history.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:12:22 +08:00
layue13andCursor 9d43a6fc0f feat: install TDLM via Server Modpack fileApi and enable lwjgl3ify by default
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Canceled after 0s
Wire fixed fileApi first-install/update, and apply the pack's tdlm/lwjgl3ify MultiMC companion on install and launch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 16:44:22 +08:00
layue13 23811dad7f Merge pull request 'ci: 减少重复构建并加缓存' (#5) from ci/faster-builds into main
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Canceled after 0s
2026-11-08 08:30:18 +00:00
layue13andClaude Sonnet 5.5 e792bfc554 ci: try installing xvfb before refreshing the package index
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Failing after 16s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:21:40 +08:00
layue13andClaude Sonnet 5.5 d6b4af2d96 ci: one workflow run per change, cancel superseded runs, cache the Gradle distribution
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Canceled after 0s
Pushes to feature branches built twice (push and pull request) and Check Codes
repeated the compile in a second workflow. Build now runs only on main, tags and
pull requests, cancels older runs of the same ref, folds checkstyle and
checkTranslations into the single Gradle invocation, caches the wrapper download
and skips apt when xvfb is installed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:19:50 +08:00
layue13 2a915460ae Merge pull request 'fix: 账号浮层添加账号入口,统一账号页与设置页样式' (#4) from fix/account-add-when-empty into main
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Successful in 7m28s
Java CI / build (push) Failing after 3h0m3s
2026-11-08 08:18:58 +00:00
layue13andClaude Sonnet 5.5 e9b577f66f style: light cards and lists, sans section titles, hairline outlined buttons, quieter sidebar labels
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Failing after 2m7s
Check Codes / build (push) Successful in 5m52s
Check Codes / build (pull_request) Successful in 7m0s
Java CI / build (pull_request) Failing after 3h0m10s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:08:31 +08:00
layue13andClaude Sonnet 5.5 e12c7e53f6 fix: keep the add-account action in the account popup when there are no accounts
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Failing after 2m30s
Check Codes / build (push) Successful in 4m58s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:50:52 +08:00
layue13 61caf8a925 Merge pull request 'feat: 服务器专用启动器(一键进服、新界面、精简)' (#3) from feat/server-launcher-redesign into main
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Canceled after 7m7s
Java CI / build (push) Failing after 3h0m4s
2026-11-08 07:31:54 +00:00
layue13andClaude Sonnet 5.5 a7123a389e style: fix checkstyle indentation, unused imports and a Classical Chinese spelling check
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Successful in 8m11s
Check Codes / build (pull_request) Successful in 9m17s
Java CI / build (push) Canceled after 53m53s
Java CI / build (pull_request) Canceled after 54m7s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:03:48 +08:00
layue13andClaude Sonnet 5.5 3a6e80da0e refactor: remove multi-instance list pages and unused wizard pages
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Failing after 3m52s
Check Codes / build (pull_request) Failing after 2m45s
Java CI / build (push) Canceled after 1h0m4s
Java CI / build (pull_request) Canceled after 56m22s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:57:06 +08:00
layue13andClaude Sonnet 5.5 2c8446fa33 refactor: replace the 1190-line theme system with a fixed warm theme; drop theme packs, wallpapers and their tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:56:14 +08:00
layue13andClaude Sonnet 5.5 21fcac1b0c refactor: single settings page; hide inherit-from-global toggles, drop global game settings tab
Check Codes / build (push) Failing after 3m51s
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Canceled after 1h8m30s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:42:22 +08:00
layue13andClaude Sonnet 5.5 095d71fea7 feat: show server status (players, latency) on the home card via Server List Ping
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:40:43 +08:00
layue13andClaude Sonnet 5.5 07ff244cd2 feat: platform-style window buttons, 21px dialog radius, thin round progress bar, title mark
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:33:44 +08:00
layue13andClaude Sonnet 5.5 0269e54b95 feat: route switcher on home card, subtler transitions, flat press feedback; add ServerEndpoint tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:27:04 +08:00
layue13andClaude Sonnet 5.5 06ef27f0e0 feat: lightweight first-run dialog for picking a game name or signing in
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:22:04 +08:00
layue13andClaude Sonnet 5.5 b3f649c026 feat: account popup above the bottom-left entry with add and manage actions
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:21:16 +08:00
layue13andClaude Sonnet 5.5 0531d0cbc2 feat: sidebar with modpack, settings and bottom-left account; server name title
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:13:03 +08:00
layue13andClaude Sonnet 5.5 84b6d50421 test: point JFXSwitchTest at the warm palette
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:58:17 +08:00
layue13andClaude Sonnet 5.5 dfdad64e93 feat: warm cream palette, new home page with line art and join card, 987x610 window
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:58:07 +08:00
layue13andClaude Sonnet 5.5 323d09b104 feat: bundle Noto Sans SC and Noto Serif SC (OFL) as default launcher fonts
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 05:56:03 +08:00
layue13andClaude Sonnet 5.5 0f85782312 refactor: drop forced authlib account on first run and unofficial-build hint
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 05:50:45 +08:00
layue13andClaude Sonnet 5.5 7b79945391 feat: multiple server routes, ws(s) addresses passed verbatim to --server
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 05:43:00 +08:00
layue13andClaude Sonnet 5.5 441ce7220f feat: one-click join server button using manifest serverAddress
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 05:29:26 +08:00
layue13andClaude Sonnet 5.5 e8df66dcc0 refactor: drop unused UI controls
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 05:28:31 +08:00
layue13andClaude Sonnet 5.5 f7420e284d refactor: remove personalization settings page
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 05:28:12 +08:00
layue13andClaude Sonnet 5.5 a2fa7ed5ab refactor: remove mod, resource pack, world, data pack and installer pages
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 05:27:38 +08:00
浩 刘 3f67b6bda0 fix: store managed Java under the launcher workspace
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Successful in 2m48s
Java CI / build (push) Failing after 3h0m2s
Install Mojang/runtime downloads into <working-dir>/java beside
.minecraft instead of the user-global HMCL profile java store.
2026-11-07 20:35:13 +00:00
浩 刘 4cd783d6bb feat: offer Offline and Microsoft auth; keep authlib injector
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Successful in 4m59s
Java CI / build (push) Failing after 3h0m0s
Primary account creation entries are Offline and Microsoft. External
Yggdrasil / authlib-injector servers remain listed and addable for later
use. Offline creation is no longer redirected away or region-restricted.
2026-11-07 20:27:29 +00:00
浩 刘 4118030d9c feat: auto-fetch Mojang Java 25; strip April Fools, task list, unused locales
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Successful in 3m50s
Java CI / build (push) Failing after 3h0m1s
On first-capable startup, download java-runtime-epsilon via the existing
BMCLAPI-mirrored Mojang path when missing (progress dialog only; no prompt).

Remove launcher April Fools prompts/settings/UI tags, the top-right task
center page/button, dead NBT/April Fools images, and unused locale packs
(ar/de/es/ja/ru/uk) to shrink the distributable.
2026-11-07 20:19:40 +00:00
浩 刘 bcc5cda5c0 style: clear checkstyle leftovers from TDLM fork cuts
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Successful in 4m39s
Java CI / build (push) Failing after 3h0m3s
Remove unused/redundant imports, fix SettingsPage indentation, and
collapse extra blank lines so checkstyle/checkTranslations can pass on
Gitea Actions without reviewdog.
2026-11-07 19:33:20 +00:00
浩 刘 1147892830 ci: drop unmirrored reviewdog and github-script workflows
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Failing after 10m17s
Java CI / build (push) Failing after 3h1m7s
Gitea act resolves actions from git.nest.potatolab.uk; reviewdog/action-setup
and actions/github-script 404 there (even for conditional steps). Keep Gradle
checkstyle/checkTranslations only; disable PR comment and size-label flows.
2026-11-07 19:16:41 +00:00
浩 刘 a4fff896ea ci: install xvfb before Gradle build on self-hosted runners
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Failing after 3h0m4s
Self-hosted Gitea Actions runners lack xvfb by default, so xvfb-run
fails with exit 127. Install it in the Java CI workflow before build.
2026-11-07 19:02:04 +00:00
layue13 5e2da98061 Merge pull request 'Remove official groups, multiplayer, downloads, help, and self-update' (#1)
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Failing after 2m20s
Java CI / build (push) Failing after 4m0s
Merge pull request #1: Remove official groups, multiplayer, downloads, help, and self-update

Server-client HMCL fork cleanup for TDLM.
2026-11-07 18:56:23 +00:00
173 changed files with 2850 additions and 25719 deletions

No files matched your search

-40
View File
@@ -1,40 +0,0 @@
name: Check Codes Comment
on:
workflow_run:
workflows: ["Check Codes"]
types:
- completed
permissions:
actions: read
contents: read
pull-requests: write
jobs:
comment:
runs-on: ubuntu-latest
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'failure'
steps:
- name: Checkout Script
uses: actions/checkout@v7
with:
sparse-checkout: |
.github/scripts/check-codes-comment.js
sparse-checkout-cone-mode: false
- name: Download Artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: checkstyle-artifact
path: ${{ runner.temp }}/checkstyle-artifact
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Generate Review Comment
uses: actions/github-script@v9
env:
REPORTS_PATH: ${{ runner.temp }}/checkstyle-artifact/ALL_REPORTS.jsonl
PULL_REQUEST_NUMBER_PATH: ${{ runner.temp }}/checkstyle-artifact/PULL_REQUEST_NUMBER
with:
script: |
const script = require("./.github/scripts/check-codes-comment.js");
await script({ github, context, core });
-55
View File
@@ -1,55 +0,0 @@
name: Check Codes
on:
push:
paths:
- '**.java'
- '**.properties'
pull_request:
paths:
- '**.java'
- '**.properties'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup JDK 17
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: '17'
java-package: 'jdk+fx'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-cleanup: never
- name: Check Codes
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel --stacktrace
- if: failure() && github.event_name == 'pull_request'
name: Setup reviewdog
uses: reviewdog/action-setup@d8edfce3dd5e1ec6978745e801f9c50b5ef80252 # v1.4.0
with:
reviewdog_version: v0.21.0
- if: failure() && github.event_name == 'pull_request'
name: Generate Artifact
run: |
echo "${{ github.event.number }}" > PULL_REQUEST_NUMBER
> ALL_REPORTS.jsonl
find . -path "*/build/reports/checkstyle/*.xml" | while read -r xml_file; do
if [ -s "$xml_file" ]; then
echo "Processing $xml_file..."
reviewdog -f=checkstyle -name="checkstyle" -reporter=rdjsonl < "$xml_file" >> ALL_REPORTS.jsonl
else
echo "Skipping empty or missing report: $xml_file"
fi
done
- if: failure() && github.event_name == 'pull_request'
name: Upload Artifact
uses: actions/upload-artifact@v7
with:
name: checkstyle-artifact
path: |
ALL_REPORTS.jsonl
PULL_REQUEST_NUMBER
+63 -4
View File
@@ -1,54 +1,113 @@
name: Java CI
# Pushes to main and pull requests only. A branch with an open pull request used to build twice
# (once for the push, once for the pull request), which kept the runners queued.
on:
push:
branches:
- main
tags:
- '*'
pull_request:
paths-ignore:
- '**.md'
# A newer run for the same pull request or branch replaces the older one.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v6
# Same pattern as TDLM ForestryMC/IndustrialCraft2 setup-build: cache the JDK toolchain.
- name: Set up JDK 17
uses: actions/setup-java@v6
with:
distribution: 'liberica'
java-version: '17'
java-package: 'jdk+fx'
cache-jdk: true
# Nest mirrors github.com/gradle/actions as actions/gradle-actions (also as gradle/actions).
# Match ForestryMC/IC2: on Gitea use cache-provider external (no setup-gradle Actions cache).
# Do NOT actions/cache the whole ~/.gradle/caches — that hit ~4GB here and OOM'd the
# Windows Docker runner on restore; siblings never cache that tree.
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
uses: actions/gradle-actions/setup-gradle@v6
with:
cache-cleanup: never
- name: Build with Gradle
run: xvfb-run --auto-servernum ./gradlew build --no-daemon --parallel
cache-provider: ${{ github.server_url == 'https://github.com' && 'basic' || 'external' }}
# Wrapper only (small). Dependency jars re-download when cold — same as org mod CI on Gitea.
- name: Cache Gradle wrapper
uses: actions/cache@v4
with:
path: |
~/.gradle/wrapper
.gradle/wrapper-neo
key: gradle-wrapper-${{ runner.os }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/wrapper/GradleWrapperNeo.java') }}
# Xvfb alone is not enough: JavaFX Glass needs GTK/X11 libs or Platform.startup fails with
# GtkPlatformFactory "Internal Error" and :HMCL:test can hang (looks like a checkstyle stall).
- name: Cache apt packages
uses: actions/cache@v4
with:
path: ~/apt-cache
key: apt-javafx-xvfb-${{ runner.os }}-${{ runner.arch }}-1
- name: Install Xvfb and JavaFX GTK deps
run: |
pkgs='xvfb libgtk-3-0 libxtst6 libxxf86vm1 libgl1 libxi6 libx11-6 libxext6 libxrender1'
sudo mkdir -p /var/cache/apt/archives/partial
if [ -d "${HOME}/apt-cache" ] && [ -n "$(ls -A "${HOME}/apt-cache" 2>/dev/null)" ]; then
sudo cp -a "${HOME}/apt-cache/." /var/cache/apt/archives/
fi
# Prefer cached debs; refresh the index only when install cannot resolve packages.
sudo apt-get install -y --no-install-recommends $pkgs \
|| { sudo apt-get update && sudo apt-get install -y --no-install-recommends $pkgs; }
mkdir -p "${HOME}/apt-cache"
cp -a /var/cache/apt/archives/*.deb "${HOME}/apt-cache/" 2>/dev/null || true
# One Gradle run builds, tests and runs the code and translation checks, so the
# project is configured and compiled once instead of in two workflows.
- name: Build and check with Gradle
run: xvfb-run --auto-servernum ./gradlew build checkstyle checkTranslations --no-daemon --parallel
env:
JAVA_TOOL_OPTIONS: -Djava.awt.headless=true
JAVA_TOOL_OPTIONS: -Djava.awt.headless=true -Dprism.order=sw
MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }}
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
- name: Get short SHA
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
- name: Upload JAR
uses: actions/upload-artifact@v7
with:
name: HMCL-${{ env.SHORT_SHA }}-jar
path: HMCL/build/libs/HMCL-*.jar
archive: false
- name: Upload EXE
uses: actions/upload-artifact@v7
with:
name: HMCL-${{ env.SHORT_SHA }}-exe
path: HMCL/build/libs/HMCL-*.exe
archive: false
- name: Upload SH
uses: actions/upload-artifact@v7
with:
name: HMCL-${{ env.SHORT_SHA }}-sh
path: HMCL/build/libs/HMCL-*.sh
archive: false
- name: Upload DEB
uses: actions/upload-artifact@v7
with:
+6 -17
View File
@@ -1,11 +1,9 @@
name: PR Size Label
# Disabled on this Gitea fork: act resolves actions/github-script from
# git.nest.potatolab.uk and that mirror 404s. Re-enable after mirroring
# actions/github-script or replacing the labeler with a plain shell step.
on:
pull_request_target:
types:
- opened
- reopened
- synchronize
workflow_dispatch:
permissions:
@@ -16,16 +14,7 @@ permissions:
jobs:
label:
runs-on: ubuntu-latest
if: false
steps:
- name: Checkout scripts
uses: actions/checkout@v7
with:
sparse-checkout: |
.github/scripts/pr-size-label.js
sparse-checkout-cone-mode: false
- name: Label PR by filtered changed lines
uses: actions/github-script@v9
with:
script: |
const script = require("./.github/scripts/pr-size-label.js");
await script({ github, context, core });
- name: Disabled
run: echo "pr-size-label is disabled on this fork"
+249
View File
@@ -0,0 +1,249 @@
name: Publish TDLM launcher
# Builds HMCL.exe / HMCL.jar and publishes them for TDLMPack / players:
# 1. Gitea Release assets (versioned download page)
# 2. Gitea generic package tdlm-hmcl/latest (stable curl URLs)
#
# Mirrors TDLM/TDLMPack's pack.yml publish style (nest API host; Cloudflare
# in front of git.potatolab.uk blocks Python urllib for large uploads).
#
# Auth (same name as TDLMPack pack.yml):
# secrets.TDLM_PACK_TOKEN — Gitea API token with package + release write.
# Prefer an *org* secret (like MAVEN_USER/MAVEN_PASSWORD) so HMCL inherits it;
# today it exists only as a TDLMPack *repo* secret unless promoted.
# Falls back to github.token when TDLM_PACK_TOKEN is unset (same-repo release;
# org generic packages may still need the pack token).
on:
workflow_dispatch:
inputs:
release_tag:
description: "Release tag (also used as the release title). Example: 2026.11.08 or v3.dev-abc1234"
required: true
type: string
# Gitea records a push run whenever this file changes; without a push job the run
# fails with 0 jobs. Keep a no-op so the check is green; real publish stays dispatch-only.
push:
paths:
- '.github/workflows/publish-tdlm.yml'
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
noop:
if: ${{ github.event_name == 'push' }}
runs-on: ubuntu-latest
steps:
- run: echo "publish-tdlm is workflow_dispatch-only; push no-op ok"
publish:
if: ${{ github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v6
# Same caching pattern as gradle.yml / TDLM ForestryMC+IC2 setup-build.
- name: Set up JDK 17 + JavaFX
uses: actions/setup-java@v6
with:
distribution: 'liberica'
java-version: '17'
java-package: 'jdk+fx'
cache-jdk: true
- name: Setup Gradle
uses: actions/gradle-actions/setup-gradle@v6
with:
cache-cleanup: never
cache-provider: ${{ github.server_url == 'https://github.com' && 'basic' || 'external' }}
# Wrapper only — do not cache ~/.gradle/caches (org siblings don't; ~4GB killed Docker).
- name: Cache Gradle wrapper
uses: actions/cache@v4
with:
path: |
~/.gradle/wrapper
.gradle/wrapper-neo
key: gradle-wrapper-${{ runner.os }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/wrapper/GradleWrapperNeo.java') }}
- name: Cache apt packages
uses: actions/cache@v4
with:
path: ~/apt-cache
key: apt-javafx-xvfb-${{ runner.os }}-${{ runner.arch }}-1
- name: Install Xvfb and JavaFX GTK deps
run: |
pkgs='xvfb libgtk-3-0 libxtst6 libxxf86vm1 libgl1 libxi6 libx11-6 libxext6 libxrender1'
sudo mkdir -p /var/cache/apt/archives/partial
if [ -d "${HOME}/apt-cache" ] && [ -n "$(ls -A "${HOME}/apt-cache" 2>/dev/null)" ]; then
sudo cp -a "${HOME}/apt-cache/." /var/cache/apt/archives/
fi
sudo apt-get install -y --no-install-recommends $pkgs \
|| { sudo apt-get update && sudo apt-get install -y --no-install-recommends $pkgs; }
mkdir -p "${HOME}/apt-cache"
cp -a /var/cache/apt/archives/*.deb "${HOME}/apt-cache/" 2>/dev/null || true
- name: Build executables
run: xvfb-run --auto-servernum ./gradlew :HMCL:makeExecutables --no-daemon --parallel
env:
JAVA_TOOL_OPTIONS: -Djava.awt.headless=true -Dprism.order=sw
VERSION_TYPE: unofficial
MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }}
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
- name: Stage stable launcher names
run: |
set -euo pipefail
mkdir -p build/tdlm-hmcl
jar="$(ls -1 HMCL/build/libs/HMCL-*.jar | grep -v '\.sha' | head -n1)"
exe="$(ls -1 HMCL/build/libs/HMCL-*.exe | grep -v '\.sha' | head -n1)"
test -n "$jar" && test -n "$exe"
cp -f "$jar" build/tdlm-hmcl/HMCL.jar
cp -f "$exe" build/tdlm-hmcl/HMCL.exe
# Prefer Gradle-produced checksums when present; otherwise compute.
if [ -f "${jar}.sha256" ]; then cp -f "${jar}.sha256" build/tdlm-hmcl/HMCL.jar.sha256
else sha256sum build/tdlm-hmcl/HMCL.jar | awk '{print $1}' > build/tdlm-hmcl/HMCL.jar.sha256
fi
if [ -f "${exe}.sha256" ]; then cp -f "${exe}.sha256" build/tdlm-hmcl/HMCL.exe.sha256
else sha256sum build/tdlm-hmcl/HMCL.exe | awk '{print $1}' > build/tdlm-hmcl/HMCL.exe.sha256
fi
echo "STAGED_JAR=$jar" >> "$GITHUB_ENV"
echo "STAGED_EXE=$exe" >> "$GITHUB_ENV"
ls -la build/tdlm-hmcl
# Stable player/CI channel: overwritten in place under tdlm-hmcl/latest.
- name: Publish generic package tdlm-hmcl/latest
env:
# Same secret name as TDLM/TDLMPack pack.yml (org or repo). Not MAVEN_*.
TDLM_PACK_TOKEN: ${{ secrets.TDLM_PACK_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
GITEA_PACKAGE_ROOT: https://git.nest.potatolab.uk:8443/api/packages/TDLM/generic/tdlm-hmcl/latest
run: |
python3 - <<'PY'
import os, pathlib, urllib.error, urllib.parse, urllib.request
token = (os.environ.get("TDLM_PACK_TOKEN") or os.environ.get("GITHUB_TOKEN") or "").strip()
token_src = "TDLM_PACK_TOKEN" if os.environ.get("TDLM_PACK_TOKEN", "").strip() else "github.token"
if not token:
raise SystemExit(
"Need secrets.TDLM_PACK_TOKEN (promote TDLMPack's repo secret to org, like MAVEN_*) "
"or a usable github.token to publish tdlm-hmcl")
print("auth=" + token_src)
upload_root = os.environ["GITEA_PACKAGE_ROOT"].rstrip("/")
root = pathlib.Path("build/tdlm-hmcl")
def put(name: str, data: bytes):
url = upload_root + "/" + urllib.parse.quote(name, safe="")
request = urllib.request.Request(url, data=data, method="PUT")
request.add_header("Authorization", "token " + token)
request.add_header("Content-Type", "application/octet-stream")
request.add_header("User-Agent", "tdlm-hmcl-publish")
try:
with urllib.request.urlopen(request, timeout=1800) as response:
response.read()
except urllib.error.HTTPError as exc:
raise SystemExit("HTTP %s uploading %s: %s" % (exc.code, name, exc.read()[:500]))
for path in sorted(root.iterdir()):
if path.is_file():
put(path.name, path.read_bytes())
print("PACKAGE " + path.name)
print("generic=" + upload_root)
PY
# Gitea release (versioned). Nest API avoids Cloudflare blocking large urllib uploads.
- name: Publish Gitea release
env:
RELEASE_TAG: ${{ inputs.release_tag }}
TDLM_PACK_TOKEN: ${{ secrets.TDLM_PACK_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
GITEA_API_URL: https://git.nest.potatolab.uk:8443/api/v1
GITEA_PACKAGE_ROOT: https://git.nest.potatolab.uk:8443/api/packages/TDLM/generic/tdlm-hmcl/latest
TARGET_COMMIT: ${{ github.sha }}
run: |
python3 - <<'PY'
import json, os, urllib.error, urllib.parse, urllib.request
tag = os.environ["RELEASE_TAG"].strip()
token = (os.environ.get("TDLM_PACK_TOKEN") or os.environ.get("GITHUB_TOKEN") or "").strip()
token_src = "TDLM_PACK_TOKEN" if os.environ.get("TDLM_PACK_TOKEN", "").strip() else "github.token"
api = os.environ["GITEA_API_URL"].rstrip("/") + "/repos/TDLM/HMCL"
package_root = os.environ["GITEA_PACKAGE_ROOT"].rstrip("/")
target = os.environ.get("TARGET_COMMIT", "main").strip() or "main"
if not token:
raise SystemExit(
"Need secrets.TDLM_PACK_TOKEN (org-preferred, same as TDLMPack) or github.token")
print("auth=" + token_src)
if not tag:
raise SystemExit("release_tag is empty")
def call(method, path, data=None, content_type="application/json", timeout=90):
request = urllib.request.Request(api + path, data=data, method=method)
request.add_header("Authorization", "token " + token)
request.add_header("Accept", "application/json")
request.add_header("User-Agent", "tdlm-hmcl-release")
if data is not None and content_type:
request.add_header("Content-Type", content_type)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
body = response.read()
return json.loads(body) if body else {}
except urllib.error.HTTPError as exc:
if exc.code == 404:
return None
detail = exc.read()[:500]
raise SystemExit("HTTP %s for %s: %s" % (exc.code, path, detail))
body = (
"TDLM/HMCL launcher binaries for players and TDLMPack.\n\n"
"Stable generic (overwrite in place):\n"
"- %s/HMCL.exe\n"
"- %s/HMCL.jar\n\n"
"TDLMPack does not Maven-resolve this launcher; point the pack README / install docs at these URLs "
"instead of hand-copying exe/jar.\n"
"Client pack fileApi default: https://packages.dandanton.com/tdlm-client/latest\n"
"(Pack must publish/mirror the same layout; see docs/TDLM_CONSUME.md).\n"
) % (package_root, package_root)
quoted = urllib.parse.quote(tag, safe="")
release = call("GET", "/releases/tags/" + quoted)
if release and not release.get("draft"):
raise SystemExit("Release tag already exists: " + tag)
if not release:
release = call("POST", "/releases", json.dumps({
"tag_name": tag,
"name": tag,
"target_commitish": target,
"draft": True,
"prerelease": False,
"body": body,
}).encode("utf-8"))
release_id = release["id"]
staged = [
("HMCL.exe", "build/tdlm-hmcl/HMCL.exe"),
("HMCL.jar", "build/tdlm-hmcl/HMCL.jar"),
("HMCL.exe.sha256", "build/tdlm-hmcl/HMCL.exe.sha256"),
("HMCL.jar.sha256", "build/tdlm-hmcl/HMCL.jar.sha256"),
]
for name, path in staged:
if not os.path.exists(path):
raise SystemExit("missing " + path)
for old in call("GET", "/releases/%s/assets" % release_id) or []:
if old.get("name") == name:
call("DELETE", "/releases/%s/assets/%s" % (release_id, old["id"]))
with open(path, "rb") as asset:
call(
"POST",
"/releases/%s/assets?name=%s" % (release_id, urllib.parse.quote(name)),
asset.read(),
"application/octet-stream",
1800,
)
print("RELEASE asset " + name)
call("PATCH", "/releases/%s" % release_id, json.dumps({"draft": False}).encode("utf-8"))
print(release.get("html_url", "Release created"))
PY
+3
View File
@@ -77,3 +77,6 @@ language-subtag-registry
!/.gemini/config.yaml
.codex
/.gradle-*/
# Managed Java downloaded by the launcher when it is run from the repository root
/java/
@@ -141,4 +141,43 @@ public final class Metadata {
return null;
}
/// Default client Server Modpack fileApi (CDN mirror of Pack's `tdlm-client/latest` layout).
public static final String DEFAULT_CLIENT_FILE_API =
"https://packages.dandanton.com/tdlm-client/latest";
/// Stable client update root (`server-manifest.json` and `overrides/` live under this URL).
///
/// Override with system property `hmcl.client.fileApi` or jar attribute `hmcl.client.fileApi`.
public static String getClientFileApi() {
String fromProperty = System.getProperty("hmcl.client.fileApi", "").trim();
if (StringUtils.isNotBlank(fromProperty)) {
return StringUtils.removeSuffix(fromProperty, "/");
}
String fromJar = JarUtils.getAttribute("hmcl.client.fileApi", null);
if (StringUtils.isNotBlank(fromJar)) {
return StringUtils.removeSuffix(fromJar.trim(), "/");
}
return DEFAULT_CLIENT_FILE_API;
}
/// Absolute URL of the client `server-manifest.json` used for first install and differential updates.
public static String getClientManifestUrl() {
return getClientFileApi() + "/server-manifest.json";
}
/// Server address used when the installed modpack manifest does not provide one.
///
/// Can be set at build or run time with the `hmcl.server.address` system property; empty by default.
public static String getDefaultServerAddress() {
return System.getProperty("hmcl.server.address", "").trim();
}
/// The server name shown in the title bar.
///
/// Can be set with the `hmcl.server.name` system property; defaults to `TDLM`.
public static String getServerName() {
String name = System.getProperty("hmcl.server.name", "TDLM").trim();
return name.isEmpty() ? "TDLM" : name;
}
}
@@ -455,9 +455,9 @@ public class HMCLGameInstance extends DefaultGameInstance {
return GameInstanceIconType.OPTIFINE.getIcon();
GameVersionNumber version = getVersion();
if (version.isAprilFools())
return GameInstanceIconType.APRIL_FOOLS.getIcon();
else if (version instanceof GameVersionNumber.LegacySnapshot || version instanceof GameVersionNumber.Release release && release.getEaType() != GameVersionNumber.Release.ReleaseType.GA)
if (version instanceof GameVersionNumber.LegacySnapshot
|| version.isAprilFools()
|| version instanceof GameVersionNumber.Release release && release.getEaType() != GameVersionNumber.Release.ReleaseType.GA)
return GameInstanceIconType.COMMAND.getIcon();
else if (version instanceof GameVersionNumber.Old)
return GameInstanceIconType.CRAFT_TABLE.getIcon();
@@ -19,7 +19,6 @@ import org.jackhuang.hmcl.modpack.server.ServerModpackRemoteInstallTask;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.PortablePath;
import org.jackhuang.hmcl.util.function.ExceptionalConsumer;
import org.jackhuang.hmcl.util.function.ExceptionalRunnable;
@@ -44,6 +43,7 @@ import java.util.Set;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Utilities for reading, installing, and applying modpack-specific game settings.
@NotNullByDefault
@@ -314,18 +314,13 @@ public final class ModpackHelper {
setting.minMemoryProperty().setValue(c.getMinMemory());
}
// MultiMC packs are untrusted input: never auto-apply PreLaunch/Wrapper/JVM overrides.
if (c.isOverrideCommands()) {
setting.getOverrideProperties().addAll(List.of(
GameSettings.PROPERTY_COMMAND_WRAPPER,
GameSettings.PROPERTY_PRE_LAUNCH_COMMAND
));
setting.commandWrapperProperty().setValue(Objects.requireNonNullElse(c.getWrapperCommand(), ""));
setting.preLaunchCommandProperty().setValue(Objects.requireNonNullElse(c.getPreLaunchCommand(), ""));
LOG.warning("Ignoring MultiMC OverrideCommands (PreLaunch/Wrapper) from modpack for security");
}
if (c.isOverrideJavaArgs()) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_JVM_OPTIONS);
setting.jvmOptionsProperty().setValue(Objects.requireNonNullElse(c.getJvmArgs(), ""));
LOG.warning("Ignoring MultiMC OverrideJavaArgs from modpack for security");
}
if (c.isOverrideConsole()) {
@@ -348,18 +343,13 @@ public final class ModpackHelper {
}
private static void applyCommandAndJvmSettings(MultiMCInstanceConfiguration c, GameSettings.Instance setting) {
// Same policy as toGameSettings: do not import executable commands / raw JVM args from packs.
if (c.isOverrideCommands()) {
setting.getOverrideProperties().addAll(List.of(
GameSettings.PROPERTY_COMMAND_WRAPPER,
GameSettings.PROPERTY_PRE_LAUNCH_COMMAND
));
setting.commandWrapperProperty().setValue(Lang.nonNull(c.getWrapperCommand(), ""));
setting.preLaunchCommandProperty().setValue(Lang.nonNull(c.getPreLaunchCommand(), ""));
LOG.warning("Ignoring MultiMC OverrideCommands (PreLaunch/Wrapper) from modpack update for security");
}
if (c.isOverrideJavaArgs()) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_JVM_OPTIONS);
setting.jvmOptionsProperty().setValue(Lang.nonNull(c.getJvmArgs(), ""));
LOG.warning("Ignoring MultiMC OverrideJavaArgs from modpack update for security");
}
}
@@ -131,7 +131,7 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
String html;
try {
html = IOUtils.readFullyAsString(OAuthServer.class.getResourceAsStream("/assets/microsoft_auth.html"))
.replace("%style%", Themes.getTheme().toColorScheme().toStyleSheet().replace("-monet", "--monet"))
.replace("%style%", Themes.getColorScheme().toStyleSheet().replace("-monet", "--monet"))
.replace("%lang%", Locale.getDefault().toLanguageTag())
.replace("%success%", i18n("message.success"))
.replace("%ok%", i18n("button.ok"))
@@ -0,0 +1,122 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Resolves the routes that the one-click join button can connect through.
@NotNullByDefault
public final class ServerEndpoint {
private ServerEndpoint() {
}
/// One selectable connection route.
///
/// @param name the display name
/// @param address the `host[:port]` or `ws(s)://` address handed to the game
public record Route(String name, String address) {
}
/// Lists the routes configured for an instance.
///
/// Routes in the installed modpack manifest win, so they change with modpack updates. Without
/// them the manifest's single `serverAddress` and then [Metadata#getDefaultServerAddress()] are
/// used, each as a single route.
///
/// @param configurationFile the instance's `modpack.json` path
/// @return the routes in display order; empty when nothing is configured
public static @Unmodifiable List<Route> routes(Path configurationFile) {
List<Route> result = new ArrayList<>();
@Nullable ServerModpackManifest manifest = readManifest(configurationFile);
if (manifest != null) {
for (ServerModpackManifest.Route route : manifest.getRoutes()) {
if (StringUtils.isNotBlank(route.getAddress())) {
result.add(new Route(StringUtils.isBlank(route.getName()) ? route.getAddress().trim() : route.getName(), route.getAddress().trim()));
}
}
if (result.isEmpty() && StringUtils.isNotBlank(manifest.getServerAddress())) {
result.add(new Route("default", manifest.getServerAddress().trim()));
}
}
if (result.isEmpty()) {
String fallback = Metadata.getDefaultServerAddress();
if (!fallback.isEmpty()) {
result.add(new Route("default", fallback));
}
}
return List.copyOf(result);
}
/// Picks the route to join: the one the player chose last if it still exists, otherwise the first.
///
/// @param configurationFile the instance's `modpack.json` path
/// @return the route to connect through, or `null` when nothing is configured
public static @Nullable Route selected(Path configurationFile) {
List<Route> routes = routes(configurationFile);
if (routes.isEmpty()) {
return null;
}
@Nullable String saved = readSelectedName(configurationFile);
for (Route route : routes) {
if (route.name().equals(saved)) {
return route;
}
}
return routes.get(0);
}
/// Remembers the player's route choice next to the instance's modpack configuration.
///
/// @param configurationFile the instance's `modpack.json` path
/// @param route the chosen route
public static void select(Path configurationFile, Route route) {
try {
Files.writeString(selectionFile(configurationFile), route.name(), StandardCharsets.UTF_8);
} catch (IOException e) {
LOG.warning("Unable to save the selected route", e);
}
}
private static Path selectionFile(Path configurationFile) {
return configurationFile.resolveSibling("server-route.txt");
}
private static @Nullable String readSelectedName(Path configurationFile) {
try {
Path file = selectionFile(configurationFile);
return Files.isRegularFile(file) ? Files.readString(file, StandardCharsets.UTF_8).trim() : null;
} catch (IOException e) {
return null;
}
}
private static @Nullable ServerModpackManifest readManifest(Path configurationFile) {
if (!Files.isRegularFile(configurationFile)) {
return null;
}
try {
ModpackConfiguration<ServerModpackManifest> configuration =
JsonUtils.fromJsonFile(configurationFile, ModpackConfiguration.typeOf(ServerModpackManifest.class));
return configuration != null ? configuration.getManifest() : null;
} catch (Exception e) {
LOG.warning("Unable to read server routes from " + configurationFile, e);
return null;
}
}
}
@@ -61,8 +61,12 @@ public final class JavaManager {
"Semeru"
};
public static final HMCLJavaRepository REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_USER_HOME.resolve("java"));
public static final HMCLJavaRepository LOCAL_REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_LOCAL_HOME.resolve("java"));
/// Managed Mojang/Disco Java installs for this workspace (beside `.minecraft`), not the user-global HMCL profile.
public static final HMCLJavaRepository REPOSITORY =
new HMCLJavaRepository(Metadata.CURRENT_DIRECTORY.resolve("java"));
/// Optional portable installs under the launcher-local `.hmcl` directory.
public static final HMCLJavaRepository LOCAL_REPOSITORY =
new HMCLJavaRepository(Metadata.HMCL_LOCAL_HOME.resolve("java"));
public static String getMojangJavaPlatform(Platform platform) {
if (platform.getOperatingSystem() == OperatingSystem.WINDOWS) {
@@ -55,8 +55,12 @@ public final class Accounts {
public static final MicrosoftAccountFactory FACTORY_MICROSOFT = new MicrosoftAccountFactory(new MicrosoftService(OAUTH_CALLBACK));
/// Account factories offered when creating a new account from the UI.
///
/// Offline login remains loadable for existing accounts, but is no longer offered for creation.
public static final List<AccountFactory<?>> FACTORIES = List.of(FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR);
/// Primary: Offline and Microsoft. Authlib-injector remains available for external Yggdrasil servers.
public static final List<AccountFactory<?>> FACTORIES = List.of(
FACTORY_OFFLINE,
FACTORY_MICROSOFT,
FACTORY_AUTHLIB_INJECTOR
);
// ==== login type / account factory mapping ====
private static final Map<String, AccountFactory<?>> type2factory = new HashMap<>();
@@ -82,7 +82,28 @@ public final class FontManager {
private static final ObjectProperty<FontReference> font = new SimpleObjectProperty<>();
/// Bundled OFL fonts: Noto Sans SC for headings and controls, Noto Serif SC for body text.
private static final String[] BUNDLED_FONTS = {
"/assets/fonts/NotoSansSC-Regular.ttf",
"/assets/fonts/NotoSansSC-Bold.ttf",
"/assets/fonts/NotoSerifSC-Regular.ttf"
};
/// Registers the bundled fonts with JavaFX so style sheets can refer to them by family name.
private static void loadBundledFonts() {
for (String resource : BUNDLED_FONTS) {
try (var in = FontManager.class.getResourceAsStream(resource)) {
if (in == null || Font.loadFont(in, DEFAULT_FONT_SIZE) == null) {
LOG.warning("Failed to load bundled font " + resource);
}
} catch (java.io.IOException e) {
LOG.warning("Failed to load bundled font " + resource, e);
}
}
}
static {
loadBundledFonts();
updateFont();
LOG.info("Font: " + (font.get() != null ? font.get().family() : "System"));
}
@@ -22,7 +22,8 @@ public enum GameInstanceIconType {
NEO_FORGE("/assets/img/neoforge.png"),
FURNACE("/assets/img/furnace.png"),
QUILT("/assets/img/quilt.png"),
APRIL_FOOLS("/assets/img/april_fools.png"),
/// Retained for settings migration; UI no longer offers this icon.
APRIL_FOOLS("/assets/img/command.png"),
CLEANROOM("/assets/img/cleanroom.png"),
LEGACY_FABRIC("/assets/img/legacyfabric.png")
;
@@ -25,7 +25,6 @@ import org.jackhuang.hmcl.theme.BackgroundLoadPolicy;
import org.jackhuang.hmcl.theme.BuiltinBackground;
import org.jackhuang.hmcl.theme.NetworkBackgroundImageCachePolicy;
import org.jackhuang.hmcl.theme.ThemeColor;
import org.jackhuang.hmcl.theme.ThemeReference;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.*;
@@ -59,7 +58,6 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
static final String PROPERTY_SELECTED_INSTANCE = "selectedInstance";
/// Default launcher theme used when no stored theme reference is available.
public static final ThemeReference DEFAULT_THEME_REFERENCE = new ThemeReference("hmcl.default", null);
/// Theme appearance override key for theme brightness mode.
public static final String THEME_APPEARANCE_BRIGHTNESS_MODE = "themeBrightnessMode";
@@ -179,15 +177,6 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
/// Whether April Fools features are disabled.
@SerializedName("disableAprilFools")
private final BooleanProperty disableAprilFools = new SimpleBooleanProperty(false);
/// Returns the April Fools disable property.
public BooleanProperty disableAprilFoolsProperty() {
return disableAprilFools;
}
/// The common Minecraft directory selection mode.
@SerializedName("commonDirectoryType")
private final ObjectProperty<EnumCommonDirectory> commonDirectoryType = new RawPreservingObjectProperty<>(EnumCommonDirectory.DEFAULT);
@@ -234,23 +223,6 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
// UI
// Theme selection
/// The installed theme selected by the launcher, or `null` when older settings do not contain a theme reference.
@SerializedName("selectedTheme")
private final ObjectProperty<@Nullable ThemeReference> selectedTheme =
new SimpleObjectProperty<>(DEFAULT_THEME_REFERENCE);
/// Returns the selected installed theme property.
public ObjectProperty<@Nullable ThemeReference> selectedThemeProperty() {
return selectedTheme;
}
/// Returns the selected installed theme, falling back to the built-in default theme.
public ThemeReference getSelectedThemeOrDefault() {
return Objects.requireNonNullElse(selectedTheme.get(), DEFAULT_THEME_REFERENCE);
}
// Theme appearance overrides
/// Theme appearance setting keys overridden by the launcher.
@@ -9,15 +9,12 @@ import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableMap;
import org.jackhuang.hmcl.util.gson.JsonSchema;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.ObservableSetting;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
@@ -6,12 +6,6 @@ import javafx.beans.binding.Bindings;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.scene.paint.Color;
import org.glavo.monetfx.Brightness;
import org.glavo.monetfx.ColorRole;
import org.glavo.monetfx.ColorScheme;
import org.jackhuang.hmcl.theme.ResolvedTheme;
import org.jackhuang.hmcl.theme.ThemeColor;
import org.jackhuang.hmcl.theme.Themes;
import java.nio.charset.StandardCharsets;
@@ -100,55 +94,14 @@ public final class StyleSheets {
}
private static String getBrightnessStyleSheet() {
return Themes.getColorScheme().getBrightness() == Brightness.LIGHT
? "/assets/css/brightness-light.css"
: "/assets/css/brightness-dark.css";
}
private static void addColor(StringBuilder builder, String name, Color color) {
builder.append(" ").append(name)
.append(": ").append(ThemeColor.getColorDisplayName(color)).append(";\n");
}
private static void addColor(StringBuilder builder, String name, Color color, double opacity) {
builder.append(" ").append(name)
.append(": ").append(ThemeColor.getColorDisplayNameWithOpacity(color, opacity)).append(";\n");
}
private static void addColor(StringBuilder builder, ColorScheme scheme, ColorRole role, double opacity) {
builder.append(" ").append(role.getVariableName()).append("-transparent-%02d".formatted((int) (100 * opacity)))
.append(": ").append(ThemeColor.getColorDisplayNameWithOpacity(scheme.getColor(role), opacity))
.append(";\n");
return "/assets/css/brightness-light.css";
}
/// Returns the launcher color style sheet.
///
/// The launcher ships a single warm palette; user-selected themes are no longer applied.
private static String getThemeStyleSheet() {
final String blueCss = "/assets/css/blue.css";
if (ResolvedTheme.DEFAULT.equals(Themes.getTheme()))
return blueCss;
ColorScheme scheme = Themes.getColorScheme();
StringBuilder builder = new StringBuilder();
builder.append("* {\n");
for (ColorRole colorRole : ColorRole.ALL) {
addColor(builder, colorRole.getVariableName(), scheme.getColor(colorRole));
}
addColor(builder, "-monet-primary-seed", scheme.getPrimaryColorSeed());
addColor(builder, scheme, ColorRole.PRIMARY, 0.5);
addColor(builder, scheme, ColorRole.PRIMARY, 0.8);
addColor(builder, scheme, ColorRole.SECONDARY_CONTAINER, 0.5);
addColor(builder, scheme, ColorRole.SURFACE, 0.5);
addColor(builder, scheme, ColorRole.SURFACE, 0.8);
addColor(builder, scheme, ColorRole.ON_SURFACE_VARIANT, 0.38);
addColor(builder, scheme, ColorRole.SURFACE_CONTAINER_LOW, 0.8);
addColor(builder, scheme, ColorRole.SECONDARY_CONTAINER, 0.8);
addColor(builder, scheme, ColorRole.INVERSE_SURFACE, 0.8);
builder.append("}\n");
return toStyleSheetUri(builder.toString());
return "/assets/css/warm.css";
}
public static void init(Scene scene) {
@@ -1,24 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import javafx.scene.layout.Background;
import org.jetbrains.annotations.NotNullByDefault;
import java.util.Objects;
/// A launcher background and the opacity applied by its rendering node.
///
/// @param background the JavaFX background rendered by the launcher
/// @param opacity the node opacity in the range `[0, 1]`
@NotNullByDefault
public record LauncherBackground(Background background, double opacity) {
/// Creates a launcher background value.
public LauncherBackground {
Objects.requireNonNull(background);
if (!Double.isFinite(opacity) || opacity < 0.0 || opacity > 1.0) {
throw new IllegalArgumentException("Launcher background opacity must be between 0 and 1: " + opacity);
}
}
}
@@ -1,38 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import org.glavo.monetfx.*;
import org.jetbrains.annotations.NotNullByDefault;
import java.util.*;
/// Concrete launcher theme values resolved for MonetFX.
///
/// @param primaryColorSeed Color seed used to generate the MonetFX color scheme.
/// @param brightness Brightness used by the generated color scheme.
/// @param colorStyle MonetFX color style used by the generated color scheme.
/// @param contrast MonetFX contrast level used by the generated color scheme.
@NotNullByDefault
public record ResolvedTheme(ThemeColor primaryColorSeed,
Brightness brightness,
ColorStyle colorStyle,
Contrast contrast) {
/// Default launcher theme used when no custom theme values are configured.
public static final ResolvedTheme DEFAULT = new ResolvedTheme(ThemeColor.DEFAULT, Brightness.DEFAULT, ColorStyle.FIDELITY, Contrast.DEFAULT);
/// Converts these resolved values to a MonetFX color scheme.
///
/// @return the generated color scheme
public ColorScheme toColorScheme() {
return ColorScheme.newBuilder()
.setPrimaryColorSeed(primaryColorSeed.color())
.setColorStyle(colorStyle)
.setBrightness(brightness)
.setSpecVersion(ColorSpecVersion.SPEC_2025)
.setContrast(contrast)
.build();
}
}
@@ -1,229 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// One selectable theme inside a theme pack.
///
/// The theme object's top-level appearance fields provide default values. Each
/// matching override is applied in array order to produce the resolved appearance.
///
/// @param id the stable theme identifier inside its pack, or `null` for an unnamed single-theme pack
/// @param name the localized display name, or `null` for an unnamed single-theme pack
/// @param authors authors of this specific theme
/// @param description the optional localized description
/// @param icon the optional theme-pack relative icon path
/// @param appearance the default appearance fields
/// @param overrides conditional appearance patches applied in declaration order
@NotNullByDefault
public record Theme(
@Nullable String id,
@Nullable LocalizedText name,
@Unmodifiable List<ThemePackAuthor> authors,
@Nullable LocalizedText description,
@Nullable String icon,
ThemeAppearance appearance,
@Unmodifiable List<ThemeOverride> overrides) {
/// Creates a theme.
///
/// @param id the stable theme identifier inside its pack, or `null` for an unnamed single-theme pack
/// @param name the localized display name, or `null` for an unnamed single-theme pack
/// @param authors authors of this specific theme
/// @param description the optional localized description
/// @param icon the optional theme-pack relative icon path
/// @param appearance the default appearance fields
/// @param overrides conditional appearance patches applied in declaration order
public Theme {
if (id != null) {
id = ThemePackManifest.requireThemeId(id);
}
if (name != null && name.mayBeEmpty()) {
throw new IllegalArgumentException("Theme name is empty: " + name);
}
if (icon != null) {
icon = ThemePackAsset.normalizeEntryName(icon);
}
authors = List.copyOf(authors);
Objects.requireNonNull(appearance);
overrides = List.copyOf(overrides);
}
/// Parses a theme from JSON.
///
/// @param object the theme JSON object
/// @param requireIdentity whether the theme must declare an explicit ID and name
/// @return the parsed theme
/// @throws JsonParseException if required identity fields are missing or malformed
static Theme fromJson(JsonObject object, boolean requireIdentity) throws JsonParseException {
Objects.requireNonNull(object);
@Nullable String id = JsonUtils.getString(object, "id");
if (id != null) {
try {
id = ThemePackManifest.requireThemeId(id);
} catch (IllegalArgumentException e) {
if (requireIdentity) {
throw new JsonParseException(e);
}
LOG.warning("Ignored invalid theme id: " + id, e);
id = null;
}
}
if (id == null && requireIdentity) {
throw new JsonParseException("Theme is missing the id");
}
@Nullable LocalizedText name;
try {
name = LocalizedText.fromJson(object.get("name"));
if (name != null && name.mayBeEmpty()) {
throw new JsonParseException("Theme name is empty: " + name);
}
} catch (JsonParseException | IllegalArgumentException e) {
if (requireIdentity) {
throw e;
}
LOG.warning("Ignored invalid theme name", e);
name = null;
}
if (name == null && requireIdentity) {
throw new JsonParseException("Theme is missing required localized text field: " + "name");
}
List<ThemePackAuthor> authors;
try {
authors = ThemePackAuthor.parseAuthors(object.get("authors"));
} catch (Exception e) {
LOG.warning("Failed to parse authors", e);
authors = List.of();
}
@Nullable LocalizedText description;
try {
description = LocalizedText.fromJson(object.get("description"));
if (description != null) {
description = ThemePackManifest.requireLocalizedText(description, "description");
}
} catch (Exception e) {
LOG.warning("Failed to parse description", e);
description = null;
}
ThemeAppearance appearance = ThemeAppearance.fromJson(object);
JsonElement overridesJson = object.get("overrides");
List<ThemeOverride> overrides;
if (overridesJson == null || overridesJson.isJsonNull()) {
overrides = List.of();
} else if (overridesJson instanceof JsonArray array) {
overrides = new ArrayList<>(array.size());
for (JsonElement overrideJson : array) {
try {
ThemeOverride override = ThemeOverride.fromJson(overrideJson);
if (override != null)
overrides.add(override);
} catch (Exception e) {
LOG.warning("Invalid theme override", e);
}
}
} else {
LOG.warning("Invalid theme overrides: expected an array, got " + overridesJson);
overrides = List.of();
}
@Nullable String icon = JsonUtils.getString(object, "icon");
if (icon != null) {
try {
icon = ThemePackAsset.normalizeEntryName(icon);
} catch (IllegalArgumentException e) {
LOG.warning("Ignored invalid theme icon: " + icon, e);
icon = null;
}
}
return new Theme(id, name, authors, description,
icon,
appearance, overrides);
}
/// Returns the theme display name in the current locale.
///
/// @return the localized display name, or `null` when this theme has no matching display name
public @Nullable String displayName() {
return name != null ? name.getText(I18n.getLocale().getCandidateLocales()) : null;
}
/// Returns the theme description in the current locale.
///
/// @return the localized description, or `null` when this theme has no matching description
public @Nullable String displayDescription() {
return description != null ? description.getText(I18n.getLocale().getCandidateLocales()) : null;
}
/// Resolves this theme against a context by applying matching overrides.
///
/// @param context the resolution context
/// @return the resolved appearance
public ThemeAppearance resolve(ThemeResolveContext context) {
Objects.requireNonNull(context);
ThemeAppearance resolved = appearance;
for (ThemeOverride override : overrides) {
if (override.matches(context)) {
resolved = resolved.merge(override.appearance());
}
}
return resolved;
}
/// Converts this theme to its JSON representation.
///
/// @return the JSON object representing this theme
public JsonObject toJsonObject() {
JsonObject object = new JsonObject();
if (id != null) {
object.addProperty("id", id);
}
if (name != null) {
object.add("name", JsonUtils.GSON.toJsonTree(name, LocalizedText.class));
}
object.add("authors", ThemePackAuthor.toJson(authors));
if (description != null) {
object.add("description", JsonUtils.GSON.toJsonTree(description, LocalizedText.class));
}
if (icon != null) {
object.addProperty("icon", icon);
}
appearance.addToJsonObject(object);
if (!overrides.isEmpty()) {
JsonArray array = new JsonArray();
for (ThemeOverride override : overrides) {
array.add(override.toJsonObject());
}
object.add("overrides", array);
}
return object;
}
}
@@ -1,374 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import org.glavo.monetfx.Brightness;
import org.glavo.monetfx.ColorStyle;
import org.glavo.monetfx.Contrast;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Locale;
import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Appearance values contributed by a theme or override.
///
/// All fields are optional so the same type can represent both a default appearance
/// and a conditional patch.
///
/// @param color the color seed source, or `null` when inherited
/// @param brightness the controlled brightness, or `null` when inherited by a patch or not controlled by a resolved appearance
/// @param colorStyle the MonetFX color style, or `null` when inherited
/// @param contrast the MonetFX contrast level, or `null` when inherited
/// @param background the background settings, or `null` when inherited
/// @param titleBar the title-bar settings, or `null` when inherited
/// @param windowTransparent whether the launcher window should be transparent, or `null` when inherited
@NotNullByDefault
public record ThemeAppearance(
@Nullable ThemeColorSource color,
@Nullable Brightness brightness,
@Nullable ColorStyle colorStyle,
@Nullable Contrast contrast,
@Nullable ThemeBackgroundSettings background,
@Nullable ThemeTitleBar titleBar,
@Nullable Boolean windowTransparent) {
/// JSON member name for the color seed.
static final String FIELD_COLOR = "color";
/// JSON member name for the controlled launcher brightness.
static final String FIELD_BRIGHTNESS = "brightness";
/// JSON member name for the MonetFX color style.
static final String FIELD_COLOR_STYLE = "colorStyle";
/// JSON member name for the MonetFX contrast level.
static final String FIELD_CONTRAST = "contrast";
/// JSON member name for background settings.
static final String FIELD_BACKGROUND = "background";
/// JSON member name for title-bar settings.
static final String FIELD_TITLE_BAR = "titleBar";
/// JSON member name for window transparency.
static final String FIELD_WINDOW_TRANSPARENT = "windowTransparent";
/// Creates an appearance patch.
///
/// @param color the color seed source, or `null` when inherited
/// @param brightness the controlled brightness, or `null` when inherited by a patch or not controlled by a resolved appearance
/// @param colorStyle the MonetFX color style, or `null` when inherited
/// @param contrast the MonetFX contrast level, or `null` when inherited
/// @param background the background settings, or `null` when inherited
/// @param titleBar the title-bar settings, or `null` when inherited
/// @param windowTransparent whether the launcher window should be transparent, or `null` when inherited
public ThemeAppearance {
if (background != null && background.isEmpty()) {
background = null;
}
if (titleBar != null && titleBar.isEmpty()) {
titleBar = null;
}
}
/// Parses known appearance fields from a JSON object.
///
/// @param object the JSON object containing appearance fields
/// @return the parsed appearance patch
static ThemeAppearance fromJson(JsonObject object) throws JsonParseException {
Objects.requireNonNull(object);
return new ThemeAppearance(
readColor(object),
readBrightness(object),
readColorStyle(object),
readContrast(object),
readBackground(object),
readTitleBar(object),
readWindowTransparent(object));
}
/// Returns whether this appearance contains no concrete fields.
///
/// @return `true` when every appearance field is inherited
public boolean isEmpty() {
return color == null
&& brightness == null
&& colorStyle == null
&& contrast == null
&& background == null
&& titleBar == null
&& windowTransparent == null;
}
/// Adds this appearance patch's concrete fields to a JSON object.
///
/// @param object the target JSON object
void addToJsonObject(JsonObject object) {
Objects.requireNonNull(object);
if (color != null) {
object.add(FIELD_COLOR, color.toJsonElement());
}
if (brightness != null) {
object.addProperty(FIELD_BRIGHTNESS, toJsonBrightness(brightness));
}
if (colorStyle != null) {
object.addProperty(FIELD_COLOR_STYLE, colorStyle.name().toLowerCase(Locale.ROOT));
}
if (contrast != null && !contrast.equals(Contrast.DEFAULT)) {
addContrast(object, contrast);
}
if (background != null) {
object.add(FIELD_BACKGROUND, background.toJsonObject());
}
if (titleBar != null) {
object.add(FIELD_TITLE_BAR, titleBar.toJsonObject());
}
if (windowTransparent != null) {
object.addProperty(FIELD_WINDOW_TRANSPARENT, windowTransparent);
}
}
/// Converts this appearance patch to its JSON representation.
///
/// @return the JSON object representing this appearance patch
public JsonObject toJsonObject() {
JsonObject object = new JsonObject();
addToJsonObject(object);
return object;
}
/// Applies the given patch over this appearance.
///
/// @param patch the patch to apply
/// @return the merged appearance
public ThemeAppearance merge(ThemeAppearance patch) {
Objects.requireNonNull(patch);
return new ThemeAppearance(
patch.color != null ? patch.color : color,
patch.brightness != null ? patch.brightness : brightness,
patch.colorStyle != null ? patch.colorStyle : colorStyle,
patch.contrast != null ? patch.contrast : contrast,
background != null && patch.background != null
? background.merge(patch.background)
: patch.background != null ? patch.background : background,
titleBar != null && patch.titleBar != null
? titleBar.merge(patch.titleBar)
: patch.titleBar != null ? patch.titleBar : titleBar,
patch.windowTransparent != null ? patch.windowTransparent : windowTransparent);
}
/// Converts this appearance to concrete launcher theme values.
///
/// @param context the resolution context that provides fallback brightness when this appearance does not control it
/// @return the concrete theme used by MonetFX
public ResolvedTheme toResolvedTheme(ThemeResolveContext context) {
Objects.requireNonNull(context);
ThemeColor resolvedColor = color != null ? color.resolveFallback() : ResolvedTheme.DEFAULT.primaryColorSeed();
Brightness resolvedBrightness = brightness != null ? brightness : context.brightness();
ColorStyle resolvedColorStyle = colorStyle != null ? colorStyle : ResolvedTheme.DEFAULT.colorStyle();
Contrast resolvedContrast = contrast != null ? contrast : Contrast.DEFAULT;
return new ResolvedTheme(resolvedColor, resolvedBrightness, resolvedColorStyle, resolvedContrast);
}
/// Reads the optional color field.
private static @Nullable ThemeColorSource readColor(JsonObject object) {
JsonElement element = object.get(FIELD_COLOR);
if (element == null) {
return null;
}
try {
return ThemeColorSource.fromJson(element);
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme color: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional controlled brightness field.
private static @Nullable Brightness readBrightness(JsonObject object) {
try {
@Nullable String value = readString(object, FIELD_BRIGHTNESS);
if (value == null) {
return null;
}
return parseBrightness(value);
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme brightness: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional color style field.
private static @Nullable ColorStyle readColorStyle(JsonObject object) {
try {
@Nullable String value = readString(object, FIELD_COLOR_STYLE);
if (value == null) {
return null;
}
String normalized = value.trim().replace('-', '_').replace(' ', '_').toUpperCase(Locale.ROOT);
if ("DEFAULT".equals(normalized)) {
return ColorStyle.DEFAULT;
}
return ColorStyle.valueOf(normalized);
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme colorStyle: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional contrast field.
private static @Nullable Contrast readContrast(JsonObject object) {
JsonElement element = object.get(FIELD_CONTRAST);
if (element == null) {
return null;
}
if (!(element instanceof JsonPrimitive primitive)) {
LOG.warning("Ignored invalid theme contrast: expected a string or number, got " + element);
return null;
}
try {
if (primitive.isNumber()) {
return readContrastValue(primitive.getAsDouble());
}
if (!primitive.isString()) {
LOG.warning("Ignored invalid theme contrast: expected a string or number, got " + element);
return null;
}
String value = primitive.getAsString();
String normalized = value.trim().toLowerCase(Locale.ROOT);
return switch (normalized) {
case "low" -> Contrast.LOW;
case "standard" -> Contrast.DEFAULT;
case "medium" -> Contrast.MEDIUM;
case "high" -> Contrast.HIGH;
default -> {
try {
yield readContrastValue(Double.parseDouble(normalized));
} catch (NumberFormatException e) {
throw new JsonParseException("Invalid theme contrast: " + value, e);
}
}
};
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme contrast: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional background object.
private static @Nullable ThemeBackgroundSettings readBackground(JsonObject object) {
JsonElement element = object.get(FIELD_BACKGROUND);
if (element == null) {
return null;
}
if (!(element instanceof JsonObject background)) {
LOG.warning("Ignored invalid theme background: expected an object, got " + element);
return null;
}
try {
return ThemeBackgroundSettings.fromJson(background);
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme background: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional title-bar object.
private static @Nullable ThemeTitleBar readTitleBar(JsonObject object) {
JsonElement element = object.get(FIELD_TITLE_BAR);
if (element == null) {
return null;
}
if (!(element instanceof JsonObject titleBar)) {
LOG.warning("Ignored invalid theme titleBar: expected an object, got " + element);
return null;
}
try {
return ThemeTitleBar.fromJson(titleBar);
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme titleBar: " + e.getMessage(), e);
return null;
}
}
/// Reads the optional window transparency field.
private static @Nullable Boolean readWindowTransparent(JsonObject object) {
JsonElement element = object.get(FIELD_WINDOW_TRANSPARENT);
if (element == null) {
return null;
}
if (!(element instanceof JsonPrimitive primitive) || !primitive.isBoolean()) {
LOG.warning("Ignored invalid theme windowTransparent: expected a boolean, got " + element);
return null;
}
return primitive.getAsBoolean();
}
/// Reads an optional string field.
private static @Nullable String readString(JsonObject object, String field) {
JsonElement element = object.get(field);
if (element == null) {
return null;
}
if (!(element instanceof JsonPrimitive primitive) || !primitive.isString()) {
throw new JsonParseException("Theme field must be a string: " + field);
}
return primitive.getAsString();
}
/// Returns a validated contrast value.
private static Contrast readContrastValue(double value) {
try {
return Contrast.of(value);
} catch (IllegalArgumentException e) {
throw new JsonParseException("Theme contrast value must be between -1 and 1: " + value, e);
}
}
/// Adds a canonical contrast value to a JSON object.
private static void addContrast(JsonObject object, Contrast contrast) {
if (contrast.equals(Contrast.LOW)) {
object.addProperty(FIELD_CONTRAST, "low");
} else if (contrast.equals(Contrast.DEFAULT)) {
object.addProperty(FIELD_CONTRAST, "standard");
} else if (contrast.equals(Contrast.MEDIUM)) {
object.addProperty(FIELD_CONTRAST, "medium");
} else if (contrast.equals(Contrast.HIGH)) {
object.addProperty(FIELD_CONTRAST, "high");
} else {
object.addProperty(FIELD_CONTRAST, contrast.getValue());
}
}
/// Parses a serialized theme brightness value.
private static Brightness parseBrightness(String value) {
return switch (value.trim().toLowerCase(Locale.ROOT)) {
case "light" -> Brightness.LIGHT;
case "dark" -> Brightness.DARK;
default -> throw new IllegalArgumentException("Unsupported theme brightness: " + value);
};
}
/// Converts a controlled brightness value to its canonical JSON string.
private static String toJsonBrightness(Brightness brightness) {
return switch (brightness) {
case LIGHT -> "light";
case DARK -> "dark";
};
}
}
@@ -1,215 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Locale;
import java.util.Objects;
/// Background source contributed by a theme-pack appearance.
///
/// The sealed implementations describe where the wallpaper comes from. Rendering
/// parameters such as opacity are stored by [ThemeBackgroundSettings].
@NotNullByDefault
public sealed interface ThemeBackground
permits ThemeBackground.Default, ThemeBackground.Builtin, ThemeBackground.Image,
ThemeBackground.Paint, ThemeBackground.ThemeColor {
/// JSON member name for the background type.
String FIELD_TYPE = "type";
/// JSON member name for the built-in wallpaper ID.
String FIELD_ID = "id";
/// JSON member name for the local theme-pack image path.
String FIELD_PATH = "path";
/// JSON member name for a serialized paint value.
String FIELD_PAINT = "paint";
/// Adds this background source's concrete fields to a JSON object.
///
/// @param object the target JSON object
void addToJsonObject(JsonObject object);
/// Converts this background source to its JSON representation.
///
/// @return the JSON object representing this background source
default JsonObject toJsonObject() {
JsonObject object = new JsonObject();
addToJsonObject(object);
return object;
}
/// Parses a background source from a JSON object.
///
/// @param object the JSON object
/// @return the parsed background source, or `null` when no source field is present
/// @throws JsonParseException if a known field is malformed
static @Nullable ThemeBackground fromJson(JsonObject object) throws JsonParseException {
Objects.requireNonNull(object);
@Nullable String type = readString(object, FIELD_TYPE);
@Nullable String id = readString(object, FIELD_ID);
@Nullable String path = readString(object, FIELD_PATH);
@Nullable String paint = readString(object, FIELD_PAINT);
if (type == null) {
int sourceFields = 0;
if (id != null) {
sourceFields++;
}
if (path != null) {
sourceFields++;
}
if (paint != null) {
sourceFields++;
}
if (sourceFields > 1) {
throw new JsonParseException("Theme background without type must contain only one source field");
}
if (id != null) {
return new Builtin(id);
}
if (path != null) {
return new Image(path);
}
if (paint != null) {
return new Paint(paint);
}
return null;
}
return switch (type.trim().replace('-', '_').toUpperCase(Locale.ROOT)) {
case "DEFAULT" -> new Default();
case "BUILTIN" -> new Builtin(id);
case "IMAGE" -> new Image(path);
case "PAINT" -> new Paint(paint);
case "THEME_COLOR" -> new ThemeColor();
default -> throw new JsonParseException("Unsupported theme background type: " + type);
};
}
/// Reads an optional string field.
private static @Nullable String readString(JsonObject object, String field) {
JsonElement element = object.get(field);
if (element == null) {
return null;
}
if (!(element instanceof JsonPrimitive primitive) || !primitive.isString()) {
throw new JsonParseException("Theme background field must be a string: " + field);
}
return primitive.getAsString();
}
/// Returns a required non-blank string value.
private static String requireNonBlank(@Nullable String value, String field) {
if (value == null) {
throw new JsonParseException("Theme background field is missing: " + field);
}
String trimmed = value.trim();
if (trimmed.isEmpty()) {
throw new JsonParseException("Theme background field is blank: " + field);
}
return trimmed;
}
/// A source that delegates to HMCL's launcher default background resolution.
@NotNullByDefault
record Default() implements ThemeBackground {
/// Adds this source to a JSON object.
@Override
public void addToJsonObject(JsonObject object) {
object.addProperty(FIELD_TYPE, "default");
}
}
/// A source that uses a launcher built-in wallpaper.
///
/// @param id the built-in wallpaper ID, or `null` for the fallback built-in wallpaper
@NotNullByDefault
record Builtin(@Nullable String id) implements ThemeBackground {
/// Creates a source that uses the fallback built-in wallpaper.
public Builtin() {
this(null);
}
/// Creates a built-in wallpaper source.
///
/// @param id the built-in wallpaper ID, or `null` for the fallback built-in wallpaper
public Builtin {
if (id != null) {
id = requireNonBlank(id, FIELD_ID);
}
}
/// Adds this source to a JSON object.
@Override
public void addToJsonObject(JsonObject object) {
object.addProperty(FIELD_TYPE, "builtin");
if (id != null) {
object.addProperty(FIELD_ID, id);
}
}
}
/// A source that uses an image stored inside the theme pack.
///
/// @param path the theme-pack relative image path
@NotNullByDefault
record Image(String path) implements ThemeBackground {
/// Creates an image background source.
///
/// @param path the theme-pack relative image path
public Image {
path = requireNonBlank(path, FIELD_PATH);
}
/// Adds this source to a JSON object.
@Override
public void addToJsonObject(JsonObject object) {
object.addProperty(FIELD_TYPE, "image");
object.addProperty(FIELD_PATH, path);
}
}
/// A source that uses a JavaFX paint string.
///
/// @param paint the serialized paint value
@NotNullByDefault
record Paint(String paint) implements ThemeBackground {
/// Creates a paint background source.
///
/// @param paint the serialized paint value
public Paint {
paint = requireNonBlank(paint, FIELD_PAINT);
}
/// Adds this source to a JSON object.
@Override
public void addToJsonObject(JsonObject object) {
object.addProperty(FIELD_TYPE, "paint");
object.addProperty(FIELD_PAINT, paint);
}
}
/// A source that follows the current launcher theme color.
@NotNullByDefault
record ThemeColor() implements ThemeBackground {
/// Adds this source to a JSON object.
@Override
public void addToJsonObject(JsonObject object) {
object.addProperty(FIELD_TYPE, "theme_color");
}
}
}
@@ -1,103 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Background settings contributed by a theme-pack appearance.
///
/// @param source the background source, or `null` when inherited
/// @param opacity the background opacity override, or `null` when inherited
@NotNullByDefault
public record ThemeBackgroundSettings(
@Nullable ThemeBackground source,
@Nullable Double opacity) {
/// JSON member name for the background opacity.
private static final String FIELD_OPACITY = "opacity";
/// Creates background settings.
///
/// @param source the background source, or `null` when inherited
/// @param opacity the background opacity override, or `null` when inherited
public ThemeBackgroundSettings {
if (opacity != null && (opacity < 0.0 || opacity > 1.0 || !Double.isFinite(opacity))) {
throw new IllegalArgumentException("Theme background opacity must be between 0 and 1: " + opacity);
}
}
/// Parses background settings from a JSON object.
///
/// @param object the JSON object
/// @return the parsed background settings
static ThemeBackgroundSettings fromJson(JsonObject object) throws JsonParseException {
Objects.requireNonNull(object);
return new ThemeBackgroundSettings(
ThemeBackground.fromJson(object),
readOpacity(object));
}
/// Converts these settings to their JSON representation.
///
/// @return the JSON object representing these background settings
public JsonObject toJsonObject() {
JsonObject object = source != null ? source.toJsonObject() : new JsonObject();
if (opacity != null) {
object.addProperty(FIELD_OPACITY, opacity);
}
return object;
}
/// Returns whether these settings contain no concrete fields.
///
/// @return `true` when every background setting is inherited
public boolean isEmpty() {
return source == null && opacity == null;
}
/// Returns settings that apply the given patch over these settings.
///
/// @param patch the patch to apply
/// @return the merged settings
public ThemeBackgroundSettings merge(ThemeBackgroundSettings patch) {
Objects.requireNonNull(patch);
@Nullable ThemeBackground mergedSource = patch.source != null ? patch.source : source;
@Nullable Double mergedOpacity = patch.opacity != null ? patch.opacity : opacity;
return new ThemeBackgroundSettings(mergedSource, mergedOpacity);
}
/// Reads the optional opacity field.
private static @Nullable Double readOpacity(JsonObject object) {
JsonElement element = object.get(FIELD_OPACITY);
if (element == null) {
return null;
}
if (!(element instanceof JsonPrimitive primitive) || !primitive.isNumber()) {
LOG.warning("Ignored invalid theme background opacity: expected a number, got " + element);
return null;
}
try {
double opacity = primitive.getAsDouble();
if (opacity < 0.0 || opacity > 1.0 || !Double.isFinite(opacity)) {
LOG.warning("Ignored invalid theme background opacity: expected a number between 0 and 1, got "
+ opacity);
return null;
}
return opacity;
} catch (NumberFormatException e) {
LOG.warning("Ignored invalid theme background opacity: " + e.getMessage(), e);
return null;
}
}
}
@@ -1,157 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Locale;
import java.util.Objects;
/// Describes how a theme-pack appearance chooses its Monet seed color.
@NotNullByDefault
public sealed interface ThemeColorSource permits ThemeColorSource.Default, ThemeColorSource.Custom, ThemeColorSource.Wallpaper {
/// JSON member name for the source type.
String FIELD_SOURCE = "source";
/// The launcher default color source.
ThemeColorSource DEFAULT = new Default();
/// Creates a custom color source.
///
/// @param color the custom color
/// @return the custom color source
static ThemeColorSource custom(ThemeColor color) {
return new Custom(color);
}
/// Creates a wallpaper color source.
///
/// @return the wallpaper color source
static ThemeColorSource wallpaper() {
return new Wallpaper();
}
/// Parses a color source from a manifest JSON value.
///
/// @param element the JSON value
/// @return the parsed color source
/// @throws JsonParseException if the color source is malformed
static ThemeColorSource fromJson(JsonElement element) throws JsonParseException {
Objects.requireNonNull(element);
if (element instanceof JsonPrimitive primitive && primitive.isString()) {
String value = primitive.getAsString();
if ("default".equals(value.trim().replace('-', '_').toLowerCase(Locale.ROOT))) {
return DEFAULT;
}
@Nullable ThemeColor color = ThemeColor.of(value);
if (color == null) {
throw new JsonParseException("Invalid theme color: " + value);
}
return custom(color);
}
if (!(element instanceof JsonObject object)) {
throw new JsonParseException("Theme color must be a string or object");
}
JsonElement sourceElement = object.get(FIELD_SOURCE);
if (sourceElement == null) {
throw new JsonParseException("Theme color source is missing required field: " + FIELD_SOURCE);
}
if (!(sourceElement instanceof JsonPrimitive sourcePrimitive) || !sourcePrimitive.isString()) {
throw new JsonParseException("Theme color source field must be a string: " + FIELD_SOURCE);
}
String source = sourcePrimitive.getAsString();
String normalized = source.trim().replace('-', '_').toUpperCase(Locale.ROOT);
if ("DEFAULT".equals(normalized)) {
return DEFAULT;
}
if ("WALLPAPER".equals(normalized)) {
return wallpaper();
}
throw new JsonParseException("Unsupported theme color source: " + source);
}
/// Converts this color source to its JSON representation.
///
/// @return the color source JSON value
JsonElement toJsonElement();
/// Returns the best available color without accessing wallpaper pixels.
///
/// @return the custom color or launcher default color
ThemeColor resolveFallback();
/// The launcher default color seed.
@NotNullByDefault
record Default() implements ThemeColorSource {
/// Creates a default color source.
public Default {
}
/// Converts this color source to its JSON representation.
@Override
public JsonElement toJsonElement() {
JsonObject object = new JsonObject();
object.addProperty(FIELD_SOURCE, "default");
return object;
}
/// Returns the launcher default seed color.
@Override
public ThemeColor resolveFallback() {
return ThemeColor.DEFAULT;
}
}
/// A fixed color seed supplied by the manifest.
///
/// @param color the custom seed color
@NotNullByDefault
record Custom(ThemeColor color) implements ThemeColorSource {
/// Creates a custom color source.
///
/// @param color the custom seed color
public Custom {
Objects.requireNonNull(color);
}
/// Converts this color source to its JSON representation.
@Override
public JsonElement toJsonElement() {
return new JsonPrimitive(color.name());
}
/// Returns the custom seed color.
@Override
public ThemeColor resolveFallback() {
return color;
}
}
/// A dynamic seed color extracted from the effective wallpaper.
@NotNullByDefault
record Wallpaper() implements ThemeColorSource {
/// Creates a wallpaper color source.
public Wallpaper {
}
/// Converts this color source to its JSON representation.
@Override
public JsonElement toJsonElement() {
JsonObject object = new JsonObject();
object.addProperty(FIELD_SOURCE, "wallpaper");
return object;
}
/// Returns the launcher default color used before wallpaper pixels are available.
@Override
public ThemeColor resolveFallback() {
return ThemeColor.DEFAULT;
}
}
}
@@ -1,189 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Unmodifiable;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/// A simple JSON-object condition used by theme-pack overrides.
///
/// A condition is an AND of all members. A string value requires equality, and
/// an array value requires the context value to match any listed value. Unknown
/// condition keys are accepted for forward compatibility, but they do not match
/// contexts produced by this implementation.
///
/// @param requirements normalized accepted values keyed by condition name
@NotNullByDefault
public record ThemeCondition(@Unmodifiable Map<String, @Unmodifiable Set<String>> requirements) {
/// Condition key for the effective light or dark mode.
static final String KEY_BRIGHTNESS = "brightness";
/// Condition key for the current operating system.
static final String KEY_OS = "os";
/// Condition key for the current UI language.
static final String KEY_LANGUAGE = "language";
/// Supported operating system condition values.
private static final Set<String> SUPPORTED_OS_VALUES = Set.of("windows", "macos", "linux", "freebsd", "unknown");
/// Creates a condition from normalized accepted values.
///
/// @param requirements normalized accepted values keyed by condition name
public ThemeCondition {
Objects.requireNonNull(requirements);
LinkedHashMap<String, Set<String>> copy = new LinkedHashMap<>();
for (Map.Entry<String, Set<String>> entry : requirements.entrySet()) {
String key = normalizeKey(entry.getKey());
Set<String> values = entry.getValue();
if (values.isEmpty()) {
throw new IllegalArgumentException("Theme condition field has no accepted values: " + key);
}
LinkedHashSet<String> valueCopy = new LinkedHashSet<>();
for (String value : values) {
valueCopy.add(normalizeValue(key, value));
}
copy.put(key, Collections.unmodifiableSet(valueCopy));
}
requirements = Collections.unmodifiableMap(copy);
}
/// Parses a theme condition from a JSON object.
///
/// @param object the condition object
/// @return the parsed condition
/// @throws JsonParseException if the condition contains malformed values
public static ThemeCondition fromJson(JsonObject object) throws JsonParseException {
Objects.requireNonNull(object);
LinkedHashMap<String, Set<String>> requirements = new LinkedHashMap<>();
for (Map.Entry<String, JsonElement> entry : object.entrySet()) {
String key = normalizeKey(entry.getKey());
requirements.put(key, readAcceptedValues(key, entry.getValue()));
}
return new ThemeCondition(requirements);
}
/// Returns whether this condition matches the given resolution context.
///
/// @param context the context to test
/// @return `true` if every condition member matches the context
public boolean matches(ThemeResolveContext context) {
Objects.requireNonNull(context);
for (Map.Entry<String, Set<String>> entry : requirements.entrySet()) {
String value = context.conditionValue(entry.getKey());
if (value == null || !entry.getValue().contains(value)) {
return false;
}
}
return true;
}
/// Converts this condition to its JSON representation.
///
/// @return the JSON object representing this condition
public JsonObject toJsonObject() {
JsonObject object = new JsonObject();
for (Map.Entry<String, Set<String>> entry : requirements.entrySet()) {
if (entry.getValue().size() == 1) {
object.addProperty(entry.getKey(), entry.getValue().iterator().next());
} else {
JsonArray array = new JsonArray();
for (String value : entry.getValue()) {
array.add(value);
}
object.add(entry.getKey(), array);
}
}
return object;
}
/// Reads one condition field value.
private static Set<String> readAcceptedValues(String key, JsonElement element) throws JsonParseException {
LinkedHashSet<String> values = new LinkedHashSet<>();
if (element instanceof JsonPrimitive primitive && primitive.isString()) {
values.add(normalizeValue(key, primitive.getAsString()));
} else if (element instanceof JsonArray array) {
if (array.isEmpty()) {
throw new JsonParseException("Theme condition array is empty: " + key);
}
for (JsonElement item : array) {
if (!(item instanceof JsonPrimitive primitive) || !primitive.isString()) {
throw new JsonParseException("Theme condition array must contain strings: " + key);
}
values.add(normalizeValue(key, primitive.getAsString()));
}
} else {
throw new JsonParseException("Theme condition value must be a string or string array: " + key);
}
return values;
}
/// Normalizes and validates a condition key.
private static String normalizeKey(String key) {
Objects.requireNonNull(key);
String normalized = key.trim();
if (normalized.isEmpty()) {
throw new JsonParseException("Theme condition key is blank");
}
return normalized;
}
/// Normalizes and validates one condition value.
private static String normalizeValue(String key, String value) {
Objects.requireNonNull(key);
Objects.requireNonNull(value);
String trimmed = value.trim();
if (trimmed.isEmpty()) {
throw new JsonParseException("Empty theme condition value for " + key);
}
String normalized = trimmed.toLowerCase(Locale.ROOT);
return switch (key) {
case KEY_BRIGHTNESS -> switch (normalized) {
case "light", "dark" -> normalized;
default -> throw new JsonParseException("Unsupported brightness condition value: " + value);
};
case KEY_OS -> normalizeOperatingSystemValue(normalized, value);
case KEY_LANGUAGE -> normalized;
default -> trimmed;
};
}
/// Normalizes an operating system condition value.
private static String normalizeOperatingSystemValue(String normalized, String original) {
String value = switch (normalized) {
case "win", "windows" -> "windows";
case "mac", "macos", "osx" -> "macos";
case "linux" -> "linux";
case "freebsd" -> "freebsd";
case "unknown", "universal" -> "unknown";
default -> normalized;
};
if (!SUPPORTED_OS_VALUES.contains(value)) {
throw new JsonParseException("Unsupported os condition value: " + original);
}
return value;
}
}
@@ -1,76 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
/// A conditional appearance patch in a theme.
///
/// @param condition the condition required for this override to apply
/// @param appearance the appearance fields applied when the condition matches
@NotNullByDefault
public record ThemeOverride(ThemeCondition condition, ThemeAppearance appearance) {
/// JSON member name for an override condition.
private static final String FIELD_CONDITION = "condition";
/// Creates a conditional theme override.
///
/// @param condition the condition required for this override to apply
/// @param appearance the appearance fields applied when the condition matches
public ThemeOverride {
Objects.requireNonNull(condition);
Objects.requireNonNull(appearance);
if (appearance.isEmpty()) {
throw new IllegalArgumentException("Theme override does not define any appearance fields");
}
}
/// Parses a theme override from JSON.
///
/// @param element the override object
/// @return the parsed override
/// @throws JsonParseException if the override is malformed
static @Nullable ThemeOverride fromJson(@Nullable JsonElement element) throws JsonParseException {
if (element == null || element.isJsonNull())
return null;
if (!(element instanceof JsonObject object)) {
throw new JsonParseException("Invalid theme override");
}
JsonElement conditionElement = object.get(FIELD_CONDITION);
if (!(conditionElement instanceof JsonObject conditionObject)) {
throw new JsonParseException("Theme override must define an object condition");
}
ThemeCondition condition = ThemeCondition.fromJson(conditionObject);
ThemeAppearance appearance = ThemeAppearance.fromJson(object);
if (appearance.isEmpty()) {
throw new JsonParseException("Theme override does not define any appearance fields");
}
return new ThemeOverride(condition, appearance);
}
/// Returns whether this override matches the given resolution context.
///
/// @param context the context to test
/// @return `true` when the override should be applied
public boolean matches(ThemeResolveContext context) {
return condition.matches(context);
}
/// Converts this override to its JSON representation.
///
/// @return the JSON object representing this override
public JsonObject toJsonObject() {
JsonObject object = appearance.toJsonObject();
object.add(FIELD_CONDITION, condition.toJsonObject());
return object;
}
}
@@ -1,72 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import org.jetbrains.annotations.NotNullByDefault;
import java.nio.file.Path;
import java.util.Objects;
/// One resource copied into an exported theme-pack zip.
///
/// @param source the resource to copy
/// @param entryName the normalized zip entry name under `assets/`
@NotNullByDefault
public record ThemePackAsset(ThemePackResource source, String entryName) {
/// Required prefix for theme-pack asset entries.
private static final String ASSETS_PREFIX = "assets/";
/// Creates a theme-pack asset from a local file.
///
/// @param source the local file to copy
/// @param entryName the normalized zip entry name under `assets/`
public ThemePackAsset(Path source, String entryName) {
this(new ThemePackResource.File(source), entryName);
}
/// Creates a theme-pack asset entry.
///
/// @param source the resource to copy
/// @param entryName the normalized zip entry name under `assets/`
public ThemePackAsset {
Objects.requireNonNull(source);
entryName = normalizeEntryName(entryName);
}
/// Normalizes and validates a zip entry name.
///
/// @param entryName the entry name to validate
/// @return the normalized entry name
/// @throws IllegalArgumentException if the entry name is unsafe or outside `assets/`
static String normalizeEntryName(String entryName) {
Objects.requireNonNull(entryName);
String normalized = entryName.trim().replace('\\', '/');
if (normalized.isEmpty()) {
throw new IllegalArgumentException("Theme-pack asset entry is empty");
}
if (normalized.startsWith("/") || normalized.matches("^[A-Za-z]:.*")) {
throw new IllegalArgumentException("Theme-pack asset entry must be relative: " + entryName);
}
if (!normalized.startsWith(ASSETS_PREFIX)) {
throw new IllegalArgumentException("Theme-pack asset entry must be under assets/: " + entryName);
}
if (normalized.endsWith("/")) {
throw new IllegalArgumentException("Theme-pack asset entry must be a file: " + entryName);
}
for (String segment : normalized.split("/")) {
if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) {
throw new IllegalArgumentException("Theme-pack asset entry contains an unsafe segment: " + entryName);
}
for (int i = 0; i < segment.length(); i++) {
char ch = segment.charAt(i);
if (Character.isISOControl(ch) || ch == '\0') {
throw new IllegalArgumentException("Theme-pack asset entry contains a control character: " + entryName);
}
}
}
return normalized;
}
}
@@ -1,138 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Author metadata declared by a theme pack.
///
/// @param name the localized author display name
@NotNullByDefault
@JsonSerializable
@JsonAdapter(ThemePackAuthor.Adapter.class)
public record ThemePackAuthor(LocalizedText name) {
/// Parses author metadata from a JSON array.
static @Unmodifiable List<ThemePackAuthor> parseAuthors(@Nullable JsonElement element) throws JsonParseException {
if (element == null || element.isJsonNull()) {
return List.of();
}
if (!(element instanceof JsonArray jsonArray)) {
throw new JsonParseException("Theme-pack authors must be an array");
}
ArrayList<ThemePackAuthor> authors = new ArrayList<>(jsonArray.size());
int index = 0;
for (JsonElement authorJson : jsonArray) {
try {
ThemePackAuthor author = fromJson(authorJson);
if (author != null) {
authors.add(author);
}
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme-pack author at authors[" + index + "]: " + authorJson, e);
}
index++;
}
return List.copyOf(authors);
}
/// Converts author metadata to a JSON array.
static JsonArray toJson(List<ThemePackAuthor> authors) {
JsonArray array = new JsonArray();
for (ThemePackAuthor author : authors) {
array.add(author.toJsonObject());
}
return array;
}
/// Parses author metadata from a JSON object or a plain string.
///
/// @param json the author metadata JSON
/// @return the parsed author metadata, or `null` when `json` is `null`
/// @throws JsonParseException if `json` is neither an object nor a string author name
public static @Nullable ThemePackAuthor fromJson(@Nullable JsonElement json) throws JsonParseException {
if (json == null || json instanceof JsonNull)
return null;
if (json instanceof JsonPrimitive primitive && primitive.isString()) {
try {
return new ThemePackAuthor(LocalizedText.plain(primitive.getAsString()));
} catch (IllegalArgumentException e) {
throw new JsonParseException(e);
}
}
if (!(json instanceof JsonObject jsonObject)) {
throw new JsonParseException("Theme-pack author must be an object or a string");
}
JsonElement nameJson = jsonObject.get("name");
if (nameJson == null) {
throw new JsonParseException("Missing author name: " + json);
}
LocalizedText name = LocalizedText.fromJson(nameJson);
if (name == null) {
throw new JsonParseException("The author name is null");
}
try {
return new ThemePackAuthor(name);
} catch (IllegalArgumentException e) {
throw new JsonParseException(e);
}
}
/// Creates theme-pack author metadata.
///
/// @param name the localized author display name
public ThemePackAuthor {
if (name.mayBeEmpty()) {
throw new IllegalArgumentException("The author name cannot be empty");
}
}
/// Returns the author display name in the current locale.
///
/// @return the localized author display name
public String displayName() {
return name.getText(I18n.getLocale().getCandidateLocales());
}
/// Converts this author to its JSON representation.
///
/// @return the JSON object representing this author
public JsonObject toJsonObject() {
JsonObject object = new JsonObject();
object.add("name", name.toJsonElement());
return object;
}
static final class Adapter implements JsonSerializer<@Nullable ThemePackAuthor>, JsonDeserializer<@Nullable ThemePackAuthor> {
@Override
public @Nullable ThemePackAuthor deserialize(@Nullable JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return ThemePackAuthor.fromJson(json);
}
@Override
public JsonElement serialize(@Nullable ThemePackAuthor src, Type typeOfSrc, JsonSerializationContext context) {
return src != null ? src.toJsonObject() : JsonNull.INSTANCE;
}
}
}
@@ -1,100 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.Zipper;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/// Writes theme-pack manifests and assets into zip-compatible files.
@NotNullByDefault
public final class ThemePackExporter {
/// Recommended file extension for theme-pack files.
public static final String FILE_EXTENSION = ".hmcl-theme";
/// Manifest entry name used by every exported theme pack.
public static final String MANIFEST_ENTRY = "manifest.json";
/// Prevents instantiation.
private ThemePackExporter() {
}
/// Exports a theme pack to a zip-compatible file.
///
/// @param manifest the manifest to write
/// @param assets asset files to copy into the zip
/// @param outputFile the target theme-pack file
/// @throws IOException if a source file cannot be read or the zip cannot be written
/// @throws IllegalArgumentException if an asset entry is duplicated or unsafe
public static void export(
ThemePackManifest manifest,
@Unmodifiable List<ThemePackAsset> assets,
Path outputFile) throws IOException {
Objects.requireNonNull(manifest);
Objects.requireNonNull(assets);
Objects.requireNonNull(outputFile);
validateAssets(assets);
Path output = outputFile.toAbsolutePath().normalize();
Path parent = output.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Path temporaryFile = FileUtils.tmpSaveFile(output);
try {
Files.deleteIfExists(temporaryFile);
try (Zipper zipper = new Zipper(temporaryFile)) {
zipper.putTextFile(JsonUtils.GSON.toJson(manifest), MANIFEST_ENTRY);
for (ThemePackAsset asset : assets) {
try (InputStream input = asset.source().openStream()) {
zipper.putStream(input, asset.entryName());
}
}
}
Files.move(temporaryFile, output, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException | RuntimeException e) {
try {
Files.deleteIfExists(temporaryFile);
} catch (IOException suppressed) {
e.addSuppressed(suppressed);
}
throw e;
}
}
/// Validates all asset entries and source files before the zip is written.
private static void validateAssets(List<ThemePackAsset> assets) throws IOException {
Set<String> entries = new HashSet<>();
entries.add(MANIFEST_ENTRY);
for (ThemePackAsset asset : assets) {
Objects.requireNonNull(asset);
@Nullable Path sourceFile = asset.source().file();
if (sourceFile != null && !Files.isRegularFile(sourceFile)) {
throw new IOException("Theme-pack asset source is not a regular file: " + sourceFile);
}
try (InputStream ignored = asset.source().openStream()) {
// Validate readability before writing the target zip.
}
if (!entries.add(asset.entryName())) {
throw new IllegalArgumentException("Duplicate theme-pack zip entry: " + asset.entryName());
}
}
}
}
File diff suppressed because it is too large. Load diff
@@ -1,345 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import org.jackhuang.hmcl.util.gson.JsonSchema;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Parsed metadata and themes from a theme-pack manifest.
///
/// @param id the stable package identifier
/// @param version the package version string
/// @param name the localized display name
/// @param authors the package authors
/// @param icon the optional theme-pack relative icon path
/// @param description the optional localized package description
/// @param themes selectable themes declared by the package
@NotNullByDefault
@JsonSerializable
@JsonAdapter(ThemePackManifest.Adapter.class)
public record ThemePackManifest(
String id,
String version,
LocalizedText name,
@Unmodifiable List<ThemePackAuthor> authors,
@Nullable String icon,
@Nullable LocalizedText description,
@Unmodifiable List<Theme> themes) {
/// JSON schema for the current manifest format.
public static final JsonSchema CURRENT_SCHEMA =
new JsonSchema("theme-pack", new JsonSchema.Version(1, 0, 0));
/// Package and theme ID format used by theme-pack manifests.
private static final Pattern PACKAGE_ID_PATTERN = Pattern.compile(
"[A-Za-z0-9][A-Za-z0-9_-]*(\\.[A-Za-z0-9][A-Za-z0-9_-]*)*"
);
/// Creates a theme-pack manifest.
///
/// @param id the stable package identifier
/// @param version the package version string
/// @param name the localized display name
/// @param authors the package authors
/// @param icon the optional theme-pack relative icon path
/// @param description the optional localized package description
/// @param themes selectable themes declared by the package
public ThemePackManifest {
id = requirePackageId(id);
version = requireNonBlank(version, "version");
name = requireLocalizedText(name, "name");
authors = List.copyOf(authors);
if (icon != null) {
icon = ThemePackAsset.normalizeEntryName(icon);
}
if (description != null) {
description = requireLocalizedText(description, "description");
}
themes = List.copyOf(themes);
if (themes.isEmpty()) {
throw new IllegalArgumentException("Theme pack must declare at least one theme");
}
checkThemeIdentities(themes);
}
public boolean isSimpleThemePack() {
if (themes.size() == 1) {
Theme theme = themes.get(0);
return theme.id() == null && theme.name() == null;
} else {
return false;
}
}
/// Returns the package display name in the current locale.
///
/// @return the localized display name, or the package ID when no localized text matches
public String displayName() {
return Objects.requireNonNullElse(name.getText(I18n.getLocale().getCandidateLocales()), id);
}
/// Returns the package description in the current locale.
///
/// @return the localized description, or `null` when no localized text matches
public @Nullable String displayDescription() {
return description != null ? description.getText(I18n.getLocale().getCandidateLocales()) : null;
}
/// Returns the first theme with the given ID.
///
/// A `null` theme ID matches only a single-theme manifest.
///
/// @param themeId the theme ID to find, or `null` for an unnamed single-theme manifest
/// @return the matching theme, or `null` when no theme matches
public @Nullable Theme findTheme(@Nullable String themeId) {
if (themeId == null) {
return themes.size() == 1 ? themes.get(0) : null;
}
for (Theme theme : themes) {
if (themeId.equals(theme.id())) {
return theme;
}
}
return null;
}
/// Reads the required theme declaration.
private static List<Theme> readThemes(JsonObject object) {
boolean hasSingleTheme = object.has("theme");
boolean hasMultipleThemes = object.has("themes");
if (hasSingleTheme == hasMultipleThemes) {
throw new JsonParseException("Theme-pack manifest must declare exactly one of theme or themes");
}
if (hasSingleTheme) {
JsonElement element = object.get("theme");
if (!(element instanceof JsonObject themeObject)) {
throw new JsonParseException("Theme-pack theme must be an object");
}
return List.of(Theme.fromJson(themeObject, false));
}
JsonElement element = object.get("themes");
if (!(element instanceof JsonArray array)) {
throw new JsonParseException("Theme-pack manifest is missing themes array");
}
if (array.isEmpty()) {
throw new JsonParseException("Theme-pack themes array must declare at least one theme");
}
ArrayList<Theme> themes = new ArrayList<>(array.size());
for (JsonElement item : array) {
if (!(item instanceof JsonObject themeObject)) {
throw new JsonParseException("Theme-pack theme must be an object");
}
themes.add(Theme.fromJson(themeObject, true));
}
return themes;
}
/// Checks that theme IDs and names are present whenever the manifest needs them for disambiguation.
private static void checkThemeIdentities(List<Theme> themes) {
if (themes.size() <= 1) {
return;
}
for (Theme theme : themes) {
if (theme.id() == null) {
throw new IllegalArgumentException("Theme ID is required when a theme pack declares multiple themes");
}
if (theme.name() == null) {
throw new IllegalArgumentException("Theme name is required when a theme pack declares multiple themes");
}
}
}
/// Reads a required string member.
private static String requireMemberString(JsonObject object, String fieldName) {
JsonElement element = object.get(fieldName);
if (element == null) {
throw new JsonParseException("Theme-pack manifest is missing " + fieldName);
}
if (!(element instanceof JsonPrimitive primitive) || !primitive.isString()) {
throw new JsonParseException("Theme-pack manifest field must be a string: " + fieldName);
}
return requireNonBlank(primitive.getAsString(), fieldName);
}
/// Parses a localized text value.
static LocalizedText parseLocalizedText(JsonElement element, String field) {
if (element instanceof JsonPrimitive primitive && primitive.isString()) {
return LocalizedText.plain(requireNonBlank(primitive.getAsString(), field));
}
if (element instanceof JsonObject localizedObject) {
if (localizedObject.isEmpty()) {
throw new JsonParseException("Localized text field is empty: " + field);
}
LinkedHashMap<String, String> localizedValues = new LinkedHashMap<>();
for (Map.Entry<String, JsonElement> entry : localizedObject.entrySet()) {
JsonElement value = entry.getValue();
if (!(value instanceof JsonPrimitive primitive) || !primitive.isString()) {
throw new JsonParseException("Localized text values must be strings: " + field);
}
localizedValues.put(
requireNonBlank(entry.getKey(), field),
requireNonBlank(primitive.getAsString(), field));
}
return new LocalizedText(localizedValues);
}
throw new JsonParseException("Theme-pack localized text must be a string or object: " + field);
}
/// Returns a validated localized text value.
static LocalizedText requireLocalizedText(LocalizedText value, String field) {
Objects.requireNonNull(value);
JsonElement element = JsonUtils.GSON.toJsonTree(value, LocalizedText.class);
return parseLocalizedText(element, field);
}
/// Returns a non-blank string value.
private static String requireNonBlank(String value, String field) {
String trimmed = value.trim();
if (trimmed.isEmpty()) {
throw new IllegalArgumentException("Theme-pack manifest field is blank: " + field);
}
return trimmed;
}
/// Returns a package ID matching the theme-pack ID format.
static String requirePackageId(String value) {
return requireId(value, "id", "Theme-pack manifest ID must follow package ID format: ");
}
/// Returns a theme ID matching the theme-pack ID format.
static String requireThemeId(String value) {
return requireId(value, "theme id", "Theme ID must follow package ID format: ");
}
/// Returns an ID matching the theme-pack ID format.
private static String requireId(String value, String field, String messagePrefix) {
String id = requireNonBlank(value, field);
if (!PACKAGE_ID_PATTERN.matcher(id).matches()) {
throw new IllegalArgumentException(messagePrefix + value);
}
return id;
}
static final class Adapter implements JsonSerializer<@Nullable ThemePackManifest>,
JsonDeserializer<@Nullable ThemePackManifest> {
@Override
public @Nullable ThemePackManifest deserialize(
@Nullable JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
if (json == null || json instanceof JsonNull) {
return null;
}
if (!(json instanceof JsonObject object)) {
throw new JsonParseException("Theme-pack manifest is not a JsonObject");
}
if (!JsonSchema.check(object, CURRENT_SCHEMA).readable()) {
throw new JsonParseException("Unsupported theme-pack schema: " + object.get(JsonSchema.PROPERTY_SCHEMA));
}
String id = requireMemberString(object, "id");
String version = requireMemberString(object, "version");
LocalizedText name = context.deserialize(object.get("name"), LocalizedText.class);
if (name == null) {
throw new JsonParseException("Theme-pack manifest is missing name");
}
@Nullable LocalizedText description;
try {
description = context.deserialize(object.get("description"), LocalizedText.class);
if (description != null) {
description = requireLocalizedText(description, "description");
}
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme-pack description", e);
description = null;
}
List<ThemePackAuthor> authors;
try {
authors = ThemePackAuthor.parseAuthors(object.get("authors"));
} catch (JsonParseException | IllegalArgumentException e) {
LOG.warning("Ignored invalid theme-pack authors", e);
authors = List.of();
}
@Nullable String icon = JsonUtils.getString(object, "icon");
if (icon != null) {
try {
icon = ThemePackAsset.normalizeEntryName(icon);
} catch (IllegalArgumentException e) {
LOG.warning("Ignored invalid theme-pack icon: " + icon, e);
icon = null;
}
}
return new ThemePackManifest(
id,
version,
name,
authors,
icon,
description,
readThemes(object));
}
@Override
public JsonElement serialize(@Nullable ThemePackManifest src, Type typeOfSrc, JsonSerializationContext context) {
if (src == null) {
return JsonNull.INSTANCE;
}
JsonObject object = new JsonObject();
object.addProperty(JsonSchema.PROPERTY_SCHEMA, CURRENT_SCHEMA.url());
object.addProperty("id", src.id);
object.addProperty("version", src.version);
object.add("name", src.name.toJsonElement());
object.add("authors", ThemePackAuthor.toJson(src.authors));
if (src.description != null) {
object.add("description", src.description.toJsonElement());
}
if (src.icon != null) {
object.addProperty("icon", src.icon);
}
if (src.isSimpleThemePack()) {
object.add("theme", src.themes.get(0).toJsonObject());
} else {
JsonArray themeArray = new JsonArray();
for (Theme theme : src.themes) {
themeArray.add(theme.toJsonObject());
}
object.add("themes", themeArray);
}
return object;
}
}
}
@@ -1,200 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import kala.compress.archivers.zip.ZipArchiveEntry;
import kala.compress.archivers.zip.ZipArchiveReader;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
/// A readable resource contributed by a theme pack.
@NotNullByDefault
public sealed interface ThemePackResource
permits ThemePackResource.File, ThemePackResource.Zip, ThemePackResource.Builtin {
/// Returns a stable resource name used for display and extension detection.
///
/// @return the resource name
String name();
/// Opens a fresh input stream for this resource.
///
/// @return a new stream for reading the resource content
/// @throws IOException if the resource cannot be opened
InputStream openStream() throws IOException;
/// Returns the backing file when this resource is a regular filesystem file.
///
/// @return the backing file, or `null` when the resource is not a direct file
default @Nullable Path file() {
return null;
}
/// A resource stored as a regular filesystem file.
///
/// @param path the resource file path
/// @param name the stable resource name
@NotNullByDefault
record File(Path path, String name) implements ThemePackResource {
/// Creates a file-backed theme-pack resource named after its file name.
///
/// @param path the resource file path
public File(Path path) {
this(path, Objects.requireNonNull(path).getFileName().toString());
}
/// Creates a file-backed theme-pack resource.
///
/// @param path the resource file path
/// @param name the stable resource name
public File {
path = Objects.requireNonNull(path).toAbsolutePath().normalize();
name = Objects.requireNonNull(name).trim();
if (name.isEmpty()) {
throw new IllegalArgumentException("Theme-pack resource name is blank");
}
}
/// Opens the file.
@Override
public InputStream openStream() throws IOException {
return Files.newInputStream(path);
}
/// Returns the backing file.
@Override
public Path file() {
return path;
}
}
/// A resource stored as an entry inside a zip theme-pack file.
///
/// @param zipFile the zip theme-pack file
/// @param entryName the normalized zip entry name
@NotNullByDefault
record Zip(Path zipFile, String entryName) implements ThemePackResource {
/// Creates a zip-entry theme-pack resource.
///
/// @param zipFile the zip theme-pack file
/// @param entryName the normalized zip entry name
public Zip {
zipFile = Objects.requireNonNull(zipFile).toAbsolutePath().normalize();
entryName = ThemePackAsset.normalizeEntryName(entryName);
}
/// Returns the zip entry name.
@Override
public String name() {
return entryName;
}
/// Opens the zip entry and closes the zip file when the returned stream is closed.
@Override
public InputStream openStream() throws IOException {
ZipArchiveReader zip = new ZipArchiveReader(zipFile, StandardCharsets.UTF_8);
boolean success = false;
try {
ZipArchiveEntry entry = zip.getEntry(entryName);
if (entry == null || entry.isDirectory()) {
throw new IOException("Theme-pack asset is missing: " + entryName);
}
InputStream input = zip.getInputStream(entry);
success = true;
return new ZipEntryInputStream(input, zip);
} finally {
if (!success) {
zip.close();
}
}
}
}
/// A resource stored in launcher-bundled classpath resources.
///
/// @param resourcePath the classpath resource path
/// @param entryName the theme-pack entry name
@NotNullByDefault
record Builtin(String resourcePath, String entryName) implements ThemePackResource {
/// Creates a classpath-backed theme-pack resource.
///
/// @param resourcePath the classpath resource path
/// @param entryName the theme-pack entry name
public Builtin {
resourcePath = Objects.requireNonNull(resourcePath);
entryName = ThemePackAsset.normalizeEntryName(entryName);
}
/// Returns the theme-pack entry name.
@Override
public String name() {
return entryName;
}
/// Opens the classpath resource.
@Override
public InputStream openStream() throws IOException {
@Nullable InputStream input = ThemePackResource.class.getResourceAsStream(resourcePath);
if (input == null) {
throw new IOException("Built-in theme-pack asset is missing: " + entryName);
}
return input;
}
}
/// An input stream that owns a zip file handle.
@NotNullByDefault
final class ZipEntryInputStream extends FilterInputStream {
/// The zip file that must stay open while this stream is read.
private final ZipArchiveReader zipFile;
/// Whether this stream has already been closed.
private boolean closed;
/// Creates a zip-entry stream.
///
/// @param input the zip entry input stream
/// @param zipFile the owning zip file
private ZipEntryInputStream(InputStream input, ZipArchiveReader zipFile) {
super(input);
this.zipFile = Objects.requireNonNull(zipFile);
}
/// Closes both the entry stream and the owning zip file.
@Override
public void close() throws IOException {
if (closed) {
return;
}
closed = true;
@Nullable IOException exception = null;
try {
super.close();
} catch (IOException e) {
exception = e;
}
try {
zipFile.close();
} catch (IOException e) {
if (exception != null) {
exception.addSuppressed(e);
} else {
exception = e;
}
}
if (exception != null) {
throw exception;
}
}
}
}
@@ -1,30 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.annotations.SerializedName;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
/// Identifies one theme declared by an installed theme pack.
///
/// @param packId the theme-pack identifier
/// @param themeId the selected theme identifier inside the pack, or `null` for an unnamed single-theme pack
@NotNullByDefault
@JsonSerializable
public record ThemeReference(
@SerializedName("packId") String packId,
@SerializedName("themeId") @Nullable String themeId) {
/// Creates a theme reference.
///
/// @param packId the theme-pack identifier
/// @param themeId the selected theme identifier inside the pack, or `null` for an unnamed single-theme pack
public ThemeReference {
packId = ThemePackManifest.requirePackageId(packId);
if (themeId != null) {
themeId = ThemePackManifest.requireThemeId(themeId);
}
}
}
@@ -1,79 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import org.glavo.monetfx.Brightness;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Locale;
import java.util.Objects;
/// Environment values used to resolve conditional theme-pack overrides.
///
/// @param brightness the effective light or dark mode after resolving launcher and system settings
/// @param os the normalized operating system name used by theme conditions
/// @param language the normalized UI language subtag used by theme conditions
@NotNullByDefault
public record ThemeResolveContext(
Brightness brightness,
String os,
String language) {
/// Creates a theme resolution context.
///
/// @param brightness the effective light or dark mode after resolving launcher and system settings
/// @param os the normalized operating system name used by theme conditions
/// @param language the normalized UI language subtag used by theme conditions
public ThemeResolveContext {
Objects.requireNonNull(brightness);
os = normalizeToken(os, "os");
language = normalizeToken(language, "language");
}
/// Creates a context for the current process platform.
///
/// @param brightness the effective light or dark mode after resolving launcher and system settings
/// @return a context containing the current OS and default language
public static ThemeResolveContext current(Brightness brightness) {
return new ThemeResolveContext(
brightness,
normalizeOperatingSystem(OperatingSystem.CURRENT_OS),
Locale.getDefault().getLanguage());
}
/// Returns the normalized value for one supported condition key.
///
/// @param key the condition key
/// @return the normalized context value, or `null` when the key is unsupported
@Nullable String conditionValue(String key) {
return switch (key) {
case ThemeCondition.KEY_BRIGHTNESS -> brightness.name().toLowerCase(Locale.ROOT);
case ThemeCondition.KEY_OS -> os;
case ThemeCondition.KEY_LANGUAGE -> language;
default -> null;
};
}
/// Returns the normalized name for an operating system.
///
/// @param operatingSystem the operating system to normalize
/// @return the normalized operating system name
static String normalizeOperatingSystem(OperatingSystem operatingSystem) {
Objects.requireNonNull(operatingSystem);
return operatingSystem == OperatingSystem.UNKNOWN ? "unknown" : operatingSystem.getCheckedName();
}
/// Normalizes one condition context token.
private static String normalizeToken(String value, String name) {
Objects.requireNonNull(value, name);
String normalized = value.trim().toLowerCase(Locale.ROOT);
if (normalized.isEmpty()) {
throw new IllegalArgumentException("Empty theme condition context value: " + name);
}
return normalized;
}
}
@@ -1,80 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Title-bar settings contributed by a theme-pack appearance.
///
/// @param transparent whether the launcher title bar should be transparent, or `null` when inherited
@NotNullByDefault
public record ThemeTitleBar(@Nullable Boolean transparent) {
/// JSON member name for title-bar transparency.
private static final String FIELD_TRANSPARENT = "transparent";
/// Creates a title-bar patch.
///
/// @param transparent whether the launcher title bar should be transparent, or `null` when inherited
public ThemeTitleBar {
}
/// Parses title-bar settings from a JSON object.
///
/// @param object the title-bar JSON object
/// @return the parsed title-bar patch
static ThemeTitleBar fromJson(JsonObject object) throws JsonParseException {
Objects.requireNonNull(object);
return new ThemeTitleBar(readTransparent(object));
}
/// Converts this title-bar patch to its JSON representation.
///
/// @return the JSON object representing this title-bar patch
public JsonObject toJsonObject() {
JsonObject object = new JsonObject();
if (transparent != null) {
object.addProperty(FIELD_TRANSPARENT, transparent);
}
return object;
}
/// Returns whether this title-bar object contains no concrete fields.
///
/// @return `true` when every field is inherited
public boolean isEmpty() {
return transparent == null;
}
/// Returns a title-bar patch that applies the given patch over this title-bar patch.
///
/// @param patch the patch to apply
/// @return the merged title-bar patch
public ThemeTitleBar merge(ThemeTitleBar patch) {
Objects.requireNonNull(patch);
return new ThemeTitleBar(patch.transparent != null ? patch.transparent : transparent);
}
/// Reads the optional transparent field.
private static @Nullable Boolean readTransparent(JsonObject object) {
JsonElement element = object.get(FIELD_TRANSPARENT);
if (element == null) {
return null;
}
if (!(element instanceof JsonPrimitive primitive) || !primitive.isBoolean()) {
LOG.warning("Ignored invalid theme titleBar.transparent: expected a boolean, got " + element);
return null;
}
return primitive.getAsBoolean();
}
}
File diff suppressed because it is too large. Load diff
@@ -1,80 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import javafx.scene.image.Image;
import javafx.scene.paint.Color;
import org.glavo.monetfx.ColorScheme;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jetbrains.annotations.NotNullByDefault;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Objects;
/// Extracts a MonetFX seed color from wallpaper images.
@NotNullByDefault
public final class WallpaperColorExtractor {
/// Prevents instantiation.
private WallpaperColorExtractor() {
}
/// Extracts a theme color from an image file.
///
/// @param imageFile the image file
/// @param fallback the fallback color used when extraction fails
/// @return the extracted color, or `fallback` when no suitable color is found
/// @throws IOException if the image file cannot be read
public static ThemeColor extract(Path imageFile, ThemeColor fallback) throws IOException {
Objects.requireNonNull(imageFile);
Objects.requireNonNull(fallback);
Image image;
try {
image = FXUtils.loadImage(imageFile);
} catch (Exception e) {
if (e instanceof IOException ioException) {
throw ioException;
}
throw new IOException("Failed to load wallpaper image: " + imageFile, e);
}
return extract(image, fallback);
}
/// Extracts a theme color from a theme-pack resource.
///
/// @param resource the theme-pack resource
/// @param fallback the fallback color used when extraction fails
/// @return the extracted color, or `fallback` when no suitable color is found
/// @throws IOException if the resource cannot be read
static ThemeColor extract(ThemePackResource resource, ThemeColor fallback) throws IOException {
Objects.requireNonNull(resource);
Objects.requireNonNull(fallback);
Image image;
try {
image = FXUtils.loadImage(resource.openStream(), resource.name());
} catch (Exception e) {
if (e instanceof IOException ioException) {
throw ioException;
}
throw new IOException("Failed to load wallpaper image: " + resource.name(), e);
}
return extract(image, fallback);
}
/// Extracts a theme color from a loaded image.
///
/// @param image the loaded image
/// @param fallback the fallback color used when extraction fails
/// @return the extracted color, or `fallback` when no suitable color is found
public static ThemeColor extract(Image image, ThemeColor fallback) {
Objects.requireNonNull(image);
Objects.requireNonNull(fallback);
Color extracted = ColorScheme.extractColor(image, fallback.color());
return ThemeColor.of(extracted);
}
}
@@ -0,0 +1,21 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.theme;
import javafx.scene.paint.Color;
import org.jetbrains.annotations.NotNullByDefault;
/// The fixed warm cream palette used by window chrome that is painted from Java.
///
/// Values match `/assets/css/warm.css`, which supplies the same colors to style sheets.
@NotNullByDefault
public final class WarmPalette {
private WarmPalette() {
}
/// The window and title-bar background.
public static final Color WINDOW = Color.web("#F3EEE6");
/// The title-bar text and icon color.
public static final Color INK = Color.web("#2A2622");
}
@@ -8,7 +8,6 @@ import com.jfoenix.validation.base.ValidatorBase;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.property.ReadOnlyStringProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
@@ -22,16 +21,22 @@ import javafx.util.Duration;
import org.jackhuang.hmcl.Launcher;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.GameJavaVersion;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.LauncherHelper;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.java.JavaManager;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.ui.account.AccountListPage;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.construct.*;
@@ -39,14 +44,10 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.decorator.Decorator;
import org.jackhuang.hmcl.ui.main.LauncherSettingsPage;
import org.jackhuang.hmcl.ui.main.RootPage;
import org.jackhuang.hmcl.ui.instances.GameListPage;
import org.jackhuang.hmcl.ui.instances.GameInstancePage;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.upgrade.IntegrityChecker;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.function.ExceptionalRunnable;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
@@ -57,7 +58,6 @@ import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
@@ -78,8 +78,6 @@ public final class Controllers {
public static final String JAVA_INTERPRETED_MODE_TIP = "javaInterpretedMode";
/// The shown-tip key for the software-rendering warning.
public static final String SOFTWARE_RENDERING = "softwareRendering";
/// The shown-tip key storing the year of the April Fools prompt.
public static final String APRIL_FOOLS = "aprilFools";
/// The coordinator for the main window's scene graph and navigation stack.
private static @Nullable Decorator decorator;
@@ -93,31 +91,12 @@ public final class Controllers {
/// Returns the shared launcher-title property.
///
/// For official builds, first access checks integrity, asynchronously if no result is cached.
/// Failed verification changes the title to indicate a modified build; asynchronous results
/// are applied on the JavaFX application thread.
/// The title is the configured server name, or the generic launcher name when none is set.
public static ReadOnlyStringProperty launcherTitleProperty() {
if (launcherTitle == null) {
launcherTitle = new SimpleStringProperty(Controllers.class, "launcherTitle", Metadata.OFFICIAL_BUILD
? i18n("launcher.title", Metadata.VERSION)
: i18n("launcher.title.unofficial", Metadata.VERSION));
if (Metadata.OFFICIAL_BUILD) {
@Nullable Boolean selfVerified = IntegrityChecker.getSelfVerified();
if (selfVerified != null) {
if (!selfVerified) {
launcherTitle.set(i18n("launcher.title.modified", Metadata.VERSION));
}
} else {
Schedulers.io().execute(() -> {
if (!IntegrityChecker.isSelfVerified()) {
FXUtils.runInFX(() -> {
//noinspection DataFlowIssue
launcherTitle.set(i18n("launcher.title.modified", Metadata.VERSION));
});
}
});
}
}
String serverName = Metadata.getServerName();
launcherTitle = new SimpleStringProperty(Controllers.class, "launcherTitle",
serverName.isEmpty() ? i18n("launcher.name") : serverName);
}
return launcherTitle;
@@ -152,28 +131,6 @@ public final class Controllers {
return gameInstancePage;
}
/// The cached game-list page.
private static @Nullable GameListPage gameListPage;
/// Creates and attaches the game-list page for a layout pulse unless already cached.
@FXThread
public static void prepareGameListPage() {
if (gameListPage == null) {
LOG.info("Preparing game list page");
gameListPage = getDecorator().prewarmPage(new GameListPage());
}
}
/// Returns the cached game-list page, creating it if necessary.
@FXThread
public static GameListPage getGameListPage() {
if (gameListPage == null) {
gameListPage = new GameListPage();
}
return gameListPage;
}
/// The cached root page.
private static @Nullable RootPage rootPage;
@@ -259,8 +216,6 @@ public final class Controllers {
public static void initialize(Stage stage) {
LOG.info("Start initializing application");
LOG.info("April Fools: " + AprilFools.isEnabled());
if (System.getProperty("prism.lcdtext") == null) {
@Nullable String fontAntiAliasing = SettingsManager.userSettings().fontAntiAliasingProperty().get();
if ("lcd".equalsIgnoreCase(fontAntiAliasing)) {
@@ -280,7 +235,10 @@ public final class Controllers {
// Dedicated server client: always use the fixed default cache directory.
settings().commonDirectoryTypeProperty().set(EnumCommonDirectory.DEFAULT);
Lang.thread(JavaManager::initialize, "Search Java", true);
Lang.thread(() -> {
JavaManager.initialize();
FXUtils.runInFX(Controllers::tryEnsureRecommendedMojangJava);
}, "Search Java", true);
StyleSheets.init(mainScene);
@@ -363,85 +321,97 @@ public final class Controllers {
Controllers.dialog(agreementPane);
}
aprilFools:
if (AprilFools.isEnabled()) {
int currentYear = LocalDate.now().getYear();
if (state().getShownTips().get(APRIL_FOOLS) instanceof Number year && year.intValue() >= currentYear)
break aprilFools;
if (!I18n.getLocale().getLocale().getLanguage().equals("zh"))
break aprilFools;
@Nullable SupportedLocale lzh = SupportedLocale.getSupportedLocales().stream()
.filter(locale -> "lzh".equals(locale.getName()))
.findFirst().orElse(null);
if (lzh == null) {
LOG.warning("No supported locale found for lzh");
break aprilFools;
}
Runnable updateShowTips = () -> state().getShownTips().put(APRIL_FOOLS, currentYear);
Controllers.confirmWithCountdown(i18n("launcher.april_fools.switch_lzh"), null, 10,
MessageType.QUESTION, () -> {
Controllers.confirm(i18n("launcher.april_fools.switch_lzh.confirm"), null, MessageType.QUESTION, () -> {
LOG.info("Switching locale to " + lzh);
updateShowTips.run();
settings().languageProperty().set(lzh);
Controllers.onApplicationStop();
try {
FileSaver.waitForAllSaves();
} catch (InterruptedException ignored) {
// Ignore
}
try {
Restarter.restartSelf();
} catch (IOException e) {
LOG.warning("Failed to restart self", e);
}
Platform.exit();
}, updateShowTips);
}, updateShowTips);
}
tryInstallBundledModpack(GameDirectoryManager.getSelectedRepository());
}
/// Installs the package found by [Metadata#findBundledModpackFile()] asynchronously, if present.
/// Downloads Mojang Runtime Java 25 when missing on supported platforms.
///
/// Shows progress without a cancel action and attempts to delete the package after success.
/// Failures are logged; a retained package may be retried at the next startup.
private static void tryInstallBundledModpack(HMCLGameRepository repository) {
@Nullable Path modpackFile = Metadata.findBundledModpackFile();
if (modpackFile == null) {
/// No confirm dialog: if already installed, returns quietly; otherwise shows a progress dialog.
private static void tryEnsureRecommendedMojangJava() {
org.jackhuang.hmcl.util.platform.Platform platform =
org.jackhuang.hmcl.util.platform.Platform.SYSTEM_PLATFORM;
GameJavaVersion target = GameJavaVersion.JAVA_25;
if (!GameJavaVersion.getSupportedVersions(platform).contains(target)) {
return;
}
if (JavaManager.getMojangJavaPlatform(platform) == null) {
return;
}
if (JavaManager.REPOSITORY.isInstalled(platform, target)) {
return;
}
LOG.info("Found bundled modpack at " + modpackFile + "; starting automatic install");
LOG.info("Recommended Mojang Java " + target.majorVersion()
+ " (" + target.component() + ") missing; starting automatic download");
Controllers.taskDialog(
Task.composeAsync(Schedulers.io(), () -> {
Modpack modpack = ModpackHelper.readModpackManifest(modpackFile);
return ModpackHelper.getInstallTask(
repository, modpackFile, new GameInstanceID(modpack.getName()), modpack, null);
})
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
JavaManager.getDownloadJavaTask(DownloadProviders.getDownloadProvider(), platform, target)
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception != null) {
LOG.warning("Failed to install bundled modpack", exception);
LOG.warning("Failed to download recommended Mojang Java " + target.majorVersion(), exception);
} else {
LOG.info("Installed Mojang Java " + target.majorVersion() + " at " + result.getBinary());
}
}),
i18n("java.download"),
TaskCancellationAction.NO_CANCEL
);
}
/// Installs a launcher-bundled modpack when present; otherwise, when the repository has no
/// instances yet, pulls the org client [Metadata#getClientManifestUrl] and opens the install wizard.
private static void tryInstallBundledModpack(HMCLGameRepository repository) {
@Nullable Path modpackFile = Metadata.findBundledModpackFile();
if (modpackFile != null) {
LOG.info("Found bundled modpack at " + modpackFile + "; starting automatic install");
Controllers.taskDialog(
Task.composeAsync(Schedulers.io(), () -> {
Modpack modpack = ModpackHelper.readModpackManifest(modpackFile);
return ModpackHelper.getInstallTask(
repository, modpackFile, new GameInstanceID(modpack.getName()), modpack, null);
})
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
if (exception != null) {
LOG.warning("Failed to install bundled modpack", exception);
return;
}
try {
Files.deleteIfExists(modpackFile);
} catch (IOException e) {
LOG.warning("Failed to delete bundled modpack: " + modpackFile, e);
}
}), i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL
);
return;
}
if (repository.getDisplayInstances().findAny().isPresent()) {
return;
}
String manifestUrl = Metadata.getClientManifestUrl();
LOG.info("No instances yet; fetching client Server Modpack from " + manifestUrl);
Controllers.taskDialog(
new GetTask(WebURL.parse(manifestUrl))
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception != null) {
LOG.warning("Failed to fetch client Server Modpack manifest", exception);
return;
}
try {
Files.deleteIfExists(modpackFile);
} catch (IOException e) {
LOG.warning("Failed to delete bundled modpack: " + modpackFile, e);
ServerModpackManifest manifest =
JsonUtils.fromMaybeMalformedJson(result, ServerModpackManifest.class);
if (manifest == null) {
LOG.warning("Malformed client Server Modpack manifest from " + manifestUrl);
return;
}
}), i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL
Controllers.getDecorator().startWizard(
new ModpackInstallWizardProvider(repository, manifest),
i18n("install.modpack"));
}),
i18n("message.downloading"),
TaskCancellationAction.NO_CANCEL
);
}
@@ -729,7 +699,6 @@ public final class Controllers {
onApplicationStop();
rootPage = null;
gameInstancePage = null;
gameListPage = null;
accountListPage = null;
settingsPage = null;
decorator = null;
@@ -602,6 +602,10 @@ public final class FXUtils {
return;
String url = NetworkUtils.encodeLocation(link);
if (!isAllowedOpenLink(url)) {
LOG.warning("Refusing to open link with disallowed scheme: " + link);
return;
}
thread(() -> {
try {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
@@ -635,6 +639,23 @@ public final class FXUtils {
});
}
/// Only {@code http}/{@code https}/{@code mailto} may be handed to the OS URL handler.
static boolean isAllowedOpenLink(String link) {
try {
URI uri = new URI(link);
String scheme = uri.getScheme();
if (scheme == null) {
return false;
}
return switch (scheme.toLowerCase(java.util.Locale.ROOT)) {
case "http", "https", "mailto" -> true;
default -> false;
};
} catch (Exception e) {
return false;
}
}
public static <T> void bind(JFXTextField textField, Property<T> property, StringConverter<T> converter) {
TextFieldBinding<T> binding = new TextFieldBinding<>(textField, property, converter);
binding.updateTextField();
@@ -1,182 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXSpinner;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.geometry.Insets;
import javafx.scene.Cursor;
import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.SkinBase;
import javafx.scene.control.Tooltip;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.construct.TaskExecutorDialogPane;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.TaskManager;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// Displays active tasks and pending completion actions.
@NotNullByDefault
public final class TaskManagerPage extends Control implements DecoratorPage {
/// Navigation metadata for the task list.
private final ReadOnlyObjectWrapper<State> state =
new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("task.list")));
/// Creates a page backed by the application-wide task manager.
public TaskManagerPage() {
getStyleClass().add("content-background");
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state.getReadOnlyProperty();
}
@Override
protected SkinBase<TaskManagerPage> createDefaultSkin() {
return new Skin(this);
}
/// Binds a list view to active dialogs and pending completion actions.
@NotNullByDefault
private static final class Skin extends SkinBase<TaskManagerPage> {
/// Creates the task list and its empty-state message.
private Skin(TaskManagerPage control) {
super(control);
StackPane container = new StackPane();
container.setPadding(new Insets(10));
container.getStyleClass().add("notice-pane");
ComponentList root = new ComponentList();
root.getStyleClass().add("no-padding");
JFXListView<TaskExecutorDialogPane> list = new JFXListView<>();
list.setItems(TaskManager.getInstance().getTasks());
list.setCellFactory(view -> new TaskCell());
list.setPadding(Insets.EMPTY);
list.getStyleClass().add("no-horizontal-scrollbar");
FXUtils.ignoreEvent(list, KeyEvent.KEY_PRESSED, event -> event.getCode() == KeyCode.ESCAPE);
StackPane placeholder = new StackPane(new Label(i18n("task.list.empty")));
placeholder.getStyleClass().add("notice-pane");
list.setPlaceholder(placeholder);
ComponentList.setVgrow(list, Priority.ALWAYS);
root.getContent().setAll(list);
container.getChildren().setAll(root);
getChildren().setAll(container);
}
}
/// Displays a live task title and an action to restore progress or continue a completed task.
@NotNullByDefault
private static final class TaskCell extends ListCell<@Nullable TaskExecutorDialogPane> {
/// The list item whose title is bound to the current dialog.
private final TwoLineListItem content = new TwoLineListItem();
/// The row's shared hover and ripple feedback.
private final RipplerContainer graphic;
/// Centers running and pending indicators in a fixed-size slot to keep row titles aligned.
private final StackPane statusIcon = new StackPane();
/// Restores progress or invokes the pending completion action.
private final JFXButton actionButton = FXUtils.newToggleButton4(SVG.ARROW_FORWARD);
/// Describes the current row action.
private final Tooltip actionTooltip = new Tooltip();
/// Creates a row that activates the current task through its body or trailing action.
private TaskCell() {
content.getStyleClass().add("md-list-cell");
content.setPadding(new Insets(8));
statusIcon.setMinSize(32, 32);
statusIcon.setPrefSize(32, 32);
statusIcon.setMaxSize(32, 32);
statusIcon.setMouseTransparent(true);
content.setMinWidth(0);
TwoLineListItem.setMargin(statusIcon, new Insets(0, 8, 0, 0));
content.setLeading(statusIcon);
FXUtils.installFastTooltip(actionButton, actionTooltip);
actionButton.setOnAction(event -> activateTask());
TwoLineListItem.setMargin(actionButton, new Insets(0, 0, 0, 8));
content.setTrailing(actionButton);
graphic = new RipplerContainer(content);
graphic.setCursor(Cursor.HAND);
graphic.setOnAction(event -> activateTask());
}
/// Invokes a pending action or restores the current task's progress dialog.
private void activateTask() {
@Nullable TaskExecutorDialogPane pane = getItem();
if (pane != null) {
if (pane.pendingActionProperty().get() != null)
pane.runPendingAction();
else
pane.restore();
}
}
/// Rebinds a reused cell and updates its progress or pending-action presentation.
@Override
protected void updateItem(@Nullable TaskExecutorDialogPane pane, boolean empty) {
super.updateItem(pane, empty);
graphic.resetFeedback();
content.titleProperty().unbind();
content.setSubtitle(null);
if (empty || pane == null) {
content.setTitle("");
statusIcon.getChildren().clear();
setGraphic(null);
} else {
content.titleProperty().bind(pane.titleProperty());
@Nullable TaskExecutorDialogPane.PendingAction action = pane.pendingActionProperty().get();
actionButton.getStyleClass().removeAll("toggle-icon4", "jfx-button-border");
if (action != null) {
actionButton.getStyleClass().add("jfx-button-border");
statusIcon.getChildren().setAll(SVG.PENDING.createIcon(24));
content.setSubtitle(action.description());
actionButton.setGraphic(null);
actionButton.setText(action.text());
actionButton.setAccessibleText(action.text());
actionTooltip.setText(action.text());
} else {
actionButton.getStyleClass().add("toggle-icon4");
if (pane.queuedProperty().get()) {
statusIcon.getChildren().setAll(SVG.PENDING.createIcon(24));
content.setSubtitle(i18n("task.queued"));
} else if (statusIcon.getChildren().isEmpty() || !(statusIcon.getChildren().get(0) instanceof JFXSpinner)) {
JFXSpinner spinner = new JFXSpinner();
spinner.setRadius(12);
statusIcon.getChildren().setAll(spinner);
}
actionButton.setGraphic(SVG.ARROW_FORWARD.createIcon());
actionButton.setText(null);
actionButton.setAccessibleText(i18n("task.show"));
actionTooltip.setText(i18n("task.show"));
}
setGraphic(graphic);
}
}
}
}
@@ -6,14 +6,13 @@ import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.AccountFactory;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.setting.SettingsManager;
@@ -24,41 +23,19 @@ import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.RegionUtils;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.UnmodifiableView;
import java.util.Locale;
import static org.jackhuang.hmcl.setting.SettingsManager.userSettings;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.createSelectedItemPropertyFor;
/// Displays account creation methods and the configured account list.
public final class AccountListPage extends DecoratorAnimatedPage implements DecoratorPage {
/// Whether offline and third-party account creation is restricted.
static final BooleanProperty RESTRICTED = new SimpleBooleanProperty(true);
static {
String property = System.getProperty("hmcl.offline.auth.restricted", "auto");
if ("false".equals(property)
|| "auto".equals(property) && "CN".equals(RegionUtils.MACHINE_REGION)
|| SettingsManager.userSettings().enableOfflineAccountProperty().get())
RESTRICTED.set(false);
else
userSettings().enableOfflineAccountProperty().addListener(new ChangeListener<Boolean>() {
@Override
public void changed(ObservableValue<? extends Boolean> o, Boolean oldValue, Boolean newValue) {
if (newValue) {
userSettings().enableOfflineAccountProperty().removeListener(this);
RESTRICTED.set(false);
}
}
});
}
/// Offline / authlib-injector creation is always allowed for this fork.
static final BooleanProperty RESTRICTED = new SimpleBooleanProperty(false);
/// The account entries derived from the configured accounts.
private final @UnmodifiableView ObservableList<AccountListItem> items;
@@ -78,6 +55,12 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
items = MappedObservableList.create(accounts, AccountListItem::new);
selectedAccount = createSelectedItemPropertyFor(items, Account.class);
AdvancedListItem offlineItem = new AdvancedListItem();
offlineItem.getStyleClass().add("navigation-drawer-item");
offlineItem.setTitle(i18n("account.methods.offline"));
offlineItem.setLeftIcon(SVG.PERSON);
offlineItem.setOnAction(e -> openCreateAccount(Accounts.FACTORY_OFFLINE));
AdvancedListItem microsoftItem = new AdvancedListItem();
microsoftItem.getStyleClass().add("navigation-drawer-item");
microsoftItem.setTitle(i18n("account.methods.microsoft"));
@@ -91,14 +74,14 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
});
InvalidationListener buildSidebarListener = observable -> {
boolean restricted = RESTRICTED.get();
getSidebar().clear();
getSidebar()
.startCategory(i18n("account.create").toUpperCase(Locale.ROOT))
.startCategory(i18n("account.create"))
.add(offlineItem)
.add(microsoftItem);
// External Yggdrasil / authlib-injector servers stay listed for future use.
for (AuthlibInjectorServer server : authServers) {
AdvancedListItem item = new AdvancedListItem();
item.getStyleClass().add("navigation-drawer-item");
@@ -111,24 +94,13 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
}
});
ObservableValue<String> title = BindingMapping.of(server, AuthlibInjectorServer::getName);
var title = BindingMapping.of(server, AuthlibInjectorServer::getName);
item.titleProperty().bind(title);
item.setSubtitle(server.getDisplayHostUrl());
Tooltip tooltip = new Tooltip();
if (restricted) {
tooltip.setText(i18n("account.login.restricted"));
item.setActionDisabled(true);
} else {
tooltip.textProperty().bind(Bindings.format("%s (%s)", title, server.getUrl()));
item.setOnAction(e -> {
if (SettingsManager.isUserGameAccountsReadOnly()) {
confirmOverwriteUserAccounts(() -> Controllers.dialog(new CreateAccountPane(server)));
} else {
Controllers.dialog(new CreateAccountPane(server));
}
});
}
tooltip.textProperty().bind(Bindings.format("%s (%s)", title, server.getUrl()));
item.setOnAction(e -> openCreateAccount(Accounts.getAccountFactoryByAuthlibInjectorServer(server)));
FXUtils.installFastTooltip(item, tooltip);
getSidebar().add(item);
@@ -139,7 +111,6 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
WeakInvalidationListener weakBuildSidebarListener = holder.weak(buildSidebarListener);
authServers.addListener(weakBuildSidebarListener);
RESTRICTED.addListener(weakBuildSidebarListener);
AdvancedListItem addAuthServerItem = new AdvancedListItem();
addAuthServerItem.getStyleClass().add("navigation-drawer-item");
@@ -172,6 +143,14 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
setContent(scrollPane);
}
private void openCreateAccount(AccountFactory<?> factory) {
if (SettingsManager.isUserGameAccountsReadOnly()) {
confirmOverwriteUserAccounts(() -> Controllers.dialog(new CreateAccountPane(factory)));
} else {
Controllers.dialog(new CreateAccountPane(factory));
}
}
/// Returns the account selection shared with the launcher.
public ObjectProperty<@Nullable Account> selectedAccountProperty() {
return selectedAccount;
@@ -11,11 +11,17 @@ import javafx.beans.binding.BooleanBinding;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
import org.jackhuang.hmcl.ui.construct.AdvancedListBox;
import java.util.List;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class AccountListPopupMenu extends StackPane {
@@ -31,12 +37,26 @@ public final class AccountListPopupMenu extends StackPane {
@SuppressWarnings("FieldCanBeLocal")
private final InvalidationListener listener;
/// Creates a footer entry shown below the account list.
private static AdvancedListItem footerItem(SVG icon, String title) {
AdvancedListItem item = new AdvancedListItem();
item.setLeftIcon(icon);
item.setTitle(title);
return item;
}
/// Closes the popup that currently hosts this menu, if any.
private void hidePopup() {
if (getScene() != null && getScene().getWindow() instanceof JFXPopup popup)
popup.hide();
}
public AccountListPopupMenu() {
AdvancedListBox box = new AdvancedListBox();
box.getStyleClass().add("no-padding");
box.setPrefWidth(220);
box.setPrefHeight(-1);
box.setMaxHeight(260);
box.setMaxHeight(320);
listener = o -> {
box.clear();
@@ -61,9 +81,31 @@ public final class AccountListPopupMenu extends StackPane {
Label placeholder = new Label(i18n("account.empty"));
placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: -monet-on-surface-variant; -fx-font-style: italic;");
FXUtils.onChangeAndOperate(isEmpty, empty -> {
getChildren().setAll(empty ? placeholder : box);
// Footer actions stay visible even when there is no account yet: add one, or open the full account page.
AdvancedListItem addItem = footerItem(SVG.ADD_CIRCLE, i18n("account.add"));
addItem.setOnAction(e -> {
hidePopup();
// The first account uses the short name dialog; later ones can pick any login method.
if (Accounts.getAccounts().isEmpty()) {
Controllers.dialog(new FirstRunAccountDialog());
} else {
Controllers.dialog(new CreateAccountPane());
}
});
AdvancedListItem manageItem = footerItem(SVG.SETTINGS, i18n("account.open_manage"));
manageItem.setOnAction(e -> {
hidePopup();
Controllers.navigate(Controllers.getAccountListPage());
});
VBox footer = new VBox(addItem, manageItem);
footer.setPrefWidth(220);
VBox content = new VBox();
FXUtils.onChangeAndOperate(isEmpty, empty -> {
footer.getChildren().setAll(empty ? List.of(addItem) : List.of(addItem, manageItem));
content.getChildren().setAll(empty ? List.of(placeholder, footer) : List.of(box, footer));
});
getChildren().setAll(content);
}
}
@@ -39,7 +39,6 @@ import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.upgrade.IntegrityChecker;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jetbrains.annotations.Nullable;
@@ -81,20 +80,15 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
public CreateAccountPane(AccountFactory<?> factory) {
if (factory == null) {
if (AccountListPage.RESTRICTED.get()) {
showMethodSwitcher = false;
factory = Accounts.FACTORY_MICROSOFT;
} else {
showMethodSwitcher = true;
String preferred = settings().preferredLoginTypeProperty().get();
try {
factory = Accounts.getAccountFactory(preferred);
if (factory == Accounts.FACTORY_OFFLINE) {
factory = Accounts.FACTORY_MICROSOFT;
}
} catch (IllegalArgumentException e) {
factory = Accounts.FACTORY_MICROSOFT;
showMethodSwitcher = true;
String preferred = settings().preferredLoginTypeProperty().get();
try {
factory = Accounts.getAccountFactory(preferred);
if (!Accounts.FACTORIES.contains(factory)) {
factory = Accounts.FACTORY_OFFLINE;
}
} catch (IllegalArgumentException e) {
factory = Accounts.FACTORY_OFFLINE;
}
} else {
showMethodSwitcher = false;
@@ -345,15 +339,6 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
int rowIndex = 0;
if (!IntegrityChecker.isOfficial() && !(factory instanceof OfflineAccountFactory)) {
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING);
hintPane.setSegment(i18n("unofficial.hint"));
GridPane.setColumnSpan(hintPane, 2);
add(hintPane, 0, rowIndex);
rowIndex++;
}
if (factory instanceof BoundAuthlibInjectorAccountFactory) {
this.server = ((BoundAuthlibInjectorAccountFactory) factory).getServer();
@@ -0,0 +1,92 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.account;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXTextField;
import javafx.scene.control.Label;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.auth.offline.OfflineAccountFactory;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.construct.DialogAware;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jetbrains.annotations.NotNullByDefault;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// A small dialog shown when the player has no account yet.
///
/// The player either types a game name, which creates an offline account, or chooses to sign in
/// with a Microsoft account instead. Authentication itself is left to the server.
@NotNullByDefault
public final class FirstRunAccountDialog extends JFXDialogLayout implements DialogAware {
/// Names accepted by the game: letters, digits and underscores, at most 16 characters.
private static final Pattern NAME_PATTERN = Pattern.compile("^[A-Za-z0-9_]{1,16}$");
private final JFXTextField nameField = new JFXTextField();
private final Label hint = new Label();
private final JFXButton acceptButton = new JFXButton(i18n("first_run.accept"));
/// Creates the dialog with its name field and the two actions.
public FirstRunAccountDialog() {
Label heading = new Label(i18n("first_run.title"));
setHeading(heading);
Label description = new Label(i18n("first_run.description"));
description.setWrapText(true);
nameField.setPromptText(i18n("first_run.name"));
hint.setStyle("-fx-text-fill: -monet-error;");
hint.setWrapText(true);
VBox body = new VBox(13, description, nameField, hint);
body.setPrefWidth(377);
setBody(body);
JFXButton microsoftButton = new JFXButton(i18n("first_run.microsoft"));
microsoftButton.getStyleClass().add("dialog-cancel");
microsoftButton.setOnAction(e -> {
fireEvent(new DialogCloseEvent());
Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_MICROSOFT));
});
acceptButton.getStyleClass().add("dialog-accept");
acceptButton.setOnAction(e -> createAccount());
nameField.setOnAction(e -> acceptButton.fire());
setActions(microsoftButton, acceptButton);
}
/// Validates the typed name and creates and selects the offline account.
private void createAccount() {
String name = nameField.getText() == null ? "" : nameField.getText().trim();
if (!NAME_PATTERN.matcher(name).matches()) {
hint.setText(i18n("first_run.invalid"));
return;
}
hint.setText("");
acceptButton.setDisable(true);
Task.supplyAsync(() -> Accounts.FACTORY_OFFLINE.create(
new CreateAccountPane.DialogCharacterSelector(), name, null, null,
new OfflineAccountFactory.AdditionalData(null, null)))
.whenComplete(Schedulers.javafx(), account -> {
if (!Accounts.getAccounts().contains(account)) {
Accounts.getAccounts().add(account);
}
Accounts.setSelectedAccount(account);
fireEvent(new DialogCloseEvent());
}, exception -> {
acceptButton.setDisable(false);
hint.setText(Accounts.localizeErrorMessage(exception));
}).start();
}
@Override
public void onDialogShown() {
nameField.requestFocus();
}
}
@@ -121,7 +121,7 @@ public enum ContainerAnimations implements TransitionPane.AnimationProducer {
/// Animates forward navigation with temporary incoming-page caching.
@Override
public Timeline animate(Pane container, Node previousNode, Node nextNode) {
double offset = container.getWidth() > 0 ? container.getWidth() * 0.2 : 50;
double offset = 12.0;
return new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(nextNode.cacheProperty(), isCacheAllowed(nextNode), Interpolator.DISCRETE),
@@ -152,7 +152,7 @@ public enum ContainerAnimations implements TransitionPane.AnimationProducer {
/// Animates backward navigation with temporary incoming-page caching.
@Override
public Timeline animate(Pane container, Node previousNode, Node nextNode) {
double offset = container.getWidth() > 0 ? container.getWidth() * 0.2 : 50;
double offset = 12.0;
return new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(nextNode.cacheProperty(), isCacheAllowed(nextNode), Interpolator.DISCRETE),
@@ -23,10 +23,10 @@ import org.jetbrains.annotations.Nullable;
public class TransitionPane extends StackPane {
/// The default easing curve for page transitions.
public static final Interpolator DEFAULT_INTERPOLATOR = Motion.EASE;
public static final Interpolator DEFAULT_INTERPOLATOR = Motion.EASE_OUT_CUBIC;
/// The default duration for page transitions.
public static final Duration DEFAULT_DURATION = Motion.SHORT4;
public static final Duration DEFAULT_DURATION = Duration.millis(320);
/// Half the default duration for transitions with two phases.
public static final Duration DEFAULT_DURATION_HALF = DEFAULT_DURATION.divide(2);
@@ -135,6 +135,7 @@ public class ComponentList extends Control implements NoPaddingComponent {
{
Label advanced = new Label(title);
advanced.getStyleClass().add("component-list-title");
node.getChildren().setAll(advanced);
}
@@ -1,42 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.beans.NamedArg;
import javafx.scene.control.TextInputControl;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
public class DoubleValidator extends ValidatorBase {
private final boolean nullable;
public DoubleValidator() {
this(false);
}
public DoubleValidator(@NamedArg("nullable") boolean nullable) {
this.nullable = nullable;
}
public DoubleValidator(@NamedArg("message") String message, @NamedArg("nullable") boolean nullable) {
super(message);
this.nullable = nullable;
}
@Override
protected void eval() {
if (srcControl.get() instanceof TextInputControl) {
evalTextInputField();
}
}
private void evalTextInputField() {
TextInputControl textField = ((TextInputControl) srcControl.get());
if (StringUtils.isBlank(textField.getText()))
hasErrors.set(!nullable);
else
hasErrors.set(Lang.toDoubleOrNull(textField.getText()) == null);
}
}
@@ -1,50 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.construct;
import static javafx.collections.FXCollections.emptyObservableList;
import static javafx.collections.FXCollections.observableList;
import static javafx.collections.FXCollections.singletonObservableList;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import com.jfoenix.controls.JFXComboBox;
import com.jfoenix.controls.JFXListCell;
import javafx.beans.binding.Bindings;
import javafx.scene.text.Font;
public final class FontComboBox extends JFXComboBox<String> {
private boolean loaded = false;
public FontComboBox() {
setMinWidth(260);
styleProperty().bind(Bindings.concat("-fx-font-family: \"", valueProperty(), "\""));
setCellFactory(listView -> new JFXListCell<>() {
@Override
public void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (!empty) {
setText(item);
setGraphic(null);
setStyle("-fx-font-family: \"" + item + "\"");
}
}
});
itemsProperty().bind(BindingMapping.of(valueProperty())
.map(value -> value == null ? emptyObservableList() : singletonObservableList(value)));
FXUtils.onClicked(this, () -> {
if (loaded)
return;
itemsProperty().unbind();
setItems(observableList(Font.getFamilies()));
loaded = true;
});
}
}
@@ -1,53 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXCheckBox;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.util.Callback;
/// @author Glavo
public final class JFXCheckBoxTableCell<S, T> extends TableCell<S, T> {
public static <S> Callback<TableColumn<S, Boolean>, TableCell<S, Boolean>> forTableColumn(
final TableColumn<S, Boolean> column) {
return list -> new JFXCheckBoxTableCell<>();
}
private final JFXCheckBox checkBox = new JFXCheckBox();
private BooleanProperty booleanProperty;
public JFXCheckBoxTableCell() {
this.getStyleClass().add("jfx-checkbox-table-cell");
}
@Override
protected void updateItem(T item, boolean empty) {
super.updateItem(item, empty);
if (empty) {
setText(null);
setGraphic(null);
checkBox.disableProperty().unbind();
} else {
setGraphic(checkBox);
if (booleanProperty != null) {
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
}
if (getTableColumn().getCellObservableValue(getIndex()) instanceof BooleanProperty obsValue) {
booleanProperty = obsValue;
checkBox.selectedProperty().bindBidirectional(booleanProperty);
}
checkBox.disableProperty().bind(Bindings.not(
getTableView().editableProperty().and(
getTableColumn().editableProperty()).and(
editableProperty())
));
}
}
}
@@ -1,227 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.construct;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.stage.DirectoryChooser;
import javafx.stage.FileChooser;
import javafx.stage.Window;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class LineFileChooserButton extends LineButton {
private static final String DEFAULT_STYLE_CLASS = "line-file-select-button";
public LineFileChooserButton() {
getStyleClass().add(DEFAULT_STYLE_CLASS);
setTrailingIcon(SVG.EDIT);
}
/// Converts the given path to absolute/relative(if possible) path according to [#convertToRelativePathProperty()].
private String processPath(Path path) {
if (isConvertToRelativePath() && path.isAbsolute()) {
try {
return Metadata.CURRENT_DIRECTORY.relativize(path).normalize().toString();
} catch (IllegalArgumentException e) {
// the given path can't be relativized against current path
}
}
return path.normalize().toString();
}
@Override
public void fire() {
super.fire();
Scene scene = this.getScene();
Window owner = scene != null ? scene.getWindow() : null; // TODO: Allow user to set owner stage
String windowTitle = getFileChooserTitle();
Path initialDirectory = null;
if (getLocation() != null) {
Path file;
try {
file = FileUtils.toAbsolute(Path.of(getLocation()));
if (Files.exists(file)) {
if (Files.isRegularFile(file))
initialDirectory = file.getParent();
else if (Files.isDirectory(file))
initialDirectory = file;
}
} catch (IllegalArgumentException e) {
LOG.warning("Failed to resolve path: " + getLocation());
}
}
Path path;
Type type = getType();
if (type == Type.OPEN_DIRECTORY) {
var directoryChooser = new DirectoryChooser();
if (windowTitle != null)
directoryChooser.setTitle(windowTitle);
if (initialDirectory != null)
directoryChooser.setInitialDirectory(initialDirectory.toFile());
path = FileUtils.toPath(directoryChooser.showDialog(owner));
} else {
var fileChooser = new FileChooser();
if (windowTitle != null)
fileChooser.setTitle(windowTitle);
if (initialDirectory != null)
fileChooser.setInitialDirectory(initialDirectory.toFile());
if (extensionFilters != null)
fileChooser.getExtensionFilters().setAll(extensionFilters);
fileChooser.setInitialFileName(getInitialFileName());
path = FileUtils.toPath(switch (type) {
case OPEN_FILE -> fileChooser.showOpenDialog(owner);
case SAVE_FILE -> fileChooser.showSaveDialog(owner);
default -> throw new AssertionError("Unknown Type: " + type);
});
}
if (path != null) {
setLocation(processPath(path));
}
}
private final StringProperty location = new StringPropertyBase() {
@Override
public Object getBean() {
return LineFileChooserButton.this;
}
@Override
public String getName() {
return "location";
}
@Override
protected void invalidated() {
setTrailingText(get());
}
};
public StringProperty locationProperty() {
return location;
}
public String getLocation() {
return locationProperty().get();
}
public void setLocation(String location) {
locationProperty().set(location);
}
private final StringProperty fileChooserTitle = new SimpleStringProperty(this, "fileChooserTitle");
public StringProperty fileChooserTitleProperty() {
return fileChooserTitle;
}
public String getFileChooserTitle() {
return fileChooserTitleProperty().get();
}
public void setFileChooserTitle(String fileChooserTitle) {
fileChooserTitleProperty().set(fileChooserTitle);
}
private ObjectProperty<Type> type;
public ObjectProperty<Type> typeProperty() {
if (type == null) {
type = new SimpleObjectProperty<>(this, "type", Type.OPEN_FILE);
}
return type;
}
public Type getType() {
return type != null ? type.get() : Type.OPEN_FILE;
}
public void setType(Type type) {
typeProperty().set(type);
}
private ObjectProperty<String> initialFileName;
public final ObjectProperty<String> initialFileNameProperty() {
if (initialFileName == null)
initialFileName = new SimpleObjectProperty<>(this, "initialFileName");
return initialFileName;
}
public final String getInitialFileName() {
return initialFileName != null ? initialFileName.get() : null;
}
public final void setInitialFileName(String value) {
initialFileNameProperty().set(value);
}
private ObservableList<FileChooser.ExtensionFilter> extensionFilters;
public ObservableList<FileChooser.ExtensionFilter> getExtensionFilters() {
if (extensionFilters == null)
extensionFilters = FXCollections.observableArrayList();
return extensionFilters;
}
private BooleanProperty convertToRelativePath;
public BooleanProperty convertToRelativePathProperty() {
if (convertToRelativePath == null)
convertToRelativePath = new BooleanPropertyBase(false) {
@Override
public Object getBean() {
return LineFileChooserButton.this;
}
@Override
public String getName() {
return "convertToRelativePath";
}
@Override
protected void invalidated() {
String location = getLocation();
if (location == null)
return;
try {
setLocation(processPath(FileUtils.toAbsolute(Path.of(getLocation()))));
} catch (IllegalArgumentException ignored) {
}
}
};
return convertToRelativePath;
}
public boolean isConvertToRelativePath() {
return convertToRelativePath != null && convertToRelativePath.get();
}
public void setConvertToRelativePath(boolean convertToRelativePath) {
convertToRelativePathProperty().set(convertToRelativePath);
}
public enum Type {
OPEN_FILE,
OPEN_DIRECTORY,
SAVE_FILE
}
}
@@ -48,6 +48,9 @@ public final class LineInheritableSwitch extends LineButtonBase {
this.inheritButton = new JFXButton();
inheritButton.getStyleClass().add(INHERIT_BUTTON_STYLE_CLASS);
inheritButton.setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
// Hidden: the launcher has a single settings page, so there is no inherited/overridden choice to make.
inheritButton.setVisible(false);
inheritButton.setManaged(false);
inheritButton.setGraphic(SVG.PUBLIC.createIcon(INHERIT_BUTTON_ICON_SIZE));
this.inheritTooltip = new Tooltip();
FXUtils.installFastTooltip(inheritButton, inheritTooltip);
@@ -1,77 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.construct;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.control.MultipleSelectionModel;
public class NoneMultipleSelectionModel<T> extends MultipleSelectionModel<T> {
public NoneMultipleSelectionModel() {
}
@Override
public ObservableList<Integer> getSelectedIndices() {
return FXCollections.emptyObservableList();
}
@Override
public ObservableList<T> getSelectedItems() {
return FXCollections.emptyObservableList();
}
@Override
public void selectIndices(int index, int... indices) {
}
@Override
public void selectAll() {
}
@Override
public void clearAndSelect(int index) {
}
@Override
public void select(int index) {
}
@Override
public void select(T obj) {
}
@Override
public void clearSelection(int index) {
}
@Override
public void clearSelection() {
}
@Override
public boolean isSelected(int index) {
return false;
}
@Override
public boolean isEmpty() {
return true;
}
@Override
public void selectPrevious() {
}
@Override
public void selectNext() {
}
@Override
public void selectFirst() {
}
@Override
public void selectLast() {
}
}
@@ -81,10 +81,10 @@ import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
@NotNullByDefault
public final class Decorator {
/// The minimum width, in pixels, of the visible window content.
private static final double MIN_CONTENT_WIDTH = 800.0;
private static final double MIN_CONTENT_WIDTH = 987.0;
/// The minimum height, in pixels, including the 450-pixel background and 40-pixel title bar.
private static final double MIN_CONTENT_HEIGHT = 450.0 + 40.0;
private static final double MIN_CONTENT_HEIGHT = 570.0 + 40.0;
/// The minimum visible overlap, in pixels, used to accept a persisted window position.
private static final double MIN_VISIBLE_WINDOW_AREA = 20.0;
@@ -3,7 +3,6 @@
package org.jackhuang.hmcl.ui.decorator;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXSpinner;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
@@ -22,24 +21,20 @@ import javafx.scene.layout.Pane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.SVGPath;
import javafx.util.Duration;
import org.glavo.monetfx.ColorRole;
import org.glavo.monetfx.ColorScheme;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.theme.WarmPalette;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jackhuang.hmcl.util.TaskManager;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import static org.jackhuang.hmcl.ui.animation.TransitionPane.DEFAULT_DURATION;
import static org.jackhuang.hmcl.ui.animation.TransitionPane.DEFAULT_INTERPOLATOR;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// Renders the clipped background, title bar, and navigation content owned by a [Decorator].
@NotNullByDefault
@@ -85,7 +80,7 @@ final class MainWindowPane extends StackPane {
() -> Themes.windowTransparentProperty().get()
? null
: new Background(new BackgroundFill(
Themes.getColorScheme().getColor(ColorRole.SURFACE_CONTAINER),
WarmPalette.WINDOW,
CornerRadii.EMPTY,
Insets.EMPTY)),
Themes.windowTransparentProperty(),
@@ -105,7 +100,11 @@ final class MainWindowPane extends StackPane {
titleBar = new BorderPane();
titleBar.setPickOnBounds(false);
titleBar.getStyleClass().add("jfx-tool-bar");
titleBar.setRight(windowButtons);
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
titleBar.setLeft(windowButtons);
} else {
titleBar.setRight(windowButtons);
}
navBarPane = new TransitionPane();
titleBar.setCenter(navBarPane);
@@ -115,7 +114,6 @@ final class MainWindowPane extends StackPane {
updateTitleBarBackground();
InvalidationListener titleBarBackgroundListener = observable -> updateTitleBarBackground();
Themes.colorSchemeProperty().addListener(holder.weak(titleBarBackgroundListener));
Themes.titleBarTransparentProperty().addListener(holder.weak(titleBarBackgroundListener));
InvalidationListener navBarListener = observable -> updateNavBar();
decorator.stateProperty().addListener(holder.weak(navBarListener));
@@ -157,91 +155,74 @@ final class MainWindowPane extends StackPane {
Region backgroundNode = new Region();
backgroundNode.setMouseTransparent(true);
StackPane.setAlignment(backgroundNode, Pos.BOTTOM_CENTER);
holder.onWeakChangeAndOperate(Themes.backgroundProperty(), background -> {
double opacity = background.opacity();
if (opacity >= 0.001) {
backgroundNode.setBackground(background.background());
backgroundNode.setOpacity(opacity);
} else {
backgroundNode.setBackground(null);
backgroundNode.setOpacity(0);
}
});
// The launcher paints a flat cream window; wallpapers are no longer used.
return backgroundNode;
}
/// Creates the task, minimize, and application-close buttons.
/// Creates the minimize and application-close buttons.
///
/// @return a size-limited top-right button row
private HBox createWindowButtons() {
HBox buttons = new HBox();
buttons.setAlignment(Pos.TOP_RIGHT);
decorator.capableDraggingWindow(buttons);
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
// macOS style: round close/minimize buttons on the left. Zoom is shown but disabled.
buttons.setAlignment(Pos.CENTER_LEFT);
buttons.setSpacing(8);
buttons.setPadding(new Insets(0, 0, 0, 20));
buttons.setMaxSize(Region.USE_PREF_SIZE, 40);
JFXButton closeButton = macButton("mac-close", decorator::closeWindow);
JFXButton minimizeButton = macButton("mac-minimize", decorator::minimizeWindow);
JFXButton zoomButton = macButton("mac-zoom", () -> {
});
zoomButton.setDisable(true);
buttons.getChildren().setAll(closeButton, minimizeButton, zoomButton);
return buttons;
}
// Windows and Linux style: flat rectangular buttons on the right.
buttons.setAlignment(Pos.TOP_RIGHT);
buttons.setMaxSize(Region.USE_PREF_SIZE, 40);
JFXButton tasksButton = new JFXButton();
tasksButton.setFocusTraversable(false);
tasksButton.setAccessibleText(i18n("task.list"));
tasksButton.getStyleClass().add("jfx-decorator-button");
tasksButton.setOnAction(event -> {
if (!(decorator.getNavigator().getCurrentPage() instanceof TaskManagerPage))
Controllers.navigate(new TaskManagerPage());
});
decorator.forbidDraggingWindow(tasksButton);
SVGPath tasksEmptyIcon = SVG.LIST_ALT.createIcon(Themes.titleFillProperty());
holder.onWeakChangeAndOperate(TaskManager.getInstance().statusProperty(), status -> {
switch (status) {
case IDLE -> tasksButton.setGraphic(tasksEmptyIcon);
case PENDING -> tasksButton.setGraphic(SVG.PENDING.createIcon(Themes.titleFillProperty()));
case RUNNING -> {
JFXSpinner tasksSpinner = new JFXSpinner();
tasksSpinner.getStyleClass().add("small-spinner");
tasksSpinner.setRadius(9);
tasksSpinner.setMouseTransparent(true);
FXUtils.setLimitWidth(tasksSpinner, 20);
FXUtils.setLimitHeight(tasksSpinner, 20);
tasksButton.setGraphic(tasksSpinner);
}
}
});
JFXButton minimizeButton = new JFXButton();
minimizeButton.setFocusTraversable(false);
minimizeButton.setGraphic(SVG.MINIMIZE_CENTER.createIcon(Themes.titleFillProperty()));
minimizeButton.getStyleClass().add("jfx-decorator-button");
minimizeButton.getStyleClass().add("window-button");
minimizeButton.setOnAction(event -> decorator.minimizeWindow());
decorator.forbidDraggingWindow(minimizeButton);
JFXButton closeButton = new JFXButton();
closeButton.setFocusTraversable(false);
closeButton.setGraphic(SVG.CLOSE.createIcon(Themes.titleFillProperty()));
closeButton.getStyleClass().add("jfx-decorator-button");
closeButton.getStyleClass().addAll("window-button", "window-close-button");
closeButton.setOnAction(event -> decorator.closeWindow());
decorator.forbidDraggingWindow(closeButton);
buttons.getChildren().setAll(tasksButton, minimizeButton, closeButton);
buttons.getChildren().setAll(minimizeButton, closeButton);
return buttons;
}
/// Creates one round macOS-style window button.
///
/// @param styleClass the class that selects the button color
/// @param action the action to run when the button is pressed
private JFXButton macButton(String styleClass, Runnable action) {
JFXButton button = new JFXButton();
button.setFocusTraversable(false);
button.getStyleClass().addAll("mac-window-button", styleClass);
button.setOnAction(event -> action.run());
decorator.forbidDraggingWindow(button);
return button;
}
/// Recomputes the title-bar background for the current theme and transparency preference.
private void updateTitleBarBackground() {
@Nullable ColorScheme colorScheme = Themes.getColorScheme();
if (colorScheme == null) {
titleBar.setBackground(null);
} else if (Themes.titleBarTransparentProperty().get()) {
Color surface = colorScheme.getSurface();
titleBar.setBackground(new Background(new BackgroundFill(
new Color(surface.getRed(), surface.getGreen(), surface.getBlue(), 0.5),
CornerRadii.EMPTY,
Insets.EMPTY)));
} else {
titleBar.setBackground(new Background(new BackgroundFill(
colorScheme.getPrimaryContainer(),
CornerRadii.EMPTY,
Insets.EMPTY)));
}
titleBar.setBackground(new Background(new BackgroundFill(
WarmPalette.WINDOW,
CornerRadii.EMPTY,
Insets.EMPTY)));
}
/// Rebuilds the navigation bar from the current page state.
@@ -1,76 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.download;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.util.FileNameSet;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.util.concurrent.CancellationException;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Helpers for downloading a remote addon file into an instance subdirectory.
///
/// The former free-install / CurseForge-Modrinth browse hub was removed for the
/// server-client launcher; only install-into-folder callbacks remain for update flows.
public final class DownloadPage {
public static final org.jackhuang.hmcl.ui.instances.DownloadPage.DownloadCallback FOR_RESOURCE_PACK =
(downloadProvider, repository, version, pack, file) -> download(downloadProvider, repository, version, file, "resourcepacks");
public static final org.jackhuang.hmcl.ui.instances.DownloadPage.DownloadCallback FOR_SHADER =
(downloadProvider, repository, version, shader, file) -> download(downloadProvider, repository, version, file, "shaderpacks");
private DownloadPage() {
}
public static void download(DownloadProvider downloadProvider, HMCLGameRepository repository, @Nullable GameInstanceID instanceId, RemoteAddon.Version file, String subdirectoryName) {
@Nullable HMCLGameInstance instance = instanceId != null
? repository.findInstance(instanceId)
: repository.getSelectedInstance();
Path runDirectory = instance != null ? instance.getRunDirectory() : repository.getBaseDirectory();
var targetPath = runDirectory.resolve(subdirectoryName);
FileNameSet existingPaths;
try {
existingPaths = FileNameSet.list(targetPath, null);
} catch (Exception e) {
LOG.warning("Failed to list folders in " + targetPath, e);
existingPaths = new FileNameSet(false);
}
Controllers.prompt(i18n("archive.file.name"), (result, handler) -> {
Path dest = targetPath.resolve(result);
Controllers.taskDialog(Task.composeAsync(() -> {
var task = new FileDownloadTask(downloadProvider.getDownloadCandidates(file.file().url()), dest);
task.setName(file.name());
return task;
}).whenComplete(Schedulers.javafx(), exception -> {
if (exception != null) {
if (!(exception instanceof CancellationException)) {
Controllers.dialog(I18n.localizeErrorMessage(exception), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
}
} else {
Controllers.showToast(i18n("install.success"));
}
}), i18n("message.downloading"), TaskCancellationAction.NORMAL);
handler.resolve();
}, file.file().filename(), new Validator(i18n("install.new_game.malformed"), FileUtils::isNameValid), new Validator(i18n("game_directory.already_exists"), existingPaths::notContains));
}
}
@@ -1,139 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.download;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.InstallerItem;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.RequiredValidator;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import static javafx.beans.binding.Bindings.createBooleanBinding;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class InstallersPage extends AbstractInstallersPage {
private boolean isNameModifiedByUser = false;
public InstallersPage(WizardController controller, HMCLGameRepository repository, GameVersionNumber gameVersion, DownloadProvider downloadProvider) {
super(controller, gameVersion, downloadProvider);
txtName.getValidators().addAll(
new RequiredValidator(),
new Validator(i18n("install.new_game.already_exists"), str -> !repository.instanceIdConflicts(str)),
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidInstanceId));
installable.bind(createBooleanBinding(txtName::validate, txtName.textProperty()));
txtName.textProperty().addListener((obs, oldText, newText) -> isNameModifiedByUser = true);
}
@Override
public String getTitle() {
return ((ComponentRemoteVersion) controller.getSettings().get("game")).getGameVersion().toString();
}
private String getVersion(String id) {
return I18n.getDisplayVersion((ComponentRemoteVersion) controller.getSettings().get(id));
}
protected void reload() {
for (InstallerItem component : group.getComponents()) {
String libraryId = component.getComponentType().getPatchId();
if (controller.getSettings().containsKey(libraryId)) {
component.versionProperty().set(new InstallerItem.InstalledState(getVersion(libraryId), false, false));
} else {
component.versionProperty().set(null);
}
}
if (!isNameModifiedByUser) {
setTxtNameWithLoaders();
}
}
@Override
public void cleanup(SettingsMap settings) {
}
private static boolean checkName(String name) {
for (int i = 0; i < name.length(); i++) {
char c = name.charAt(i);
if (!(c >= '0' && c <= '9')
&& !(c >= 'a' && c <= 'z')
&& !(c >= 'A' && c <= 'Z')
&& c != '-' && c != '_' && c != '.'
)
return false;
}
return true;
}
protected void onInstall() {
GameInstanceID instanceId = new GameInstanceID(txtName.getText());
if (!checkName(instanceId.id())) {
Controllers.dialog(new MessageDialogPane.Builder(
i18n("install.name.invalid"),
i18n("message.warning"),
MessageDialogPane.MessageType.QUESTION)
.yesOrNo(() -> {
controller.getSettings().put(INSTANCE_ID, instanceId);
controller.onFinish();
}, () -> {
// The user selects Cancel and does nothing.
})
.build());
} else {
controller.getSettings().put(INSTANCE_ID, instanceId);
controller.onFinish();
}
}
private void setTxtNameWithLoaders() {
StringBuilder nameBuilder = new StringBuilder(getTitle());
for (InstallerItem component : group.getComponents()) {
if (component.getComponentType() == GameComponentType.GAME
|| !controller.getSettings().containsKey(component.getComponentType().getPatchId()))
continue;
String loaderName = switch (component.getComponentType()) {
case FORGE -> "Forge";
case NEO_FORGE -> "NeoForge";
case CLEANROOM -> "Cleanroom";
case LEGACY_FABRIC -> "LegacyFabric";
case FABRIC -> "Fabric";
case LITELOADER -> "LiteLoader";
case QUILT -> "Quilt";
case OPTIFINE -> "OptiFine";
default -> null;
};
if (loaderName != null)
nameBuilder.append('-').append(loaderName);
}
txtName.setText(nameBuilder.toString());
isNameModifiedByUser = false;
}
@Override
protected boolean showExtendPane() {
return true;
}
@Override
protected void resetDefaultName() {
isNameModifiedByUser = false;
setTxtNameWithLoaders();
}
}
@@ -28,25 +28,40 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
private final HMCLGameRepository repository;
private final Path file;
private final String updateVersion;
private final ServerModpackManifest remoteManifest;
private String iconUrl;
private boolean hasSource;
public ModpackInstallWizardProvider(HMCLGameRepository repository) {
this(repository, null, null);
this(repository, null, null, null);
}
public ModpackInstallWizardProvider(HMCLGameRepository repository, Path modpackFile) {
this(repository, modpackFile, null);
this(repository, modpackFile, null, null);
}
public ModpackInstallWizardProvider(HMCLGameRepository repository, GameInstanceID updateInstanceId) {
this(repository, null, updateInstanceId);
this(repository, null, updateInstanceId, null);
}
/// Starts the wizard with a remote Server Modpack manifest (TDLM fileApi install).
public ModpackInstallWizardProvider(HMCLGameRepository repository, ServerModpackManifest remoteManifest) {
this(repository, null, null, remoteManifest);
}
public ModpackInstallWizardProvider(HMCLGameRepository repository, Path modpackFile, GameInstanceID updateInstanceId) {
this(repository, modpackFile, updateInstanceId, null);
}
private ModpackInstallWizardProvider(
HMCLGameRepository repository,
Path modpackFile,
GameInstanceID updateInstanceId,
ServerModpackManifest remoteManifest) {
this.repository = repository;
this.file = modpackFile;
this.updateVersion = updateInstanceId != null ? updateInstanceId.toString() : null;
this.remoteManifest = remoteManifest;
}
public void setIconUrl(String iconUrl) {
@@ -57,6 +72,8 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
public void start(SettingsMap settings) {
if (file != null)
settings.put(LocalModpackPage.MODPACK_FILE, file);
if (remoteManifest != null)
settings.put(RemoteModpackPage.MODPACK_SERVER_MANIFEST, remoteManifest);
if (updateVersion != null)
settings.put(LocalModpackPage.MODPACK_NAME, updateVersion);
if (StringUtils.isNotBlank(iconUrl))
@@ -145,7 +145,6 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
}, "", new URLValidator());
}
@Override
public void cleanup(SettingsMap settings) {
}
@@ -132,7 +132,6 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
ALL,
RELEASE,
SNAPSHOTS,
APRIL_FOOLS,
OLD
}
@@ -234,13 +233,8 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
imageView.setImage(GameInstanceIconType.GRASS.getIcon());
}
case SNAPSHOT, PENDING, UNOBFUSCATED -> {
if (versionType == ComponentRemoteVersion.Type.SNAPSHOT && gameVersion.isAprilFools()) {
tags.add(new Tag(i18n("instance.game.april_fools")));
imageView.setImage(GameInstanceIconType.APRIL_FOOLS.getIcon());
} else {
tags.add(new Tag(i18n("instance.game.snapshot")));
imageView.setImage(GameInstanceIconType.COMMAND.getIcon());
}
tags.add(new Tag(i18n("instance.game.snapshot")));
imageView.setImage(GameInstanceIconType.COMMAND.getIcon());
}
default -> {
tags.add(new Tag(i18n("instance.game.old")));
@@ -334,7 +328,6 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
VersionTypeFilter.ALL,
VersionTypeFilter.RELEASE,
VersionTypeFilter.SNAPSHOTS,
VersionTypeFilter.APRIL_FOOLS,
VersionTypeFilter.OLD
);
categoryField.getSelectionModel().select(VersionTypeFilter.RELEASE);
@@ -461,8 +454,6 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
case SNAPSHOTS -> versionType == ComponentRemoteVersion.Type.SNAPSHOT
|| versionType == ComponentRemoteVersion.Type.PENDING
|| versionType == ComponentRemoteVersion.Type.UNOBFUSCATED;
case APRIL_FOOLS -> versionType == ComponentRemoteVersion.Type.SNAPSHOT
&& it.getGameVersion().isAprilFools();
case OLD -> versionType == ComponentRemoteVersion.Type.OLD;
// case ALL,
default -> true;
@@ -1348,6 +1348,9 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
var button = new JFXButton();
button.getStyleClass().add(INHERIT_BUTTON_STYLE_CLASS);
button.setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
// The launcher has a single settings page, so players never choose between inheriting and overriding.
button.setVisible(false);
button.setManaged(false);
Tooltip tooltip = new Tooltip();
button.getProperties().put(INHERIT_BUTTON_TOOLTIP_KEY, tooltip);
FXUtils.installFastTooltip(button, tooltip);
@@ -1,515 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXCheckBox;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXListView;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.addon.update.AddonUpdate;
import org.jackhuang.hmcl.addon.LocalAddonFile;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.mod.LocalModFile;
import org.jackhuang.hmcl.addon.resourcepack.ResourcePackFile;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameInstanceIconType;
import org.jackhuang.hmcl.addon.update.AddonUpdateTask;
import org.jackhuang.hmcl.addon.update.AddonUpdates;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.Lazy;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.CSVTable;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
import org.jetbrains.annotations.Nullable;
import java.lang.ref.WeakReference;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import org.jackhuang.hmcl.util.javafx.Subscription;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class AddonUpdatesPage extends BorderPane implements DecoratorPage {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(DecoratorPage.State.fromTitle(i18n("addon.check_update")));
private final DownloadProvider downloadProvider;
private final Path localAddonDirectory;
private final ObservableList<AddonUpdateObject> objects;
private final JFXListView<AddonUpdateObject> listView;
public AddonUpdatesPage(DownloadProvider downloadProvider, Path localAddonDirectory, AddonUpdates updates) {
this.downloadProvider = downloadProvider;
this.localAddonDirectory = localAddonDirectory;
getStyleClass().add("gray-background");
var common = new Lazy<>(() -> updates.commonUpdates().stream().map(AddonUpdateObject::new).toList());
var release = new Lazy<>(() -> updates.releaseUpdates().stream().map(AddonUpdateObject::new).toList());
objects = FXCollections.observableArrayList();
{
StackPane centerWrapper = new StackPane();
centerWrapper.setStyle("-fx-padding: 10;");
ComponentList centrePane = new ComponentList();
centrePane.getStyleClass().add("no-padding");
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("addon.check_update.only_preview"));
placeholderContainer.getChildren().add(placeholderLabel);
listView = new JFXListView<>();
listView.setItems(objects);
listView.getStyleClass().add("jfx-list-view-float");
listView.getStyleClass().add("no-horizontal-scrollbar");
listView.setPlaceholder(placeholderContainer);
setMargin(listView, new Insets(0, 10, 0, 10));
VBox.setVgrow(listView, Priority.ALWAYS);
listView.setCellFactory(AddonUpdateListCell::new);
ComponentList.setVgrow(listView, Priority.ALWAYS);
centrePane.getContent().setAll(listView);
centerWrapper.getChildren().setAll(centrePane);
setCenter(centerWrapper);
}
{
HBox actions = new HBox(8);
actions.setPadding(new Insets(8));
actions.setAlignment(Pos.CENTER_RIGHT);
JFXCheckBox allEnabledBox = new JFXCheckBox();
allEnabledBox.setText(i18n("button.select_all"));
FXUtils.bindAllEnabled(allEnabledBox.selectedProperty(), objects.stream().map(o -> o.enabled).toArray(BooleanProperty[]::new));
JFXCheckBox showPreviewCheck = new JFXCheckBox(i18n("addon.check_update.preview"));
{
List<Subscription> subscriptions = new ArrayList<>();
FXUtils.onChangeAndOperate(showPreviewCheck.selectedProperty(), preview -> {
if (preview) objects.setAll(common.get());
else objects.setAll(release.get());
subscriptions.forEach(Subscription::unsubscribe);
subscriptions.clear();
subscriptions.addAll(FXUtils.bindAllEnabled(allEnabledBox.selectedProperty(), objects.stream().map(AddonUpdateObject::enabledProperty).toArray(BooleanProperty[]::new)));
});
}
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
JFXButton exportListButton = FXUtils.newRaisedButton(i18n("button.export"));
exportListButton.setOnAction(e -> exportList());
JFXButton nextButton = FXUtils.newRaisedButton(i18n("addon.check_update.confirm"));
nextButton.setOnAction(e -> updateFiles());
JFXButton cancelButton = FXUtils.newRaisedButton(i18n("button.cancel"));
cancelButton.setOnAction(e -> fireEvent(new PageCloseEvent()));
onEscPressed(this, cancelButton::fire);
onEscPressed(listView, cancelButton::fire);
actions.getChildren().setAll(allEnabledBox, showPreviewCheck, spacer, exportListButton, nextButton, cancelButton);
setBottom(actions);
}
}
private void updateFiles() {
AddonUpdateTask task = new AddonUpdateTask(
downloadProvider,
localAddonDirectory,
objects.stream()
.filter(AddonUpdateObject::isEnabled)
.map(AddonUpdateObject::getData)
.toList()
);
Controllers.taskDialog(
task.whenComplete(Schedulers.javafx(), exception -> {
fireEvent(new PageCloseEvent());
if (!task.getFailedAddons().isEmpty()) {
Controllers.dialog(i18n("addon.check_update.failed_download") + "\n" +
task.getFailedAddons().stream().map(LocalAddonFile::getFileName).collect(Collectors.joining("\n")),
i18n("install.failed"),
MessageDialogPane.MessageType.ERROR);
}
if (exception == null) {
Controllers.dialog(i18n("install.success"));
}
}),
i18n("addon.check_update"),
TaskCancellationAction.NORMAL);
}
private void exportList() {
Path path = Paths.get("hmcl-mod-update-list-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")) + ".csv").toAbsolutePath();
Controllers.taskDialog(Task.runAsync(() -> {
CSVTable csvTable = new CSVTable();
csvTable.set(0, 0, "Source File Name");
csvTable.set(1, 0, "Current Version");
csvTable.set(2, 0, "Target Version");
csvTable.set(3, 0, "Update Source");
for (int i = 0; i < objects.size(); i++) {
csvTable.set(0, i + 1, objects.get(i).getFileName());
csvTable.set(1, i + 1, objects.get(i).getCurrentVersion());
csvTable.set(2, i + 1, objects.get(i).getTargetVersion());
csvTable.set(3, i + 1, objects.get(i).getSource());
}
csvTable.write(path);
FXUtils.showFileInExplorer(path);
}).whenComplete(Schedulers.javafx(), exception -> {
if (exception == null) {
Controllers.dialog(path.toString(), i18n("message.success"));
} else {
Controllers.dialog("", i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
}), i18n("button.export"), TaskCancellationAction.NORMAL);
}
@Override
public ReadOnlyObjectWrapper<State> stateProperty() {
return state;
}
private static final class AddonUpdateObject {
final AddonUpdate data;
final String targetVersionType;
final String source;
String changelog = null;
final BooleanProperty enabled = new SimpleBooleanProperty();
final ItemPropertyAsyncCache<Image, AddonUpdateObject> iconCache;
public AddonUpdateObject(AddonUpdate data) {
this.data = data;
this.targetVersionType = switch (data.targetVersion().versionType()) {
case Release -> i18n("addon.channel.release");
case Beta -> i18n("addon.channel.beta");
case Alpha -> i18n("addon.channel.alpha");
};
this.source = switch (data.currentVersion().source()) {
case CURSEFORGE -> i18n("addon.curseforge");
case MODRINTH -> i18n("addon.modrinth");
};
this.enabled.set(!data.localAddonFile().isDisabled());
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
}
public AddonUpdate getData() {
return data;
}
public boolean isEnabled() {
return enabled.get();
}
public BooleanProperty enabledProperty() {
return enabled;
}
public LocalAddonFile<?> getLocalAddonFile() {
return data.localAddonFile();
}
public String getFileName() {
return data.localAddonFile().getFileName();
}
public String getCurrentVersion() {
return data.currentVersion().version();
}
public String getTargetVersion() {
return data.targetVersion().version();
}
public String getTargetVersionType() {
return targetVersionType;
}
public String getSource() {
return source;
}
private Image getDefaultIcon() {
if (data.localAddonFile() instanceof LocalModFile localModFile) {
return GameInstanceIconType
.getIconType(localModFile.getModLoaderType())
.getIcon();
}
return null;
}
private Image loadIcon() {
if (data.localAddonFile() instanceof LocalModFile localModFile) {
List<String> iconPaths = new ArrayList<>();
if (StringUtils.isNotBlank(localModFile.getLogoPath())) {
iconPaths.add(localModFile.getLogoPath());
}
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(localModFile.getFile())) {
for (String path : iconPaths) {
Path iconPath = fs.getPath(path);
if (Files.exists(iconPath)) {
Image image = FXUtils.loadImage(
iconPath,
80,
80,
true,
true
);
if (!image.isError()
&& image.getWidth() > 0
&& image.getHeight() > 0
&& Math.abs(image.getWidth() - image.getHeight()) < 1
) {
return image;
}
}
}
} catch (Exception e) {
LOG.warning("Failed to load addon icons", e);
}
}
if (data.localAddonFile() instanceof ResourcePackFile resourcePackFile) {
Image image = resourcePackFile.loadIcon();
if (image != null
&& !image.isError()
&& image.getWidth() > 0
&& image.getHeight() > 0
&& Math.abs(image.getWidth() - image.getHeight()) < 1
) {
return image;
}
}
return getDefaultIcon();
}
}
private static class AddonUpdateListCell extends ListCell<AddonUpdateObject> {
private final StackPane pane = new StackPane();
private final RipplerContainer ripplerContainer;
private final JFXCheckBox enabledBox = new JFXCheckBox();
private final ImageContainer imageContainer = new ImageContainer(40);
private final TwoLineListItem content = new TwoLineListItem();
private final JFXButton changelogButton = FXUtils.newToggleButton4(SVG.INFO);
AddonUpdateListCell(ListView<AddonUpdatesPage.AddonUpdateObject> listView) {
HBox hbox = new HBox(16);
HBox.setHgrow(content, Priority.ALWAYS);
hbox.setAlignment(Pos.CENTER);
hbox.getChildren().setAll(enabledBox, imageContainer, content, changelogButton);
pane.getStyleClass().add("md-list-cell");
StackPane.setMargin(hbox, new Insets(10, 16, 10, 16));
ripplerContainer = new RipplerContainer(hbox);
pane.getChildren().setAll(ripplerContainer);
enabledBox.setMouseTransparent(false);
imageContainer.setMouseTransparent(true);
FXUtils.installFastTooltip(changelogButton, i18n("addon.changelog"));
changelogButton.setOnAction(event -> {
AddonUpdateObject item = getItem();
if (item != null) {
Controllers.dialog(new AddonChangelog(item));
}
});
FXUtils.onClicked(this, () -> {
AddonUpdateObject item = getItem();
if (item != null) {
item.enabled.set(!item.isEnabled());
}
});
FXUtils.limitCellWidth(listView, this);
}
/// Updates addon version information and replaces tags together, reusing their labels.
@Override
public void updateItem(@Nullable AddonUpdateObject item, boolean empty) {
@Nullable AddonUpdateObject oldItem = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(item, empty);
if (oldItem == item && oldEmpty == empty) {
return;
}
ripplerContainer.resetFeedback();
if (oldItem != null) {
enabledBox.selectedProperty().unbindBidirectional(oldItem.enabledProperty());
}
if (empty || item == null) {
setGraphic(null);
return;
}
enabledBox.selectedProperty().bindBidirectional(item.enabledProperty());
item.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(this.itemProperty()));
LocalAddonFile<?> addonFile = item.getLocalAddonFile();
var tags = new ArrayList<Tag>(3);
if (addonFile instanceof LocalModFile modFile)
tags.add(new Tag(modFile.getId()));
tags.add(new Tag(item.getSource()));
tags.add(new Tag(item.getTargetVersionType()));
content.getTags().setAll(tags);
if (addonFile instanceof LocalModFile modFile) {
content.setTitle(modFile.getName());
} else if (addonFile instanceof ResourcePackFile resourcePackFile) {
content.setTitle(resourcePackFile.getFileName());
}
content.setSubtitle(item.getCurrentVersion() + " → " + item.getTargetVersion());
setGraphic(pane);
}
}
private static final class AddonChangelog extends JFXDialogLayout {
public AddonChangelog(AddonUpdateObject object) {
RemoteAddon.Version targetVersion = object.data.targetVersion();
this.setHeading(new HBox(new Label(i18n("addon.changelog") + " - " + targetVersion.name())));
VBox box = new VBox(8);
box.setPadding(new Insets(8));
SpinnerPane spinnerPane = new SpinnerPane();
ScrollPane scrollPane = new ScrollPane();
scrollPane.setFitToWidth(true);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
FXUtils.setOverflowHidden(scrollPane, 8);
loadChangelog(object, spinnerPane, scrollPane);
spinnerPane.setOnFailedAction(e -> loadChangelog(object, spinnerPane, scrollPane));
spinnerPane.setContent(scrollPane);
box.getChildren().add(spinnerPane);
VBox.setVgrow(spinnerPane, Priority.SOMETIMES);
this.setBody(box);
JFXHyperlink versionPageBtn = new JFXHyperlink(i18n("mods.url"));
versionPageBtn.setDisable(true);
loadVersionPageUrl(object, versionPageBtn);
JFXButton closeButton = new JFXButton(i18n("button.ok"));
closeButton.getStyleClass().add("dialog-accept");
closeButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
setActions(versionPageBtn, closeButton);
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.7));
onEscPressed(this, closeButton::fire);
}
private void loadChangelog(AddonUpdateObject object, SpinnerPane spinnerPane, ScrollPane scrollPane) {
spinnerPane.setLoading(true);
RemoteAddonRepository repo = object.data.targetVersion().source().getRepository();
Task.supplyAsync(() -> {
if (object.changelog != null) return object.changelog;
RemoteAddon.Version version = object.data.targetVersion();
return StringUtils.convertToHtml(
repo.getAddonChangelog(DownloadProviders.getDownloadProvider(), version.projectId(), version.versionId()),
"238222".equals(object.data.targetVersion().projectId())
);
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
object.changelog = StringUtils.isNotBlank(result) ? result : i18n("addon.changelog.empty");
scrollPane.setContent(FXUtils.renderAddonChangelog(object.changelog, repo.getBaseUrl()));
FXUtils.smoothScrolling(scrollPane);
spinnerPane.setFailedReason(null);
} else {
spinnerPane.setFailedReason(i18n("download.failed.refresh"));
}
spinnerPane.setLoading(false);
}).start();
}
private void loadVersionPageUrl(AddonUpdateObject object, JFXHyperlink button) {
Task.supplyAsync(() ->
object.data.targetVersion().source().getRepository().getVersionPageUrl(object.data.targetVersion())
).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null && StringUtils.isNotBlank(result)) {
button.setExternalLink(result);
button.setDisable(false);
} else {
LOG.warning("Failed to load addon version page url", exception);
}
}).start();
}
}
}
@@ -1,121 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.property.BooleanProperty;
import javafx.collections.ObservableList;
import javafx.scene.control.Skin;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.addon.datapack.DataPack;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.ListPageBase;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class DataPackListPage extends ListPageBase<DataPackListPageSkin.DataPackInfoObject> implements WorldManagePage.WorldRefreshable {
private final World world;
private final DataPack dataPack;
final BooleanProperty readOnly;
public DataPackListPage(WorldManagePage worldManagePage) {
world = worldManagePage.getWorld();
dataPack = new DataPack(world.getFile().resolve("datapacks"));
setItems(MappedObservableList.create(dataPack.getPacks(), DataPackListPageSkin.DataPackInfoObject::new));
readOnly = worldManagePage.readOnlyProperty();
FXUtils.applyDragListener(this, it -> Objects.equals("zip", FileUtils.getExtension(it)),
this::installMultiDataPack, this::refresh);
refresh();
}
private void installMultiDataPack(List<Path> dataPackPath) {
dataPackPath.forEach(this::installSingleDataPack);
if (readOnly.get()) {
Controllers.showToast(i18n("datapack.reload.toast"));
}
}
private void installSingleDataPack(Path dataPack) {
try {
this.dataPack.installPack(dataPack, world.getGameVersion());
} catch (IOException | IllegalArgumentException e) {
LOG.warning("Unable to parse datapack file " + dataPack, e);
}
}
@Override
protected Skin<?> createDefaultSkin() {
return new DataPackListPageSkin(this);
}
public void refresh() {
setLoading(true);
Task.runAsync(dataPack::loadFromDir)
.withRunAsync(Schedulers.javafx(), () -> setLoading(false))
.start();
}
public void add() {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("datapack.add.title"));
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("extension.datapack"), "*.zip"));
List<Path> res = Controllers.showOpenMultipleDialog(chooser);
if (res != null) {
installMultiDataPack(res);
}
dataPack.loadFromDir();
}
void removeSelected(ObservableList<DataPackListPageSkin.DataPackInfoObject> selectedItems) {
List<String> failed = new ArrayList<>();
selectedItems.stream()
.map(DataPackListPageSkin.DataPackInfoObject::getPackInfo)
.forEach(pack -> {
try {
dataPack.deletePack(pack);
} catch (IOException e) {
failed.add(pack.getPath().toString());
LOG.warning("Failed to delete datapack \"" + pack.getId() + "\"", e);
}
});
if (!failed.isEmpty())
Controllers.dialog(i18n("datapack.delete.failed") + "\n" + String.join(",\n", failed),
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
void setSelectedEnabled(ObservableList<DataPackListPageSkin.DataPackInfoObject> selectedItems, boolean enabled) {
List<String> failed = new ArrayList<>();
selectedItems.stream()
.map(DataPackListPageSkin.DataPackInfoObject::getPackInfo)
.forEach(pack -> {
try {
pack.setActive(enabled);
} catch (IOException e) {
failed.add(pack.getPath().toString());
LOG.warning("Failed to enable or disable datapack \"" + pack.getId() + "\"", e);
}
});
if (!failed.isEmpty())
Controllers.dialog(i18n("datapack.toggle.failed") + "\n" + String.join(",\n", failed),
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
void openDataPackFolder() {
FXUtils.openFolder(dataPack.getPath());
}
}
@@ -1,370 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXSwitch;
import com.jfoenix.controls.JFXTextField;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.collections.ListChangeListener;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.SelectionMode;
import javafx.scene.control.SkinBase;
import javafx.scene.image.Image;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.util.Duration;
import org.jackhuang.hmcl.addon.datapack.DataPack;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.function.Predicate;
import java.util.regex.PatternSyntaxException;
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@NotNullByDefault
final class DataPackListPageSkin extends SkinBase<DataPackListPage> {
private final TransitionPane toolbarPane;
private final HBox searchBar;
private final HBox normalToolbar;
private final HBox selectingToolbar;
private final JFXListView<DataPackInfoObject> listView;
/// Whether the search mechanism is currently active.
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
private final JFXTextField searchField;
/// Timer for debouncing search input to avoid executing search on every keystroke.
private final PauseTransition searchPause = new PauseTransition(Duration.millis(100));
DataPackListPageSkin(DataPackListPage skinnable) {
super(skinnable);
StackPane pane = new StackPane();
pane.setPadding(new Insets(10));
pane.getStyleClass().addAll("notice-pane");
ComponentList root = new ComponentList();
root.getStyleClass().add("no-padding");
listView = new JFXListView<>();
listView.getStyleClass().add("no-horizontal-scrollbar");
// reason for not using selectAll() is that selectAll() first clears all selected then selects all, causing the toolbar to flicker
var selectAllButton = createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> listView.getSelectionModel().selectRange(0, listView.getItems().size()));
ListChangeListener<Object> listener = change -> selectAllButton.setDisable(!listView.getItems().isEmpty()
&& listView.getSelectionModel().getSelectedItems().size() == listView.getItems().size());
{
toolbarPane = new TransitionPane();
searchBar = new HBox();
normalToolbar = new HBox();
selectingToolbar = new HBox();
normalToolbar.getChildren().addAll(
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
createToolbarButton2(i18n("datapack.add"), SVG.ADD, skinnable::add),
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::openDataPackFolder),
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
);
JFXButton removeButton = createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () ->
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () ->
skinnable.removeSelected(listView.getSelectionModel().getSelectedItems()), null));
JFXButton enableButton = createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () ->
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), true));
JFXButton disableButton = createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () ->
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), false));
removeButton.disableProperty().bind(getSkinnable().readOnly);
enableButton.disableProperty().bind(getSkinnable().readOnly);
disableButton.disableProperty().bind(getSkinnable().readOnly);
listView.getSelectionModel().getSelectedItems().addListener(listener);
selectingToolbar.getChildren().addAll(
removeButton,
enableButton,
disableButton,
selectAllButton,
createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () ->
listView.getSelectionModel().clearSelection())
);
searchBar.setAlignment(Pos.CENTER);
searchBar.setPadding(new Insets(0, 5, 0, 5));
searchField = new JFXTextField();
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
searchPause.setOnFinished(e -> search());
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
searchPause.setRate(1);
searchPause.playFromStart();
}
});
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE,
() -> {
changeToolbar(normalToolbar);
searchField.clear();
searchPause.stop();
isSearching.set(false);
Bindings.bindContent(listView.getItems(), skinnable.getItems());
});
FXUtils.onEscPressed(searchField, closeSearchBar::fire);
searchBar.getChildren().addAll(searchField, closeSearchBar);
root.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
if (e.getCode() == KeyCode.ESCAPE) {
if (listView.getSelectionModel().getSelectedItem() != null) {
listView.getSelectionModel().clearSelection();
e.consume();
}
}
});
FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(),
selectedItem -> {
if (selectedItem == null)
changeToolbar(isSearching.get() ? searchBar : normalToolbar);
else
changeToolbar(selectingToolbar);
});
FXUtils.setOverflowHidden(toolbarPane, 8);
root.getContent().add(toolbarPane);
}
{
SpinnerPane center = new SpinnerPane();
ComponentList.setVgrow(center, Priority.ALWAYS);
center.loadingProperty().bind(skinnable.loadingProperty());
listView.setCellFactory(x -> new DataPackInfoListCell(listView, getSkinnable().readOnly));
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("datapack.empty"));
placeholderLabel.textProperty().bind(
Bindings.createStringBinding(() -> {
if (isSearching.get()) {
return i18n("search.no_results_found");
} else {
return i18n("datapack.empty");
}
},
isSearching)
);
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
Bindings.bindContent(listView.getItems(), skinnable.getItems());
skinnable.getItems().addListener((ListChangeListener<? super DataPackInfoObject>) change -> {
if (isSearching.get()) {
search();
}
});
listView.getItems().addListener(listener);
// ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here
FXUtils.ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
center.setContent(listView);
root.getContent().add(center);
}
pane.getChildren().setAll(root);
getChildren().setAll(pane);
}
/// Filters pack IDs and descriptions using the shared query syntax; invalid regular expressions leave the list empty.
private void search() {
isSearching.set(true);
Bindings.unbindContent(listView.getItems(), getSkinnable().getItems());
String queryString = searchField.getText();
if (StringUtils.isBlank(queryString)) {
listView.getItems().setAll(getSkinnable().getItems());
} else {
listView.getItems().clear();
Predicate<@Nullable String> predicate;
try {
predicate = StringUtils.compileQuery(queryString);
} catch (PatternSyntaxException e) {
LOG.warning("Illegal regular expression", e);
return;
}
for (DataPackInfoObject item : getSkinnable().getItems()) {
if (predicate.test(item.getTitle()) || predicate.test(item.getSubtitle())) {
listView.getItems().add(item);
}
}
}
}
private void changeToolbar(HBox newToolbar) {
@Nullable Node oldToolbar = toolbarPane.getCurrentNode();
if (newToolbar != oldToolbar) {
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
if (newToolbar == searchBar) {
// search button click will get focus while searchField request focus, this cause conflict.
// Defer focus request to next pulse avoids this conflict.
Platform.runLater(searchField::requestFocus);
}
}
}
static class DataPackInfoObject {
private final DataPack.Pack packInfo;
private final ItemPropertyAsyncCache<Image, DataPackInfoObject> iconCache;
DataPackInfoObject(DataPack.Pack packInfo) {
this.packInfo = packInfo;
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
}
String getTitle() {
return packInfo.getId();
}
String getSubtitle() {
return packInfo.getDescription().toString();
}
DataPack.Pack getPackInfo() {
return packInfo;
}
private Image getDefaultIcon() {
return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
}
private Image loadIcon() {
Image image = null;
Path imagePath;
if (this.getPackInfo().isDirectory()) {
imagePath = getPackInfo().getPath().resolve("pack.png");
try {
image = FXUtils.loadImage(imagePath, 64, 64, true, true);
} catch (Exception e) {
LOG.warning("fail to load image, datapack path: " + getPackInfo().getPath(), e);
}
} else {
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(getPackInfo().getPath())) {
imagePath = fs.getPath("/pack.png");
if (Files.exists(imagePath)) {
image = FXUtils.loadImage(imagePath, 64, 64, true, true);
}
} catch (Exception e) {
LOG.warning("fail to load image, datapack path: " + getPackInfo().getPath(), e);
}
}
if (image != null && !image.isError() && image.getWidth() > 0 && image.getHeight() > 0 &&
Math.abs(image.getWidth() - image.getHeight()) < 1) {
return image;
} else {
return getDefaultIcon();
}
}
}
private static final class DataPackInfoListCell extends MDListCell<DataPackInfoObject> {
private final JFXSwitch activeSwitch = new JFXSwitch();
ImageContainer imageContainer = new ImageContainer(32);
final TwoLineListItem content = new TwoLineListItem();
private final InvalidationListener activeListener = obs -> activeSwitch.setSelected(getItem().getPackInfo().isActive());
private final WeakInvalidationListener weakActiveListener = new WeakInvalidationListener(activeListener);
private @Nullable ReadOnlyBooleanProperty observedActiveProperty;
/// Creates a selectable data pack row whose switch follows the read-only state.
DataPackInfoListCell(JFXListView<DataPackInfoObject> listView, BooleanProperty isReadOnlyProperty) {
super(listView);
HBox container = new HBox(8);
container.setPickOnBounds(false);
container.setAlignment(Pos.CENTER_LEFT);
container.setPadding(new Insets(0, 8, 0, 8));
HBox.setHgrow(content, Priority.ALWAYS);
setSelectable();
activeSwitch.disableProperty().bind(isReadOnlyProperty);
activeSwitch.setSize(20);
activeSwitch.setMinSize(30, 30);
activeSwitch.setPrefSize(30, 30);
activeSwitch.setMaxSize(30, 30);
HBox.setMargin(activeSwitch, new Insets(0, 0, 0, 6));
imageContainer.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_pack.png"));
StackPane.setMargin(container, new Insets(8));
container.getChildren().setAll(imageContainer, content, activeSwitch);
getContainer().getChildren().setAll(container);
}
@Override
protected void updateControl(@Nullable DataPackInfoObject dataItem, boolean empty) {
if (observedActiveProperty != null)
observedActiveProperty.removeListener(weakActiveListener);
if (empty || dataItem == null) return;
content.setTitle(dataItem.getTitle());
content.setSubtitle(dataItem.getSubtitle());
DataPack.Pack packInfo = dataItem.getPackInfo();
(observedActiveProperty = packInfo.activeProperty()).addListener(weakActiveListener);
activeSwitch.setDisableAnimation(true);
activeSwitch.setSelected(packInfo.isActive());
activeSwitch.setOnAction(event -> {
try {
packInfo.setActive(activeSwitch.isSelected());
} catch (IOException e) {
LOG.warning("Unable to change state of datapack " + packInfo.getPath(), e);
Controllers.showToast(i18n("datapack.toggle.failed"));
} finally {
activeSwitch.setSelected(packInfo.isActive());
}
});
activeSwitch.setDisableAnimation(!AnimationUtils.isAnimationEnabled());
dataItem.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(this.itemProperty()));
}
}
}
@@ -1,637 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXComboBox;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXTextField;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.AddonProjectInfo;
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.download.DownloadCandidates;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.*;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
import static org.jackhuang.hmcl.ui.FXUtils.stringConverter;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.selectedItemPropertyFor;
public class DownloadListPage extends Control implements DecoratorPage {
protected final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
private final BooleanProperty loading = new SimpleBooleanProperty(false);
private final BooleanProperty failed = new SimpleBooleanProperty(false);
private final boolean instanceSelection;
private final ObjectProperty<HMCLGameInstance.Optional> instanceReference = new SimpleObjectProperty<>();
private final IntegerProperty pageOffset = new SimpleIntegerProperty(0);
private final IntegerProperty pageCount = new SimpleIntegerProperty(-1);
private final ListProperty<RemoteAddon> items = new SimpleListProperty<>(this, "items", FXCollections.observableArrayList());
private final ObservableList<GameInstanceID> instances = FXCollections.observableArrayList();
private final ObjectProperty<GameInstanceID> selectedInstance = new SimpleObjectProperty<>();
private final DownloadPage.DownloadCallback callback;
private boolean searchInitialized = false;
protected final BooleanProperty supportChinese = new SimpleBooleanProperty();
private final ObservableList<Node> actions = FXCollections.observableArrayList();
protected final ListProperty<String> downloadSources = new SimpleListProperty<>(this, "downloadSources", FXCollections.observableArrayList());
protected final StringProperty downloadSource = new SimpleStringProperty();
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private int searchID = 0;
protected final RemoteAddonRepository repository;
protected final @NotNull RemoteAddon.Type type;
private final DownloadProvider downloadProvider;
private Runnable retrySearch;
public DownloadListPage(@NotNull RemoteAddon.Type type, RemoteAddonRepository repository) {
this(type, repository, null, false);
}
public DownloadListPage(@NotNull RemoteAddon.Type type, RemoteAddonRepository repository, DownloadPage.DownloadCallback callback, boolean instanceSelection) {
this.type = Objects.requireNonNull(type);
this.repository = repository;
this.callback = callback;
this.instanceSelection = instanceSelection;
this.downloadProvider = DownloadProviders.getDownloadProvider();
}
public DownloadProvider getDownloadProvider() {
return downloadProvider;
}
public ObservableList<Node> getActions() {
return actions;
}
public void loadInstance(HMCLGameInstance.Optional instance) {
this.instanceReference.set(instance);
setLoading(false);
setFailed(false);
if (!searchInitialized) {
searchInitialized = true;
search("", null, 0, "", RemoteAddonRepository.SortType.RELEVANCY);
}
if (instanceSelection) {
HMCLGameRepository repository = instance.repository();
instances.setAll(repository.getDisplayInstances()
.map(DefaultGameInstance::getId)
.toList());
@Nullable HMCLGameInstance repositorySelection = repository.getSelectedInstance();
selectedInstance.set(repositorySelection != null ? repositorySelection.getId() : null);
}
}
public boolean isFailed() {
return failed.get();
}
public BooleanProperty failedProperty() {
return failed;
}
public void setFailed(boolean failed) {
this.failed.set(failed);
}
public boolean isLoading() {
return loading.get();
}
public BooleanProperty loadingProperty() {
return loading;
}
public void setLoading(boolean loading) {
this.loading.set(loading);
}
public void selectInstance(GameInstanceID instanceId) {
FXUtils.runInFX(() -> selectedInstance.set(instanceId));
}
public DownloadPage.DownloadCallback getCallback() {
return callback;
}
private void search(String userGameVersion, RemoteAddonRepository.Category category, int pageOffset, String searchFilter, RemoteAddonRepository.SortType sort) {
retrySearch = null;
setLoading(true);
setFailed(false);
int currentSearchID = searchID = searchID + 1;
Task.supplyAsync(() -> {
HMCLGameInstance.Optional instanceReference = this.instanceReference.get();
@Nullable HMCLGameInstance instance = instanceReference.instance();
if (instance == null) {
return userGameVersion;
} else {
GameVersionNumber version = instance.getVersion();
return version != GameVersionNumber.unknown() ? version.toString() : "";
}
}).thenApplyAsync(
gameVersion -> repository.search(downloadProvider, type, gameVersion, category, pageOffset, 50, searchFilter, sort, RemoteAddonRepository.SortOrder.DESC)
).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (searchID != currentSearchID) {
return;
}
setLoading(false);
if (exception == null) {
items.setAll(result.results().collect(Collectors.toList()));
pageCount.set(result.totalPages());
failed.set(false);
} else {
failed.set(true);
pageCount.set(-1);
retrySearch = () -> search(userGameVersion, category, pageOffset, searchFilter, sort);
}
}).executor(true);
}
protected String getLocalizedCategory(String category, Object self) {
return repository instanceof ModrinthRemoteAddonRepository
? i18n("modrinth.category." + category)
: i18n("curse.category." + category);
}
protected boolean shouldDisplayCategory(String category) {
return !"minecraft".equals(category);
}
private String getLocalizedCategoryIndent(ModDownloadListPageSkin.CategoryIndented category) {
return StringUtils.repeats(' ', category.indent * 4) +
(category.category() == null
? i18n("curse.category.0")
: getLocalizedCategory(category.category().id(), category.category().self()));
}
protected String getLocalizedOfficialPage() {
if (repository instanceof ModrinthRemoteAddonRepository) {
return i18n("addon.modrinth");
} else {
return i18n("addon.curseforge");
}
}
protected HMCLGameInstance.Optional getInstanceOptional() {
if (instanceSelection) {
@Nullable GameInstanceID instanceId = selectedInstance.get();
return HMCLGameInstance.Optional.of(instanceReference.get().repository(), instanceId);
} else {
return instanceReference.get();
}
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state.getReadOnlyProperty();
}
@Override
protected Skin<?> createDefaultSkin() {
return new ModDownloadListPageSkin(this);
}
private static class ModDownloadListPageSkin extends SkinBase<DownloadListPage> {
private final JFXListView<RemoteAddon> listView = new JFXListView<>();
private final RemoteImageLoader iconLoader;
protected ModDownloadListPageSkin(DownloadListPage control) {
super(control);
listView.getStyleClass().add("no-horizontal-scrollbar");
iconLoader = new RemoteImageLoader(control.downloadProvider) {
@Override
protected @NotNull Task<Image> createLoadTask(@NotNull DownloadCandidates urls) {
return FXUtils.getRemoteImageTask(urls, 80, 80, true, true);
}
};
BorderPane pane = new BorderPane();
GridPane searchPane = new GridPane();
pane.setTop(searchPane);
searchPane.getStyleClass().addAll("card");
BorderPane.setMargin(searchPane, new Insets(10, 10, 0, 10));
ColumnConstraints nameColumn = new ColumnConstraints();
nameColumn.setMinWidth(USE_PREF_SIZE);
ColumnConstraints column1 = new ColumnConstraints();
column1.setHgrow(Priority.ALWAYS);
ColumnConstraints column2 = new ColumnConstraints();
column2.setHgrow(Priority.ALWAYS);
searchPane.getColumnConstraints().setAll(nameColumn, column1, nameColumn, column2);
searchPane.setHgap(16);
searchPane.setVgap(10);
{
int rowIndex = 0;
if (control.instanceSelection || !control.downloadSources.isEmpty()) {
searchPane.addRow(rowIndex);
int columns = 0;
Node lastNode = null;
if (control.instanceSelection) {
JFXComboBox<GameInstanceID> instancesComboBox = new JFXComboBox<>();
instancesComboBox.setMaxWidth(Double.MAX_VALUE);
Bindings.bindContent(instancesComboBox.getItems(), control.instances);
selectedItemPropertyFor(instancesComboBox).bindBidirectional(control.selectedInstance);
searchPane.add(new Label(i18n("instance")), columns++, rowIndex);
searchPane.add(lastNode = instancesComboBox, columns++, rowIndex);
}
if (control.downloadSources.getSize() > 1) {
JFXComboBox<String> downloadSourceComboBox = new JFXComboBox<>();
downloadSourceComboBox.setMaxWidth(Double.MAX_VALUE);
downloadSourceComboBox.getItems().setAll(control.downloadSources.get());
downloadSourceComboBox.setConverter(stringConverter(I18n::i18n));
selectedItemPropertyFor(downloadSourceComboBox).bindBidirectional(control.downloadSource);
searchPane.add(new Label(i18n("settings.launcher.download_source")), columns++, rowIndex);
searchPane.add(lastNode = downloadSourceComboBox, columns++, rowIndex);
}
if (columns == 2) {
GridPane.setColumnSpan(lastNode, 3);
}
rowIndex++;
}
JFXTextField nameField = new JFXTextField();
nameField.setPromptText(getSkinnable().supportChinese.get() ? i18n("search.hint.chinese") : i18n("search.hint.english"));
if (getSkinnable().supportChinese.get()) {
FXUtils.installFastTooltip(nameField, i18n("search.hint.chinese"));
} else {
FXUtils.installFastTooltip(nameField, i18n("search.hint.english"));
}
JFXComboBox<String> gameVersionField = new JFXComboBox<>();
gameVersionField.setMaxWidth(Double.MAX_VALUE);
gameVersionField.setEditable(true);
gameVersionField.getItems().setAll(GameVersionNumber.getDefaultGameVersions());
Label lblGameVersion = new Label(i18n("world.game_version"));
searchPane.addRow(rowIndex++, new Label(i18n("mods.name")), nameField, lblGameVersion, gameVersionField);
ObjectBinding<Boolean> hasVersion = BindingMapping.of(getSkinnable().instanceReference)
.map(instanceReference -> instanceReference.instanceId() == null);
lblGameVersion.managedProperty().bind(hasVersion);
lblGameVersion.visibleProperty().bind(hasVersion);
gameVersionField.managedProperty().bind(hasVersion);
gameVersionField.visibleProperty().bind(hasVersion);
FXUtils.installFastTooltip(gameVersionField, i18n("search.enter"));
FXUtils.onChangeAndOperate(getSkinnable().instanceReference, instanceReference -> {
if (instanceReference.instanceId() != null) {
GridPane.setColumnSpan(nameField, 3);
} else {
GridPane.setColumnSpan(nameField, 1);
}
});
StackPane categoryStackPane = new StackPane();
JFXComboBox<CategoryIndented> categoryComboBox = new JFXComboBox<>();
categoryComboBox.getItems().setAll(CategoryIndented.ALL);
categoryStackPane.getChildren().setAll(categoryComboBox);
categoryComboBox.prefWidthProperty().bind(categoryStackPane.widthProperty());
categoryComboBox.getStyleClass().add("fit-width");
categoryComboBox.setPromptText(i18n("addon.category"));
categoryComboBox.getSelectionModel().select(0);
categoryComboBox.setConverter(stringConverter(getSkinnable()::getLocalizedCategoryIndent));
FXUtils.onChangeAndOperate(getSkinnable().downloadSource, downloadSource -> {
categoryComboBox.getItems().setAll(CategoryIndented.ALL);
categoryComboBox.getSelectionModel().select(0);
Task.supplyAsync(() -> getSkinnable().repository.getCategories(getSkinnable().type))
.thenAcceptAsync(Schedulers.javafx(), categories -> {
if (!Objects.equals(getSkinnable().downloadSource.get(), downloadSource)) {
return;
}
List<CategoryIndented> result = new ArrayList<>();
result.add(CategoryIndented.ALL);
for (RemoteAddonRepository.Category category : Lang.toIterable(categories)) {
resolveCategory(category, 0, result);
}
categoryComboBox.getItems().setAll(result);
categoryComboBox.getSelectionModel().select(0);
}).start();
});
StackPane sortStackPane = new StackPane();
JFXComboBox<RemoteAddonRepository.SortType> sortComboBox = new JFXComboBox<>();
sortStackPane.getChildren().setAll(sortComboBox);
sortComboBox.prefWidthProperty().bind(sortStackPane.widthProperty());
sortComboBox.getStyleClass().add("fit-width");
sortComboBox.setConverter(stringConverter(sortType -> i18n("addon.sort." + sortType.name().toLowerCase(Locale.ROOT))));
sortComboBox.getItems().setAll(RemoteAddonRepository.SortType.values());
sortComboBox.getSelectionModel().select(0);
searchPane.addRow(rowIndex++, new Label(i18n("addon.category")), categoryStackPane, new Label(i18n("search.sort")), sortStackPane);
IntegerProperty filterID = new SimpleIntegerProperty(this, "Filter ID", 0);
IntegerProperty currentFilterID = new SimpleIntegerProperty(this, "Current Filter ID", -1);
EventHandler<ActionEvent> searchAction = e -> {
iconLoader.clearInvalidCache();
if (currentFilterID.get() != -1 && currentFilterID.get() != filterID.get()) {
control.pageOffset.set(0);
}
currentFilterID.set(filterID.get());
int pageOffset = control.pageOffset.get();
getSkinnable().search(gameVersionField.getSelectionModel().getSelectedItem(),
Optional.ofNullable(categoryComboBox.getSelectionModel().getSelectedItem())
.map(CategoryIndented::category)
.orElse(null),
pageOffset == -1 ? 0 : pageOffset,
nameField.getText(),
sortComboBox.getSelectionModel().getSelectedItem());
};
control.listenerHolder.add(FXUtils.observeWeak(
() -> filterID.set(filterID.get() + 1),
control.downloadSource,
gameVersionField.getSelectionModel().selectedItemProperty(),
categoryComboBox.getSelectionModel().selectedItemProperty(),
nameField.textProperty(),
sortComboBox.getSelectionModel().selectedItemProperty()
));
HBox actionsBox = new HBox(8);
GridPane.setColumnSpan(actionsBox, 4);
actionsBox.setAlignment(Pos.CENTER);
{
AggregatedObservableList<Node> actions = new AggregatedObservableList<>();
Holder<Runnable> changeButton = new Holder<>();
JFXButton firstPageButton = FXUtils.newBorderButton(i18n("search.first_page"));
firstPageButton.setOnAction(event -> {
control.pageOffset.set(0);
searchAction.handle(event);
changeButton.value.run();
});
JFXButton previousPageButton = FXUtils.newBorderButton(i18n("search.previous_page"));
previousPageButton.setOnAction(event -> {
int pageOffset = control.pageOffset.get();
if (pageOffset > 0) {
control.pageOffset.set(pageOffset - 1);
searchAction.handle(event);
changeButton.value.run();
}
});
Label pageDescription = new Label();
pageDescription.textProperty().bind(Bindings.createStringBinding(() -> {
int pageCount = control.pageCount.get();
return i18n("search.page_n", control.pageOffset.get() + 1, pageCount == -1 ? "-" : String.valueOf(pageCount));
}, control.pageOffset, control.pageCount));
JFXButton nextPageButton = FXUtils.newBorderButton(i18n("search.next_page"));
nextPageButton.setOnAction(event -> {
int nv = control.pageOffset.get() + 1;
if (nv < control.pageCount.get()) {
control.pageOffset.set(nv);
searchAction.handle(event);
changeButton.value.run();
}
});
JFXButton lastPageButton = FXUtils.newBorderButton(i18n("search.last_page"));
lastPageButton.setOnAction(event -> {
control.pageOffset.set(control.pageCount.get() - 1);
searchAction.handle(event);
changeButton.value.run();
});
firstPageButton.setDisable(true);
previousPageButton.setDisable(true);
lastPageButton.setDisable(true);
nextPageButton.setDisable(true);
changeButton.value = () -> {
int pageOffset = control.pageOffset.get();
int pageCount = control.pageCount.get();
boolean disableAll = pageCount >= -1 && pageCount <= 1;
boolean disablePrevious = disableAll || pageOffset == 0;
firstPageButton.setDisable(disablePrevious);
previousPageButton.setDisable(disablePrevious);
boolean disableNext = disableAll || pageOffset == pageCount - 1;
nextPageButton.setDisable(disableNext);
lastPageButton.setDisable(disableNext);
listView.scrollTo(0);
};
FXUtils.onChange(control.pageCount, pageCountN -> {
int pageCount = pageCountN.intValue();
if (pageCount != -1) {
if (control.pageOffset.get() + 1 >= pageCount) {
control.pageOffset.set(pageCount - 1);
}
}
changeButton.value.run();
});
FXUtils.onChange(control.pageOffset, pageOffsetN -> {
changeButton.value.run();
});
Pane placeholder = new Pane();
HBox.setHgrow(placeholder, Priority.SOMETIMES);
JFXButton searchButton = FXUtils.newRaisedButton(i18n("search"));
searchButton.setOnAction(searchAction);
actions.appendList(FXCollections.observableArrayList(firstPageButton, previousPageButton, pageDescription, nextPageButton, lastPageButton, placeholder, searchButton));
actions.appendList(control.actions);
Bindings.bindContent(actionsBox.getChildren(), actions.getAggregatedList());
}
searchPane.addRow(rowIndex++, actionsBox);
FXUtils.onChange(control.downloadSource, v -> searchAction.handle(null));
nameField.setOnAction(searchAction);
gameVersionField.setOnAction(searchAction);
categoryComboBox.setOnAction(searchAction);
sortComboBox.setOnAction(searchAction);
}
SpinnerPane spinnerPane = new SpinnerPane();
pane.setCenter(spinnerPane);
{
spinnerPane.loadingProperty().bind(getSkinnable().loadingProperty());
spinnerPane.failedReasonProperty().bind(
Bindings.createStringBinding(() -> {
if (getSkinnable().isFailed()) {
return i18n("download.failed.refresh");
} else if (!getSkinnable().isLoading() && getSkinnable().pageCount.get() >= 0 && getSkinnable().items.isEmpty()) {
return i18n("search.no_results_found");
} else {
return null;
}
},
getSkinnable().failedProperty(),
getSkinnable().loadingProperty(),
getSkinnable().pageCount,
getSkinnable().items)
);
spinnerPane.setOnFailedAction(e -> {
if (getSkinnable().isFailed() && getSkinnable().retrySearch != null) {
getSkinnable().retrySearch.run();
}
});
spinnerPane.setContent(listView);
Bindings.bindContent(listView.getItems(), getSkinnable().items);
listView.setSelectionModel(new NoneMultipleSelectionModel<>());
// ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
listView.setCellFactory(x -> new DownloadListCell(getSkinnable(), listView, iconLoader));
}
this.getChildren().setAll(pane);
}
private record CategoryIndented(int indent, RemoteAddonRepository.Category category) {
private static final CategoryIndented ALL = new CategoryIndented(0, null);
}
private static void resolveCategory(RemoteAddonRepository.Category category, int indent, List<CategoryIndented> result) {
result.add(new CategoryIndented(indent, category));
for (RemoteAddonRepository.Category subcategory : category.subcategories()) {
resolveCategory(subcategory, indent + 1, result);
}
}
}
private static final class DownloadListCell extends ListCell<RemoteAddon> {
private final DownloadListPage page;
private final JFXListView<RemoteAddon> listView;
private final RemoteImageLoader iconLoader;
private static final Insets PADDING = new Insets(9, 9, 0, 9);
private static final Insets LAST_PADDING = new Insets(9, 9, 9, 9);
private final RipplerContainer graphic;
private final StackPane wrapper = new StackPane();
private final TwoLineListItem content = new TwoLineListItem();
private final ImageContainer imageContainer = new ImageContainer(40);
/// Creates a download card with its icon and text laid out by the same row.
private DownloadListCell(DownloadListPage page, JFXListView<RemoteAddon> listView, RemoteImageLoader iconLoader) {
this.page = page;
this.listView = listView;
this.iconLoader = iconLoader;
setPadding(PADDING);
content.setPadding(new Insets(8));
content.setCursor(Cursor.HAND);
TwoLineListItem.setMargin(imageContainer, new Insets(0, 8, 0, 0));
imageContainer.setMouseTransparent(true);
content.setLeading(imageContainer);
this.graphic = new RipplerContainer(content);
wrapper.getChildren().setAll(this.graphic);
wrapper.getStyleClass().add("card-no-padding");
FXUtils.onClicked(wrapper, () -> {
RemoteAddon item = getItem();
if (item != null) {
Controllers.navigate(new DownloadPage(page, item, page.getInstanceOptional(), page.callback));
}
});
setPrefWidth(0);
FXUtils.limitCellWidth(listView, this);
}
/// Updates addon text and replaces category tags together so their labels can be reused.
@Override
protected void updateItem(@Nullable RemoteAddon item, boolean empty) {
@Nullable RemoteAddon oldItem = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(item, empty);
if (oldItem == item && oldEmpty == empty) return;
this.graphic.resetFeedback();
if (empty || item == null) {
setGraphic(null);
} else {
setPadding(
getIndex() == listView.getItems().size() - 1
? LAST_PADDING
: PADDING
);
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(page.type);
@Nullable AddonProjectInfo mod = registry != null ? registry.getInfoByCurseForgeId(item.slug()) : null;
content.setTitle(mod != null && I18n.isUseChinese() ? mod.displayName() : item.title());
@Nullable String description = item.description();
if (description != null) {
description = description.replaceAll("\\R", " ");
}
content.setSubtitle(description);
content.getTags().setAll(item.categories().stream()
.filter(page::shouldDisplayCategory)
.map(category -> new Tag(page.getLocalizedCategory(category, null)))
.toList());
iconLoader.load(imageContainer.imageProperty(), item.iconUrl());
setGraphic(wrapper);
}
}
}
}
@@ -1,722 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.addon.AddonLoader;
import org.jackhuang.hmcl.addon.AddonProjectInfo;
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.SVGContainer;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class DownloadPage extends Control implements DecoratorPage {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
private final BooleanProperty loaded = new SimpleBooleanProperty(false);
private final BooleanProperty loading = new SimpleBooleanProperty(false);
private final BooleanProperty failed = new SimpleBooleanProperty(false);
private final RemoteAddonRepository repository;
private final RemoteAddon addon;
/// Localized project metadata, or null when no registry or matching project is available.
private final @Nullable AddonProjectInfo mod;
private final HMCLGameInstance.Optional instanceReference;
private final DownloadCallback callback;
private final DownloadListPage page;
private final RemoteAddon.Type type;
private SimpleMultimap<String, RemoteAddon.Version, List<RemoteAddon.Version>> versions;
public DownloadPage(DownloadListPage page, RemoteAddon addon, HMCLGameInstance.Optional instanceReference, @Nullable DownloadCallback callback) {
this.page = page;
this.repository = page.repository;
this.addon = addon;
this.type = Objects.requireNonNullElse(addon.type(), page.type);
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(this.type);
this.mod = registry != null ? registry.getInfoByCurseForgeId(addon.slug()) : null;
this.instanceReference = instanceReference;
this.callback = callback;
loadAddonVersions();
this.state.set(State.fromTitle(addon.title()));
}
private void loadAddonVersions() {
setLoading(true);
setFailed(false);
Task.supplyAsync(() -> {
Stream<RemoteAddon.Version> versions = addon.data().loadVersions(repository, page.getDownloadProvider());
return sortVersions(versions);
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
this.versions = result;
loaded.set(true);
setFailed(false);
} else {
setFailed(true);
}
setLoading(false);
}).start();
}
private SimpleMultimap<String, RemoteAddon.Version, List<RemoteAddon.Version>> sortVersions(Stream<RemoteAddon.Version> versions) {
SimpleMultimap<String, RemoteAddon.Version, List<RemoteAddon.Version>> classifiedVersions
= new SimpleMultimap<>(HashMap::new, ArrayList::new);
versions.forEach(version -> {
for (String gameVersion : version.gameVersions()) {
classifiedVersions.put(gameVersion, version);
}
});
for (String gameVersion : classifiedVersions.keys()) {
List<RemoteAddon.Version> versionList = classifiedVersions.get(gameVersion);
versionList.sort(Comparator.comparing(RemoteAddon.Version::datePublished).reversed());
}
return classifiedVersions;
}
public RemoteAddon getAddon() {
return addon;
}
public HMCLGameInstance.Optional getInstanceOptional() {
return instanceReference;
}
public boolean isLoading() {
return loading.get();
}
public BooleanProperty loadingProperty() {
return loading;
}
public void setLoading(boolean loading) {
this.loading.set(loading);
}
public boolean isFailed() {
return failed.get();
}
public BooleanProperty failedProperty() {
return failed;
}
public void setFailed(boolean failed) {
this.failed.set(failed);
}
public void download(RemoteAddon addon, RemoteAddon.Version file) {
if (this.callback == null) {
saveAs(file);
} else {
this.callback.download(page.getDownloadProvider(), instanceReference.repository(), instanceReference.instanceId(), addon, file);
}
}
public void saveAs(RemoteAddon.Version file) {
String extension = StringUtils.substringAfterLast(file.file().filename(), '.');
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("button.save_as"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("file"), "*." + extension));
fileChooser.setInitialFileName(file.file().filename());
Path dest = Controllers.showSaveDialog(fileChooser);
if (dest == null) {
return;
}
Controllers.taskDialog(
Task.composeAsync(() -> {
var task = new FileDownloadTask(file.file().url(), dest, file.file().getIntegrityCheck());
task.setName(file.name());
return task;
}),
i18n("message.downloading"),
TaskCancellationAction.NORMAL);
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state.getReadOnlyProperty();
}
@Override
protected Skin<?> createDefaultSkin() {
return new DownloadPageSkin(this);
}
private static class DownloadPageSkin extends SkinBase<DownloadPage> {
protected DownloadPageSkin(DownloadPage control) {
super(control);
VBox pane = new VBox(8);
pane.getStyleClass().add("gray-background");
pane.setPadding(new Insets(10));
HBox descriptionPane = new HBox(8);
descriptionPane.setMinHeight(Region.USE_PREF_SIZE);
descriptionPane.setAlignment(Pos.CENTER);
pane.getChildren().add(descriptionPane);
descriptionPane.getStyleClass().add("card-non-transparent");
{
var imageContainer = new ImageContainer(40);
if (StringUtils.isNotBlank(getSkinnable().addon.iconUrl())) {
imageContainer.imageProperty().bind(FXUtils.newRemoteImage(getSkinnable().addon.iconUrl(), 80, 80, true, true));
}
descriptionPane.getChildren().add(imageContainer);
TwoLineListItem content = new TwoLineListItem();
HBox.setHgrow(content, Priority.ALWAYS);
@Nullable AddonProjectInfo mod = getSkinnable().mod;
content.setTitle(mod != null && I18n.isUseChinese() ? mod.displayName() : getSkinnable().addon.title());
content.setSubtitle(getSkinnable().addon.description());
content.getSubtitleLabel().setWrapText(true);
getSkinnable().addon.categories().stream()
.filter(category -> getSkinnable().page.shouldDisplayCategory(category))
.map(category -> getSkinnable().page.getLocalizedCategory(category, null))
.forEach(content::addTag);
descriptionPane.getChildren().add(content);
@Nullable String mcmodUrl = mod != null ? mod.mcmodUrl() : null;
if (mcmodUrl != null) {
JFXHyperlink openMcmodButton = new JFXHyperlink(i18n("mods.mcmod"));
openMcmodButton.setExternalLink(mcmodUrl);
descriptionPane.getChildren().add(openMcmodButton);
openMcmodButton.setMinWidth(Region.USE_PREF_SIZE);
}
JFXHyperlink openUrlButton = new JFXHyperlink(control.page.getLocalizedOfficialPage());
openUrlButton.setExternalLink(getSkinnable().addon.pageUrl());
descriptionPane.getChildren().add(openUrlButton);
openUrlButton.setMinWidth(Region.USE_PREF_SIZE);
}
SpinnerPane spinnerPane = new SpinnerPane();
VBox.setVgrow(spinnerPane, Priority.ALWAYS);
pane.getChildren().add(spinnerPane);
{
spinnerPane.loadingProperty().bind(getSkinnable().loadingProperty());
spinnerPane.failedReasonProperty().bind(Bindings.createStringBinding(() -> {
if (getSkinnable().isFailed()) {
return i18n("download.failed.refresh");
} else {
return null;
}
}, getSkinnable().failedProperty()));
spinnerPane.setOnFailedAction(e -> getSkinnable().loadAddonVersions());
ComponentList list = new ComponentList();
ScrollPane scrollPane = new ScrollPane(list);
FXUtils.smoothScrolling(scrollPane);
scrollPane.setFitToWidth(true);
scrollPane.setFitToHeight(true);
FXUtils.setOverflowHidden(scrollPane, 8);
StackPane.setAlignment(scrollPane, Pos.TOP_CENTER);
spinnerPane.setContent(scrollPane);
FXUtils.onChangeAndOperate(control.loaded, loaded -> {
if (control.versions == null) return;
@Nullable HMCLGameInstance instance = control.instanceReference.instance();
if (instance != null) {
String gameVersion = instance.getVersion().toString();
if (!GameVersionNumber.unknown().equals(instance.getVersion())
&& control.versions.containsKey(gameVersion)) {
List<RemoteAddon.Version> addonVersions = control.versions.get(gameVersion);
if (addonVersions != null && !addonVersions.isEmpty()) {
Set<ModLoaderType> targetLoaders = instance.getModLoaders();
resolve:
for (RemoteAddon.Version addonVersion : addonVersions) {
if (getSkinnable().type == RemoteAddon.Type.MOD) {
for (AddonLoader loader : addonVersion.loaders()) {
if (loader.type() instanceof ModLoaderType modLoaderType && targetLoaders.contains(modLoaderType)) {
list.getContent().addAll(
ComponentList.createComponentListTitle(i18n("addon.download.recommend", gameVersion)),
new AddonItem(control.addon, addonVersion, control)
);
break resolve;
}
}
} else {
list.getContent().addAll(
ComponentList.createComponentListTitle(i18n("addon.download.recommend", gameVersion)),
new AddonItem(control.addon, addonVersion, control)
);
break;
}
}
}
}
} else if (getSkinnable().type == RemoteAddon.Type.MODPACK) {
control.versions
.keys()
.stream()
.max(Comparator.comparing(GameVersionNumber::asGameVersion))
.flatMap(gameVersion ->
control.versions.get(gameVersion)
.stream()
.max(Comparator.comparing(RemoteAddon.Version::datePublished))
.map(v -> Pair.pair(gameVersion, v))
).ifPresent(pair ->
list.getContent().addAll(
ComponentList.createComponentListTitle(i18n("addon.download.recommend", pair.key())),
new AddonItem(control.addon, pair.value(), control)
)
);
}
final class Versions {
static ComponentSublist createSublist(DownloadPage control, String title, List<String> target) {
var sublist = new ComponentSublist(() -> {
ArrayList<AddonItem> items = new ArrayList<>();
for (String gv : target) {
List<RemoteAddon.Version> versions = control.versions.get(gv);
if (versions != null) {
for (RemoteAddon.Version v : versions) {
items.add(new AddonItem(control.addon, v, control));
}
}
}
return items;
});
sublist.setTitle(title);
sublist.getStyleClass().add("no-padding");
return sublist;
}
final List<String> releases = new ArrayList<>();
final List<String> snapshots = new ArrayList<>();
}
TreeMap<GameVersionNumber, Versions> map = new TreeMap<>(Collections.reverseOrder());
for (String version : control.versions.keys()) {
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(version);
GameVersionNumber releaseOfSnapshot = GameVersionNumber.getReleaseOfSnapshot(gameVersion);
GameVersionNumber releaseVersion = Objects.requireNonNullElse(releaseOfSnapshot, gameVersion);
Versions lists = map.computeIfAbsent(releaseVersion, v -> new Versions());
if (releaseOfSnapshot == null) {
lists.releases.add(version);
} else {
lists.snapshots.add(version);
}
}
for (Map.Entry<GameVersionNumber, Versions> entry : map.entrySet()) {
GameVersionNumber gameVersion = entry.getKey();
Versions versions = entry.getValue();
if (!versions.releases.isEmpty())
list.getContent().add(Versions.createSublist(control, i18n("addon.download.title.release", gameVersion), versions.releases));
if (!versions.snapshots.isEmpty())
list.getContent().add(Versions.createSublist(control, i18n("addon.download.title.snapshot", gameVersion), versions.snapshots));
}
});
}
getChildren().setAll(pane);
}
}
/// Displays an addon dependency and opens its download page when activated.
private static final class DependencyAddonItem extends LineButton {
/// Returns the localized dependency type, or its lowercase enum name for other types.
public static String translateDependencyType(RemoteAddon.DependencyType dependencyType) {
return switch (dependencyType) {
case EMBEDDED -> i18n("addon.dependency.embedded");
case OPTIONAL -> i18n("addon.dependency.optional");
case REQUIRED -> i18n("addon.dependency.required");
default -> dependencyType.name().toLowerCase(Locale.ROOT);
};
}
/// The dependency represented by this row.
public final RemoteAddon addon;
/// Creates a dependency row that opens the addon's download page.
DependencyAddonItem(DownloadListPage page, RemoteAddon addon, HMCLGameInstance.Optional instanceReference) {
this.addon = addon;
container.setPadding(new Insets(10, 24, 10, 24));
container.getTitleLabel().setMinWidth(0);
setLargeTitle(true);
var imageView = new ImageContainer(40);
imageView.setMouseTransparent(true);
setMargin(imageView, new Insets(0, 8, 0, 0));
setLeading(imageView);
FXUtils.setLimitHeight(this, 60);
RemoteAddon.@Nullable Type type = addon.type();
@Nullable DownloadCallback callback = type == null ? null : switch (type) {
case RESOURCE_PACK -> org.jackhuang.hmcl.ui.download.DownloadPage.FOR_RESOURCE_PACK;
case SHADER_PACK -> org.jackhuang.hmcl.ui.download.DownloadPage.FOR_SHADER;
default -> null; // Mods, modpacks, worlds, and custom types are not downloadable from this UI
};
setOnAction((e) -> {
fireEvent(new DialogCloseEvent());
Controllers.navigate(new DownloadPage(page, addon, instanceReference, callback));
});
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(type);
@Nullable AddonProjectInfo mod = registry != null ? registry.getInfoByCurseForgeId(addon.slug()) : null;
setTitle(mod != null && I18n.isUseChinese() ? mod.displayName() : addon.title());
setSubtitle(addon.description());
container.getSubtitleLabel().setWrapText(false);
for (String category : addon.categories()) {
if (page.shouldDisplayCategory(category))
getTags().add(new Tag(page.getLocalizedCategory(category, null), Tag.Type.NORMAL));
}
if (StringUtils.isNotBlank(addon.iconUrl())) {
imageView.imageProperty().bind(FXUtils.newRemoteImage(addon.iconUrl(), 80, 80, true, true));
}
}
}
private static final class AddonItem extends StackPane {
/// Creates a version summary that opens its details when clicked.
AddonItem(RemoteAddon mod, RemoteAddon.Version dataItem, DownloadPage selfPage) {
this(mod, dataItem, selfPage, true);
}
/// Creates a version summary, optionally enabling navigation to its details.
AddonItem(RemoteAddon mod, RemoteAddon.Version dataItem, DownloadPage selfPage, boolean interactive) {
VBox pane = new VBox(8);
pane.setPadding(new Insets(8, 0, 8, 0));
{
HBox descPane = new HBox(8);
descPane.setPadding(new Insets(0, 8, 0, 8));
descPane.setAlignment(Pos.CENTER_LEFT);
descPane.setPickOnBounds(false);
{
StackPane graphicPane = new StackPane();
graphicPane.setMouseTransparent(true);
TwoLineListItem content = new TwoLineListItem();
HBox.setHgrow(content, Priority.ALWAYS);
content.setTitle(dataItem.name());
content.setSubtitle(I18n.formatDateTime(dataItem.datePublished()));
@Nullable SVG svg = null;
@Nullable String styleClass = null;
if (dataItem.versionType() != null) {
switch (dataItem.versionType()) {
case Alpha:
content.addTag(i18n("addon.channel.alpha"));
svg = SVG.ALPHA_CIRCLE;
styleClass = "alpha";
break;
case Beta:
content.addTag(i18n("addon.channel.beta"));
svg = SVG.BETA_CIRCLE;
styleClass = "beta";
break;
case Release:
content.addTag(i18n("addon.channel.release"));
svg = SVG.RELEASE_CIRCLE;
styleClass = "release";
break;
default:
styleClass = "";
break;
}
}
if (svg != null) {
SVGContainer icon = svg.createIcon(24);
icon.getStyleClass().addAll("addon-channel-icon", styleClass);
graphicPane.getChildren().setAll(icon);
}
Set<String> tags = new LinkedHashSet<>();
for (AddonLoader loader : dataItem.loaders()) {
tags.add(I18n.translateLoaderName(loader));
}
content.addTags(tags);
descPane.getChildren().setAll(graphicPane, content);
}
pane.getChildren().add(descPane);
}
RipplerContainer container = new RipplerContainer(pane);
container.setPressFeedbackEnabled(interactive);
if (interactive)
FXUtils.onClicked(container, () -> Controllers.dialog(new AddonVersion(mod, dataItem, selfPage)));
getChildren().setAll(container);
// Workaround for https://github.com/HMCL-dev/HMCL/issues/2129
this.setMinHeight(50);
}
}
private static final class AddonVersion extends JFXDialogLayout {
/// Creates version details with dependency downloads and a non-navigating summary.
public AddonVersion(RemoteAddon mod, RemoteAddon.Version version, DownloadPage selfPage) {
RemoteAddon.Type type = selfPage.type;
String title = switch (type) {
case RESOURCE_PACK -> "resourcepack.download.title";
case SHADER_PACK -> "shaderpack.download.title";
default -> "mods.download.title";
};
this.setHeading(new HBox(new Label(i18n(title, version.name()))));
VBox box = new VBox(8);
box.setPadding(new Insets(8));
var addonItem = new AddonItem(mod, version, selfPage, false);
box.getChildren().setAll(addonItem);
JFXHyperlink changelogButton = new JFXHyperlink(i18n("addon.changelog"));
changelogButton.setOnAction(__ -> Controllers.dialog(new AddonChangelog(version, selfPage.repository, selfPage.page.getDownloadProvider())));
JFXHyperlink versionPageBtn = new JFXHyperlink(i18n("mods.url"));
versionPageBtn.setDisable(true);
loadVersionPageUrl(version, selfPage.repository, versionPageBtn);
HBox additionalBox = new HBox(changelogButton, versionPageBtn);
box.getChildren().add(additionalBox);
SpinnerPane spinnerPane = new SpinnerPane();
ScrollPane scrollPane = new ScrollPane();
ComponentList dependenciesList = new ComponentList();
loadDependencies(version, selfPage, spinnerPane, dependenciesList);
spinnerPane.setOnFailedAction(e -> loadDependencies(version, selfPage, spinnerPane, dependenciesList));
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scrollPane.setContent(dependenciesList);
scrollPane.setFitToWidth(true);
scrollPane.setFitToHeight(true);
FXUtils.onChangeAndOperate(scrollPane.widthProperty(), d -> FXUtils.setLimitWidth(dependenciesList, d.doubleValue()));
FXUtils.smoothScrolling(scrollPane);
FXUtils.setOverflowHidden(scrollPane, 8);
spinnerPane.setContent(scrollPane);
box.getChildren().add(spinnerPane);
VBox.setVgrow(spinnerPane, Priority.SOMETIMES);
this.setBody(box);
JFXButton downloadButton = null;
if (selfPage.callback != null) {
downloadButton = new JFXButton(i18n("mods.install"));
downloadButton.getStyleClass().add("dialog-accept");
downloadButton.setOnAction(e -> {
if (!spinnerPane.isLoading() && spinnerPane.getFailedReason() == null) {
fireEvent(new DialogCloseEvent());
}
selfPage.download(mod, version);
});
}
JFXButton saveAsButton = new JFXButton(i18n("mods.save_as"));
saveAsButton.getStyleClass().add("dialog-accept");
saveAsButton.setOnAction(e -> {
if (!spinnerPane.isLoading() && spinnerPane.getFailedReason() == null) {
fireEvent(new DialogCloseEvent());
}
selfPage.saveAs(version);
});
JFXButton cancelButton = new JFXButton(i18n("button.cancel"));
cancelButton.getStyleClass().add("dialog-cancel");
cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
if (downloadButton == null) {
this.setActions(saveAsButton, cancelButton);
} else {
this.setActions(downloadButton, saveAsButton, cancelButton);
}
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.8));
onEscPressed(this, cancelButton::fire);
}
private void loadDependencies(RemoteAddon.Version version, DownloadPage selfPage, SpinnerPane spinnerPane, ComponentList dependenciesList) {
spinnerPane.setLoading(true);
Task.composeAsync(() -> {
// TODO: Massive tasks may cause OOM.
EnumMap<RemoteAddon.DependencyType, Pair<Label, List<DependencyAddonItem>>> dependencies = new EnumMap<>(RemoteAddon.DependencyType.class);
AtomicBoolean hasBroken = new AtomicBoolean(false);
List<Task<?>> queue = new ArrayList<>(version.dependencies().size());
for (RemoteAddon.Dependency dependency : version.dependencies()) {
if (dependency.getType() == RemoteAddon.DependencyType.INCOMPATIBLE) continue;
if (dependency.getType() == RemoteAddon.DependencyType.BROKEN) {
hasBroken.set(true);
continue;
}
if (!dependencies.containsKey(dependency.getType())) {
Label title = new Label(DependencyAddonItem.translateDependencyType(dependency.getType()));
title.setPadding(new Insets(0, 8, 0, 8));
List<DependencyAddonItem> list = new ArrayList<>();
dependencies.put(dependency.getType(), Pair.pair(title, list));
}
queue.add(Task.supplyAsync(Schedulers.io(), () -> dependency.load(selfPage.page.getDownloadProvider()))
.setSignificance(Task.TaskSignificance.MINOR)
.thenAcceptAsync(Schedulers.javafx(), dep -> {
if (dep == RemoteAddon.BROKEN) {
hasBroken.set(true);
return;
}
DependencyAddonItem dependencyAddonItem = new DependencyAddonItem(selfPage.page, dep, selfPage.instanceReference);
var listener = FXUtils.onWeakChangeAndOperate(dependenciesList.widthProperty(), d -> FXUtils.setLimitWidth(dependencyAddonItem, d.doubleValue()));
dependencyAddonItem.getProperties().put("DependencyAddonItem.width", listener);
dependencies.get(dependency.getType()).value().add(dependencyAddonItem);
})
.setSignificance(Task.TaskSignificance.MINOR));
}
return Task.allOf(queue).thenSupplyAsync(() -> {
var dependenciesStream = dependencies.values().stream().flatMap(types -> {
if (types.value().isEmpty()) return Stream.empty();
return Stream.concat(
Stream.of(types.key()),
types.value().stream().sorted(Comparator.comparing(item -> item.addon.slug(), String.CASE_INSENSITIVE_ORDER)));
});
if (!hasBroken.get()) {
return dependenciesStream;
} else {
Label warning = new Label(i18n("addon.dependencies.has_broken"));
warning.setWrapText(true);
warning.setPadding(new Insets(0, 8, 0, 8));
return Stream.concat(
Stream.of(warning),
dependenciesStream
);
}
});
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
dependenciesList.getContent().setAll(result.toList());
spinnerPane.setFailedReason(null);
} else {
dependenciesList.getContent().setAll();
spinnerPane.setFailedReason(i18n("download.failed.refresh"));
}
spinnerPane.setLoading(false);
}).start();
}
private void loadVersionPageUrl(RemoteAddon.Version version, RemoteAddonRepository repo, JFXHyperlink button) {
Task.supplyAsync(() -> repo.getVersionPageUrl(version))
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null && StringUtils.isNotBlank(result)) {
button.setExternalLink(result);
button.setDisable(false);
} else {
LOG.warning("Failed to load addon version page url", exception);
}
})
.start();
}
}
private static final class AddonChangelog extends JFXDialogLayout {
public AddonChangelog(RemoteAddon.Version version, RemoteAddonRepository repo, DownloadProvider provider) {
setHeading(new HBox(new Label(i18n("addon.changelog") + " - " + version.name())));
VBox box = new VBox(8);
box.setPadding(new Insets(8));
SpinnerPane spinnerPane = new SpinnerPane();
ScrollPane scrollPane = new ScrollPane();
scrollPane.setFitToWidth(true);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
FXUtils.setOverflowHidden(scrollPane, 8);
loadChangelog(version, repo, provider, spinnerPane, scrollPane);
spinnerPane.setOnFailedAction(e -> loadChangelog(version, repo, provider, spinnerPane, scrollPane));
spinnerPane.setContent(scrollPane);
box.getChildren().add(spinnerPane);
VBox.setVgrow(spinnerPane, Priority.SOMETIMES);
this.setBody(box);
JFXButton closeButton = new JFXButton(i18n("button.ok"));
closeButton.getStyleClass().add("dialog-accept");
closeButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
setActions(closeButton);
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.8));
onEscPressed(this, closeButton::fire);
}
private void loadChangelog(RemoteAddon.Version version, RemoteAddonRepository repo, DownloadProvider provider, SpinnerPane spinnerPane, ScrollPane scrollPane) {
spinnerPane.setLoading(true);
Task.supplyAsync(() ->
StringUtils.convertToHtml(
repo.getAddonChangelog(provider, version.projectId(), version.versionId()),
"238222".equals(version.projectId()) // https://github.com/HMCL-dev/HMCL/pull/4828#issuecomment-3791068569 FUCK YOU JEI, FUCK YOU CURSEFORGE
)
).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
String changelog = StringUtils.isNotBlank(result) ? result : i18n("addon.changelog.empty");
scrollPane.setContent(FXUtils.renderAddonChangelog(changelog, repo.getBaseUrl()));
FXUtils.smoothScrolling(scrollPane);
spinnerPane.setFailedReason(null);
} else {
spinnerPane.setFailedReason(i18n("download.failed.refresh"));
}
spinnerPane.setLoading(false);
}).start();
}
}
@FunctionalInterface
public interface DownloadCallback {
void download(DownloadProvider downloadProvider, HMCLGameRepository repository, @Nullable GameInstanceID instanceId, RemoteAddon addon, RemoteAddon.Version file);
}
}
@@ -45,7 +45,6 @@ public class GameInstanceIconDialog extends JFXDialogLayout {
createIcon(GameInstanceIconType.CHEST),
createIcon(GameInstanceIconType.CHICKEN),
createIcon(GameInstanceIconType.COMMAND),
createIcon(GameInstanceIconType.APRIL_FOOLS),
createIcon(GameInstanceIconType.OPTIFINE),
createIcon(GameInstanceIconType.CRAFT_TABLE),
createIcon(GameInstanceIconType.FABRIC),
@@ -41,14 +41,6 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
private final TabController tabController;
/// The settings tab bound to the current game instance.
private final TabItem<GameSettingsPage<GameSettings.Instance>> gameSettingsTab;
/// The component management tab bound to the current game instance.
private final TabItem<InstallerListPage> installerListTab;
/// The installed mods tab bound to the current game instance.
private final TabItem<ModListPage> modListTab;
/// The saved worlds tab bound to the current game instance.
private final TabItem<WorldListPage> worldListTab;
/// The installed resource packs tab bound to the current game instance.
private final TabItem<ResourcePackListPage> resourcePackTab;
/// The screenshots tab bound to the current game instance.
private final TabItem<ScreenshotsPage> screenshotsTab;
private final TransitionPane transitionPane = new TransitionPane();
@@ -79,18 +71,10 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
public GameInstancePage() {
// Child tabs subscribe to instanceProperty() themselves and reload on change.
this.gameSettingsTab = new TabItem<>(() -> new GameSettingsPage<>(GameSettings.Instance.class, instance));
this.installerListTab = new TabItem<>(() -> new InstallerListPage(instance));
this.modListTab = new TabItem<>(() -> new ModListPage(instance));
this.resourcePackTab = new TabItem<>(() -> new ResourcePackListPage(instance));
this.worldListTab = new TabItem<>(() -> new WorldListPage(instance));
this.screenshotsTab = new TabItem<>(() -> new ScreenshotsPage(instance));
tabController = new TabController(transitionPane,
gameSettingsTab,
installerListTab,
modListTab,
resourcePackTab,
worldListTab,
screenshotsTab);
tabController.selectTab(gameSettingsTab);
@@ -118,11 +102,7 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
}));
getSidebar()
.addNavigationDrawerTab(tabController, gameSettingsTab, i18n("settings.game"), SVG.SETTINGS, SVG.SETTINGS_FILL)
.addNavigationDrawerTab(tabController, installerListTab, i18n("settings.tabs.installers"), SVG.DEPLOYED_CODE, SVG.DEPLOYED_CODE_FILL)
.addNavigationDrawerTab(tabController, modListTab, i18n("mods.manage"), SVG.EXTENSION, SVG.EXTENSION_FILL)
.addNavigationDrawerTab(tabController, resourcePackTab, i18n("resourcepack.manage"), SVG.TEXTURE)
.addNavigationDrawerTab(tabController, worldListTab, i18n("world.manage"), SVG.PUBLIC);
.addNavigationDrawerTab(tabController, gameSettingsTab, i18n("settings.game"), SVG.SETTINGS, SVG.SETTINGS_FILL);
PopupMenu browseList = new PopupMenu();
JFXPopup browsePopup = new JFXPopup(browseList);
@@ -1,132 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.property.*;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
import org.jackhuang.hmcl.setting.GameDirectory;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import static org.jackhuang.hmcl.util.Lang.threadPool;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class GameItem {
private static final ThreadPoolExecutor POOL_VERSION_RESOLVE = threadPool("VersionResolve", true, 1, 10, TimeUnit.SECONDS);
protected final HMCLGameInstance gameInstance;
private boolean initialized = false;
private StringProperty title;
private StringProperty tag;
private StringProperty subtitle;
private ObjectProperty<Image> image;
public GameItem(HMCLGameInstance gameInstance) {
this.gameInstance = gameInstance;
}
public GameDirectory getGameDirectory() {
return gameInstance.getRepository().getGameDirectory();
}
public HMCLGameRepository getRepository() {
return gameInstance.getRepository();
}
public GameInstanceID getInstanceId() {
return gameInstance.getId();
}
public HMCLGameInstance getGameInstance() {
return gameInstance;
}
public String getId() {
return gameInstance.getId().toString();
}
private void init() {
if (initialized)
return;
initialized = true;
title = new SimpleStringProperty();
tag = new SimpleStringProperty();
subtitle = new SimpleStringProperty();
image = new SimpleObjectProperty<>();
record Result(@Nullable String gameVersion, @Nullable String tag) {
}
CompletableFuture.supplyAsync(() -> {
// Version detection performs blocking JAR reads and may hash the entire file.
GameVersionNumber version = gameInstance.getVersion();
@Nullable String gameVersion = version == GameVersionNumber.unknown() ? null : version.toString();
@Nullable String modPackVersion = null;
try {
@Nullable ModpackConfiguration<?> config = gameInstance.readModpackConfiguration();
modPackVersion = config != null ? config.getVersion() : null;
} catch (IOException e) {
LOG.warning("Failed to read modpack configuration from " + getId(), e);
}
return new Result(gameVersion, modPackVersion);
}, POOL_VERSION_RESOLVE).whenCompleteAsync((result, exception) -> {
if (exception == null) {
if (result.tag != null) {
tag.set(result.tag);
}
StringBuilder libraries = new StringBuilder(Objects.requireNonNullElse(result.gameVersion, i18n("message.unknown")));
GameComponentAnalyzer analyzer = gameInstance.getAnalyzer();
for (GameComponentAnalyzer.Mark mark : analyzer) {
if (mark.componentType() == GameComponentType.GAME) continue;
if (I18n.hasKey("install.installer." + mark.componentType().getPatchId())) {
libraries.append(", ").append(i18n("install.installer." + mark.componentType().getPatchId()));
if (mark.version() != null)
libraries.append(": ").append(mark.version().replaceAll("(?i)" + mark.componentType().getPatchId(), ""));
}
}
subtitle.set(libraries.toString());
} else {
LOG.warning("Failed to read version info from " + getId(), exception);
}
}, Schedulers.javafx());
title.set(getId());
image.set(gameInstance.getIconImage());
}
public ReadOnlyStringProperty titleProperty() {
init();
return title;
}
public ReadOnlyStringProperty tagProperty() {
init();
return tag;
}
public ReadOnlyStringProperty subtitleProperty() {
init();
return subtitle;
}
public ReadOnlyObjectProperty<Image> imageProperty() {
init();
return image;
}
}
@@ -1,191 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.*;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.event.ActionEvent;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.HBox;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.Nullable;
import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class GameListCell extends ListCell<GameListItem> {
private final RipplerContainer graphic;
private final ImageContainer imageView;
private final TwoLineListItem content;
private final JFXRadioButton chkSelected;
private final JFXButton btnUpgrade;
/// Opens the settings for this cell's game instance.
private final JFXButton btnEditInstance;
private final JFXButton btnManage;
private final HBox right;
private final StringProperty tag = new SimpleStringProperty();
/// Creates a game instance cell constrained to the supplied list's viewport width.
public GameListCell(ListView<GameListItem> listView) {
FXUtils.limitCellWidth(listView, this);
TwoLineListItem root = this.content = new TwoLineListItem();
root.getStyleClass().add("md-list-cell");
root.setPadding(new Insets(8, 8, 8, 0));
RipplerContainer container = new RipplerContainer(root);
this.graphic = container;
{
this.chkSelected = new JFXRadioButton() {
/// Selects this cell's game instance when the radio button is enabled and unselected.
@Override
public void fire() {
if (!isDisable() && !isSelected()) {
fireEvent(new ActionEvent());
@Nullable GameListItem item = GameListCell.this.getItem();
if (item != null) {
item.getRepository().setSelectedInstance(item.getGameInstance());
}
}
}
};
}
{
this.imageView = new ImageContainer(32);
imageView.setMouseTransparent(true);
HBox leading = new HBox(8, chkSelected, imageView);
leading.setAlignment(Pos.CENTER_LEFT);
leading.setPickOnBounds(false);
TwoLineListItem.setMargin(leading, new Insets(0, 8, 0, 0));
content.setLeading(leading);
FXUtils.onChangeAndOperate(tag, tag -> {
if (StringUtils.isNotBlank(tag))
content.getTags().setAll(new Tag(tag));
else
content.getTags().clear();
});
}
{
this.right = new HBox();
content.setTrailing(right);
right.setAlignment(Pos.CENTER_RIGHT);
right.setPickOnBounds(false);
this.btnUpgrade = FXUtils.newToggleButton4(SVG.UPDATE);
btnUpgrade.setOnAction(e -> {
@Nullable GameListItem item = this.getItem();
if (item != null)
item.update();
});
FXUtils.installFastTooltip(btnUpgrade, i18n("instance.update"));
right.getChildren().add(btnUpgrade);
this.btnEditInstance = FXUtils.newToggleButton4(SVG.SETTINGS);
btnEditInstance.setOnAction(e -> {
@Nullable GameListItem item = this.getItem();
if (item != null)
item.modifyGameSettings();
});
FXUtils.installFastTooltip(btnEditInstance, i18n("instance.manage.manage"));
right.getChildren().add(btnEditInstance);
this.btnManage = FXUtils.newToggleButton4(SVG.MORE_VERT);
btnManage.setOnAction(e -> {
@Nullable GameListItem item = this.getItem();
if (item == null)
return;
JFXPopup popup = getPopup(item);
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup);
popup.show(root, vPosition, JFXPopup.PopupHPosition.RIGHT, 0, vPosition == JFXPopup.PopupVPosition.TOP ? root.getHeight() : -root.getHeight());
});
FXUtils.installFastTooltip(btnManage, i18n("settings.game.management"));
right.getChildren().add(btnManage);
}
root.setCursor(Cursor.HAND);
container.setOnMouseClicked(e -> {
@Nullable GameListItem item = getItem();
if (item == null)
return;
if (e.getButton() == MouseButton.PRIMARY) {
if (e.getClickCount() == 1) {
item.getRepository().setSelectedInstance(item.getGameInstance());
}
} else if (e.getButton() == MouseButton.SECONDARY) {
JFXPopup popup = getPopup(item);
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(root, popup);
popup.show(root, vPosition, JFXPopup.PopupHPosition.LEFT, e.getX(), vPosition == JFXPopup.PopupVPosition.TOP ? e.getY() : e.getY() - root.getHeight());
}
});
}
@Override
public void updateItem(GameListItem item, boolean empty) {
GameListItem oldItem = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(item, empty);
if (oldItem == item && oldEmpty == empty) return;
this.graphic.resetFeedback();
this.imageView.imageProperty().unbind();
this.content.titleProperty().unbind();
this.content.subtitleProperty().unbind();
this.tag.unbind();
this.right.getChildren().clear();
this.chkSelected.selectedProperty().unbind();
if (empty || item == null) {
setGraphic(null);
} else {
setGraphic(this.graphic);
this.chkSelected.selectedProperty().bind(item.selectedProperty());
this.imageView.imageProperty().bind(item.imageProperty());
this.content.titleProperty().bind(item.titleProperty());
this.content.subtitleProperty().bind(item.subtitleProperty());
this.tag.bind(item.tagProperty());
if (item.canUpdate())
this.right.getChildren().add(btnUpgrade);
this.right.getChildren().addAll(btnEditInstance, btnManage);
}
}
private static JFXPopup getPopup(GameListItem item) {
PopupMenu menu = new PopupMenu();
JFXPopup popup = new JFXPopup(menu);
menu.getContent().setAll(
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("instance.launch.test"), item::testGame, popup),
new IconedMenuItem(SVG.SCRIPT, i18n("instance.launch_script"), item::generateLaunchScript, popup),
new MenuSeparator(),
new IconedMenuItem(SVG.EDIT, i18n("instance.manage.rename"), item::rename, popup),
new IconedMenuItem(SVG.FOLDER_COPY, i18n("instance.manage.duplicate"), item::duplicate, popup),
new IconedMenuItem(SVG.DELETE, i18n("instance.manage.remove"), item::remove, popup),
new MenuSeparator(),
new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.game"), item::browse, popup));
return popup;
}
}
@@ -1,77 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jetbrains.annotations.Nullable;
public class GameListItem extends GameItem {
private final boolean isModpack;
private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected");
public GameListItem(HMCLGameInstance gameInstance) {
super(gameInstance);
HMCLGameRepository repository = gameInstance.getRepository();
GameInstanceID instanceId = gameInstance.getId();
this.isModpack = gameInstance.isModpack();
selected.bind(Bindings.createBooleanBinding(
() -> {
if (repository.getGameDirectory() != GameDirectoryManager.getSelectedGameDirectory()) return false;
@Nullable HMCLGameInstance selectedInstance = repository.getSelectedInstance();
return selectedInstance != null && selectedInstance.getId().equals(instanceId);
},
GameDirectoryManager.selectedGameDirectoryProperty(),
GameDirectoryManager.selectedInstanceProperty()));
}
public ReadOnlyBooleanProperty selectedProperty() {
return selected;
}
public void rename() {
Instances.renameInstance(gameInstance);
}
public void duplicate() {
Instances.duplicateInstance(gameInstance);
}
public void remove() {
Instances.deleteInstance(gameInstance);
}
public void browse() {
Instances.openFolder(gameInstance);
}
public void testGame() {
Instances.testGame(gameInstance);
}
public void launch() {
Instances.launch(gameInstance);
}
public void modifyGameSettings() {
Instances.modifyGameSettings(gameInstance);
}
public void generateLaunchScript() {
Instances.generateLaunchScript(gameInstance);
}
public boolean canUpdate() {
return isModpack;
}
public void update() {
Instances.updateInstance(gameInstance);
}
}
@@ -1,248 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXTextField;
import javafx.animation.PauseTransition;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.Skin;
import javafx.scene.control.SkinBase;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import javafx.util.Duration;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import static org.jackhuang.hmcl.ui.FXUtils.*;
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("instance.manage")));
/// Creates the fixed-directory instance list.
public GameListPage() {
getSidebar()
.addNavigationDrawerItem(i18n("settings.type.global.manage"), SVG.SETTINGS, this::modifyGlobalGameSettings);
setContent(new GameList());
FXUtils.applyDragListener(this, file -> ModpackHelper.isFileModpackByExtension(file) || "json".equalsIgnoreCase(FileUtils.getNameWithoutExtension(file)), files -> {
Path file = files.get(0);
if (ModpackHelper.isFileModpackByExtension(file)) {
Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(GameDirectoryManager.getSelectedRepository(), file), i18n("install.modpack"));
} else if ("json".equalsIgnoreCase(FileUtils.getExtension(file))) {
Instances.installFromJson(GameDirectoryManager.getSelectedRepository(), file);
}
});
}
public void modifyGlobalGameSettings() {
Instances.modifyGlobalSettings(GameDirectoryManager.getSelectedRepository());
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state.getReadOnlyProperty();
}
private static class GameList extends ListPageBase<GameListItem> {
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private final ObservableList<GameListItem> sourceList = FXCollections.observableArrayList();
private final FilteredList<GameListItem> filteredList = new FilteredList<>(sourceList);
public GameList() {
setItems(filteredList);
GameDirectoryManager.registerVersionsListener(this::loadVersions);
setOnFailedAction(e -> Instances.installServerModpack());
}
@FXThread
private void loadVersions(HMCLGameRepository repository) {
listenerHolder.clear();
setLoading(true);
setFailedReason(null);
List<GameListItem> instanceItems = repository.getDisplayInstances()
.map(GameListItem::new)
.toList();
sourceList.setAll(instanceItems);
if (instanceItems.isEmpty()) {
setFailedReason(i18n("instance.empty.hint"));
}
setLoading(false);
}
private Predicate<GameListItem> createPredicate(String searchText) {
if (searchText == null || searchText.isEmpty()) {
return item -> true;
}
if (searchText.startsWith("regex:")) {
String regex = searchText.substring("regex:".length());
try {
Pattern pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
return item -> pattern.matcher(item.getId()).find();
} catch (PatternSyntaxException e) {
return item -> false;
}
} else {
return item -> item.getId().toLowerCase(Locale.ROOT).contains(searchText.toLowerCase(Locale.ROOT));
}
}
public void refreshList() {
GameDirectoryManager.getSelectedRepository().refreshAsync().start();
}
@Override
protected Skin<?> createDefaultSkin() {
return new GameListSkin(this);
}
private static class GameListSkin extends SkinBase<GameList> {
private final TransitionPane toolbarPane;
private final HBox searchBar;
private final HBox toolbarNormal;
/// Whether the search mechanism is currently active.
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
private final JFXTextField searchField;
/// Creates the instance list with search controls and viewport-constrained rows.
public GameListSkin(GameList skinnable) {
super(skinnable);
StackPane pane = new StackPane();
pane.setPadding(new Insets(10));
pane.getStyleClass().addAll("notice-pane");
ComponentList root = new ComponentList();
root.getStyleClass().add("no-padding");
JFXListView<GameListItem> listView = new JFXListView<>();
listView.getStyleClass().add("no-horizontal-scrollbar");
{
toolbarPane = new TransitionPane();
searchBar = new HBox();
toolbarNormal = new HBox();
searchBar.setAlignment(Pos.CENTER);
searchBar.setPadding(new Insets(0, 5, 0, 5));
searchField = new JFXTextField();
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
PauseTransition pause = new PauseTransition(Duration.millis(100));
pause.setOnFinished(e -> skinnable.filteredList.setPredicate(skinnable.createPredicate(searchField.getText())));
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
pause.setRate(1);
pause.playFromStart();
}
});
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE, () -> {
changeToolbar(toolbarNormal);
searchField.clear();
pause.stop();
skinnable.filteredList.setPredicate(null);
isSearching.set(false);
});
onEscPressed(searchField, closeSearchBar::fire);
searchBar.getChildren().setAll(searchField, closeSearchBar);
toolbarNormal.getChildren().setAll(createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refreshList), createToolbarButton2(i18n("install.modpack"), SVG.PACKAGE2, Instances::installServerModpack), createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar)));
toolbarPane.setContent(toolbarNormal, ContainerAnimations.FADE);
FXUtils.setOverflowHidden(toolbarPane, 8);
root.getContent().add(toolbarPane);
}
{
SpinnerPane center = new SpinnerPane();
ComponentList.setVgrow(center, Priority.ALWAYS);
center.loadingProperty().bind(skinnable.loadingProperty());
center.failedReasonProperty().bind(skinnable.failedReasonProperty());
listView.setCellFactory(GameListCell::new);
listView.setItems(skinnable.getItems());
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label();
placeholderLabel.textProperty().bind(
Bindings.when(isSearching).then(i18n("search.no_results_found")).otherwise("")
);
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
center.setContent(listView);
root.getContent().add(center);
}
pane.getChildren().setAll(root);
getChildren().setAll(pane);
}
private void changeToolbar(HBox newToolbar) {
Node oldToolbar = toolbarPane.getCurrentNode();
if (newToolbar != oldToolbar) {
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
if (newToolbar == searchBar) {
runInFX(searchField::requestFocus);
isSearching.set(true);
}
}
}
}
}
}
@@ -1,187 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXPopup;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.stage.WindowEvent;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.ImageContainer;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.construct.Tag;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// Displays game instances in a popup selection list.
///
/// @author Glavo
public final class GameListPopupMenu extends StackPane {
private static final String KEY = GameListPopupMenu.class.getName() + ".popup";
public static boolean hideShowing(Node owner) {
JFXPopup popup = (JFXPopup) owner.getProperties().get(KEY);
if (popup != null && popup.isShowing()) {
popup.hide();
return true;
} else {
return false;
}
}
/// Shows an instance selection popup relative to its owner.
///
/// @param owner the node used to position the popup
/// @param vAlign the popup's vertical alignment relative to `owner`
/// @param hAlign the popup's horizontal alignment relative to `owner`
/// @param initOffsetX the horizontal offset from the aligned position
/// @param initOffsetY the vertical offset from the aligned position
/// @param instances the instances to copy into the popup, in display order
public static void show(Node owner, JFXPopup.PopupVPosition vAlign, JFXPopup.PopupHPosition hAlign,
double initOffsetX, double initOffsetY,
List<HMCLGameInstance> instances) {
if (!hideShowing(owner)) {
showAndGetPopup(owner, vAlign, hAlign, initOffsetX, initOffsetY, instances);
}
}
/// Shows and returns an instance selection popup relative to its owner.
///
/// @param owner the node used to position the popup
/// @param vAlign the popup's vertical alignment relative to `owner`
/// @param hAlign the popup's horizontal alignment relative to `owner`
/// @param initOffsetX the horizontal offset from the aligned position
/// @param initOffsetY the vertical offset from the aligned position
/// @param instances the instances to copy into the popup, in display order
/// @return the shown popup
public static JFXPopup showAndGetPopup(Node owner, JFXPopup.PopupVPosition vAlign, JFXPopup.PopupHPosition hAlign,
double initOffsetX, double initOffsetY,
List<HMCLGameInstance> instances) {
GameListPopupMenu menu = new GameListPopupMenu();
menu.getItems().setAll(instances.stream()
.map(GameItem::new)
.toList());
JFXPopup popup = new JFXPopup(menu);
owner.getProperties().put(KEY, popup);
popup.addEventFilter(WindowEvent.WINDOW_HIDDEN, event -> owner.getProperties().remove(KEY, popup));
popup.show(owner, vAlign, hAlign, initOffsetX, initOffsetY, true);
return popup;
}
private final JFXListView<GameItem> listView = new JFXListView<>();
private final BooleanBinding isEmpty = Bindings.isEmpty(listView.getItems());
public GameListPopupMenu() {
this.setMaxHeight(365);
this.getStyleClass().add("popup-menu-content");
listView.setCellFactory(Cell::new);
listView.setFixedCellSize(50);
listView.setPrefWidth(300);
listView.prefHeightProperty().bind(Bindings.size(getItems()).multiply(50).add(2));
Label placeholder = new Label(i18n("instance.empty"));
placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: -monet-on-surface-variant; -fx-font-style: italic;");
FXUtils.onChangeAndOperate(isEmpty, empty -> getChildren().setAll(empty ? placeholder : listView));
}
public ObservableList<GameItem> getItems() {
return listView.getItems();
}
private static final class Cell extends ListCell<GameItem> {
private final Region graphic;
private final RipplerContainer ripplerContainer;
private final ImageContainer imageView;
private final TwoLineListItem content;
private final StringProperty tag = new SimpleStringProperty();
/// Creates a popup row with a leading icon and the shared advanced-item text styling.
public Cell(ListView<GameItem> listView) {
this.setPadding(Insets.EMPTY);
this.imageView = new ImageContainer(32);
this.imageView.setMouseTransparent(true);
this.content = new TwoLineListItem();
content.getStyleClass().add("container");
content.setLeading(imageView);
FXUtils.onChangeAndOperate(tag, tag -> {
if (StringUtils.isNotBlank(tag))
content.getTags().setAll(new Tag(tag));
else
content.getTags().clear();
});
this.ripplerContainer = new RipplerContainer(content);
StackPane rootPane = new StackPane();
rootPane.getStyleClass().add("advanced-list-item");
rootPane.getChildren().setAll(ripplerContainer);
rootPane.maxWidthProperty().bind(listView.widthProperty().subtract(5));
FXUtils.onClicked(rootPane, () -> {
@Nullable GameItem item = getItem();
if (item != null) {
item.getRepository().setSelectedInstance(item.getGameInstance());
if (getScene().getWindow() instanceof JFXPopup popup)
popup.hide();
}
});
this.graphic = rootPane;
}
@Override
protected void updateItem(GameItem item, boolean empty) {
GameItem oldItem = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(item, empty);
if (oldItem == item && oldEmpty == empty) return;
this.ripplerContainer.resetFeedback();
this.imageView.imageProperty().unbind();
this.content.titleProperty().unbind();
this.content.subtitleProperty().unbind();
this.tag.unbind();
if (empty || item == null) {
setGraphic(null);
} else {
setGraphic(this.graphic);
this.imageView.imageProperty().bind(item.imageProperty());
this.content.titleProperty().bind(item.titleProperty());
this.content.subtitleProperty().bind(item.subtitleProperty());
this.tag.bind(item.tagProperty());
}
}
}
}
@@ -1,110 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.game.LocalizedRemoteAddonRepository;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.util.i18n.I18n;
import java.util.MissingResourceException;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
public static DownloadListPage ofResourcePack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.RESOURCE_PACK, true, true);
}
public static DownloadListPage ofShaderPack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
var page = new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.SHADER_PACK, true, true);
page.supportChinese.set(false);
return page;
}
private HMCLLocalizedDownloadListPage(DownloadPage.DownloadCallback callback, boolean instanceSelection, RemoteAddon.Type type, boolean curseForge, boolean modrinth) {
super(type, new Repository(), callback, instanceSelection);
((Repository) repository).downloadSource.bind(downloadSource);
supportChinese.set(true);
boolean supportedCurseForge = curseForge && CurseForgeRemoteAddonRepository.isAvailable();
downloadSources.setAll("addon.modrinth");
if (supportedCurseForge) {
downloadSources.add("addon.curseforge");
}
if ("curseforge".equalsIgnoreCase(settings().defaultAddonSourceProperty().get())) {
if (supportedCurseForge) {
downloadSource.set("addon.curseforge");
} else if (modrinth) {
downloadSource.set("addon.modrinth");
} else {
throw new AssertionError("Should not be here.");
}
} else {
if (modrinth) {
downloadSource.set("addon.modrinth");
} else if (supportedCurseForge) {
downloadSource.set("addon.curseforge");
} else {
throw new AssertionError("Should not be here.");
}
}
}
private static class Repository extends LocalizedRemoteAddonRepository {
public Repository() {
}
public final StringProperty downloadSource = new SimpleStringProperty();
@Override
protected RemoteAddonRepository getBackedRemoteModRepository() {
if ("addon.modrinth".equals(downloadSource.get())) {
return ModrinthRemoteAddonRepository.getInstance();
} else {
return CurseForgeRemoteAddonRepository.getInstance();
}
}
}
@Override
protected String getLocalizedCategory(String category, Object self) {
if (category.isEmpty()) {
return "";
}
String key = ("addon.modrinth".equals(downloadSource.get()) ? "modrinth" : "curse") + ".category." + category;
try {
return I18n.getResourceBundle().getString(key);
} catch (MissingResourceException e) {
LOG.warning("Cannot find key " + key + " in resource bundle");
if (self instanceof CurseForgeRemoteAddonRepository.Category curseCategory) {
return curseCategory.getName();
}
return category;
}
}
@Override
protected String getLocalizedOfficialPage() {
if ("addon.modrinth".equals(downloadSource.get())) {
return i18n("addon.modrinth");
} else {
return i18n("addon.curseforge");
}
}
}
@@ -1,131 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.value.ObservableValue;
import javafx.scene.Node;
import javafx.scene.control.Skin;
import org.jackhuang.hmcl.game.GameComponentAnalyzer;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.*;
import org.jetbrains.annotations.Nullable;
import java.util.*;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class InstallerListPage extends ListPageBase<InstallerItem> {
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private @Nullable HMCLGameInstance gameInstance;
/// Creates an installer list that reloads when `instanceContext` changes.
///
/// @param instanceContext the parent page's instance property
public InstallerListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
Objects.requireNonNull(instanceContext, "instanceContext");
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
if (current != null) {
loadInstance(current);
}
}));
}
@Override
protected Skin<?> createDefaultSkin() {
return new InstallerListPageSkin();
}
/// Rebuilds the component controls for the selected instance, or clears them if none is selected.
/// Minecraft editing follows the stored manifest's modifiability; other installed components
/// require a recognized patch to expose editing controls.
public void loadInstance(HMCLGameInstance.Optional instance) {
@Nullable HMCLGameInstance gameInstance = instance.instance();
this.gameInstance = gameInstance;
if (gameInstance == null) {
itemsProperty().clear();
return;
}
HMCLGameRepository repository = gameInstance.getRepository();
itemsProperty().clear();
InstallerItem.InstallerItemGroup group = new InstallerItem.InstallerItemGroup(gameInstance.getVersion(), InstallerItem.Style.LIST_ITEM);
// Conventional libraries: game, fabric, legacyfabric, forge, cleanroom, neoforge, liteloader, optifine
for (InstallerItem component : group.getComponents()) {
// Skip fabric-api and quilt-api and legacyfabric-api
if (component.getComponentType().getPatchId().endsWith("-api")) {
continue;
}
@Nullable String libraryVersion = gameInstance.getComponentVersion(component.getComponentType());
if (libraryVersion != null) {
component.versionProperty().set(new InstallerItem.InstalledState(
libraryVersion,
component.getComponentType() == GameComponentType.GAME
? !gameInstance.getManifest().isModifiable()
: !gameInstance.getAnalyzer().isClear(component.getComponentType()),
false
));
} else {
component.versionProperty().set(null);
}
component.setOnRemove(() -> repository.getDependency().updateInstanceAsync(
gameInstance.getId(),
publishedInstance -> repository.getDependency().removeComponentAsync(
publishedInstance,
component.getComponentType()))
.withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance)
.start());
itemsProperty().add(component);
}
var currentItems = getItems().stream().map(InstallerItem::getComponentType).toList();
// other third-party libraries which are unable to manage.
for (GameComponentAnalyzer.Mark mark : gameInstance.getAnalyzer()) {
if (currentItems.contains(mark.componentType())) continue;
// we have done this library above.
InstallerItem installerItem = new InstallerItem(mark.componentType(), InstallerItem.Style.LIST_ITEM);
installerItem.versionProperty().set(new InstallerItem.InstalledState(mark.version(), false, false));
installerItem.setOnRemove(() -> repository.getDependency().updateInstanceAsync(
gameInstance.getId(),
publishedInstance -> repository.getDependency().removeComponentAsync(
publishedInstance,
mark.componentType()))
.withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance)
.start());
itemsProperty().add(installerItem);
}
}
private void reloadCurrentInstance() {
if (gameInstance != null) {
loadInstance(HMCLGameInstance.Optional.of(gameInstance.getRepository(), gameInstance.getId()));
}
}
private class InstallerListPageSkin extends ToolbarListPageSkin<InstallerItem, InstallerListPage> {
InstallerListPageSkin() {
super(InstallerListPage.this);
}
@Override
protected List<Node> initializeToolbar(InstallerListPage skinnable) {
return Collections.emptyList();
}
}
}
@@ -7,18 +7,20 @@ import javafx.application.Platform;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.game.GameAssetDownloadTask;
import org.jackhuang.hmcl.download.game.GameDownloadTask;
import org.jackhuang.hmcl.download.game.GameLibrariesTask;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.setting.*;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.account.CreateAccountPane;
import org.jackhuang.hmcl.ui.account.FirstRunAccountDialog;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.PromptDialogPane;
@@ -46,9 +48,10 @@ public final class Instances {
private Instances() {
}
/// Installs or updates the server client from the launcher-bundled modpack when present.
/// Installs or updates the TDLM client.
///
/// Falls back to the local/remote modpack import wizard when no bundled package is found.
/// Prefers a launcher-bundled zip, then the org [Metadata#getClientManifestUrl] Server Modpack,
/// then the generic local/remote import wizard.
public static void installServerModpack() {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
if (!repository.isLoaded()) {
@@ -63,7 +66,33 @@ public final class Instances {
return;
}
importModpack();
Controllers.taskDialog(
new GetTask(WebURL.parse(Metadata.getClientManifestUrl()))
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception != null) {
Controllers.dialog(
exception.getMessage(),
i18n("message.error"),
MessageDialogPane.MessageType.ERROR);
importModpack();
return;
}
ServerModpackManifest manifest = JsonUtils.fromMaybeMalformedJson(
result, ServerModpackManifest.class);
if (manifest == null) {
Controllers.dialog(
i18n("modpack.type.server.malformed"),
i18n("message.error"),
MessageDialogPane.MessageType.ERROR);
importModpack();
return;
}
Controllers.getDecorator().startWizard(
new ModpackInstallWizardProvider(repository, manifest),
i18n("install.modpack"));
}),
i18n("message.downloading"),
TaskCancellationAction.NORMAL);
}
public static void importModpack() {
@@ -78,7 +107,6 @@ public final class Instances {
installServerModpack();
}
public static void deleteInstance(HMCLGameInstance gameInstance) {
HMCLGameRepository repository = gameInstance.getRepository();
GameInstanceID instanceId = gameInstance.getId();
@@ -127,7 +155,6 @@ public final class Instances {
new Validator(i18n("install.new_game.already_exists"), newVersionName -> !repository.instanceIdConflicts(newVersionName) || newVersionName.equals(instanceId.toString())));
}
public static void openFolder(HMCLGameInstance gameInstance) {
FXUtils.openFolder(gameInstance.getRunDirectory());
}
@@ -331,6 +358,24 @@ public final class Instances {
});
}
/// Launches the instance and connects straight to the server configured for it.
///
/// When no server address is configured the game starts at the title screen.
///
/// @param gameInstance the instance to launch, or `null` when none is available
public static void joinServer(@Nullable HMCLGameInstance gameInstance) {
if (gameInstance == null) {
launch(null);
return;
}
ServerEndpoint.@Nullable Route route = ServerEndpoint.selected(gameInstance.getModpackConfigurationFile());
if (route == null) {
launch(gameInstance);
} else {
launch(gameInstance, helper -> helper.setQuickPlayOption(new QuickPlayOption.MultiPlayer(route.address())));
}
}
public static void testGame(HMCLGameInstance gameInstance) {
launch(gameInstance, LauncherHelper::setTestMode);
}
@@ -347,20 +392,8 @@ public final class Instances {
private static void ensureSelectedAccount(Consumer<Account> action) {
Account account = Accounts.getSelectedAccount();
if (SettingsManager.isNewlyCreated() && !AuthlibInjectorServers.getServers().isEmpty() &&
!(account instanceof AuthlibInjectorAccount && AuthlibInjectorServers.getServers().contains(((AuthlibInjectorAccount) account).getServer()))) {
CreateAccountPane dialog = new CreateAccountPane(AuthlibInjectorServers.getServers().iterator().next());
dialog.addEventHandler(DialogCloseEvent.CLOSE, e -> {
Account newAccount = Accounts.getSelectedAccount();
if (newAccount == null) {
// user cancelled operation
} else {
Platform.runLater(() -> action.accept(newAccount));
}
});
Controllers.dialog(dialog);
} else if (account == null) {
CreateAccountPane dialog = new CreateAccountPane();
if (account == null) {
FirstRunAccountDialog dialog = new FirstRunAccountDialog();
dialog.addEventHandler(DialogCloseEvent.CLOSE, e -> {
Account newAccount = Accounts.getSelectedAccount();
if (newAccount == null) {
@@ -376,7 +409,6 @@ public final class Instances {
}
public static void modifyGlobalSettings(HMCLGameRepository repository) {
Controllers.getSettingsPage().showGameSettings(repository);
Controllers.navigate(Controllers.getSettingsPage());
}
@@ -1,901 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.*;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.DoubleBinding;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import javafx.util.Duration;
import org.jackhuang.hmcl.addon.AddonLoader;
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.mod.LocalModFile;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.addon.mod.ModManager;
import org.jackhuang.hmcl.addon.mod.ModProjectInfo;
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.addon.update.AddonCheckUpdatesTask;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameDirectory;
import org.jackhuang.hmcl.setting.GameInstanceIconType;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.Lazy;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.ref.WeakReference;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Predicate;
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> implements PageAware {
private final ReentrantLock lock = new ReentrantLock();
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private ModManager modManager;
private @Nullable HMCLGameInstance gameInstance;
final EnumSet<ModLoaderType> supportedLoaders = EnumSet.noneOf(ModLoaderType.class);
private final BooleanProperty isLoadingMods = new SimpleBooleanProperty(false);
private long modLoadRequestId;
/// Creates a mod list that reloads when `instanceContext` changes.
///
/// @param instanceContext the parent page's instance property
public ModListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
Objects.requireNonNull(instanceContext, "instanceContext");
FXUtils.applyDragListener(this, it -> ModManager.MOD_EXTENSIONS.contains(FileUtils.getExtension(it).toLowerCase(Locale.ROOT)), mods -> {
mods.forEach(it -> {
try {
modManager.addMod(it);
} catch (IOException | IllegalArgumentException e) {
LOG.warning("Unable to parse mod file " + it, e);
}
});
loadMods(modManager);
});
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
if (current != null) {
loadInstance(current);
}
}));
}
@Override
protected Skin<?> createDefaultSkin() {
return new ModListPageSkin(this);
}
public void refresh() {
if (isLoadingMods.get()) {
return;
}
loadMods(modManager);
}
public void loadInstance(HMCLGameInstance.Optional instance) {
this.gameInstance = instance.instance();
if (gameInstance == null) {
return;
}
loadMods(gameInstance.getModManager());
}
private void loadMods(ModManager modManager) {
long requestId = ++modLoadRequestId;
setLoading(true);
isLoadingMods.set(true);
if (this.modManager != modManager) {
getItems().clear();
}
this.modManager = modManager;
CompletableFuture.supplyAsync(() -> {
lock.lock();
try {
modManager.refresh();
return modManager.getLocalFiles().stream().map(ModInfoObject::new).toList();
} catch (IOException e) {
throw new UncheckedIOException(e);
} finally {
lock.unlock();
}
}, Schedulers.io()).whenCompleteAsync((list, exception) -> {
if (this.modManager != modManager || requestId != modLoadRequestId) {
return;
}
try {
supportedLoaders.clear();
supportedLoaders.addAll(modManager.getSupportedLoaders());
if (exception == null) {
getItems().setAll(list);
} else {
LOG.warning("Failed to load mods", exception);
getItems().clear();
}
} finally {
isLoadingMods.set(false);
setLoading(false);
}
}, Schedulers.javafx());
}
public void add() {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("mods.add.title"));
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("extension.mod"), "*.jar", "*.zip", "*.litemod"));
List<Path> res = Controllers.showOpenMultipleDialog(chooser);
if (res == null) return;
// It's guaranteed that succeeded and failed are thread safe here.
List<String> succeeded = new ArrayList<>(res.size());
List<String> failed = new ArrayList<>();
Task.runAsync(() -> {
for (Path file : res) {
try {
modManager.addMod(file);
succeeded.add(FileUtils.getName(file));
} catch (Exception e) {
LOG.warning("Unable to add mod " + file, e);
failed.add(FileUtils.getName(file));
// Actually addMod will not throw exceptions because FileChooser has already filtered files.
}
}
}).withRunAsync(Schedulers.javafx(), () -> {
List<String> prompt = new ArrayList<>(1);
if (!succeeded.isEmpty())
prompt.add(i18n("mods.add.success", String.join(", ", succeeded)));
if (!failed.isEmpty())
prompt.add(i18n("mods.add.failed", String.join(", ", failed)));
Controllers.dialog(String.join("\n", prompt), i18n("mods.add"));
loadMods(modManager);
}).start();
}
void removeSelected(ObservableList<ModInfoObject> selectedItems) {
List<String> failed = new ArrayList<>();
selectedItems.stream()
.filter(Objects::nonNull)
.map(ModInfoObject::getModInfo)
.forEach(info -> {
try {
info.delete();
} catch (IOException e) {
failed.add(info.getFile().toString());
LOG.warning("Failed to delete mod \"" + info.getId() + "\"", e);
}
});
loadMods(modManager);
if (!failed.isEmpty())
Controllers.dialog(i18n("mods.delete.failed") + "\n" + String.join(",\n", failed),
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
void setSelectedEnabled(ObservableList<ModInfoObject> selectedItems, boolean enabled) {
List<String> failed = new ArrayList<>();
selectedItems.stream()
.filter(Objects::nonNull)
.map(ModInfoObject::getModInfo)
.forEach(info -> {
try {
info.setActive(enabled);
} catch (IOException e) {
failed.add(info.getFile().toString());
}
});
if (!failed.isEmpty())
Controllers.dialog(i18n("mods.toggle.failed") + "\n" + String.join(",\n", failed),
i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
public void openModFolder() {
if (gameInstance != null) {
FXUtils.openFolder(gameInstance.getModsDirectory());
}
}
/// Checks the supplied mods for updates and opens the results for the current instance.
/// The target directory is retained even if this page switches instances during the check.
/// Does nothing while the list is loading or no instance is selected.
///
/// @param mods the mods to check
public void checkUpdates(Collection<LocalModFile> mods) {
Objects.requireNonNull(mods);
@Nullable HMCLGameInstance gameInstance = this.gameInstance;
if (isLoading() || gameInstance == null) {
return;
}
Path modDirectory = gameInstance.getModManager().getDirectory();
Runnable action = () -> Controllers.taskDialog(Task
.composeAsync(() -> {
GameVersionNumber version = gameInstance.getVersion();
if (version != GameVersionNumber.unknown()) {
return new AddonCheckUpdatesTask(DownloadProviders.getDownloadProvider(), version.toString(), mods);
} else {
LOG.warning("Failed to check for updates, due to unable to get instance game version");
return null;
}
})
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception instanceof CancellationException) return;
if (exception != null || result == null) {
Controllers.dialog(i18n("addon.check_update.failed_check"), i18n("message.failed"), MessageDialogPane.MessageType.ERROR);
if (exception != null) LOG.warning("Failed to check for updates", exception);
} else if (result.commonUpdates().isEmpty()) {
Controllers.dialog(i18n("addon.check_update.empty"));
} else {
Controllers.navigateForward(new AddonUpdatesPage(DownloadProviders.getDownloadProvider(), modDirectory, result));
}
})
.withStagesHints("update.checking"),
i18n("addon.check_update"), TaskCancellationAction.NORMAL);
if (gameInstance.isModpack()) {
Controllers.confirm(
i18n("mods.update_modpack_mod.warning"), null,
MessageDialogPane.MessageType.WARNING,
action, null);
} else {
action.run();
}
}
public void rollback(LocalModFile from, LocalModFile to) {
try {
modManager.rollback(from, to);
refresh();
} catch (IOException ex) {
Controllers.showToast(i18n("message.failed"));
}
}
public GameDirectory getGameDirectory() {
return gameInstance != null ? gameInstance.getRepository().getGameDirectory() : null;
}
public HMCLGameRepository getRepository() {
return gameInstance != null ? gameInstance.getRepository() : null;
}
public GameInstanceID getInstanceId() {
return gameInstance != null ? gameInstance.getId() : null;
}
@NotNullByDefault
private static final class ModListPageSkin extends SkinBase<ModListPage> {
private final TransitionPane toolbarPane;
private final HBox searchBar;
private final HBox toolbarNormal;
private final HBox toolbarSelecting;
private final JFXListView<ModListPage.ModInfoObject> listView;
/// Whether the search mechanism is currently active.
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
private final JFXTextField searchField;
/// Timer for debouncing search input to avoid executing search on every keystroke.
private final PauseTransition searchPause = new PauseTransition(Duration.millis(100));
public ModListPageSkin(ModListPage skinnable) {
super(skinnable);
StackPane pane = new StackPane();
pane.setPadding(new Insets(10));
pane.getStyleClass().addAll("notice-pane");
ComponentList root = new ComponentList();
pane.getChildren().setAll(root);
root.getStyleClass().add("no-padding");
listView = new JFXListView<>();
listView.getStyleClass().add("no-horizontal-scrollbar");
{
toolbarPane = new TransitionPane();
searchBar = new HBox();
toolbarNormal = new HBox();
toolbarSelecting = new HBox();
// Search Bar
searchBar.setAlignment(Pos.CENTER);
searchBar.setPadding(new Insets(0, 5, 0, 5));
searchField = new JFXTextField();
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
searchPause.setOnFinished(e -> search());
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
searchPause.setRate(1);
searchPause.playFromStart();
}
});
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE,
() -> {
changeToolbar(toolbarNormal);
searchField.clear();
searchPause.stop();
isSearching.set(false);
Bindings.bindContent(listView.getItems(), getSkinnable().getItems());
});
onEscPressed(searchField, closeSearchBar::fire);
searchBar.getChildren().setAll(searchField, closeSearchBar);
JFXButton refreshButton = createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh);
refreshButton.disableProperty().bind(skinnable.isLoadingMods);
// Toolbar Normal
toolbarNormal.getChildren().setAll(
refreshButton,
createToolbarButton2(i18n("mods.add"), SVG.ADD, skinnable::add),
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::openModFolder),
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
skinnable.checkUpdates(
listView.getItems().stream()
.map(ModListPage.ModInfoObject::getModInfo)
.toList()
)
),
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
);
// Toolbar Selecting
// reason for not using selectAll() is that selectAll() first clears all selected then selects all, causing the toolbar to flicker
var selectAll = createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> listView.getSelectionModel().selectRange(0, listView.getItems().size()));
ListChangeListener<Object> listener = change -> {
selectAll.setDisable(!listView.getItems().isEmpty()
&& listView.getSelectionModel().getSelectedItems().size() == listView.getItems().size());
};
listView.getSelectionModel().getSelectedItems().addListener(listener);
listView.getItems().addListener(listener);
toolbarSelecting.getChildren().setAll(
createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () -> {
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> {
skinnable.removeSelected(listView.getSelectionModel().getSelectedItems());
}, null);
}),
createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () ->
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), true)),
createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () ->
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), false)),
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
skinnable.checkUpdates(
listView.getSelectionModel().getSelectedItems().stream()
.map(ModListPage.ModInfoObject::getModInfo)
.toList()
)
),
selectAll,
createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () ->
listView.getSelectionModel().clearSelection())
);
FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(),
selectedItem -> {
if (selectedItem == null)
changeToolbar(isSearching.get() ? searchBar : toolbarNormal);
else
changeToolbar(toolbarSelecting);
});
FXUtils.setOverflowHidden(toolbarPane, 8);
root.getContent().add(toolbarPane);
// Clear selection when pressing ESC
root.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
if (e.getCode() == KeyCode.ESCAPE) {
if (listView.getSelectionModel().getSelectedItem() != null) {
listView.getSelectionModel().clearSelection();
e.consume();
}
}
});
}
{
SpinnerPane center = new SpinnerPane();
ComponentList.setVgrow(center, Priority.ALWAYS);
center.loadingProperty().bind(skinnable.loadingProperty());
listView.setCellFactory(x -> new ModListPage.ModInfoListCell(listView, skinnable));
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("mods.empty"));
placeholderLabel.textProperty().bind(
Bindings.createStringBinding(() -> {
if (isSearching.get()) {
return i18n("search.no_results_found");
} else {
return i18n("mods.empty");
}
},
isSearching)
);
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
Bindings.bindContent(listView.getItems(), skinnable.getItems());
skinnable.getItems().addListener((ListChangeListener<? super ModListPage.ModInfoObject>) c -> {
if (isSearching.get()) {
search();
}
});
listView.setOnContextMenuRequested(event -> {
ModListPage.ModInfoObject selectedItem = listView.getSelectionModel().getSelectedItem();
if (selectedItem != null && listView.getSelectionModel().getSelectedItems().size() == 1) {
listView.getSelectionModel().clearSelection();
Controllers.dialog(new ModListPage.ModInfoDialog(selectedItem));
}
});
// ListViewBehavior would consume ESC pressed event, preventing us from handling it
// So we ignore it here
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
center.setContent(listView);
root.getContent().add(center);
}
getChildren().setAll(pane);
}
private void changeToolbar(HBox newToolbar) {
Node oldToolbar = toolbarPane.getCurrentNode();
if (newToolbar != oldToolbar) {
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
if (newToolbar == searchBar) {
Platform.runLater(searchField::requestFocus);
}
}
}
private void search() {
isSearching.set(true);
Bindings.unbindContent(listView.getItems(), getSkinnable().getItems());
String queryString = searchField.getText();
if (StringUtils.isBlank(queryString)) {
listView.getItems().setAll(getSkinnable().getItems());
} else {
listView.getItems().clear();
Predicate<@Nullable String> predicate;
try {
predicate = StringUtils.compileQuery(queryString);
} catch (Throwable e) {
LOG.warning("Illegal regular expression", e);
return;
}
// Do we need to search in the background thread?
for (ModListPage.ModInfoObject item : getSkinnable().getItems()) {
LocalModFile modInfo = item.getModInfo();
if (predicate.test(modInfo.getFileName())
|| predicate.test(modInfo.getName())
|| predicate.test(modInfo.getVersion())
|| predicate.test(modInfo.getGameVersion())
|| predicate.test(modInfo.getId())
|| predicate.test(Objects.toString(modInfo.getModLoaderType()))
|| predicate.test((item.getProjectInfo() != null ? item.getProjectInfo().displayName() : null))) {
listView.getItems().add(item);
}
}
}
}
}
public static final class ModInfoObject {
private final LocalModFile localModFile;
/// Matching project metadata, or null if the local mod cannot be identified.
private final @Nullable ModProjectInfo projectInfo;
private final ItemPropertyAsyncCache<Image, ModInfoObject> iconCache;
ModInfoObject(LocalModFile localModFile) {
this.localModFile = localModFile;
this.projectInfo = AddonProjectInfoRegistry.MOD.getInfo(localModFile.getId(), localModFile.getName());
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
}
public LocalModFile getModInfo() {
return localModFile;
}
/// Returns matching project metadata, or null if no project was found.
public @Nullable ModProjectInfo getProjectInfo() {
return projectInfo;
}
private Image getDefaultIcon() {
return GameInstanceIconType.getIconType(this.localModFile.getModLoaderType()).getIcon();
}
private Image loadIcon() {
List<String> iconPaths = new ArrayList<>();
if (StringUtils.isNotBlank(this.localModFile.getLogoPath())) {
iconPaths.add(this.localModFile.getLogoPath());
}
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(this.localModFile.getFile())) {
for (String path : iconPaths) {
Path iconPath = fs.getPath(path);
if (Files.exists(iconPath)) {
Image image = FXUtils.loadImage(iconPath, 80, 80, true, true);
if (!image.isError() && image.getWidth() > 0 && image.getHeight() > 0 &&
Math.abs(image.getWidth() - image.getHeight()) < 1) {
return image;
}
}
}
} catch (Exception e) {
LOG.warning("Failed to load mod icons", e);
}
return getDefaultIcon();
}
}
private static final class ModInfoDialog extends JFXDialogLayout {
ModInfoDialog(ModInfoObject modInfo) {
HBox titleContainer = new HBox();
titleContainer.setSpacing(8);
titleContainer.setPadding(new Insets(0, 0, 12, 0));
DoubleBinding widthBinding = Controllers.getDecorator().contentWidthProperty().multiply(0.7);
prefWidthProperty().bind(widthBinding);
maxWidthProperty().bind(widthBinding);
var imageContainer = new ImageContainer(40);
titleContainer.setAlignment(Pos.CENTER_LEFT);
modInfo.iconCache.attachValue(imageContainer.imageProperty(), null);
TwoLineListItem title = new TwoLineListItem();
title.getTitleLabel().setWrapText(true);
String displayName = modInfo.getModInfo().getName();
if (modInfo.getProjectInfo() != null && I18n.isUseChinese()) {
String chineseName = modInfo.getProjectInfo().mainName();
if (StringUtils.containsChinese(chineseName)) {
if (StringUtils.isNotBlank(chineseName) && !displayName.equalsIgnoreCase(chineseName))
displayName = chineseName + " (" + displayName + ")";
}
}
title.setTitle(displayName);
StringJoiner subtitle = new StringJoiner("\n");
subtitle.add(i18n("archive.file.name") + ": " + FileUtils.getName(modInfo.getModInfo().getFile()));
if (StringUtils.isNotBlank(modInfo.getModInfo().getGameVersion())) {
subtitle.add(i18n("mods.game.version") + ": " + modInfo.getModInfo().getGameVersion());
}
if (StringUtils.isNotBlank(modInfo.getModInfo().getVersion())) {
subtitle.add(i18n("archive.version") + ": " + modInfo.getModInfo().getVersion());
}
if (StringUtils.isNotBlank(modInfo.getModInfo().getAuthors())) {
subtitle.add(i18n("archive.author") + ": " + modInfo.getModInfo().getAuthors());
}
title.setSubtitle(subtitle.toString());
titleContainer.getChildren().setAll(imageContainer, title);
setHeading(titleContainer);
Label description = new Label(modInfo.getModInfo().getDescription().toString());
description.setWrapText(true);
FXUtils.copyOnDoubleClick(description);
ScrollPane descriptionPane = new ScrollPane(description);
FXUtils.smoothScrolling(descriptionPane);
descriptionPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
descriptionPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
descriptionPane.setFitToWidth(true);
description.heightProperty().addListener((obs, oldVal, newVal) -> {
double maxHeight = Controllers.getDecorator().contentHeightProperty().get() * 0.5;
double targetHeight = Math.min(newVal.doubleValue(), maxHeight);
descriptionPane.setPrefViewportHeight(targetHeight);
});
setBody(descriptionPane);
if (StringUtils.isNotBlank(modInfo.getModInfo().getId())) {
for (Pair<String, ? extends RemoteAddonRepository> item : Arrays.asList(
pair("addon.curseforge", CurseForgeRemoteAddonRepository.getInstance()),
pair("addon.modrinth", ModrinthRemoteAddonRepository.getInstance())
)) {
RemoteAddonRepository repository = item.getValue();
JFXHyperlink button = new JFXHyperlink(i18n(item.getKey()));
Task.runAsync(() -> {
Optional<RemoteAddon.Version> versionOptional = repository.getRemoteVersionByLocalFile(modInfo.getModInfo().getFile());
if (versionOptional.isPresent()) {
RemoteAddon remoteAddon = repository.getAddonById(DownloadProviders.getDownloadProvider(), versionOptional.get().projectId());
FXUtils.runInFX(() -> {
Set<String> tags = new LinkedHashSet<>();
for (AddonLoader loader : versionOptional.get().loaders()) {
String tag = I18n.translateLoaderName(loader);
if (tag != null) tags.add(tag);
}
title.addTagsIfNotExist(tags);
button.setExternalLink(remoteAddon.pageUrl());
button.setDisable(false);
});
}
}).start();
button.setDisable(true);
getActions().add(button);
}
}
if (StringUtils.isNotBlank(modInfo.getModInfo().getUrl())) {
JFXHyperlink officialPageButton = new JFXHyperlink(i18n("mods.url"));
officialPageButton.setExternalLink(modInfo.getModInfo().getUrl());
getActions().add(officialPageButton);
}
@Nullable String mcmodUrl = modInfo.getProjectInfo() != null ? modInfo.getProjectInfo().mcmodUrl() : null;
if (mcmodUrl == null) {
JFXHyperlink searchButton = new JFXHyperlink(i18n("mods.mcmod.search"));
searchButton.setExternalLink(NetworkUtils.withQuery("https://search.mcmod.cn/s", mapOf(
pair("key", modInfo.getModInfo().getName()),
pair("site", "all"),
pair("filter", "0")
)));
getActions().add(searchButton);
} else {
JFXHyperlink mcmodButton = new JFXHyperlink(i18n("mods.mcmod.page"));
mcmodButton.setExternalLink(mcmodUrl);
getActions().add(mcmodButton);
}
JFXButton okButton = new JFXButton();
okButton.getStyleClass().add("dialog-accept");
okButton.setText(i18n("button.ok"));
okButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
getActions().add(okButton);
onEscPressed(this, okButton::fire);
}
}
private static final Lazy<PopupMenu> menu = new Lazy<>(PopupMenu::new);
private static final Lazy<JFXPopup> popup = new Lazy<>(() -> new JFXPopup(menu.get()));
private static final class ModInfoListCell extends MDListCell<ModInfoObject> {
private static final PseudoClass WARNING = PseudoClass.getPseudoClass("warning");
private final ModListPage page;
private final ImageContainer imageContainer = new ImageContainer(32);
private final TwoLineListItem content = new TwoLineListItem();
private final JFXButton restoreButton = FXUtils.newToggleButton4(SVG.RESTORE);
private final JFXButton revealButton = FXUtils.newToggleButton4(SVG.FOLDER);
private final JFXButton infoButton = FXUtils.newToggleButton4(SVG.INFO);
private final JFXSwitch activeSwitch = new JFXSwitch();
private final InvalidationListener activeListener = obs -> activeSwitch.setSelected(getItem().getModInfo().isActive());
private final WeakInvalidationListener weakActiveListener = new WeakInvalidationListener(activeListener);
private @Nullable ReadOnlyBooleanProperty observedActiveProperty;
private Tooltip warningTooltip;
/// Creates a selectable mod row with file actions for the supplied page.
ModInfoListCell(JFXListView<ModInfoObject> listView, ModListPage page) {
super(listView);
this.page = page;
this.getStyleClass().add("mod-info-list-cell");
HBox container = new HBox(8);
container.setPickOnBounds(false);
container.setAlignment(Pos.CENTER_LEFT);
container.setPadding(new Insets(0, 8, 0, 8));
HBox.setHgrow(content, Priority.ALWAYS);
setSelectable();
imageContainer.setImage(GameInstanceIconType.COMMAND.getIcon());
FXUtils.installFastTooltip(restoreButton, i18n("mods.restore"));
// free up space when not visible
restoreButton.managedProperty().bind(restoreButton.visibleProperty());
HBox actions = new HBox();
actions.setAlignment(Pos.CENTER);
actions.getChildren().setAll(restoreButton, revealButton, infoButton, activeSwitch);
activeSwitch.setSize(20);
activeSwitch.setMinSize(30, 30);
activeSwitch.setPrefSize(30, 30);
activeSwitch.setMaxSize(30, 30);
HBox.setMargin(activeSwitch, new Insets(0, 0, 0, 6));
container.getChildren().setAll(imageContainer, content, actions);
StackPane.setMargin(container, new Insets(8));
getContainer().getChildren().setAll(container);
}
/// Updates mod details, warnings, and tags while retaining reusable tag labels.
@Override
protected void updateControl(@Nullable ModInfoObject dataItem, boolean empty) {
if (observedActiveProperty != null)
observedActiveProperty.removeListener(weakActiveListener);
pseudoClassStateChanged(WARNING, false);
if (warningTooltip != null) {
Tooltip.uninstall(this, warningTooltip);
warningTooltip = null;
}
if (empty || dataItem == null) return;
List<String> warning = new ArrayList<>();
var tags = new ArrayList<Tag>(2);
LocalModFile modInfo = dataItem.getModInfo();
@Nullable ModProjectInfo projectInfo = dataItem.getProjectInfo();
ModLoaderType modLoaderType = modInfo.getModLoaderType();
dataItem.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(this.itemProperty()));
String displayName = modInfo.getName();
if (projectInfo != null && I18n.isUseChinese()) {
String chineseName = projectInfo.mainName();
if (StringUtils.containsChinese(chineseName)) {
if (StringUtils.isNotBlank(chineseName) && !displayName.equalsIgnoreCase(chineseName))
displayName = chineseName + " (" + displayName + ")";
}
}
content.setTitle(displayName);
StringJoiner joiner = new StringJoiner(" | ");
if (modLoaderType != ModLoaderType.UNKNOWN && StringUtils.isNotBlank(modInfo.getId()))
joiner.add(modInfo.getId());
joiner.add(FileUtils.getName(modInfo.getFile()));
content.setSubtitle(joiner.toString());
if (modLoaderType == ModLoaderType.UNKNOWN) {
tags.add(Tag.warning(i18n("mods.unknown")));
} else if (!page.supportedLoaders.contains(modLoaderType)) {
warning.add(i18n("mods.warning.loader_mismatch"));
tags.add(Tag.warning(I18n.translateLoaderType(dataItem.getModInfo().getModLoaderType())));
}
String modVersion = modInfo.getVersion();
if (StringUtils.isNotBlank(modVersion) && !"${version}".equals(modVersion)) {
tags.add(new Tag(modVersion));
}
content.getTags().setAll(tags);
(observedActiveProperty = modInfo.activeProperty()).addListener(weakActiveListener);
activeSwitch.setDisableAnimation(true);
activeSwitch.setSelected(modInfo.isActive());
activeSwitch.setOnAction(event -> {
try {
modInfo.setActive(activeSwitch.isSelected());
} catch (IOException e) {
LOG.warning("Unable to change state of mod file " + modInfo.getFile(), e);
Controllers.showToast(i18n("mods.toggle.failed"));
} finally {
activeSwitch.setSelected(modInfo.isActive());
}
});
activeSwitch.setDisableAnimation(!AnimationUtils.isAnimationEnabled());
restoreButton.setVisible(!modInfo.getMod().getOldFiles().isEmpty());
restoreButton.setOnAction(e -> {
menu.get().getContent().setAll(modInfo.getMod().getOldFiles().stream()
.map(localModFile -> new IconedMenuItem(null, localModFile.getVersion(),
() -> page.rollback(modInfo, localModFile),
popup.get()))
.toList()
);
popup.get().show(restoreButton, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.RIGHT, 0, restoreButton.getHeight());
});
revealButton.setOnAction(e -> FXUtils.showFileInExplorer(modInfo.getFile()));
infoButton.setOnAction(e -> Controllers.dialog(new ModInfoDialog(dataItem)));
if (!warning.isEmpty()) {
pseudoClassStateChanged(WARNING, true);
//noinspection ConstantValue
this.warningTooltip = warning.size() == 1
? new Tooltip(warning.get(0))
: new Tooltip(String.join("\n", warning));
FXUtils.installFastTooltip(this, warningTooltip);
}
}
}
}
@@ -1,690 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.*;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ObservableValue;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import javafx.util.Duration;
import org.jackhuang.hmcl.addon.LocalAddonFile;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.addon.resourcepack.ResourcePackFile;
import org.jackhuang.hmcl.addon.resourcepack.ResourcePackManager;
import org.jackhuang.hmcl.addon.update.AddonCheckUpdatesTask;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.ListPageBase;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.javafx.ItemPropertyAsyncCache;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Predicate;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class ResourcePackListPage extends ListPageBase<ResourcePackListPage.ResourcePackInfoObject> {
private static final String TIP_KEY = "resourcePackWarning";
private static @Nullable String getWarning(ResourcePackFile.Compatibility compatibility) {
return switch (compatibility) {
case TOO_NEW -> i18n("resourcepack.warning.too_new");
case TOO_OLD -> i18n("resourcepack.warning.too_old");
case INVALID -> i18n("resourcepack.warning.invalid");
case MISSING_PACK_META -> i18n("resourcepack.warning.missing_pack_meta");
case MISSING_GAME_META -> i18n("resourcepack.warning.missing_game_meta");
default -> null;
};
}
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private @Nullable HMCLGameInstance gameInstance;
private Path resourcePackDirectory;
private ResourcePackManager resourcePackManager;
private final ReentrantLock lock = new ReentrantLock();
/// Creates a resource-pack list that reloads when `instanceContext` changes.
///
/// @param instanceContext the parent page's instance property
public ResourcePackListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
Objects.requireNonNull(instanceContext, "instanceContext");
FXUtils.applyDragListener(this, ResourcePackFile::isFileResourcePack, this::addFiles);
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
if (current != null) {
loadInstance(current);
}
}));
}
@Override
protected Skin<?> createDefaultSkin() {
return new ResourcePackListPageSkin(this);
}
public void loadInstance(HMCLGameInstance.Optional instance) {
this.gameInstance = instance.instance();
if (gameInstance == null) {
this.resourcePackManager = null;
this.resourcePackDirectory = null;
getItems().clear();
return;
}
this.resourcePackManager = gameInstance.getResourcePackManager();
this.resourcePackDirectory = this.resourcePackManager.getDirectory();
refresh();
}
public void refresh() {
if (resourcePackManager == null) return;
setDisable(false);
setLoading(true);
Task.supplyAsync(Schedulers.io(), () -> {
lock.lock();
try {
if (!ResourcePackManager.isMcVersionSupported(resourcePackManager.getMinecraftVersion())) return null;
resourcePackManager.refresh();
return resourcePackManager.arePacksEnabled(resourcePackManager.getLocalFiles().stream())
.map(ResourcePackInfoObject::new)
.toList();
} finally {
lock.unlock();
}
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
if (result != null) {
getItems().setAll(result);
} else { // Unsupported mc version
getItems().clear();
setDisable(true);
}
} else {
LOG.warning("Failed to load resource packs", exception);
getItems().clear();
}
setLoading(false);
}).start();
}
public void addFiles(List<Path> files) {
if (resourcePackManager == null) return;
List<Path> failures = new ArrayList<>();
Task.runAsync(() -> {
for (Path file : files) {
try {
resourcePackManager.importResourcePack(file);
} catch (Exception e) {
LOG.warning("Failed to add resource pack", e);
failures.add(file);
}
}
}).withRunAsync(Schedulers.javafx(), () -> {
if (!failures.isEmpty()) {
StringBuilder failure = new StringBuilder(i18n("resourcepack.add.failed"));
for (Path file: failures) {
failure.append("\n").append(file.toString());
}
Controllers.dialog(failure.toString(), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
refresh();
}).start();
}
public void onAddFiles() {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("resourcepack.add"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("resourcepack"), "*.zip"));
List<Path> files = Controllers.showOpenMultipleDialog(fileChooser);
if (files != null && !files.isEmpty()) {
addFiles(files);
}
}
private void onOpenFolder() {
if (resourcePackDirectory != null) {
FXUtils.openFolder(resourcePackDirectory);
}
}
private void setSelectedEnabled(List<ResourcePackInfoObject> selectedItems, boolean enabled) {
if (!Boolean.TRUE.equals(SettingsManager.state().getShownTips().get(TIP_KEY)) && enabled && !selectedItems.stream().map(ResourcePackInfoObject::getFile).allMatch(ResourcePackFile::isCompatible)) {
Controllers.confirm(
i18n("resourcepack.warning.manipulate"),
i18n("message.warning"),
MessageDialogPane.MessageType.WARNING,
() -> {
SettingsManager.state().getShownTips().put(TIP_KEY, true);
setSelectedEnabled(selectedItems, true);
}, null);
} else {
if (resourcePackManager == null) return;
if (enabled) {
resourcePackManager.enableResourcePacks(selectedItems.stream().map(ResourcePackInfoObject::getFile).toList());
} else {
resourcePackManager.disableResourcePacks(selectedItems.stream().map(ResourcePackInfoObject::getFile).toList());
}
for (ResourcePackInfoObject item : selectedItems) {
item.enabledProperty().set(enabled);
}
}
}
private void removeSelected(List<ResourcePackInfoObject> selectedItems) {
try {
if (resourcePackManager != null) {
if (resourcePackManager.removeResourcePacks(selectedItems.stream().map(ResourcePackInfoObject::getFile).toList())) {
refresh();
}
}
} catch (IOException e) {
Controllers.dialog(i18n("resourcepack.delete.failed", e.getMessage()), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
LOG.warning("Failed to delete resource packs", e);
}
}
/// Checks the supplied resource packs for updates and opens the results for the current instance.
/// The target directory is retained even if this page switches instances during the check.
/// Does nothing when no instance is selected.
///
/// @param resourcePacks the resource packs to check
public void checkUpdates(Collection<ResourcePackFile> resourcePacks) {
@Nullable HMCLGameInstance gameInstance = this.gameInstance;
if (gameInstance == null) {
return;
}
Path resourcePackDirectory = gameInstance.getResourcePackManager().getDirectory();
Runnable action = () -> Controllers.taskDialog(Task
.composeAsync(() -> {
GameVersionNumber version = gameInstance.getVersion();
if (version != GameVersionNumber.unknown()) {
return new AddonCheckUpdatesTask(DownloadProviders.getDownloadProvider(), version.toString(), resourcePacks);
} else {
LOG.warning("Failed to check for updates, due to unable to get instance game version");
return null;
}
})
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception != null || result == null) {
Controllers.dialog(i18n("addon.check_update.failed_check"), i18n("message.failed"), MessageDialogPane.MessageType.ERROR);
} else if (result.commonUpdates().isEmpty()) {
Controllers.dialog(i18n("addon.check_update.empty"));
} else {
Controllers.navigateForward(new AddonUpdatesPage(DownloadProviders.getDownloadProvider(), resourcePackDirectory, result));
}
})
.withStagesHints("update.checking"),
i18n("addon.check_update"), TaskCancellationAction.NORMAL);
if (gameInstance.isModpack()) {
Controllers.confirm(
i18n("resourcepack.update_in_modpack.warning"), null,
MessageDialogPane.MessageType.WARNING,
action, null);
} else {
action.run();
}
}
@NotNullByDefault
private static final class ResourcePackListPageSkin extends SkinBase<ResourcePackListPage> {
private final JFXListView<ResourcePackInfoObject> listView;
private final JFXTextField searchField = new JFXTextField();
private final TransitionPane toolbarPane = new TransitionPane();
private final HBox searchBar = new HBox();
private final HBox toolbarNormal = new HBox();
private final HBox toolbarSelecting = new HBox();
/// Whether the search mechanism is currently active.
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
/// Timer for debouncing search input to avoid executing search on every keystroke.
private final PauseTransition searchPause = new PauseTransition(Duration.millis(100));
private ResourcePackListPageSkin(ResourcePackListPage control) {
super(control);
StackPane pane = new StackPane();
pane.setPadding(new Insets(10));
pane.getStyleClass().addAll("notice-pane");
ComponentList root = new ComponentList();
root.getStyleClass().add("no-padding");
listView = new JFXListView<>();
{
// Toolbar Selecting
toolbarSelecting.getChildren().setAll(
createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () -> {
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> {
control.removeSelected(listView.getSelectionModel().getSelectedItems());
}, null);
}),
createToolbarButton2(i18n("button.enable"), SVG.CHECK, () ->
control.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), true)),
createToolbarButton2(i18n("button.disable"), SVG.CLOSE, () ->
control.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), false)),
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
control.checkUpdates(
listView.getSelectionModel().getSelectedItems().stream().map(ResourcePackInfoObject::getFile).toList()
)
),
createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () ->
listView.getSelectionModel().selectAll()),
createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () ->
listView.getSelectionModel().clearSelection())
);
// Search Bar
searchBar.setAlignment(Pos.CENTER);
searchBar.setPadding(new Insets(0, 5, 0, 5));
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
searchPause.setOnFinished(e -> search());
FXUtils.onChange(searchField.textProperty(), newValue -> {
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
searchPause.setRate(1);
searchPause.playFromStart();
}
});
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE,
() -> {
changeToolbar(toolbarNormal);
searchField.clear();
searchPause.stop();
isSearching.set(false);
Bindings.bindContent(listView.getItems(), getSkinnable().getItems());
});
onEscPressed(searchField, closeSearchBar::fire);
searchBar.getChildren().setAll(searchField, closeSearchBar);
// Toolbar Normal
toolbarNormal.setAlignment(Pos.CENTER_LEFT);
toolbarNormal.setPickOnBounds(false);
toolbarNormal.getChildren().setAll(
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, control::refresh),
createToolbarButton2(i18n("resourcepack.add"), SVG.ADD, control::onAddFiles),
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, control::onOpenFolder),
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
control.checkUpdates(listView.getItems().stream().map(ResourcePackInfoObject::getFile).toList())
),
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
);
FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(),
selectedItem -> {
if (selectedItem == null)
changeToolbar(isSearching.get() ? searchBar : toolbarNormal);
else
changeToolbar(toolbarSelecting);
});
FXUtils.setOverflowHidden(toolbarPane, 8);
root.getContent().add(toolbarPane);
// Clear selection when pressing ESC
root.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
if (e.getCode() == KeyCode.ESCAPE) {
if (listView.getSelectionModel().getSelectedItem() != null) {
listView.getSelectionModel().clearSelection();
e.consume();
}
}
});
}
{
SpinnerPane center = new SpinnerPane();
ComponentList.setVgrow(center, Priority.ALWAYS);
center.loadingProperty().bind(control.loadingProperty());
listView.setCellFactory(x -> new ResourcePackListCell(listView, control));
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("resourcepack.empty"));
placeholderLabel.textProperty().bind(
Bindings.createStringBinding(() -> {
if (isSearching.get()) {
return i18n("search.no_results_found");
} else {
return i18n("resourcepack.empty");
}
},
isSearching)
);
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
Bindings.bindContent(listView.getItems(), control.getItems());
listView.setOnContextMenuRequested(event -> {
ResourcePackInfoObject selectedItem = listView.getSelectionModel().getSelectedItem();
if (selectedItem != null && listView.getSelectionModel().getSelectedItems().size() == 1) {
listView.getSelectionModel().clearSelection();
Controllers.dialog(new ResourcePackInfoDialog(selectedItem));
}
});
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
listView.getStyleClass().add("no-horizontal-scrollbar");
center.setContent(listView);
root.getContent().add(center);
}
pane.getChildren().setAll(root);
getChildren().setAll(pane);
}
private void changeToolbar(HBox newToolbar) {
Node oldToolbar = toolbarPane.getCurrentNode();
if (newToolbar != oldToolbar) {
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
if (newToolbar == searchBar) {
Platform.runLater(searchField::requestFocus);
}
}
}
private void search() {
isSearching.set(true);
Bindings.unbindContent(listView.getItems(), getSkinnable().getItems());
String queryString = searchField.getText();
if (StringUtils.isBlank(queryString)) {
listView.getItems().setAll(getSkinnable().getItems());
} else {
listView.getItems().clear();
Predicate<@Nullable String> predicate;
try {
predicate = StringUtils.compileQuery(queryString);
} catch (Throwable e) {
LOG.warning("Illegal regular expression", e);
return;
}
// Do we need to search in the background thread?
for (ResourcePackInfoObject item : getSkinnable().getItems()) {
ResourcePackFile resourcePack = item.getFile();
LocalAddonFile.Description description = resourcePack.getDescription();
Stream<String> descriptionParts = description == null
? Stream.empty()
: description.parts().stream().map(LocalAddonFile.Description.Part::text);
if (predicate.test(resourcePack.getFileNameWithExtension())
|| predicate.test(resourcePack.getFileName())
|| descriptionParts.anyMatch(predicate)) {
listView.getItems().add(item);
}
}
}
}
}
public static final class ResourcePackInfoObject {
private final ResourcePackFile file;
private final BooleanProperty enabled;
private final ItemPropertyAsyncCache<Image, ResourcePackInfoObject> iconCache;
public ResourcePackInfoObject(Pair<ResourcePackFile, Boolean> pair) {
this.file = pair.key();
this.enabled = new SimpleBooleanProperty(this, "enabled", pair.value());
this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
}
public ResourcePackFile getFile() {
return file;
}
public BooleanProperty enabledProperty() {
return enabled;
}
private Image getDefaultIcon() {
return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
}
private Image loadIcon() {
Image icon = file.loadIcon();
if (icon != null && !icon.isError() && icon.getWidth() > 0 && icon.getHeight() > 0 && Math.abs(icon.getWidth() - icon.getHeight()) < 1) {
return icon;
}
return getDefaultIcon();
}
}
private static final class ResourcePackListCell extends MDListCell<ResourcePackInfoObject> {
private static final PseudoClass WARNING = PseudoClass.getPseudoClass("warning");
private final ResourcePackListPage page;
private final ImageContainer imageContainer = new ImageContainer(32);
private final TwoLineListItem content = new TwoLineListItem();
private final JFXButton btnReveal = FXUtils.newToggleButton4(SVG.FOLDER);
private final JFXButton btnInfo = FXUtils.newToggleButton4(SVG.INFO);
private final JFXSwitch activeSwitch;
private BooleanProperty booleanProperty = null;
/// Creates a selectable pack row with compatibility confirmation and file actions.
public ResourcePackListCell(JFXListView<ResourcePackInfoObject> listView, ResourcePackListPage page) {
super(listView);
this.page = page;
getStyleClass().add("resource-pack-list-cell");
HBox root = new HBox(8);
root.setPickOnBounds(false);
root.setAlignment(Pos.CENTER_LEFT);
root.setPadding(new Insets(0, 8, 0, 8));
activeSwitch = new JFXSwitch() {
@Override
public void fire() {
if (!Boolean.TRUE.equals(SettingsManager.state().getShownTips().get(TIP_KEY)) && !isSelected() && getItem() != null && !getItem().getFile().isCompatible()) {
Controllers.confirm(
i18n("resourcepack.warning.manipulate"),
i18n("message.info"),
MessageDialogPane.MessageType.INFO,
() -> {
super.fire();
SettingsManager.state().getShownTips().put(TIP_KEY, true);
}, null);
} else {
super.fire();
}
}
};
activeSwitch.setOnAction(e -> {
if (getItem() != null) getItem().getFile().setEnabled(activeSwitch.isSelected());
});
activeSwitch.setSize(20);
activeSwitch.setMinSize(30, 30);
activeSwitch.setPrefSize(30, 30);
activeSwitch.setMaxSize(30, 30);
HBox.setMargin(activeSwitch, new Insets(0, 0, 0, 6));
HBox.setHgrow(content, Priority.ALWAYS);
HBox actions = new HBox();
actions.setAlignment(Pos.CENTER);
actions.getChildren().setAll(btnReveal, btnInfo, activeSwitch);
root.getChildren().setAll(imageContainer, content, actions);
setSelectable();
StackPane.setMargin(root, new Insets(8));
getContainer().getChildren().add(root);
}
/// Updates pack details and reuses the compatibility tag when a warning remains.
@Override
protected void updateControl(@Nullable ResourcePackInfoObject item, boolean empty) {
pseudoClassStateChanged(WARNING, false);
if (empty || item == null) return;
ResourcePackFile file = item.getFile();
item.iconCache.attachValue(imageContainer.imageProperty(), new WeakReference<>(itemProperty()));
content.setTitle(file.getFileName());
{
var description = file.getDescription();
content.setSubtitle(description != null ? description.toStringSingleLine() : "");
}
FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager"));
btnReveal.setOnAction(event -> FXUtils.showFileInExplorer(file.getFile()));
btnInfo.setOnAction(e -> Controllers.dialog(new ResourcePackInfoDialog(item)));
if (booleanProperty != null) {
activeSwitch.selectedProperty().unbindBidirectional(booleanProperty);
}
activeSwitch.selectedProperty().bindBidirectional(booleanProperty = item.enabledProperty());
{
var compatibility = file.getCompatibility();
if (compatibility != ResourcePackFile.Compatibility.COMPATIBLE) {
pseudoClassStateChanged(WARNING, true);
content.getTags().setAll(Tag.warning(getWarning(compatibility)));
} else {
content.getTags().clear();
}
}
}
}
private static final class ResourcePackInfoDialog extends JFXDialogLayout {
ResourcePackInfoDialog(ResourcePackInfoObject packInfoObject) {
ResourcePackFile pack = packInfoObject.getFile();
HBox titleContainer = new HBox();
titleContainer.setSpacing(8);
maxWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
ImageContainer imageContainer = new ImageContainer(40);
packInfoObject.iconCache.attachValue(imageContainer.imageProperty(), null);
TwoLineListItem title = new TwoLineListItem();
title.setTitle(pack.getFileName());
title.setSubtitle(pack.getFileNameWithExtension());
var compatibility = pack.getCompatibility();
if (compatibility != ResourcePackFile.Compatibility.COMPATIBLE) {
title.addTagWarning(getWarning(compatibility));
}
titleContainer.getChildren().setAll(imageContainer, title);
setHeading(titleContainer);
Label description = new Label(Objects.requireNonNullElse(pack.getDescription(), "").toString());
description.setWrapText(true);
FXUtils.copyOnDoubleClick(description);
ScrollPane descriptionPane = new ScrollPane(description);
FXUtils.smoothScrolling(descriptionPane);
descriptionPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
descriptionPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
descriptionPane.setFitToWidth(true);
FXUtils.onChange(description.heightProperty(), newVal -> {
double maxHeight = Controllers.getDecorator().contentHeightProperty().get() * 0.5;
double targetHeight = Math.min(newVal.doubleValue(), maxHeight);
descriptionPane.setPrefViewportHeight(targetHeight);
});
setBody(descriptionPane);
for (Pair<String, ? extends RemoteAddonRepository> item : Arrays.asList(
pair("addon.curseforge", CurseForgeRemoteAddonRepository.getInstance()),
pair("addon.modrinth", ModrinthRemoteAddonRepository.getInstance())
)) {
RemoteAddonRepository repository = item.getValue();
JFXHyperlink button = new JFXHyperlink(i18n(item.getKey()));
Task.runAsync(() -> {
Optional<RemoteAddon.Version> versionOptional = repository.getRemoteVersionByLocalFile(packInfoObject.getFile().getFile());
if (versionOptional.isPresent()) {
RemoteAddon remoteAddon = repository.getAddonById(DownloadProviders.getDownloadProvider(), versionOptional.get().projectId());
FXUtils.runInFX(() -> {
button.setExternalLink(remoteAddon.pageUrl());
button.setDisable(false);
});
}
}).start();
button.setDisable(true);
getActions().add(button);
}
JFXButton okButton = new JFXButton();
okButton.getStyleClass().add("dialog-accept");
okButton.setText(i18n("button.ok"));
okButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
getActions().add(okButton);
onEscPressed(this, okButton::fire);
}
}
}
@@ -1,75 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.task.Task;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.channels.FileChannel;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.LocalDateTime;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* @author Glavo
*/
public final class WorldBackupTask extends Task<Path> {
private final World world;
private final Path backupsDir;
private final boolean needLock;
public WorldBackupTask(World world, Path backupsDir, boolean needLock) {
this.world = world;
this.backupsDir = backupsDir;
this.needLock = needLock;
}
@Override
public void execute() throws Exception {
try (FileChannel lockChannel = needLock ? world.lock() : null) {
Files.createDirectories(backupsDir);
String time = LocalDateTime.now().format(WorldBackupsPage.TIME_FORMATTER);
String baseName = time + "_" + world.getFileName();
Path backupFile = null;
OutputStream outputStream = null;
int count;
for (count = 0; count < 256; count++) {
try {
backupFile = backupsDir.resolve(baseName + (count == 0 ? "" : " " + count) + ".zip").toAbsolutePath();
outputStream = Files.newOutputStream(backupFile, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW);
break;
} catch (FileAlreadyExistsException ignored) {
}
}
if (outputStream == null)
throw new IOException("Too many attempts");
try (ZipOutputStream zipOutputStream = new ZipOutputStream(new BufferedOutputStream(outputStream))) {
String rootName = world.getFileName();
Path rootDir = this.world.getFile();
Files.walkFileTree(this.world.getFile(), new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) throws IOException {
if (path.endsWith("session.lock")) {
return FileVisitResult.CONTINUE;
}
zipOutputStream.putNextEntry(new ZipEntry(rootName + "/" + rootDir.relativize(path).toString().replace('\\', '/')));
Files.copy(path, zipOutputStream);
zipOutputStream.closeEntry();
return FileVisitResult.CONTINUE;
}
});
}
setResult(backupFile);
}
}
}
@@ -1,265 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import javafx.beans.property.BooleanProperty;
import javafx.collections.FXCollections;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.control.Skin;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.game.WorldLockedException;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.construct.ImageContainer;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jetbrains.annotations.NotNull;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.StringUtils.parseColorEscapes;
import static org.jackhuang.hmcl.util.i18n.I18n.formatDateTime;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
public final class WorldBackupsPage extends ListPageBase<WorldBackupsPage.BackupInfo> implements WorldManagePage.WorldRefreshable {
static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss");
private final World world;
private final Path backupsDir;
private final BooleanProperty readOnly;
private final Pattern backupFileNamePattern;
public WorldBackupsPage(WorldManagePage worldManagePage) {
this.world = worldManagePage.getWorld();
this.backupsDir = worldManagePage.getBackupsDir();
this.readOnly = worldManagePage.readOnlyProperty();
this.backupFileNamePattern = Pattern.compile("(?<datetime>[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2})_" + Pattern.quote(world.getFileName()) + "( (?<count>[0-9]+))?\\.zip");
refresh();
}
public void refresh() {
setLoading(true);
Task.supplyAsync(() -> {
if (Files.isDirectory(backupsDir)) {
try (Stream<Path> paths = Files.list(backupsDir)) {
ArrayList<BackupInfo> result = new ArrayList<>();
paths.forEach(path -> {
if (Files.isRegularFile(path)) {
try {
Matcher matcher = backupFileNamePattern.matcher(path.getFileName().toString());
if (matcher.matches()) {
LocalDateTime time = LocalDateTime.parse(matcher.group("datetime"), TIME_FORMATTER);
int count = 0;
if (matcher.group("count") != null) {
count = Integer.parseInt(matcher.group("count"));
}
result.add(new BackupInfo(path, new World(path), time, count));
}
} catch (Throwable e) {
LOG.warning("Failed to load backup file " + path, e);
}
}
});
result.sort(Comparator.naturalOrder());
return result;
}
} else {
return new ArrayList<BackupInfo>();
}
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
this.setLoading(false);
if (exception == null) {
this.setItems(FXCollections.observableList(result));
} else {
LOG.warning("Failed to load backups", exception);
}
}).start();
}
@Override
protected Skin<?> createDefaultSkin() {
return new WorldBackupsPageSkin();
}
void createBackup() {
Controllers.taskDialog(new WorldBackupTask(world, backupsDir, false).setName(i18n("world.backup.processing")).thenApplyAsync(path -> {
Matcher matcher = backupFileNamePattern.matcher(path.getFileName().toString());
if (!matcher.matches()) {
throw new AssertionError("Wrong backup file name" + path);
}
LocalDateTime time = LocalDateTime.parse(matcher.group("datetime"), TIME_FORMATTER);
int count = 0;
if (matcher.group("count") != null) {
count = Integer.parseInt(matcher.group("count"));
}
return Pair.pair(path, new BackupInfo(path, new World(path), time, count));
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
WorldBackupsPage.this.getItems().add(result.getValue());
WorldBackupsPage.this.getItems().sort(Comparator.naturalOrder());
Controllers.dialog(i18n("world.backup.create.success", result.getKey()), null, MessageDialogPane.MessageType.INFO);
} else if (exception instanceof WorldLockedException) {
Controllers.dialog(i18n("world.locked.failed"), null, MessageDialogPane.MessageType.WARNING);
} else {
LOG.warning("Failed to create backup", exception);
Controllers.dialog(i18n("world.backup.create.failed", StringUtils.getStackTrace(exception)), null, MessageDialogPane.MessageType.WARNING);
}
}), i18n("world.backup"), TaskCancellationAction.NO_CANCEL);
}
private final class WorldBackupsPageSkin extends ToolbarListPageSkin<BackupInfo, WorldBackupsPage> {
WorldBackupsPageSkin() {
super(WorldBackupsPage.this);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("world.backup.empty"));
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
}
@Override
protected List<Node> initializeToolbar(WorldBackupsPage skinnable) {
JFXButton createBackup = createToolbarButton2(i18n("world.backup.create.new_one"), SVG.ARCHIVE, skinnable::createBackup);
createBackup.disableProperty().bind(getSkinnable().readOnly);
return Arrays.asList(
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
createBackup
);
}
}
public final class BackupInfo extends Control implements Comparable<BackupInfo> {
private final Path file;
private final World backupWorld;
private final LocalDateTime backupTime;
private final int count;
public BackupInfo(Path file, World backupWorld, LocalDateTime backupTime, int count) {
this.file = file;
this.backupWorld = backupWorld;
this.backupTime = backupTime;
this.count = count;
}
public World getBackupWorld() {
return backupWorld;
}
public LocalDateTime getBackupTime() {
return backupTime;
}
@Override
protected Skin<?> createDefaultSkin() {
return new BackupInfoSkin(this);
}
void onReveal() {
FXUtils.showFileInExplorer(file);
}
void onDelete() {
WorldBackupsPage.this.getItems().remove(this);
Task.runAsync(() -> Files.delete(file)).start();
}
@Override
public int compareTo(@NotNull WorldBackupsPage.BackupInfo that) {
int c = this.backupTime.compareTo(that.backupTime);
return c != 0 ? c : Integer.compare(this.count, that.count);
}
}
private static final class BackupInfoSkin extends SkinBase<BackupInfo> {
BackupInfoSkin(BackupInfo skinnable) {
super(skinnable);
World world = skinnable.getBackupWorld();
BorderPane root = new BorderPane();
root.getStyleClass().add("md-list-cell");
root.setPadding(new Insets(8));
{
StackPane left = new StackPane();
root.setLeft(left);
left.setPadding(new Insets(0, 8, 0, 0));
var imageView = new ImageContainer(32);
left.getChildren().add(imageView);
imageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon());
}
{
TwoLineListItem item = new TwoLineListItem();
root.setCenter(item);
if (skinnable.getBackupWorld().getWorldName() != null)
item.setTitle(parseColorEscapes(skinnable.getBackupWorld().getWorldName()));
item.setSubtitle(formatDateTime(skinnable.getBackupTime()) + (skinnable.count == 0 ? "" : " (" + skinnable.count + ")"));
if (world.getGameVersion() != null)
item.addTag(I18n.getDisplayVersion(world.getGameVersion()));
}
{
HBox right = new HBox(8);
root.setRight(right);
right.setAlignment(Pos.CENTER_RIGHT);
JFXButton btnReveal = FXUtils.newToggleButton4(SVG.FOLDER_OPEN);
right.getChildren().add(btnReveal);
FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager"));
btnReveal.setOnAction(event -> skinnable.onReveal());
JFXButton btnDelete = FXUtils.newToggleButton4(SVG.DELETE_FOREVER);
right.getChildren().add(btnDelete);
FXUtils.installFastTooltip(btnDelete, i18n("world.backup.delete"));
btnDelete.setOnAction(event -> Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), skinnable::onDelete, null));
}
getChildren().setAll(new RipplerContainer(root));
}
}
}
@@ -1,65 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.scene.control.Skin;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.wizard.WizardSinglePage;
import org.jackhuang.hmcl.util.i18n.I18n;
import java.nio.file.Path;
import java.nio.file.Paths;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class WorldExportPage extends WizardSinglePage {
private final StringProperty path = new SimpleStringProperty();
private final StringProperty gameVersion = new SimpleStringProperty();
private final StringProperty worldName = new SimpleStringProperty();
private final World world;
public WorldExportPage(World world, Path export, Runnable onFinish) {
super(onFinish);
this.world = world;
path.set(export.toString());
if (world.getGameVersion() != null)
gameVersion.set(I18n.getDisplayVersion(world.getGameVersion()));
worldName.set(world.getWorldName());
}
@Override
protected Skin<?> createDefaultSkin() {
return new WorldExportPageSkin(this);
}
public StringProperty pathProperty() {
return path;
}
public StringProperty gameVersionProperty() {
return gameVersion;
}
public StringProperty worldNameProperty() {
return worldName;
}
public void export() {
onFinish.run();
}
@Override
public String getTitle() {
return i18n("world.export.wizard", world.getFileName());
}
@Override
protected Object finish() {
return Task.runAsync(i18n("world.export.wizard", worldName.get()), () -> world.export(Paths.get(path.get()), worldName.get()));
}
}
@@ -1,72 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXTextField;
import javafx.beans.binding.Bindings;
import javafx.geometry.Pos;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.LineFileChooserButton;
import org.jackhuang.hmcl.ui.construct.LinePane;
import org.jackhuang.hmcl.ui.construct.LineTextPane;
import java.nio.file.Files;
import java.nio.file.Paths;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class WorldExportPageSkin extends SkinBase<WorldExportPage> {
public WorldExportPageSkin(WorldExportPage skinnable) {
super(skinnable);
VBox container = new VBox();
container.setSpacing(16);
container.setAlignment(Pos.CENTER);
FXUtils.setLimitWidth(container, 500);
ComponentList list = new ComponentList();
var chooseFileButton = new LineFileChooserButton();
chooseFileButton.setTitle(i18n("world.export.location"));
chooseFileButton.setType(LineFileChooserButton.Type.SAVE_FILE);
chooseFileButton.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("world"), "*.zip"));
chooseFileButton.locationProperty().bindBidirectional(skinnable.pathProperty());
var worldNamePane = new LinePane();
worldNamePane.setTitle(i18n("world.name"));
JFXTextField txtWorldName = new JFXTextField();
txtWorldName.textProperty().bindBidirectional(skinnable.worldNameProperty());
txtWorldName.setPrefWidth(300);
worldNamePane.setRight(txtWorldName);
LineTextPane gameVersionPane = new LineTextPane();
gameVersionPane.setTitle(i18n("world.game_version"));
gameVersionPane.textProperty().bind(skinnable.gameVersionProperty());
list.getContent().setAll(chooseFileButton, worldNamePane, gameVersionPane);
container.getChildren().setAll(
ComponentList.createComponentListTitle(i18n("world.export")),
list
);
JFXButton btnExport = FXUtils.newRaisedButton(i18n("button.export"));
btnExport.disableProperty().bind(Bindings.createBooleanBinding(() -> txtWorldName.getText().isEmpty() || Files.exists(Paths.get(chooseFileButton.getLocation())),
txtWorldName.textProperty().isEmpty(), chooseFileButton.locationProperty()));
btnExport.setOnAction(e -> skinnable.export());
HBox bottom = new HBox();
bottom.setAlignment(Pos.CENTER_RIGHT);
bottom.getChildren().setAll(btnExport);
container.getChildren().add(bottom);
getChildren().setAll(container);
}
}
@@ -1,670 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2024 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXTextField;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.effect.BoxBlur;
import javafx.scene.image.Image;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import org.glavo.nbt.tag.*;
import org.glavo.nbt.tag.Tag;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.DecimalFormat;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.Locale;
import static org.jackhuang.hmcl.util.i18n.I18n.formatDateTime;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.WorldRefreshable {
private final WorldManagePage worldManagePage;
private boolean isReadOnly;
private final World world;
private CompoundTag levelData;
private CompoundTag playerData;
private final ImageContainer iconImageView = new ImageContainer(32);
public WorldInfoPage(WorldManagePage worldManagePage) {
this.worldManagePage = worldManagePage;
this.world = worldManagePage.getWorld();
refresh();
}
private void updateControls() {
CompoundTag dataTag = (CompoundTag) levelData.get("Data");
CompoundTag playerTag = playerData;
ScrollPane scrollPane = new ScrollPane();
scrollPane.setFitToHeight(true);
scrollPane.setFitToWidth(true);
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.ALWAYS);
setContent(scrollPane);
VBox rootPane = new VBox();
rootPane.setFillWidth(true);
scrollPane.setContent(rootPane);
FXUtils.smoothScrolling(scrollPane);
rootPane.getStyleClass().add("card-list");
ComponentList worldInfo = new ComponentList();
{
var worldNamePane = new LinePane();
{
worldNamePane.setTitle(i18n("world.name"));
JFXTextField worldNameField = new JFXTextField();
setRightTextField(worldNamePane, worldNameField, 200);
if (dataTag.get("LevelName") instanceof StringTag worldNameTag) {
var worldName = new SimpleStringProperty(worldNameTag.get());
FXUtils.bindString(worldNameField, worldName);
worldNameField.getProperties().put(WorldInfoPage.class.getName() + ".worldNameProperty", worldName);
worldName.addListener((observable, oldValue, newValue) -> {
if (StringUtils.isNotBlank(newValue)) {
try {
world.setWorldName(newValue);
worldManagePage.setTitle(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())));
} catch (Exception e) {
LOG.warning("Failed to set world name", e);
}
}
});
} else {
worldNameField.setDisable(true);
}
}
var gameVersionPane = new LineTextPane();
{
gameVersionPane.setTitle(i18n("world.info.game_version"));
gameVersionPane.setText(world.getGameVersion() == null ? "" : world.getGameVersion().toNormalizedString());
}
var iconPane = new LinePane();
{
iconPane.setTitle(i18n("world.icon"));
{
iconImageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon());
}
JFXButton editIconButton = FXUtils.newToggleButton4(SVG.EDIT, 20);
JFXButton resetIconButton = FXUtils.newToggleButton4(SVG.RESTORE, 20);
{
editIconButton.setDisable(isReadOnly);
editIconButton.setOnAction(event -> Controllers.confirm(
I18n.i18n("world.icon.change.tip"),
I18n.i18n("world.icon.change"),
MessageDialogPane.MessageType.INFO,
this::changeWorldIcon,
null
));
FXUtils.installFastTooltip(editIconButton, i18n("button.edit"));
resetIconButton.setDisable(isReadOnly);
resetIconButton.setOnAction(event -> this.clearWorldIcon());
FXUtils.installFastTooltip(resetIconButton, i18n("button.reset"));
}
HBox hBox = new HBox(8);
hBox.setAlignment(Pos.CENTER_LEFT);
hBox.getChildren().addAll(iconImageView, editIconButton, resetIconButton);
iconPane.setRight(hBox);
}
var seedPane = new LinePane();
{
seedPane.setTitle(i18n("world.info.random_seed"));
SimpleBooleanProperty visibility = new SimpleBooleanProperty();
JFXButton visibilityButton = FXUtils.newToggleButton4(SVG.VISIBILITY_OFF);
{
visibilityButton.setAlignment(Pos.CENTER);
visibilityButton.setOnAction((event) -> visibility.set(!visibility.get()));
}
Label seedLabel = new Label();
{
FXUtils.copyOnDoubleClick(seedLabel);
seedLabel.setAlignment(Pos.CENTER_RIGHT);
seedLabel.setText(world.getSeed() != null ? world.getSeed().toString() : "");
BoxBlur blur = new BoxBlur();
blur.setIterations(3);
FXUtils.onChangeAndOperate(visibility, isVisibility -> {
SVG icon = isVisibility ? SVG.VISIBILITY : SVG.VISIBILITY_OFF;
visibilityButton.setGraphic(icon.createIcon(16));
seedLabel.setEffect(isVisibility ? null : blur);
});
}
HBox right = new HBox(8);
{
right.setAlignment(Pos.CENTER_RIGHT);
right.getChildren().setAll(visibilityButton, seedLabel);
seedPane.setRight(right);
}
}
var worldSpawnPoint = new LineTextPane();
{
worldSpawnPoint.setTitle(i18n("world.info.spawn"));
String value;
// Valid after 1.21.9-pre1
if (dataTag.get("spawn") instanceof CompoundTag spawnTag && spawnTag.get("pos") instanceof IntArrayTag posTag) {
value = (spawnTag.get("dimension") instanceof StringTag dimensionTag)
? Dimension.of(dimensionTag).formatPosition(posTag)
: Dimension.OVERWORLD.formatPosition(posTag);
}
// Valid before 1.21.9-pre1
else if (dataTag.get("SpawnX") instanceof IntTag intX
&& dataTag.get("SpawnY") instanceof IntTag intY
&& dataTag.get("SpawnZ") instanceof IntTag intZ) {
value = Dimension.OVERWORLD.formatPosition(intX.getValue(), intY.getValue(), intZ.getValue());
} else {
value = null;
}
worldSpawnPoint.setText(value);
}
var lastPlayedPane = new LineTextPane();
{
lastPlayedPane.setTitle(i18n("world.info.last_played"));
lastPlayedPane.setText(formatDateTime(Instant.ofEpochMilli(world.getLastPlayed())));
}
var timePane = new LineTextPane();
{
timePane.setTitle(i18n("world.info.time"));
if (dataTag.get("Time") instanceof LongTag timeTag) {
Duration duration = Duration.ofSeconds(timeTag.getValue() / 20);
timePane.setText(i18n("world.info.time.format", duration.toDays(), duration.toHoursPart(), duration.toMinutesPart()));
}
}
var allowCheatsButton = new LineSwitch();
{
allowCheatsButton.setTitle(i18n("world.info.allow_cheats"));
allowCheatsButton.setDisable(isReadOnly);
bindTagAndSwitch(dataTag.get("allowCommands"), allowCheatsButton);
}
var generateFeaturesButton = new LineSwitch();
{
generateFeaturesButton.setTitle(i18n("world.info.generate_features"));
generateFeaturesButton.setDisable(isReadOnly);
// Valid before (1.16)20w20a
if (dataTag.get("MapFeatures") instanceof ByteTag generateFeaturesTag) {
bindTagAndSwitch(generateFeaturesTag, generateFeaturesButton);
}
// Valid after (1.16)20w20a
else if (world.getNormalizedWorldGenSettingsData() != null) {
CompoundTag unifiedWorldGenSettingsDataTag = world.getNormalizedWorldGenSettingsData();
Tag tag = unifiedWorldGenSettingsDataTag.get("generate_features"); // Valid between (1.16)20w20a and 26.1-snapshot-6
if (tag == null)
tag = unifiedWorldGenSettingsDataTag.get("generate_structures"); // Valid after 26.1-snapshot-6
bindTagAndSwitch(tag, generateFeaturesButton);
} else {
generateFeaturesButton.setDisable(true);
}
}
var difficultyButton = new LineSelectButton<Difficulty>();
{
difficultyButton.setTitle(i18n("world.info.difficulty"));
difficultyButton.setDisable(isReadOnly);
difficultyButton.setItems(Difficulty.items);
Difficulty difficulty;
// Valid before 26.1-snapshot-6
if (dataTag.get("Difficulty") instanceof ByteTag difficultyTag
&& (difficulty = Difficulty.of(difficultyTag.getValue())) != null) {
difficultyButton.setValue(difficulty);
difficultyButton.valueProperty().addListener((o, oldValue, newValue) -> {
if (newValue != null) {
difficultyTag.setValue((byte) newValue.ordinal());
saveWorldData();
}
});
}
// Valid after 26.1-snapshot-6
else if (dataTag.get("difficulty_settings") instanceof CompoundTag difficultySettingTag
&& difficultySettingTag.get("difficulty") instanceof StringTag difficultyTag
&& (difficulty = Difficulty.of(difficultyTag.getValue())) != null) {
difficultyButton.setValue(difficulty);
difficultyButton.valueProperty().addListener((o, oldValue, newValue) -> {
if (newValue != null) {
difficultyTag.setValue(newValue.getTagStringValue());
saveWorldData();
}
});
} else {
difficultyButton.setDisable(true);
}
}
var difficultyLockPane = new LineSwitch();
{
difficultyLockPane.setTitle(i18n("world.info.difficulty_lock"));
difficultyLockPane.setDisable(isReadOnly);
// Valid before 26.1-snapshot-6
if (dataTag.get("DifficultyLocked") instanceof ByteTag difficultyLockedTag) {
bindTagAndSwitch(difficultyLockedTag, difficultyLockPane);
}
// Valid after 26.1-snapshot-6
else if (dataTag.get("difficulty_settings") instanceof CompoundTag difficultySettingTag
&& difficultySettingTag.get("locked") instanceof ByteTag lockedTag) {
bindTagAndSwitch(lockedTag, difficultyLockPane);
} else {
difficultyLockPane.setDisable(true);
}
}
worldInfo.getContent().setAll(
worldNamePane, gameVersionPane, iconPane, seedPane, worldSpawnPoint, lastPlayedPane, timePane,
allowCheatsButton, generateFeaturesButton, difficultyButton, difficultyLockPane);
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("world.info")), worldInfo);
}
if (playerTag != null) {
ComponentList playerInfo = new ComponentList();
var locationPane = new LineTextPane();
{
locationPane.setTitle(i18n("world.info.player.location"));
Dimension dimension = Dimension.of(playerTag.get("Dimension"));
if (dimension != null && playerTag.get("Pos") instanceof ListTag<?> posTag) {
locationPane.setText(dimension.formatPosition(posTag));
}
}
var lastDeathLocationPane = new LineTextPane();
{
lastDeathLocationPane.setTitle(i18n("world.info.player.last_death_location"));
// Valid after 22w14a; prior to this version, the game did not record the last death location data.
if (playerTag.get("LastDeathLocation") instanceof CompoundTag LastDeathLocationTag) {
Dimension dimension = Dimension.of(LastDeathLocationTag.get("dimension"));
if (dimension != null && LastDeathLocationTag.get("pos") instanceof IntArrayTag posTag) {
lastDeathLocationPane.setText(dimension.formatPosition(posTag));
}
}
}
var spawnPane = new LineTextPane();
{
spawnPane.setTitle(i18n("world.info.player.spawn"));
if (playerTag.get("respawn") instanceof CompoundTag respawnTag
&& respawnTag.get("dimension") instanceof StringTag dimensionTag
&& respawnTag.get("pos") instanceof IntArrayTag intArrayTag
&& intArrayTag.size() >= 3) { // Valid after 25w07a
spawnPane.setText(Dimension.of(dimensionTag).formatPosition(intArrayTag));
} else if (playerTag.get("SpawnX") instanceof IntTag intX
&& playerTag.get("SpawnY") instanceof IntTag intY
&& playerTag.get("SpawnZ") instanceof IntTag intZ) { // Valid before 25w07a
// SpawnDimension tag is valid after 20w12a. Prior to this version, the game did not record the respawn point dimension and respawned in the Overworld.
spawnPane.setText((playerTag.get("SpawnDimension") instanceof StringTag dimensionTag ? Dimension.of(dimensionTag) : Dimension.OVERWORLD)
.formatPosition(intX.getValue(), intY.getValue(), intZ.getValue()));
}
}
var playerGameTypePane = new LineSelectButton<GameType>();
{
playerGameTypePane.setTitle(i18n("world.info.player.game_type"));
playerGameTypePane.setDisable(worldManagePage.isReadOnly());
playerGameTypePane.setItems(GameType.items);
// Valid before 26.1-snapshot-6
Tag rawHardcoreTag = dataTag.get("hardcore");
// Valid after 26.1-snapshot-6
if (rawHardcoreTag == null && dataTag.get("difficulty_settings") instanceof CompoundTag difficultySettingsTag) {
rawHardcoreTag = difficultySettingsTag.get("hardcore");
}
if (playerTag.get("playerGameType") instanceof IntTag playerGameTypeTag && rawHardcoreTag instanceof ByteTag hardcoreTag) {
boolean isHardcore = hardcoreTag.getValue() == 1;
GameType gameType = GameType.of(playerGameTypeTag.getValue(), isHardcore);
if (gameType != null) {
playerGameTypePane.setValue(gameType);
playerGameTypePane.valueProperty().addListener((o, oldValue, newValue) -> {
if (newValue != null) {
if (newValue == GameType.HARDCORE) {
playerGameTypeTag.setValue(0); // survival (hardcore worlds are survival+hardcore flag)
hardcoreTag.setValue((byte) 1);
} else {
playerGameTypeTag.setValue(newValue.ordinal());
hardcoreTag.setValue((byte) 0);
}
saveWorldData();
}
});
} else {
playerGameTypePane.setDisable(true);
}
} else {
playerGameTypePane.setDisable(true);
}
}
var healthPane = new LinePane();
{
healthPane.setTitle(i18n("world.info.player.health"));
setRightTextField(healthPane, 50, playerTag.get("Health"));
}
var foodLevelPane = new LinePane();
{
foodLevelPane.setTitle(i18n("world.info.player.food_level"));
setRightTextField(foodLevelPane, 50, playerTag.get("foodLevel"));
}
var foodSaturationPane = new LinePane();
{
foodSaturationPane.setTitle(i18n("world.info.player.food_saturation_level"));
setRightTextField(foodSaturationPane, 50, playerTag.get("foodSaturationLevel"));
}
var xpLevelPane = new LinePane();
{
xpLevelPane.setTitle(i18n("world.info.player.xp_level"));
setRightTextField(xpLevelPane, 50, playerTag.get("XpLevel"));
}
playerInfo.getContent().setAll(
locationPane, lastDeathLocationPane, spawnPane, playerGameTypePane,
healthPane, foodLevelPane, foodSaturationPane, xpLevelPane
);
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("world.info.player")), playerInfo);
}
}
private void setRightTextField(LinePane linePane, int perfWidth, Tag tag) {
JFXTextField textField = new JFXTextField();
setRightTextField(linePane, textField, perfWidth);
if (tag instanceof IntTag intTag) {
bindTagAndTextField(intTag, textField);
} else if (tag instanceof FloatTag floatTag) {
bindTagAndTextField(floatTag, textField);
} else {
textField.setDisable(true);
}
}
private void setRightTextField(LinePane linePane, JFXTextField textField, int perfWidth) {
textField.setDisable(isReadOnly);
textField.setPrefWidth(perfWidth);
linePane.setRight(textField);
}
/// Saves boolean byte-tag edits through the switch, disabling it for missing or invalid tags.
private void bindTagAndSwitch(@Nullable Tag tag, LineSwitch switchControl) {
if (tag instanceof ByteTag byteTag) {
byte value = byteTag.getValue();
if (value == 0 || value == 1) {
switchControl.setSelected(value == 1);
switchControl.selectedProperty().addListener((o, oldValue, newValue) -> {
try {
byteTag.setValue((byte) (newValue ? 1 : 0));
saveWorldData();
} catch (Exception e) {
switchControl.setSelected(oldValue);
LOG.warning("Exception happened when saving world info", e);
}
});
} else {
switchControl.setDisable(true);
}
} else {
switchControl.setDisable(true);
}
}
private void bindTagAndTextField(IntTag intTag, JFXTextField jfxTextField) {
jfxTextField.setText(intTag.getValue().toString());
jfxTextField.textProperty().addListener((o, oldValue, newValue) -> {
if (newValue != null) {
try {
Integer integer = Lang.toIntOrNull(newValue);
if (integer != null) {
intTag.setValue(integer);
saveWorldData();
}
} catch (Exception e) {
jfxTextField.setText(oldValue);
LOG.warning("Exception happened when saving world data", e);
}
}
});
FXUtils.setValidateWhileTextChanged(jfxTextField, true);
jfxTextField.setValidators(new NumberValidator(i18n("input.number"), true));
}
private void bindTagAndTextField(FloatTag floatTag, JFXTextField jfxTextField) {
jfxTextField.setText(new DecimalFormat("0.#").format(floatTag.getValue()));
jfxTextField.textProperty().addListener((o, oldValue, newValue) -> {
if (newValue != null) {
try {
Float floatValue = Lang.toFloatOrNull(newValue);
if (floatValue != null) {
floatTag.setValue(floatValue);
saveWorldData();
}
} catch (Exception e) {
jfxTextField.setText(oldValue);
LOG.warning("Exception happened when saving world data", e);
}
}
});
FXUtils.setValidateWhileTextChanged(jfxTextField, true);
jfxTextField.setValidators(new DoubleValidator(i18n("input.number"), true));
}
private void saveWorldData() {
LOG.info("Saving data of world " + world.getWorldName());
try {
this.world.writeWorldData();
} catch (IOException e) {
LOG.warning("Failed to save world data", e);
}
}
@Override
public void refresh() {
setFailedReason(null);
try {
this.isReadOnly = worldManagePage.isReadOnly();
this.levelData = world.getLevelData();
this.playerData = world.getPlayerData();
updateControls();
} catch (Exception e) {
LOG.warning("Failed to refresh world info", e);
setFailedReason(i18n("world.info.failed"));
}
}
private record Dimension(String name) {
static final Dimension OVERWORLD = new Dimension(null);
static final Dimension THE_NETHER = new Dimension(i18n("world.info.dimension.the_nether"));
static final Dimension THE_END = new Dimension(i18n("world.info.dimension.the_end"));
static Dimension of(Tag tag) {
if (tag instanceof IntTag intTag) {
return switch (intTag.get()) {
case 0 -> OVERWORLD;
case -1 -> THE_NETHER;
case 1 -> THE_END;
default -> null;
};
} else if (tag instanceof StringTag stringTag) {
String id = stringTag.getValue();
return switch (id) {
case "overworld", "minecraft:overworld" -> OVERWORLD;
case "the_nether", "minecraft:the_nether" -> THE_NETHER;
case "the_end", "minecraft:the_end" -> THE_END;
default -> new Dimension(id);
};
} else {
return null;
}
}
String formatPosition(Tag tag) {
if (tag instanceof ListTag<?> listTag && listTag.size() == 3) {
if (listTag.getTag(0) instanceof DoubleTag x
&& listTag.getTag(1) instanceof DoubleTag y
&& listTag.getTag(2) instanceof DoubleTag z) {
return formatPosition(x.get(), y.get(), z.get());
}
return null;
}
if (tag instanceof IntArrayTag intArrayTag) {
int x = intArrayTag.get(0);
int y = intArrayTag.get(1);
int z = intArrayTag.get(2);
return formatPosition(x, y, z);
}
return null;
}
String formatPosition(int x, int y, int z) {
return this == OVERWORLD
? "(%d, %d, %d)".formatted(x, y, z)
: "%s (%d, %d, %d)".formatted(name, x, y, z);
}
String formatPosition(double x, double y, double z) {
return this == OVERWORLD
? "(%.2f, %.2f, %.2f)".formatted(x, y, z)
: "%s (%.2f, %.2f, %.2f)".formatted(name, x, y, z);
}
}
private enum Difficulty {
PEACEFUL, EASY, NORMAL, HARD;
static final ObservableList<Difficulty> items = FXCollections.observableList(Arrays.asList(values()));
static Difficulty of(int d) {
return (d >= 0 && d < items.size()) ? items.get(d) : null;
}
static Difficulty of(String name) {
for (Difficulty d : items) if (d.name().toLowerCase(Locale.ROOT).equals(name)) return d;
return null;
}
String getTagStringValue() {
return this.name().toLowerCase(Locale.ROOT);
}
@Override
public String toString() {
return i18n("world.info.difficulty." + name().toLowerCase(Locale.ROOT));
}
}
private enum GameType {
SURVIVAL, CREATIVE, ADVENTURE, SPECTATOR, HARDCORE;
static final ObservableList<GameType> items = FXCollections.observableList(Arrays.asList(values()));
static GameType of(int d, boolean hardcore) {
if (hardcore && d == 0) return HARDCORE; // hardcore + survival
return d >= 0 && d < 4 ? items.get(d) : null;
}
@Override
public String toString() {
return i18n("world.info.player.game_type." + name().toLowerCase(Locale.ROOT));
}
}
private void changeWorldIcon() {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("world.icon.choose.title"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("extension.png"), "*.png"));
fileChooser.setInitialFileName("icon.png");
Path iconPath = Controllers.showOpenDialog(fileChooser);
if (iconPath == null) return;
Image image;
try {
image = FXUtils.loadImage(iconPath);
} catch (Exception e) {
LOG.warning("Failed to load image", e);
Controllers.dialog(i18n("world.icon.change.fail.load.text"), i18n("world.icon.change.fail.load.title"), MessageDialogPane.MessageType.ERROR);
return;
}
if ((int) image.getWidth() == 64 && (int) image.getHeight() == 64) {
Path output = world.getFile().resolve("icon.png");
saveWorldIcon(iconPath, image, output);
} else {
Controllers.dialog(i18n("world.icon.change.fail.not_64x64.text", (int) image.getWidth(), (int) image.getHeight()), i18n("world.icon.change.fail.not_64x64.title"), MessageDialogPane.MessageType.ERROR);
}
}
private void saveWorldIcon(Path sourcePath, Image image, Path targetPath) {
Image oldImage = iconImageView.getImage();
try {
FileUtils.copyFile(sourcePath, targetPath);
iconImageView.setImage(image);
Controllers.showToast(i18n("world.icon.change.succeed.toast"));
} catch (IOException e) {
LOG.warning("Failed to save world icon " + e.getMessage());
iconImageView.setImage(oldImage);
}
}
private void clearWorldIcon() {
Path output = world.getFile().resolve("icon.png");
try {
Files.deleteIfExists(output);
iconImageView.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_server.png"));
} catch (IOException e) {
LOG.warning("Failed to delete world icon ", e);
}
}
}
@@ -1,427 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXCheckBox;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXPopup;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.Skin;
import javafx.scene.control.Tooltip;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.ChunkBaseApp;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition;
import static org.jackhuang.hmcl.util.StringUtils.parseColorEscapes;
import static org.jackhuang.hmcl.util.i18n.I18n.formatDateTime;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class WorldListPage extends ListPageBase<World> {
private final BooleanProperty showAll = new SimpleBooleanProperty(this, "showAll", false);
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private Path savesDir;
private List<World> worlds;
private @Nullable HMCLGameInstance gameInstance;
private final BooleanProperty supportQuickPlay = new SimpleBooleanProperty(this, "supportQuickPlay", false);
private int refreshCount = 0;
/// Creates a world list that reloads when `instanceContext` changes.
///
/// @param instanceContext the parent page's instance property
public WorldListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
Objects.requireNonNull(instanceContext, "instanceContext");
FXUtils.applyDragListener(this, it -> "zip".equals(FileUtils.getExtension(it)), modpacks -> {
installWorld(modpacks.get(0));
});
showAll.addListener(e -> updateWorldList());
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
if (current != null) {
loadInstance(current);
}
}));
}
@Override
protected Skin<WorldListPage> createDefaultSkin() {
return new WorldListPageSkin();
}
public void loadInstance(HMCLGameInstance.Optional instance) {
this.gameInstance = instance.instance();
this.savesDir = gameInstance != null ? gameInstance.getSavesDirectory() : null;
refresh();
}
private void updateWorldList() {
if (worlds == null || gameInstance == null) {
getItems().clear();
} else if (showAll.get()) {
getItems().setAll(worlds);
} else {
GameVersionNumber gameVersion = gameInstance.getVersion();
getItems().setAll(worlds.stream()
.filter(world -> world.getGameVersion() == null || world.getGameVersion().equals(gameVersion))
.toList());
}
}
public void refresh() {
if (gameInstance == null || savesDir == null)
return;
int currentRefresh = ++refreshCount;
HMCLGameInstance gameInstance = this.gameInstance;
setLoading(true);
Task.supplyAsync(Schedulers.io(), () -> {
// Ensure the game version number is parsed
gameInstance.getVersion();
return World.getWorlds(savesDir);
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (refreshCount != currentRefresh) {
// A newer refresh task is running, discard this result
return;
}
supportQuickPlay.set(World.supportQuickPlay(gameInstance.getVersion()));
worlds = result;
updateWorldList();
if (exception != null)
LOG.warning("Failed to load world list page", exception);
setLoading(false);
}).start();
}
public void openSavesFolder() {
if (gameInstance != null) {
FXUtils.openFolder(gameInstance.getSavesDirectory());
}
}
public void add() {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("world.add.title"));
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("extension.world"), "*.zip"));
List<Path> res = Controllers.showOpenMultipleDialog(chooser);
if (res == null || res.isEmpty()) return;
installWorld(res.get(0));
}
private void installWorld(Path zipFile) {
// Only accept one world file because user is required to confirm the new world name
// Or too many input dialogs are popped.
Task.supplyAsync(() -> new World(zipFile))
.whenComplete(Schedulers.javafx(), world -> {
Controllers.prompt(i18n("world.name.enter"), (name, handler) -> {
Task.runAsync(() -> world.install(savesDir, name))
.whenComplete(Schedulers.javafx(), () -> {
handler.resolve();
refresh();
}, e -> {
if (e instanceof FileAlreadyExistsException)
handler.reject(i18n("world.add.failed", i18n("world.add.already_exists")));
else if (e instanceof IOException && e.getCause() instanceof InvalidPathException)
handler.reject(i18n("world.add.failed", i18n("install.new_game.malformed")));
else
handler.reject(i18n("world.add.failed", e.getClass().getName() + ": " + e.getLocalizedMessage()));
}).start();
}, world.getWorldName(), new Validator(i18n("install.new_game.malformed"), FileUtils::isNameValid));
}, e -> {
LOG.warning("Unable to parse world file " + zipFile, e);
Controllers.dialog(i18n("world.add.invalid"));
}).start();
}
private void showManagePage(World world) {
if (gameInstance != null) {
Controllers.navigate(new WorldManagePage(world, gameInstance));
}
}
public void export(World world) {
WorldManageUIUtils.export(world);
}
public void delete(World world) {
WorldManageUIUtils.delete(world, this::refresh);
}
public void copy(World world) {
WorldManageUIUtils.copyWorld(world, this::refresh);
}
public void reveal(World world) {
FXUtils.openFolder(world.getFile());
}
public void launch(World world) {
if (gameInstance != null) {
Instances.launchAndEnterWorld(gameInstance, world.getFileName());
}
}
public void generateLaunchScript(World world) {
if (gameInstance != null) {
Instances.generateLaunchScriptForQuickEnterWorld(gameInstance, world.getFileName());
}
}
public BooleanProperty showAllProperty() {
return showAll;
}
public ReadOnlyBooleanProperty supportQuickPlayProperty() {
return supportQuickPlay;
}
private final class WorldListPageSkin extends ToolbarListPageSkin<World, WorldListPage> {
WorldListPageSkin() {
super(WorldListPage.this);
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("world.empty"));
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
}
@Override
protected List<Node> initializeToolbar(WorldListPage skinnable) {
JFXCheckBox chkShowAll = new JFXCheckBox(i18n("world.show_all"));
chkShowAll.selectedProperty().bindBidirectional(skinnable.showAllProperty());
return Arrays.asList(
chkShowAll,
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
createToolbarButton2(i18n("world.add"), SVG.ADD, skinnable::add),
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::openSavesFolder)
);
}
@Override
protected ListCell<World> createListCell(JFXListView<World> listView) {
return new WorldListCell(getSkinnable());
}
}
private static final class WorldListCell extends ListCell<World> {
private final WorldListPage page;
private final RipplerContainer graphic;
private final ImageContainer imageView;
private final Tooltip leftTooltip;
private final TwoLineListItem content;
private final JFXButton btnLaunch;
/// Creates a world row with launch and management actions for the supplied page.
public WorldListCell(WorldListPage page) {
this.page = page;
var root = new BorderPane();
root.getStyleClass().add("md-list-cell");
root.setPadding(new Insets(8));
{
StackPane left = new StackPane();
this.leftTooltip = new Tooltip();
FXUtils.installSlowTooltip(left, leftTooltip);
root.setLeft(left);
left.setPadding(new Insets(0, 8, 0, 0));
this.imageView = new ImageContainer(32);
left.getChildren().add(imageView);
}
{
this.content = new TwoLineListItem();
root.setCenter(content);
}
{
HBox right = new HBox(8);
root.setRight(right);
right.setAlignment(Pos.CENTER_RIGHT);
btnLaunch = FXUtils.newToggleButton4(SVG.ROCKET_LAUNCH);
btnLaunch.visibleProperty().bind(page.supportQuickPlayProperty());
btnLaunch.managedProperty().bind(btnLaunch.visibleProperty());
right.getChildren().add(btnLaunch);
FXUtils.installFastTooltip(btnLaunch, i18n("instance.launch"));
btnLaunch.setOnAction(event -> {
World world = getItem();
if (world != null)
page.launch(world);
});
JFXButton btnMore = FXUtils.newToggleButton4(SVG.MORE_VERT);
right.getChildren().add(btnMore);
btnMore.setOnAction(event -> {
World world = getItem();
if (world != null)
showPopupMenu(world, page.supportQuickPlayProperty().get(), JFXPopup.PopupHPosition.RIGHT, 0, root.getHeight());
});
}
this.graphic = new RipplerContainer(root);
graphic.setOnMouseClicked(event -> {
if (event.getClickCount() != 1)
return;
World world = getItem();
if (world == null)
return;
if (event.getButton() == MouseButton.PRIMARY)
page.showManagePage(world);
else if (event.getButton() == MouseButton.SECONDARY)
showPopupMenu(world, page.supportQuickPlayProperty().get(), JFXPopup.PopupHPosition.LEFT, event.getX(), event.getY());
});
}
/// Updates world details and replaces version and lock tags together.
@Override
protected void updateItem(@Nullable World world, boolean empty) {
@Nullable World oldWorld = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(world, empty);
if (oldWorld == world && oldEmpty == empty) return;
this.graphic.resetFeedback();
if (empty || world == null) {
content.getTags().clear();
setGraphic(null);
imageView.setImage(null);
leftTooltip.setText("");
content.setTitle("");
content.setSubtitle("");
} else {
imageView.setImage(world.getIcon() == null ? FXUtils.newBuiltinImage("/assets/img/unknown_server.png") : world.getIcon());
leftTooltip.setText(world.getFile().toString());
content.setTitle(world.getWorldName() != null ? parseColorEscapes(world.getWorldName()) : "");
var tags = new ArrayList<Tag>(2);
if (world.getGameVersion() != null)
tags.add(new Tag(I18n.getDisplayVersion(world.getGameVersion())));
if (world.isLocked()) {
tags.add(new Tag(i18n("world.locked")));
btnLaunch.setDisable(true);
} else {
btnLaunch.setDisable(false);
}
content.getTags().setAll(tags);
content.setSubtitle(i18n("world.datetime", formatDateTime(Instant.ofEpochMilli(world.getLastPlayed()))));
setGraphic(graphic);
}
}
// Popup Menu
public void showPopupMenu(World world, boolean supportQuickPlay, JFXPopup.PopupHPosition hPosition, double initOffsetX, double initOffsetY) {
boolean worldLocked = world.isLocked();
PopupMenu popupMenu = new PopupMenu();
JFXPopup popup = new JFXPopup(popupMenu);
if (supportQuickPlay) {
IconedMenuItem launchItem = new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("instance.launch_and_enter_world"), () -> page.launch(world), popup);
launchItem.setDisable(worldLocked);
popupMenu.getContent().addAll(
launchItem,
new IconedMenuItem(SVG.SCRIPT, i18n("instance.launch_script"), () -> page.generateLaunchScript(world), popup),
new MenuSeparator()
);
}
popupMenu.getContent().add(new IconedMenuItem(SVG.SETTINGS, i18n("world.manage.button"), () -> page.showManagePage(world), popup));
if (ChunkBaseApp.isSupported(world)) {
popupMenu.getContent().addAll(
new MenuSeparator(),
new IconedMenuItem(SVG.EXPLORE, i18n("world.chunkbase.seed_map"), () -> ChunkBaseApp.openSeedMap(world), popup),
new IconedMenuItem(SVG.VISIBILITY, i18n("world.chunkbase.stronghold"), () -> ChunkBaseApp.openStrongholdFinder(world), popup),
new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(world), popup)
);
if (ChunkBaseApp.supportEndCity(world)) {
popupMenu.getContent().add(new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"), () -> ChunkBaseApp.openEndCityFinder(world), popup));
}
}
IconedMenuItem exportMenuItem = new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), () -> page.export(world), popup);
exportMenuItem.setDisable(worldLocked);
IconedMenuItem deleteMenuItem = new IconedMenuItem(SVG.DELETE_FOREVER, i18n("world.delete"), () -> page.delete(world), popup);
deleteMenuItem.setDisable(worldLocked);
IconedMenuItem duplicateMenuItem = new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), () -> page.copy(world), popup);
duplicateMenuItem.setDisable(worldLocked);
popupMenu.getContent().addAll(
new MenuSeparator(),
exportMenuItem,
deleteMenuItem,
duplicateMenuItem
);
popupMenu.getContent().addAll(
new MenuSeparator(),
new IconedMenuItem(SVG.FOLDER_OPEN, i18n("folder.world"), () -> page.reveal(world), popup)
);
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(this, popup);
popup.show(this, vPosition, hPosition, initOffsetX, vPosition == JFXPopup.PopupVPosition.TOP ? initOffsetY : -initOffsetY);
}
}
}
@@ -1,250 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXPopup;
import javafx.application.Platform;
import javafx.beans.property.*;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.ChunkBaseApp;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Displays world information, backups, and management actions.
public final class WorldManagePage extends DecoratorAnimatedPage implements DecoratorPage {
private final World world;
private final Path backupsDir;
private final HMCLGameInstance gameInstance;
private final boolean supportQuickPlay;
/// The active world lock, or null when the world is read-only.
private @Nullable FileChannel sessionLockChannel;
private final ObjectProperty<State> state;
private boolean isFirstNavigation = true;
private final BooleanProperty refreshable = new SimpleBooleanProperty(true);
private final BooleanProperty readOnly = new SimpleBooleanProperty(false);
private final TransitionPane transitionPane = new TransitionPane();
/// Manages tab selection and content transitions independently of the sidebar.
private final TabController tabController = new TabController(transitionPane);
/// The tab displaying information about this world.
private final TabItem<WorldInfoPage> worldInfoTab;
/// The tab for managing backups of this world.
private final TabItem<WorldBackupsPage> worldBackupsTab;
/// The tab for managing data packs in this world.
private final TabItem<DataPackListPage> dataPackTab;
/// Loads world metadata and creates its tabs and sidebar actions.
public WorldManagePage(World world, HMCLGameInstance gameInstance) {
this.world = world;
this.gameInstance = gameInstance;
this.backupsDir = gameInstance.getBackupsDirectory();
updateSessionLockChannel();
try {
this.world.reloadWorldData();
} catch (IOException e) {
LOG.warning("Can not load world data of world: " + this.world.getFile(), e);
this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> closePageForLoadingFail());
}
this.worldInfoTab = new TabItem<>(() -> new WorldInfoPage(this));
this.worldBackupsTab = new TabItem<>(() -> new WorldBackupsPage(this));
this.dataPackTab = new TabItem<>(() -> new DataPackListPage(this));
this.state = new SimpleObjectProperty<>(new State(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())), null, true, true, true));
supportQuickPlay = World.supportQuickPlay(gameInstance.getVersion());
this.addEventHandler(Navigator.NavigationEvent.EXITED, this::onExited);
this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated);
setContent(transitionPane);
initializeSidebar();
setSidebarBottom(createToolBar());
}
/// Reloads world metadata and refreshes only tab pages whose content is already initialized.
@Override
public void refresh() {
updateSessionLockChannel();
try {
world.reloadWorldData();
} catch (IOException e) {
LOG.warning("Can not load world data of world: " + world.getFile(), e);
closePageForLoadingFail();
return;
}
for (var tab : tabController.getTabs()) {
if (tab.isInitialized() && tab.getNode() instanceof WorldRefreshable r) {
r.refresh();
}
}
}
private void closePageForLoadingFail() {
Platform.runLater(() -> {
fireEvent(new PageCloseEvent());
Controllers.dialog(i18n("world.load.fail"), null, MessageDialogPane.MessageType.ERROR);
});
}
private void updateSessionLockChannel() {
if (sessionLockChannel == null || !sessionLockChannel.isOpen()) {
sessionLockChannel = WorldManageUIUtils.getSessionLockChannel(world);
readOnly.set(sessionLockChannel == null);
}
}
private void onNavigated(Navigator.NavigationEvent event) {
if (isFirstNavigation)
isFirstNavigation = false;
else
refresh();
}
public void onExited(Navigator.NavigationEvent event) {
try {
WorldManageUIUtils.closeSessionLockChannel(world, sessionLockChannel);
} catch (IOException ignored) {
}
}
public void launch() {
fireEvent(new PageCloseEvent());
Instances.launchAndEnterWorld(gameInstance, world.getFileName());
}
public void generateLaunchScript() {
Instances.generateLaunchScriptForQuickEnterWorld(gameInstance, world.getFileName());
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state;
}
public void setTitle(String title) {
this.state.set(new DecoratorPage.State(title, null, true, true, true));
}
public World getWorld() {
return world;
}
public Path getBackupsDir() {
return backupsDir;
}
public boolean isReadOnly() {
return readOnly.get();
}
public BooleanProperty readOnlyProperty() {
return readOnly;
}
@Override
public BooleanProperty refreshableProperty() {
return refreshable;
}
/// Registers supported world tabs and selects the information tab.
private void initializeSidebar() {
tabController.getTabs().addAll(worldInfoTab, worldBackupsTab);
tabController.selectTab(worldInfoTab);
getSidebar().addNavigationDrawerTab(tabController, worldInfoTab, i18n("world.info"), SVG.INFO, SVG.INFO_FILL)
.addNavigationDrawerTab(tabController, worldBackupsTab, i18n("world.backup"), SVG.ARCHIVE, SVG.ARCHIVE_FILL);
if (world.supportDataPacks()) {
tabController.getTabs().add(dataPackTab);
getSidebar().addNavigationDrawerTab(tabController, dataPackTab, i18n("world.datapack"), SVG.EXTENSION, SVG.EXTENSION_FILL);
}
}
/// Creates world actions and binds write operations to the lock state.
private AdvancedListBox createToolBar() {
AdvancedListBox toolbar = new AdvancedListBox();
{
if (supportQuickPlay) {
toolbar.addNavigationDrawerItem(i18n("instance.launch"), SVG.ROCKET_LAUNCH, () -> launch(), advancedListItem -> advancedListItem.disableProperty().bind(readOnlyProperty()));
}
if (ChunkBaseApp.isSupported(world)) {
PopupMenu chunkBasePopupMenu = new PopupMenu();
JFXPopup chunkBasePopup = new JFXPopup(chunkBasePopupMenu);
chunkBasePopupMenu.getContent().addAll(
new IconedMenuItem(SVG.EXPLORE, i18n("world.chunkbase.seed_map"), () -> ChunkBaseApp.openSeedMap(world), chunkBasePopup),
new IconedMenuItem(SVG.VISIBILITY, i18n("world.chunkbase.stronghold"), () -> ChunkBaseApp.openStrongholdFinder(world), chunkBasePopup),
new IconedMenuItem(SVG.FORT, i18n("world.chunkbase.nether_fortress"), () -> ChunkBaseApp.openNetherFortressFinder(world), chunkBasePopup)
);
if (ChunkBaseApp.supportEndCity(world)) {
chunkBasePopupMenu.getContent().add(
new IconedMenuItem(SVG.LOCATION_CITY, i18n("world.chunkbase.end_city"), () -> ChunkBaseApp.openEndCityFinder(world), chunkBasePopup));
}
toolbar.addNavigationDrawerItem(i18n("world.chunkbase"), SVG.EXPLORE, null, chunkBaseMenuItem ->
chunkBaseMenuItem.setOnAction(e ->
chunkBasePopup.show(chunkBaseMenuItem,
JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT,
chunkBaseMenuItem.getWidth(), 0)));
}
toolbar.addNavigationDrawerItem(i18n("settings.game.exploration"), SVG.FOLDER_OPEN, () -> FXUtils.openFolder(world.getFile()));
{
PopupMenu managePopupMenu = new PopupMenu();
JFXPopup managePopup = new JFXPopup(managePopupMenu);
if (supportQuickPlay) {
managePopupMenu.getContent().addAll(
new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("instance.launch"), () -> launch(), managePopup),
new IconedMenuItem(SVG.SCRIPT, i18n("instance.launch_script"), () -> generateLaunchScript(), managePopup),
new MenuSeparator()
);
}
managePopupMenu.getContent().addAll(
new IconedMenuItem(SVG.OUTPUT, i18n("world.export"), () -> WorldManageUIUtils.export(world, sessionLockChannel), managePopup),
new IconedMenuItem(SVG.DELETE_FOREVER, i18n("world.delete"), () -> WorldManageUIUtils.delete(world, () -> fireEvent(new PageCloseEvent()), sessionLockChannel), managePopup),
new IconedMenuItem(SVG.CONTENT_COPY, i18n("world.duplicate"), () -> WorldManageUIUtils.copyWorld(world, null), managePopup)
);
toolbar.addNavigationDrawerItem(i18n("settings.game.management"), SVG.MENU, null, managePopupMenuItem ->
{
managePopupMenuItem.setOnAction(e ->
managePopup.show(managePopupMenuItem,
JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT,
managePopupMenuItem.getWidth(), 0));
managePopupMenuItem.disableProperty().bind(readOnlyProperty());
});
}
}
return toolbar;
}
public interface WorldRefreshable {
void refresh();
}
}
@@ -1,127 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.World;
import org.jackhuang.hmcl.game.WorldLockedException;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.RequiredValidator;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.wizard.SinglePageWizardProvider;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class WorldManageUIUtils {
private WorldManageUIUtils() {
}
public static void delete(World world, Runnable runnable) {
delete(world, runnable, null);
}
public static void delete(World world, Runnable runnable, FileChannel sessionLockChannel) {
Controllers.confirm(
i18n("button.remove.confirm"),
i18n("world.delete"),
() -> Task.runAsync(() -> closeSessionLockChannel(world, sessionLockChannel))
.thenRunAsync(world::delete)
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
runnable.run();
} else if (exception instanceof WorldLockedException) {
Controllers.dialog(i18n("world.locked.failed"), null, MessageDialogPane.MessageType.WARNING);
} else {
Controllers.dialog(i18n("world.delete.failed", StringUtils.getStackTrace(exception)), null, MessageDialogPane.MessageType.WARNING);
}
}).start(),
null
);
}
public static void export(World world) {
export(world, null);
}
public static void export(World world, FileChannel sessionLockChannel) {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("world.export.title"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("world"), "*.zip"));
fileChooser.setInitialFileName(world.getWorldName() + ".zip");
Path file = Controllers.showSaveDialog(fileChooser);
if (file == null) {
return;
}
try {
closeSessionLockChannel(world, sessionLockChannel);
} catch (IOException e) {
return;
}
Controllers.getDecorator().startWizard(new SinglePageWizardProvider(controller -> new WorldExportPage(world, file, controller::onFinish)));
}
public static void copyWorld(World world, Runnable runnable) {
Path worldPath = world.getFile();
Controllers.prompt(
i18n("world.duplicate.prompt"),
(result, handler) -> {
Path targetDir = worldPath.resolveSibling(result);
if (Files.exists(targetDir)) {
handler.reject(i18n("world.duplicate.failed.already_exists"));
return;
}
Task.runAsync(Schedulers.io(), () -> world.copy(result))
.thenAcceptAsync(Schedulers.javafx(), (Void) -> Controllers.showToast(i18n("world.duplicate.success.toast")))
.thenAcceptAsync(Schedulers.javafx(), (Void) -> {
if (runnable != null) {
runnable.run();
}
}
).whenComplete(Schedulers.javafx(), (throwable) -> {
if (throwable == null) {
handler.resolve();
} else {
handler.reject(i18n("world.duplicate.failed"));
LOG.warning("Failed to duplicate world " + world.getFile(), throwable);
}
})
.start();
}, "", new RequiredValidator(), new Validator(i18n("world.duplicate.failed.invalid_name"), FileUtils::isNameValid));
}
public static void closeSessionLockChannel(World world, FileChannel sessionLockChannel) throws IOException {
if (sessionLockChannel != null) {
try {
sessionLockChannel.close();
LOG.info("Closed session lock channel of the world " + world.getFileName());
} catch (IOException e) {
throw new IOException("Failed to close session lock channel of the world " + world.getFile(), e);
}
}
}
public static FileChannel getSessionLockChannel(World world) {
try {
FileChannel lock = world.lock();
LOG.info("Acquired lock on world " + world.getFileName());
return lock;
} catch (WorldLockedException ignored) {
return null;
}
}
}
@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.main;
import javafx.animation.Interpolator;
import javafx.animation.Timeline;
import javafx.animation.TranslateTransition;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.layout.Pane;
import javafx.scene.shape.Line;
import javafx.scene.shape.Polygon;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Shape;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jetbrains.annotations.NotNullByDefault;
/// Builds the line illustration on the home page: a floating block island with a tree and a blocky figure.
///
/// All shapes are unfilled strokes whose colors come from the `.home-art` style classes, so the drawing
/// follows the launcher palette. The island drifts slowly up and down unless animations are disabled.
@NotNullByDefault
final class HomeArt {
private HomeArt() {
}
/// Creates the illustration, sized 290 by 250 pixels.
///
/// @return the illustration root node
static Node create() {
Group island = new Group(
// Stepped underside of the island, then the grass line along its top.
stroke(new Polygon(30, 150, 250, 150, 250, 166, 232, 166, 232, 184, 212, 184, 212, 204,
176, 204, 176, 222, 104, 222, 104, 204, 68, 204, 68, 184, 48, 184, 48, 166, 30, 166), "home-art-line"),
stroke(new Line(30, 158, 250, 158), "home-art-line"),
// Tree: trunk and three stacked leaf blocks.
stroke(rect(186, 96, 8, 54), "home-art-line"),
stroke(rect(162, 70, 56, 26), "home-art-line"),
stroke(rect(170, 50, 40, 20), "home-art-line"),
stroke(rect(178, 34, 24, 16), "home-art-line"),
// Generic blocky figure: legs, torso, arms and head.
stroke(rect(88, 126, 10, 24), "home-art-figure"),
stroke(rect(102, 126, 10, 24), "home-art-figure"),
stroke(rect(88, 92, 24, 34), "home-art-figure"),
stroke(rect(76, 92, 12, 34), "home-art-figure"),
stroke(rect(112, 92, 12, 34), "home-art-figure"),
stroke(rect(88, 68, 24, 24), "home-art-figure"));
Pane root = new Pane(
stroke(new Line(20, 30, 54, 30), "home-art-line"),
stroke(new Line(30, 38, 52, 38), "home-art-line"),
stroke(new Line(200, 14, 240, 14), "home-art-line"),
stroke(new Line(210, 22, 234, 22), "home-art-line"),
stroke(rect(22, 62, 14, 14), "home-art-line"),
stroke(rect(42, 100, 10, 10), "home-art-line"),
stroke(rect(238, 108, 10, 10), "home-art-line"),
island);
root.setPrefSize(290, 250);
root.setMaxSize(290, 250);
if (AnimationUtils.isAnimationEnabled()) {
TranslateTransition bob = new TranslateTransition(Duration.seconds(4), island);
bob.setFromY(0);
bob.setToY(-5);
bob.setAutoReverse(true);
bob.setCycleCount(Timeline.INDEFINITE);
bob.setInterpolator(Interpolator.EASE_BOTH);
bob.play();
}
return root;
}
/// Creates an unfilled rectangle.
private static Rectangle rect(double x, double y, double width, double height) {
return new Rectangle(x, y, width, height);
}
/// Makes a shape an unfilled outline styled by `styleClass`.
private static <T extends Shape> T stroke(T shape, String styleClass) {
shape.setFill(null);
shape.getStyleClass().addAll("home-art", styleClass);
return shape;
}
}
@@ -4,18 +4,12 @@ package org.jackhuang.hmcl.ui.main;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.game.GameSettingsPage;
import java.util.Locale;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -23,14 +17,10 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("settings")));
/// Manages tab selection and content transitions independently of the sidebar.
private final TabController tabController;
/// The tab for editing game settings presets.
private final TabItem<GameSettingsPage<GameSettings.Preset>> gameTab;
/// The tab whose content manages installed Java runtimes.
private final TabItem<JavaManagementPage> javaManagementTab;
/// The tab for general launcher settings.
private final TabItem<SettingsPage> settingsTab;
/// The tab for launcher appearance settings.
private final TabItem<PersonalizationPage> personalizationTab;
/// The tab for download settings.
private final TabItem<DownloadSettingsPage> downloadTab;
/// The tab displaying launcher information.
@@ -39,31 +29,22 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
/// Creates the settings tabs and fills the shared navigation sidebar.
public LauncherSettingsPage() {
gameTab = new TabItem<>(() -> new GameSettingsPage<>(GameSettings.Preset.class, null));
javaManagementTab = new TabItem<>(JavaManagementPage::new);
settingsTab = new TabItem<>(SettingsPage::new);
personalizationTab = new TabItem<>(PersonalizationPage::new);
downloadTab = new TabItem<>(DownloadSettingsPage::new);
aboutTab = new TabItem<>(AboutPage::new);
tabController = new TabController(transitionPane,
gameTab,
javaManagementTab,
settingsTab,
personalizationTab,
downloadTab,
aboutTab);
tabController.selectTab(gameTab);
addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> gameTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(GameDirectoryManager.getSelectedRepository())));
tabController.selectTab(settingsTab);
getSidebar()
.addNavigationDrawerTab(tabController, gameTab, i18n("settings.type.global.manage"), SVG.STADIA_CONTROLLER, SVG.STADIA_CONTROLLER_FILL)
.addNavigationDrawerTab(tabController, javaManagementTab, i18n("java.management"), SVG.LOCAL_CAFE, SVG.LOCAL_CAFE_FILL)
.startCategory(i18n("launcher").toUpperCase(Locale.ROOT))
.addNavigationDrawerTab(tabController, settingsTab, i18n("settings.launcher.general"), SVG.TUNE)
.addNavigationDrawerTab(tabController, personalizationTab, i18n("settings.launcher.appearance"), SVG.STYLE, SVG.STYLE_FILL)
.addNavigationDrawerTab(tabController, downloadTab, i18n("download"), SVG.DOWNLOAD)
.startCategory(i18n("about").toUpperCase(Locale.ROOT))
.addNavigationDrawerTab(tabController, aboutTab, i18n("about"), SVG.INFO, SVG.INFO_FILL);
setContent(transitionPane);
@@ -79,11 +60,6 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
tabController.onPageHidden();
}
public void showGameSettings(HMCLGameRepository repository) {
gameTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository));
tabController.selectTab(gameTab, false);
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state.getReadOnlyProperty();
@@ -4,41 +4,40 @@ package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXPopup;
import javafx.animation.RotateTransition;
import javafx.beans.property.*;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.util.Duration;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.construct.IconedMenuItem;
import org.jackhuang.hmcl.ui.construct.PopupMenu;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.instances.GameListPopupMenu;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.ServerAddress;
import org.jackhuang.hmcl.util.ServerStatusPing;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.UnmodifiableView;
import java.util.function.Consumer;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -59,14 +58,13 @@ public final class MainPage extends StackPane implements DecoratorPage {
BindingMapping.of(GameDirectoryManager.selectedRepositoryProperty())
.flatMap(HMCLGameRepository::snapshotProperty);
private final JFXButton menuButton;
{
HBox titleNode = new HBox(8);
titleNode.setPadding(new Insets(0, 0, 0, 2));
titleNode.setAlignment(Pos.CENTER_LEFT);
ImageView titleIcon = new ImageView(FXUtils.newBuiltinImage("/assets/img/icon-title.png"));
Region titleIcon = new Region();
titleIcon.getStyleClass().add("title-mark");
Label titleLabel = new Label();
titleLabel.textProperty().bind(Controllers.launcherTitleProperty());
if (I18n.isUpsideDown()) {
@@ -79,118 +77,146 @@ public final class MainPage extends StackPane implements DecoratorPage {
state.setValue(new State(null, titleNode, false, false, true));
setPadding(new Insets(20));
setPadding(new Insets(34, 55, 34, 55));
HBox launchPane = new HBox();
launchPane.getStyleClass().add("launch-pane");
FXUtils.onChangeAndOperate(selectedRepositorySnapshot, ignored -> mutableInstances.setAll(GameDirectoryManager.getSelectedRepository().getDisplayInstances().toList()));
FXUtils.onScroll(launchPane, instances, list -> {
@Nullable HMCLGameInstance currentGame = getCurrentGame();
@Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null;
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
}, instance -> instance.getRepository().setSelectedInstance(instance));
StackPane.setAlignment(launchPane, Pos.BOTTOM_RIGHT);
{
JFXButton launchButton = new JFXButton();
launchButton.getStyleClass().add("launch-button");
launchButton.setDefaultButton(true);
{
VBox graphic = new VBox();
graphic.setAlignment(Pos.CENTER);
Label launchLabel = new Label();
launchLabel.setStyle("-fx-font-size: 16px;");
Label currentLabel = new Label();
currentLabel.setStyle("-fx-font-size: 12px;");
// Left column: greeting, headline and a short description.
Label eyebrow = new Label(i18n("home.welcome"));
eyebrow.getStyleClass().add("home-eyebrow");
Label headline = new Label(i18n("home.title"));
headline.getStyleClass().add("home-title");
headline.setWrapText(true);
Label subtitle = new Label(i18n("home.subtitle"));
subtitle.getStyleClass().add("home-subtitle");
subtitle.setWrapText(true);
VBox hero = new VBox(8, eyebrow, headline, subtitle);
hero.setPrefWidth(330);
hero.setMaxWidth(330);
headline.setPrefWidth(330);
subtitle.setPrefWidth(330);
hero.setMaxHeight(Region.USE_PREF_SIZE);
StackPane.setAlignment(hero, Pos.TOP_LEFT);
hero.setPadding(new Insets(21, 0, 0, 0));
FXUtils.onChangeAndOperate(currentGameProperty(), new Consumer<>() {
private Tooltip tooltip;
// Line illustration on the right, behind the content.
Node art = HomeArt.create();
StackPane.setAlignment(art, Pos.TOP_RIGHT);
art.setMouseTransparent(true);
((Region) art).setMaxSize(290, 250);
@Override
public void accept(@Nullable HMCLGameInstance currentGame) {
if (currentGame == null) {
launchLabel.setText(i18n("instance.launch.empty"));
currentLabel.setText(null);
graphic.getChildren().setAll(launchLabel);
FXUtils.setOnActionWithCooldown(launchButton, MainPage.this::launchNoGame);
if (tooltip == null)
tooltip = new Tooltip(i18n("instance.launch.empty.tooltip"));
FXUtils.installFastTooltip(launchButton, tooltip);
} else {
launchLabel.setText(i18n("instance.launch"));
currentLabel.setText(currentGame.getId().toString());
graphic.getChildren().setAll(launchLabel, currentLabel);
FXUtils.setOnActionWithCooldown(launchButton, MainPage.this::launch);
if (tooltip != null)
Tooltip.uninstall(launchButton, tooltip);
}
// Bottom card: what will be launched, and the join button.
Label packLabel = factLabel(i18n("home.pack"));
Label packValue = new Label();
packValue.getStyleClass().add("home-fact-value");
Label routeLabel = factLabel(i18n("home.route"));
Label routeValue = new Label();
routeValue.getStyleClass().add("home-fact-value");
VBox packFact = new VBox(3, packLabel, packValue);
VBox routeFact = new VBox(3, routeLabel, routeValue);
Label serverLabel = factLabel(i18n("home.server"));
Label serverValue = new Label();
serverValue.getStyleClass().add("home-fact-value");
VBox serverFact = new VBox(3, serverLabel, serverValue);
HBox facts = new HBox(34, packFact, routeFact, serverFact);
facts.setAlignment(Pos.CENTER_LEFT);
JFXButton joinButton = new JFXButton();
joinButton.getStyleClass().add("join-button");
joinButton.setDefaultButton(true);
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
HBox card = new HBox(21, facts, spacer, joinButton);
card.getStyleClass().add("home-card");
card.setAlignment(Pos.CENTER_LEFT);
card.setMaxHeight(Region.USE_PREF_SIZE);
StackPane.setAlignment(card, Pos.BOTTOM_CENTER);
FXUtils.onChangeAndOperate(currentGameProperty(), currentGame -> {
if (currentGame == null) {
joinButton.setText(i18n("instance.launch.empty"));
FXUtils.setOnActionWithCooldown(joinButton, MainPage.this::launchNoGame);
packValue.setText(i18n("home.not_installed"));
routeFact.setVisible(false);
routeFact.setManaged(false);
serverFact.setVisible(false);
serverFact.setManaged(false);
} else {
joinButton.setText(i18n("server.join"));
FXUtils.setOnActionWithCooldown(joinButton, MainPage.this::launch);
packValue.setText(currentGame.getId().toString());
ServerEndpoint.@Nullable Route route = ServerEndpoint.selected(currentGame.getModpackConfigurationFile());
routeFact.setVisible(route != null);
routeFact.setManaged(route != null);
routeValue.setText(route != null ? route.name() : "");
refreshServerStatus(route, serverFact, serverValue);
// With more than one route (for example direct and CDN), the route can be switched.
List<ServerEndpoint.Route> routes = ServerEndpoint.routes(currentGame.getModpackConfigurationFile());
boolean switchable = routes.size() > 1;
routeFact.setCursor(switchable ? Cursor.HAND : Cursor.DEFAULT);
if (switchable && route != null) {
routeValue.setText(route.name() + " ▾");
}
routeFact.setOnMouseClicked(switchable ? event -> {
PopupMenu list = new PopupMenu();
JFXPopup popup = new JFXPopup(list);
List<Node> items = new ArrayList<>();
for (ServerEndpoint.Route candidate : routes) {
items.add(new IconedMenuItem(null, candidate.name(), () -> {
ServerEndpoint.select(currentGame.getModpackConfigurationFile(), candidate);
routeValue.setText(candidate.name() + " ▾");
refreshServerStatus(candidate, serverFact, serverValue);
}, popup));
}
});
launchButton.setGraphic(graphic);
list.getContent().setAll(items);
popup.show(routeFact, JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, 0, -routeFact.getHeight());
} : null);
}
});
menuButton = new JFXButton();
menuButton.getStyleClass().add("menu-button");
menuButton.setOnAction(e -> {
if (GameListPopupMenu.hideShowing(menuButton)) {
return;
}
JFXPopup popup = GameListPopupMenu.showAndGetPopup(
menuButton,
JFXPopup.PopupVPosition.BOTTOM,
JFXPopup.PopupHPosition.RIGHT,
0,
-menuButton.getHeight(),
instances
);
Node graphic = menuButton.getGraphic();
if (graphic != null) {
if (AnimationUtils.isAnimationEnabled()) {
Duration duration = Duration.millis(200);
RotateTransition rotateOpen = new RotateTransition(duration, graphic);
rotateOpen.setToAngle(-180);
FXUtils.playAnimation(graphic, "arrow-rotation", rotateOpen);
popup.setOnHidden(windowEvent -> {
RotateTransition rotateClose = new RotateTransition(duration, graphic);
rotateClose.setToAngle(0);
FXUtils.playAnimation(graphic, "arrow-rotation", rotateClose);
});
} else {
graphic.setRotate(-180);
popup.setOnHidden(windowEvent -> graphic.setRotate(0));
}
}
});
FXUtils.installFastTooltip(menuButton, i18n("instance.switch"));
menuButton.setGraphic(SVG.ARROW_DROP_UP.createIcon(30));
EventHandler<MouseEvent> secondaryClickHandle = event -> {
if (event.getButton() == MouseButton.SECONDARY && event.getClickCount() == 1) {
menuButton.fire();
event.consume();
}
};
launchButton.addEventHandler(MouseEvent.MOUSE_CLICKED, secondaryClickHandle);
menuButton.addEventHandler(MouseEvent.MOUSE_CLICKED, secondaryClickHandle);
launchPane.getChildren().setAll(launchButton, menuButton);
}
getChildren().addAll(launchPane);
getChildren().addAll(art, hero, card);
}
/// Pings the server behind `route` in the background and shows the result in `serverValue`.
///
/// WebSocket routes cannot be pinged with plain TCP, so the status is hidden for them.
private static void refreshServerStatus(ServerEndpoint.@Nullable Route route, Node serverFact, Label serverValue) {
String address = route == null ? "" : route.address().trim();
boolean pingable = !address.isEmpty() && !address.toLowerCase(java.util.Locale.ROOT).startsWith("ws");
serverFact.setVisible(pingable);
serverFact.setManaged(pingable);
if (!pingable) {
return;
}
serverValue.setText("…");
Schedulers.io().execute(() -> {
String text;
try {
ServerAddress parsed = ServerAddress.parse(address);
ServerStatusPing.Status status = ServerStatusPing.ping(parsed.host(), parsed.port() >= 0 ? parsed.port() : 25565, 3000);
text = status.online() + " / " + status.max() + " · " + status.latencyMillis() + " ms";
} catch (IOException | IllegalArgumentException e) {
text = i18n("home.server.unreachable");
}
String result = text;
FXUtils.runInFX(() -> serverValue.setText(result));
});
}
/// Creates the small caption above a fact value in the home card.
private static Label factLabel(String text) {
Label label = new Label(text);
label.getStyleClass().add("home-fact-label");
return label;
}
private void launch() {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
Instances.launch(repository.getSelectedInstance());
Instances.joinServer(repository.getSelectedInstance());
}
/// Opens the server modpack install/update flow when no instance is selected.
@@ -198,9 +224,6 @@ public final class MainPage extends StackPane implements DecoratorPage {
Instances.installServerModpack();
}
@Override
public ReadOnlyObjectWrapper<State> stateProperty() {
return state;
@@ -237,5 +260,4 @@ public final class MainPage extends StackPane implements DecoratorPage {
return instances;
}
}
File diff suppressed because it is too large. Load diff
@@ -4,8 +4,6 @@ package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.JFXPopup;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.scene.layout.Region;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.setting.Accounts;
@@ -21,14 +19,11 @@ import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.ui.instances.GameAdvancedListItem;
import org.jackhuang.hmcl.ui.instances.GameListPopupMenu;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.util.Locale;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -40,12 +35,12 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
/// Creates the home page and its navigation entries.
public RootPage() {
getStyleClass().remove("gray-background");
getLeft().getStyleClass().add("gray-background");
getLeft().getStyleClass().add("home-sidebar");
// first item in left sidebar
AccountAdvancedListItem accountListItem = new AccountAdvancedListItem();
accountListItem.setOnAction(e -> Controllers.navigate(Controllers.getAccountListPage()));
FXUtils.onSecondaryButtonClicked(accountListItem, () -> AccountListPopupMenu.show(accountListItem, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.LEFT, accountListItem.getWidth(), 0));
// Clicking the account opens a popup above it (the entry sits at the bottom-left): switch, add or manage.
accountListItem.setOnAction(e -> AccountListPopupMenu.show(accountListItem, JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, 0, -accountListItem.getHeight()));
accountListItem.accountProperty().bind(Accounts.selectedAccountProperty());
// second item in left sidebar
@@ -53,38 +48,19 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
gameInstanceItem.setOnAction(e -> {
@Nullable HMCLGameInstance instance = GameDirectoryManager.getSelectedRepository().getSelectedInstance();
if (instance == null) {
Controllers.navigate(Controllers.getGameListPage());
Instances.installServerModpack();
} else {
Instances.modifyGameSettings(instance);
}
});
FXUtils.onScroll(gameInstanceItem, getMainPage().getInstances(), list -> {
@Nullable HMCLGameInstance currentGame = getMainPage().getCurrentGame();
@Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null;
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
}, instance -> instance.getRepository().setSelectedInstance(instance));
FXUtils.onSecondaryButtonClicked(gameInstanceItem, () -> showGameListPopupMenu(gameInstanceItem));
if (AnimationUtils.isAnimationEnabled()) {
gameInstanceItem.setPrepareAction(Controllers::prepareGameInstancePage);
}
// third item in left sidebar
AdvancedListItem gameListItem = new AdvancedListItem();
gameListItem.setLeftIcon(SVG.FORMAT_LIST_BULLETED);
gameListItem.setTitle(i18n("instance.manage"));
gameListItem.setOnAction(e -> Controllers.navigate(Controllers.getGameListPage()));
FXUtils.onSecondaryButtonClicked(gameListItem, () -> showGameListPopupMenu(gameListItem));
if (AnimationUtils.isAnimationEnabled()) {
gameListItem.setPrepareAction(Controllers::prepareGameListPage);
}
AdvancedListItem launcherSettingsItem = new AdvancedListItem();
launcherSettingsItem.setLeftIcon(SVG.SETTINGS);
launcherSettingsItem.setTitle(i18n("settings"));
launcherSettingsItem.setOnAction(e -> {
Controllers.getSettingsPage().showGameSettings(GameDirectoryManager.getSelectedRepository());
Controllers.navigate(Controllers.getSettingsPage());
});
@@ -92,15 +68,11 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
launcherSettingsItem.setPrepareAction(Controllers::prepareSettingsPage);
}
// the left sidebar
// The sidebar holds the modpack and settings entries; the account sits at the bottom-left.
getSidebar()
.startCategory(i18n("account").toUpperCase(Locale.ROOT))
.add(accountListItem)
.startCategory(i18n("instance").toUpperCase(Locale.ROOT))
.add(gameInstanceItem)
.add(gameListItem)
.startCategory(i18n("settings.launcher.general").toUpperCase(Locale.ROOT))
.add(launcherSettingsItem);
setSidebarBottom(accountListItem);
// the root page, with the sidebar in left, navigator in center.
setContent(getMainPage());
@@ -133,14 +105,4 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
}
return mainPage;
}
/// Opens the instance selection menu beside the sidebar entry.
private void showGameListPopupMenu(Region gameListItem) {
GameListPopupMenu.show(gameListItem,
JFXPopup.PopupVPosition.TOP,
JFXPopup.PopupHPosition.LEFT,
gameListItem.getWidth(),
0,
getMainPage().getInstances());
}
}
@@ -14,7 +14,6 @@ import org.jackhuang.hmcl.Metadata;
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.*;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.util.Lang;
@@ -34,7 +33,6 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.zip.GZIPInputStream;
@@ -54,98 +52,88 @@ public final class SettingsPage extends ScrollPane {
this.setContent(rootPane);
FXUtils.smoothScrolling(this);
{
ComponentList languagePaneList = new ComponentList();
{
ComponentList languagePaneList = new ComponentList();
var chooseLanguagePane = new LineSelectButton<SupportedLocale>();
chooseLanguagePane.setTitle(i18n("settings.launcher.language"));
chooseLanguagePane.setSubtitle(i18n("settings.take_effect_after_restart"));
{
var chooseLanguagePane = new LineSelectButton<SupportedLocale>();
chooseLanguagePane.setTitle(i18n("settings.launcher.language"));
chooseLanguagePane.setSubtitle(i18n("settings.take_effect_after_restart"));
SupportedLocale currentLocale = I18n.getLocale();
chooseLanguagePane.setNullSafeConverter(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);
});
chooseLanguagePane.setItems(SupportedLocale.getSupportedLocales());
chooseLanguagePane.valueProperty().bindBidirectional(settings().languageProperty());
SupportedLocale currentLocale = I18n.getLocale();
chooseLanguagePane.setNullSafeConverter(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);
});
chooseLanguagePane.setItems(SupportedLocale.getSupportedLocales());
chooseLanguagePane.valueProperty().bindBidirectional(settings().languageProperty());
languagePaneList.getContent().add(chooseLanguagePane);
languagePaneList.getContent().add(chooseLanguagePane);
}
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.language")), languagePaneList);
}
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.language")), languagePaneList);
}
{
ComponentList miscPaneList = new ComponentList();
{
ComponentList miscPaneList = new ComponentList();
BorderPane debugPane = new BorderPane();
{
LineSwitch disableAprilFools = new LineSwitch();
disableAprilFools.setTitle(i18n("settings.launcher.disable_april_fools"));
disableAprilFools.setSubtitle(i18n("settings.take_effect_after_restart"));
disableAprilFools.selectedProperty().bindBidirectional(settings().disableAprilFoolsProperty());
miscPaneList.getContent().add(disableAprilFools);
}
Label left = new Label(i18n("settings.launcher.debug"));
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
debugPane.setLeft(left);
{
BorderPane debugPane = new BorderPane();
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);
Label left = new Label(i18n("settings.launcher.debug"));
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
debugPane.setLeft(left);
SpinnerPane exportLogPane = new SpinnerPane();
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"));
exportLogPane.setContent(logButton);
logButton.setOnAction(e -> {
exportLogPane.showSpinner();
onExportLogs().whenCompleteAsync((result, exception) -> {
exportLogPane.hideSpinner();
if (exception == null) {
Controllers.dialog(i18n("settings.launcher.launcher_log.export.success", result));
FXUtils.showFileInExplorer(result);
} else {
LOG.warning("Failed to export logs", exception);
Controllers.dialog(
i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(exception),
null,
MessageType.ERROR
);
}
}, Schedulers.javafx());
});
SpinnerPane exportLogPane = new SpinnerPane();
HBox buttonBox = new HBox();
buttonBox.setSpacing(10);
buttonBox.getChildren().addAll(openLogFolderButton, exportLogPane);
BorderPane.setAlignment(buttonBox, Pos.CENTER_RIGHT);
debugPane.setRight(buttonBox);
JFXButton logButton = FXUtils.newBorderButton(i18n("settings.launcher.launcher_log.export"));
exportLogPane.setContent(logButton);
logButton.setOnAction(e -> {
exportLogPane.showSpinner();
onExportLogs().whenCompleteAsync((result, exception) -> {
exportLogPane.hideSpinner();
if (exception == null) {
Controllers.dialog(i18n("settings.launcher.launcher_log.export.success", result));
FXUtils.showFileInExplorer(result);
} else {
LOG.warning("Failed to export logs", exception);
Controllers.dialog(
i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(exception),
null,
MessageType.ERROR
);
}
}, Schedulers.javafx());
});
HBox buttonBox = new HBox();
buttonBox.setSpacing(10);
buttonBox.getChildren().addAll(openLogFolderButton, exportLogPane);
BorderPane.setAlignment(buttonBox, Pos.CENTER_RIGHT);
debugPane.setRight(buttonBox);
miscPaneList.getContent().add(debugPane);
}
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.misc")), miscPaneList);
miscPaneList.getContent().add(debugPane);
}
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.misc")), miscPaneList);
}
}
private void openLogFolder() {
FXUtils.openFolder(LOG.getLogFile().getParent());
}
private static String getEntryName(Set<String> entryNames, String name) {
if (entryNames.add(name)) {
return name;
@@ -1,56 +0,0 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.util;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import java.time.LocalDate;
import java.time.Month;
import java.util.List;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
/// April Fools' Day utilities.
///
/// This class provides methods to check if it is April Fools' Day or near April Fools' Day.
/// It also provides a method to check if April Fools is enabled.
///
/// @author Glavo
public final class AprilFools {
private static final boolean ENABLED;
static {
var date = LocalDate.now();
// Some countries/regions may oppose April Fools' Day for various reasons.
// Therefore, we use a regional whitelist to avoid risks.
// Currently, we have only listed a limited set of countries/regions for testing.
// We will investigate more countries/regions in the future to expand this list.
boolean supportedRegion = List.of(
"CN", "TW", "HK", "MO", "JP", "KR", "VN", "SG", "MY",
"ES", "DE", "FR", "GB", "RU", "UA", "US"
).contains(LocaleUtils.SYSTEM_DEFAULT.getCountry());
boolean aprilFoolsMode;
String value = System.getProperty("hmcl.april_fools", System.getenv("HMCL_APRIL_FOOLS"));
if ("true".equalsIgnoreCase(value))
aprilFoolsMode = true;
else if ("false".equalsIgnoreCase(value) || !supportedRegion)
aprilFoolsMode = false;
else
aprilFoolsMode = date.getMonth() == Month.APRIL && date.getDayOfMonth() == 1;
ENABLED = aprilFoolsMode && !settings().disableAprilFoolsProperty().get();
}
/// Whether April Fools is enabled.
///
/// This method returns true if April Fools is enabled.
public static boolean isEnabled() {
return ENABLED;
}
private AprilFools() {
}
}
@@ -3,7 +3,6 @@
package org.jackhuang.hmcl.util;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
@@ -1,61 +0,0 @@
* {
-monet-primary: #4352A5;
-monet-on-primary: #FFFFFF;
-monet-primary-container: #5C6BC0;
-monet-on-primary-container: #F8F6FF;
-monet-primary-fixed: #DEE0FF;
-monet-primary-fixed-dim: #BAC3FF;
-monet-on-primary-fixed: #00105B;
-monet-on-primary-fixed-variant: #2F3F92;
-monet-secondary: #575C7F;
-monet-on-secondary: #FFFFFF;
-monet-secondary-container: #D0D5FD;
-monet-on-secondary-container: #565B7D;
-monet-secondary-fixed: #DEE0FF;
-monet-secondary-fixed-dim: #BFC4EC;
-monet-on-secondary-fixed: #141938;
-monet-on-secondary-fixed-variant: #3F4566;
-monet-tertiary: #775200;
-monet-on-tertiary: #FFFFFF;
-monet-tertiary-container: #976900;
-monet-on-tertiary-container: #FFF6EE;
-monet-tertiary-fixed: #FFDEAC;
-monet-tertiary-fixed-dim: #F6BD58;
-monet-on-tertiary-fixed: #281900;
-monet-on-tertiary-fixed-variant: #5F4100;
-monet-error: #BA1A1A;
-monet-on-error: #FFFFFF;
-monet-error-container: #FFDAD6;
-monet-on-error-container: #93000A;
-monet-surface: #FBF8FF;
-monet-on-surface: #1B1B21;
-monet-surface-dim: #DBD9E1;
-monet-surface-bright: #FBF8FF;
-monet-surface-container-lowest: #FFFFFF;
-monet-surface-container-low: #F5F2FA;
-monet-surface-container: #EFEDF5;
-monet-surface-container-high: #E9E7EF;
-monet-surface-container-highest: #E3E1E9;
-monet-surface-variant: #E2E1EF;
-monet-on-surface-variant: #454651;
-monet-background: #FBF8FF;
-monet-on-background: #1B1B21;
-monet-outline: #767683;
-monet-outline-variant: #C6C5D3;
-monet-shadow: #000000;
-monet-scrim: #000000;
-monet-inverse-surface: #303036;
-monet-inverse-on-surface: #F2EFF7;
-monet-inverse-primary: #BAC3FF;
-monet-surface-tint: #4858AB;
-monet-primary-seed: #5C6BC0;
-monet-primary-transparent-50: #4352A580;
-monet-primary-transparent-80: #4352A5CC;
-monet-secondary-container-transparent-50: #D0D5FD80;
-monet-surface-transparent-50: #FBF8FF80;
-monet-surface-transparent-80: #FBF8FFCC;
-monet-on-surface-variant-transparent-38: #45465161;
-monet-surface-container-low-transparent-80: #F5F2FACC;
-monet-secondary-container-transparent-80: #D0D5FDCC;
-monet-inverse-surface-transparent-80: #303036CC;
}
+10 -3
View File
@@ -2,7 +2,14 @@
* SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
* SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
*/
/* Body text uses the serif face; headings and controls use the sans face. Both are SIL OFL fonts. */
.root {
/* https://github.com/HMCL-dev/HMCL/pull/3423 */
-fx-font-family: -fx-base-font-family;
}
-fx-font-family: "Noto Serif SC";
}
.button, .jfx-button, .toggle-button, .jfx-toggle-button, .tab-label,
.jfx-layout-heading, .jfx-decorator-title, .method-title, .advanced-list-item .title,
.two-line-list-item .title, .announcement .title, .bold, .html-h3, .html-bold, .class-title,
.navigation-drawer-item, .jfx-tool-bar .label {
-fx-font-family: "Noto Sans SC";
}
+265 -12
View File
@@ -460,7 +460,7 @@
}
.launch-pane > .jfx-button > StackPane > .jfx-rippler {
-jfx-rippler-fill: -monet-on-primary-container;
-jfx-rippler-fill: transparent;
-jfx-mask-type: CIRCLE;
-fx-padding: 0;
}
@@ -494,11 +494,11 @@
/* TODO: It seems not actually used */
.tab-rippler {
-jfx-rippler-fill: derive(-monet-primary-container, 30%);
-jfx-rippler-fill: transparent;
}
.jfx-tab-pane .jfx-rippler {
-jfx-rippler-fill: white;
-jfx-rippler-fill: transparent;
}
.tab-label {
@@ -746,7 +746,7 @@
}
.jfx-tool-bar-second .jfx-rippler {
-jfx-rippler-fill: -monet-on-surface;
-jfx-rippler-fill: transparent;
}
.jfx-tool-bar-button {
@@ -864,7 +864,7 @@
}
.jfx-button .jfx-rippler {
-jfx-rippler-fill: -monet-on-surface;
-jfx-rippler-fill: transparent;
-jfx-mask-type: CIRCLE;
-fx-padding: 0.0;
}
@@ -874,7 +874,7 @@
}
.jfx-button-raised .jfx-rippler {
-jfx-rippler-fill: -monet-on-primary;
-jfx-rippler-fill: transparent;
-jfx-mask-type: CIRCLE;
-fx-padding: 0.0;
}
@@ -997,7 +997,7 @@
}
.jfx-list-cell .jfx-rippler {
-jfx-rippler-fill: -monet-primary-container;
-jfx-rippler-fill: transparent;
}
/*.list-cell:odd:selected > .jfx-rippler > StackPane,*/
@@ -1350,7 +1350,7 @@
}
.toggle-icon4 .jfx-rippler {
-jfx-rippler-fill: -monet-primary;
-jfx-rippler-fill: transparent;
-jfx-mask-type: CIRCLE;
}
@@ -1379,7 +1379,7 @@
}
.toggle-icon-tiny .jfx-rippler {
-jfx-rippler-fill: -monet-primary;
-jfx-rippler-fill: transparent;
-jfx-mask-type: CIRCLE;
}
@@ -1396,7 +1396,7 @@
}
.announcement-close-button .jfx-rippler {
-jfx-rippler-fill: -monet-primary;
-jfx-rippler-fill: transparent;
-jfx-mask-type: CIRCLE;
}
@@ -1607,7 +1607,7 @@
}
/*.combo-box-popup .list-view .jfx-list-cell .jfx-rippler {*/
/* -jfx-rippler-fill: -monet-primary-container;*/
/* -jfx-rippler-fill: transparent;*/
/*}*/
/*******************************************************************************
@@ -1654,7 +1654,7 @@
}
.custom-color-dialog .tab .jfx-rippler {
-jfx-rippler-fill: -monet-on-surface;
-jfx-rippler-fill: transparent;
}
.custom-color-dialog .tab-selected-line {
@@ -2171,3 +2171,256 @@
.date-picker-popup .title {
-fx-text-fill: -monet-on-surface-variant;
}
/* ---- Home page ---- */
.home-eyebrow {
-fx-font-family: "Noto Sans SC";
-fx-font-size: 12px;
-fx-text-fill: -monet-on-surface-variant;
}
.home-title {
-fx-font-family: "Noto Sans SC";
-fx-font-weight: bold;
-fx-font-size: 39px;
-fx-text-fill: -monet-on-surface;
}
.home-subtitle {
-fx-font-size: 15px;
-fx-line-spacing: 4px;
-fx-text-fill: -monet-on-surface-variant;
}
.home-card {
-fx-background-color: -monet-surface-bright;
-fx-background-radius: 21px;
-fx-border-color: -monet-outline-variant;
-fx-border-radius: 21px;
-fx-padding: 21px 21px 21px 34px;
}
.home-fact-label {
-fx-font-family: "Noto Sans SC";
-fx-font-size: 12px;
-fx-text-fill: -monet-on-surface-variant;
}
.home-fact-value {
-fx-font-family: "Noto Sans SC";
-fx-font-size: 15px;
-fx-text-fill: -monet-on-surface;
}
.join-button {
-fx-font-family: "Noto Sans SC";
-fx-font-size: 16px;
-fx-background-color: -monet-primary;
-fx-background-radius: 13px;
-fx-text-fill: -monet-on-primary;
-fx-padding: 13px 34px;
-fx-min-width: 179px;
-fx-cursor: hand;
}
.join-button:hover {
-fx-background-color: derive(-monet-primary, 8%);
}
.join-button:pressed {
-fx-scale-x: 0.985;
-fx-scale-y: 0.985;
}
.join-button .label {
-fx-text-fill: -monet-on-primary;
}
.home-art-line {
-fx-stroke: -monet-outline-variant;
-fx-stroke-width: 1.5px;
}
.home-art-figure {
-fx-stroke: -monet-primary;
-fx-stroke-width: 1.5px;
}
/* Sidebar: a slightly darker panel with a hairline on its right edge. */
.home-sidebar {
-fx-background-color: -monet-surface-container;
-fx-border-color: transparent -monet-outline-variant transparent transparent;
-fx-border-width: 0 1px 0 0;
}
/* ---- Window buttons ---- */
.window-button {
-fx-min-width: 46px;
-fx-pref-width: 46px;
-fx-max-width: 46px;
-fx-min-height: 40px;
-fx-pref-height: 40px;
-fx-max-height: 40px;
-fx-background-color: transparent;
-fx-background-radius: 0;
-fx-cursor: default;
}
.window-button:hover {
-fx-background-color: -monet-surface-container;
}
.window-close-button:hover {
-fx-background-color: #C42B1C;
}
.window-close-button:hover .svg {
-fx-fill: white;
}
.mac-window-button {
-fx-min-width: 12px;
-fx-pref-width: 12px;
-fx-max-width: 12px;
-fx-min-height: 12px;
-fx-pref-height: 12px;
-fx-max-height: 12px;
-fx-padding: 0;
-fx-background-radius: 6px;
-fx-border-radius: 6px;
-fx-border-width: 0.5px;
-fx-cursor: default;
}
.mac-close { -fx-background-color: #FF5F57; -fx-border-color: #E0443E; }
.mac-minimize { -fx-background-color: #FEBC2E; -fx-border-color: #DEA123; }
.mac-zoom { -fx-background-color: #28C840; -fx-border-color: #1AAB29; }
.mac-zoom:disabled { -fx-opacity: 1; -fx-background-color: #D5CEC1; -fx-border-color: #C2BAAB; }
.mac-close:hover, .mac-minimize:hover { -fx-effect: innershadow(gaussian, rgba(0, 0, 0, 0.25), 3, 0.2, 0, 0); }
/* ---- Dialogs: one corner radius family (8 / 13 / 21) ---- */
.jfx-dialog-layout, .task-executor-dialog-layout {
-fx-background-color: -monet-surface-bright;
-fx-background-radius: 21px;
-fx-border-color: -monet-outline-variant;
-fx-border-radius: 21px;
-fx-border-width: 1px;
-fx-effect: dropshadow(gaussian, rgba(60, 40, 20, 0.18), 34, 0.1, 0, 8);
}
.dialog-accept, .dialog-cancel, .dialog-error {
-fx-background-radius: 8px;
}
/* ---- Progress bar: thin, round-ended ---- */
.jfx-progress-bar > .track {
-fx-background-color: -monet-surface-container-highest;
-fx-pref-height: 6px;
-fx-min-height: 6px;
-fx-max-height: 6px;
}
.jfx-progress-bar > .bar {
-fx-background-color: -monet-primary;
-fx-padding: 0;
-fx-pref-height: 6px;
-fx-min-height: 6px;
-fx-max-height: 6px;
}
.jfx-progress-bar > .track,
.jfx-progress-bar > .bar {
-fx-background-radius: 999px;
}
.card, .card-no-padding, .card-non-transparent {
-fx-background-radius: 13px;
}
/* The small accent mark that stands in for a logo in the title bar. */
.title-mark {
-fx-background-color: -monet-primary;
-fx-background-radius: 5px;
-fx-min-width: 18px;
-fx-pref-width: 18px;
-fx-max-width: 18px;
-fx-min-height: 18px;
-fx-pref-height: 18px;
-fx-max-height: 18px;
}
/* ---- Unified list and card styling ---- */
/* Sidebar section labels: small muted text without an underline. */
.class-title {
-fx-font-family: "Noto Sans SC";
-fx-padding: 13px 16px 4px 16px;
}
.class-title Text {
-fx-fill: -monet-on-surface-variant;
}
.class-title Rectangle {
-fx-fill: transparent;
}
/* Cards: a hairline border instead of a drop shadow. */
.card, .card-no-padding, .card-non-transparent {
-fx-background-color: -monet-surface-bright;
-fx-border-color: -monet-outline-variant;
-fx-border-radius: 13px;
-fx-effect: null;
}
/* Icon buttons use a softer ink than body text. */
.toggle-icon4 .svg, .toggle-icon-tiny .svg {
-fx-fill: -monet-on-surface-variant;
}
/* ---- Settings lists ---- */
.component-list-title {
-fx-font-family: "Noto Sans SC";
-fx-font-weight: bold;
-fx-font-size: 14px;
-fx-text-fill: -monet-on-surface;
}
.options-list {
-fx-background-radius: 13px;
-fx-border-color: -monet-outline-variant;
-fx-border-radius: 13px;
-fx-effect: null;
}
.options-list-item {
-fx-background-color: -monet-surface-bright;
}
.options-list-item:first {
-fx-background-radius: 13px 13px 0 0;
}
.options-list-item:last {
-fx-background-radius: 0 0 13px 13px;
}
.options-list-item:first:last {
-fx-background-radius: 13px;
}
/* Sidebar rows: a lighter selected and hover tint. */
.advanced-list-item:selected .rippler-container,
.advanced-list-item .rippler-container:hover {
-fx-background-color: -monet-surface-container-high;
}
/* Outlined buttons: one crisp hairline. */
.jfx-button-border {
-fx-border-color: -monet-outline-variant;
-fx-border-radius: 8px;
-fx-border-width: 1px;
-fx-background-radius: 8px;
-fx-padding: 8px 16px;
}
@@ -0,0 +1,64 @@
/*
* Warm cream palette with a clay accent. Every launcher color is derived from these variables.
*/
* {
-monet-primary: #A9532F;
-monet-on-primary: #FFF8F1;
-monet-primary-container: #EFD9CC;
-monet-on-primary-container: #4A2312;
-monet-primary-fixed: #F6E3D8;
-monet-primary-fixed-dim: #E8B99F;
-monet-on-primary-fixed: #2E1307;
-monet-on-primary-fixed-variant: #7F3C1F;
-monet-secondary: #6E665B;
-monet-on-secondary: #FAF7F1;
-monet-secondary-container: #E9E1D3;
-monet-on-secondary-container: #4A433A;
-monet-secondary-fixed: #EDE6D9;
-monet-secondary-fixed-dim: #CFC6B5;
-monet-on-secondary-fixed: #231F19;
-monet-on-secondary-fixed-variant: #51493F;
-monet-tertiary: #5F7F54;
-monet-on-tertiary: #FFFFFF;
-monet-tertiary-container: #DCE5D2;
-monet-on-tertiary-container: #1F3018;
-monet-tertiary-fixed: #DCE5D2;
-monet-tertiary-fixed-dim: #B5C7A8;
-monet-on-tertiary-fixed: #121E0D;
-monet-on-tertiary-fixed-variant: #3E5A34;
-monet-error: #B3261E;
-monet-on-error: #FFFFFF;
-monet-error-container: #F9DEDC;
-monet-on-error-container: #410E0B;
-monet-surface: #F3EEE6;
-monet-on-surface: #2A2622;
-monet-surface-dim: #E2DBCF;
-monet-surface-bright: #FAF7F1;
-monet-surface-container-lowest: #FDFBF7;
-monet-surface-container-low: #F8F4EC;
-monet-surface-container: #EBE5DA;
-monet-surface-container-high: #E4DDCF;
-monet-surface-container-highest: #DDD5C7;
-monet-surface-variant: #E7E0D3;
-monet-on-surface-variant: #6E665B;
-monet-background: #F3EEE6;
-monet-on-background: #2A2622;
-monet-outline: #8C8377;
-monet-outline-variant: #DDD5C7;
-monet-shadow: #000000;
-monet-scrim: #000000;
-monet-inverse-surface: #2A2622;
-monet-inverse-on-surface: #F3EEE6;
-monet-inverse-primary: #E8A47F;
-monet-surface-tint: #A9532F;
-monet-primary-seed: #A9532F;
-monet-primary-transparent-50: #A9532F80;
-monet-primary-transparent-80: #A9532FCC;
-monet-secondary-container-transparent-50: #E9E1D380;
-monet-surface-transparent-50: #F3EEE680;
-monet-surface-transparent-80: #F3EEE6CC;
-monet-on-surface-variant-transparent-38: #6E665B61;
-monet-surface-container-low-transparent-80: #F8F4ECCC;
-monet-secondary-container-transparent-80: #E9E1D3CC;
-monet-inverse-surface-transparent-80: #2A2622CC;
}
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff. Show more