Compare commits

...
86 Commits
Author SHA1 Message Date
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
浩 刘 2b76d44ab0 Strip NBT, schematics, multi-dir, themes, and download source UI
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Failing after 14s
Check Codes / build (pull_request) Failing after 15s
PR Size Label / label (pull_request_target) Failing after 25s
Java CI / build (push) Failing after 2m48s
Java CI / build (pull_request) Failing after 6m9s
Remove NBT editor and schematic management, collapse game directories to the
fixed workspace .minecraft path, drop Countly packaging and theme-pack
marketplace UI, and hide extra download-source selectors for the server client.
2026-11-07 18:53:31 +00:00
浩 刘 cf44b2e536 Prefer Java 25 and multi China download mirrors
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Failing after 57s
Check Codes / build (pull_request) Failing after 17s
Java CI / build (push) Failing after 2m27s
PR Size Label / label (pull_request_target) Failing after 23s
Java CI / build (pull_request) Failing after 2m3s
Raise the recommended Java major to 25, default download sources to BMCLAPI
mirrors, probe and rank multiple BMCLAPI entry points, and spread concurrent
file downloads across healthy mirrors with ordered fallback.
2026-11-07 18:49:52 +00:00
浩 刘 895481ce63 Remove dead free-install hub and export UI
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Failing after 5s
Check Codes / build (pull_request) Failing after 10s
PR Size Label / label (pull_request_target) Failing after 37s
Java CI / build (push) Failing after 2m30s
Java CI / build (pull_request) Failing after 2m14s
Collapse DownloadPage to addon install helpers only, drop the orphaned
modpack export wizard package, and remove unused upstream Metadata URLs.
2026-11-07 18:42:51 +00:00
浩 刘 1e45711006 Restrict launcher to bundled server modpack installs
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Failing after 5s
Check Codes / build (pull_request) Failing after 53s
Java CI / build (push) Failing after 2m30s
PR Size Label / label (pull_request_target) Failing after 15s
Java CI / build (pull_request) Failing after 2m34s
Replace free game download with bundled modpack install, drop Help/Feedback
and upstream About links, empty the default authlib list, and remove offline
account creation while keeping Microsoft and custom authlib logins.
2026-11-07 18:40:26 +00:00
浩 刘 f4893d9f75 Remove official groups, multiplayer, downloads, help, and self-update
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
PR Size Label / label (pull_request_target) Failing after 28s
Check Codes / build (pull_request) Failing after 28s
Check Codes / build (push) Failing after 28s
Java CI / build (pull_request) Failing after 4m13s
Java CI / build (push) Failing after 4m14s
Strip Terracotta multiplayer, QQ/Discord community promo, in-launcher
mod/modpack/world download browse flows, the title-bar help button, and
launcher self-update checks/UI so this fork no longer hooks upstream HMCL
update endpoints. Local modpack import/export and resource/shader downloads
remain. Compile and setting unit tests pass.
2026-11-07 18:14:15 +00:00
快乐凤爪王 3e172608bf fix: 避免通过按钮重复刷新模组列表 (#7099) 2026-11-07 21:17:48 +08:00
鱼前三百 f0fcd28bd0 fix(datapack, ui): 删除 DataPackListPageSkin 冗余的选择逻辑 (#7216) 2026-11-07 21:16:51 +08:00
辞庐 5736ea8a24 主页更新气泡添加悬停反馈 (#7215) 2026-11-07 21:07:39 +08:00
3gf8jv4dv b510352154 chore(l10n): refine en translations on instance page toolbar (#7225) 2026-11-07 20:52:19 +08:00
3gf8jv4dv 4efd3a1e7d chore(l10n): refine en translations (#7224) 2026-11-07 20:52:01 +08:00
3gf8jv4dv c892caeba3 chore(l10n): refine zh-hant translations (#7226) 2026-11-07 20:49:58 +08:00
Glavo 3b57add252 Hide horizontal scrollbar in addon updates list (#7235) 2026-11-07 20:38:16 +08:00
Wulian233 e757b6832f 更新游戏版本/种子数据 (#7234) 2026-11-07 20:23:00 +08:00
Glavo a45ccde0a1 Limit addon update cell width in list view (#7230) 2026-11-07 14:13:49 +08:00
鱼前三百 eb14c66e1a feat(ui): 使用 JFXSwitch 改进 DataPackInfoListCell (#7201) 2026-11-06 22:07:05 +08:00
快乐凤爪王 6fa48981d5 feat: 使用 JFXSwitch 改进 ResourcePackListCell (#7200) 2026-11-06 22:05:21 +08:00
鱼前三百 3c89a012b2 refactor(datapack): 将 DataPackListPageSkin 的搜索逻辑修改为和其他列表一致 (#7217) 2026-11-06 21:50:29 +08:00
Glavo a543216e67 Exclude Cleanroom 0.7-alpha and later from available versions (#7218) 2026-11-06 21:47:04 +08:00
辞庐 86b0b855a7 GLFW / SDL -> GLFW/SDL (#6999) 2026-11-06 21:13:47 +08:00
快乐凤爪王 13d5d6b322 fix: 避免在 更换版本 弹窗选择否后关闭版本页面 (#7107) 2026-11-06 21:01:44 +08:00
gunngz cfb0a8ce68 refactor: move legacy checksum validation into Library (#7085) 2026-11-06 21:00:14 +08:00
Wulian233 fe000fb94f 统一模组列表与详情的中文名顺序,并使用模组数据文件中的英文名 (#7070)
Close #7055
2026-11-06 20:50:34 +08:00
快乐凤爪王 29acd01155 fix: 调整 ModInfoListCell 开关的大小与间距 (#7182) 2026-11-06 20:30:50 +08:00
ZZZank 7cf696e2a0 在“回退”(切换版本)按钮不可见时释放其空间 (#7199) 2026-11-06 20:11:33 +08:00
鱼前三百 09ccf73285 feat: 为游戏文件夹添加打开文件夹按钮 (#7198)
fix #7197
2026-11-06 20:07:32 +08:00
鱼前三百 7403c3ff79 fix(modpack-export): 将选择导出位置的时机移动到导出任务开始之前 (#7208)
fix #7207
2026-11-05 21:49:11 +08:00
鱼前三百 b46a07cd45 feat(modpack-export): 为整合包导出添加进度显示 (#7206)
fix #7205
2026-11-05 21:04:36 +08:00
Glavo a262a95408 Refactor tab animation handling for consistency and clarity (#7210) 2026-11-05 20:58:08 +08:00
Glavo 88222af99a Refactor animation methods to simplify signatures (#7203) 2026-11-05 17:53:05 +08:00
Glavo f1d90fe0a1 预热 GameListPage (#7204) 2026-11-05 16:49:51 +08:00
Glavo 52c84ceb29 Optimize caching and layout management for smoother animations (#7195) 2026-11-05 05:23:17 +08:00
Glavo 087b2b678d 修复无法正常启动 Minecraft 13w16a~13w23b 的问题 (#7193) 2026-11-04 22:23:24 +08:00
Glavo 3f6e7fcffd 在深色模式下默认启用原生窗口装饰 (#7192) 2026-11-04 22:12:58 +08:00
快乐凤爪王 ed59611772 feat: 为世界管理页面添加打开存档文件夹按钮 (#7177)
Closes #7175
2026-11-04 20:55:23 +08:00
Wulian233 c100f46b9d 修复悬停在iconed-menu上时鼠标指针错误 (#7178)
Fix #7179
2026-11-04 20:34:50 +08:00
快乐凤爪王 00b1b34f98 chore: tasksEmptyIcon 使用图标 ListAlt (#7185) 2026-11-04 20:30:15 +08:00
Glavo f411840199 在 updateControl 期间禁用 JFXSwitch 动画 (#7189) 2026-11-04 20:24:57 +08:00
Glavo 8d44411302 在关于页面展示机型信息 (#7186) 2026-11-04 16:23:13 +08:00
ShulkerSakura 85540268bd 修复任务列表按钮可重复打开多个任务列表的问题 (#7180) 2026-11-04 00:27:32 +08:00
Glavo e64a7b45b5 重新实现平滑滚动 (#7162) 2026-11-03 21:57:16 +08:00
Glavo 7d89e87dca 支持将任务挂起至后台 (#7030) 2026-11-03 21:46:12 +08:00
快乐凤爪王 8f002e433f fix: 实例列表禁用横向滚动条 (#7176) 2026-11-03 20:58:39 +08:00
快乐凤爪王 a639fbb1c8 feat: 使用 JFXSwitch 改进 ModInfoListCell (#7173) 2026-11-03 20:57:54 +08:00
Glavo 70e6b6ade7 改进硬件探测功能与关于页面 (#7174) 2026-11-03 18:45:01 +08:00
Glavo d3b91d439c 根据 Tab 相对位置选择动画方向 (#7172) 2026-11-03 11:10:45 +08:00
Glavo 8912ee2661 在按压 RipplerContainer 时使用控件变色代替涟漪效果 (#7171) 2026-11-03 11:01:43 +08:00
Glavo 5dd6ab5908 使用 JFXSwitch 代替 JFXToggleButton (#7167) 2026-11-03 06:08:15 +08:00
鱼前三百 57eca838e2 refactor(addon): 为 LocalAddonFile 默认实现 Comparable (#7120)
fix #7161
2026-11-02 22:27:55 +08:00
Glavo 75d070d8c9 Enhance instance manifest handling and add failed task creation method (#7163) 2026-11-02 21:27:50 +08:00
Glavo f81ba320da 在播放 UI 动画前预渲染页面 (#7160) 2026-11-02 20:37:59 +08:00
316 changed files with 12325 additions and 37525 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
+31 -2
View File
@@ -1,11 +1,22 @@
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:
@@ -23,8 +34,26 @@ jobs:
uses: gradle/actions/setup-gradle@v5
with:
cache-cleanup: never
- name: Build with Gradle
run: xvfb-run --auto-servernum ./gradlew build --no-daemon --parallel
# Only main writes the dependency cache; pull requests read it.
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
# The repository's wrapper downloads Gradle itself, so cache that download too.
- name: Cache Gradle distribution
uses: actions/cache@v4
with:
path: |
~/.gradle/wrapper
.gradle/wrapper-neo
key: gradle-dist-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/wrapper/GradleWrapperNeo.java') }}
- name: Install Xvfb
run: |
# Installing is the slowest setup step, so skip it when the runner already has xvfb, and
# only refresh the package index when a plain install fails.
command -v xvfb-run >/dev/null || sudo apt-get install -y --no-install-recommends xvfb \
|| { sudo apt-get update && sudo apt-get install -y --no-install-recommends xvfb; }
# 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
MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }}
+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"
+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/
+5 -26
View File
@@ -1,4 +1,3 @@
import org.jackhuang.hmcl.gradle.TerracottaConfigUpgradeTask
import org.jackhuang.hmcl.gradle.addon.FormatJsonFile
import org.jackhuang.hmcl.gradle.addon.UpdateAddonProjectInfos
import org.jackhuang.hmcl.gradle.ci.GitHubActionUtils
@@ -248,10 +247,6 @@ tasks.processResources {
from(createLocaleNamesResourceBundle.map { it.outputDirectory })
}
inputs.property("terracotta_version", libs.versions.terracotta)
doLast {
upgradeTerracottaConfig.get().checkValid()
}
}
fun artifactFile(ext: String) = jarPath.resolveSibling(jarPath.nameWithoutExtension + '.' + ext)
@@ -362,6 +357,11 @@ fun parseToolOptions(options: String?): MutableList<String> {
return result
}
// Tests use the same JavaFX reflective access as the packaged application.
tasks.withType<Test> {
jvmArgs(addOpens.map { "--add-opens=$it=ALL-UNNAMED" })
}
// For IntelliJ IDEA
tasks.withType<JavaExec> {
if (name != "run") {
@@ -400,27 +400,6 @@ tasks.register<JavaExec>("run") {
}
}
// terracotta
val upgradeTerracottaConfig = tasks.register<TerracottaConfigUpgradeTask>("upgradeTerracottaConfig") {
val destination = layout.projectDirectory.file("src/main/resources/assets/terracotta.json")
val source = layout.projectDirectory.file("terracotta-template.json");
classifiers.set(
listOf(
"windows-x86_64", "windows-arm64",
"macos-x86_64", "macos-arm64",
"linux-x86_64", "linux-arm64", "linux-loongarch64", "linux-riscv64",
"freebsd-x86_64"
)
)
version.set(libs.versions.terracotta)
downloadURL.set($$"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz")
templateFile.set(source)
outputFile.set(destination)
}
// Check Translations
@@ -0,0 +1,288 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package com.jfoenix.controls;
import com.jfoenix.skins.JFXSwitchSkin;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.ReadOnlyBooleanWrapper;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.css.CssMetaData;
import javafx.css.PseudoClass;
import javafx.css.SimpleStyleableDoubleProperty;
import javafx.css.Styleable;
import javafx.css.StyleableDoubleProperty;
import javafx.css.StyleablePropertyFactory;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.scene.AccessibleAction;
import javafx.scene.AccessibleAttribute;
import javafx.scene.AccessibleRole;
import javafx.scene.control.Control;
import javafx.scene.control.Skin;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.util.List;
/// A Material Design 3 switch for a boolean setting.
///
/// Supports clicking, dragging, and Space-key activation. Labels belong to the surrounding layout;
/// supply an accessible text description. Size and colors are configurable through CSS.
@NotNullByDefault
public class JFXSwitch extends Control {
/// Creates an unselected switch.
public JFXSwitch() {
getStyleClass().setAll("jfx-switch");
setFocusTraversable(true);
setAccessibleRole(AccessibleRole.TOGGLE_BUTTON);
}
/// Selection state.
private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected") {
/// Updates visual and accessible selection without firing an action.
@Override
protected void invalidated() {
pseudoClassStateChanged(SELECTED, get());
notifyAccessibleAttributeChanged(AccessibleAttribute.SELECTED);
}
};
/// Changes selection without firing an action event.
public final void setSelected(boolean value) {
selected.set(value);
}
/// Returns whether the switch is selected.
public final boolean isSelected() {
return selected.get();
}
/// Returns the bindable selection state; the default is false.
public final BooleanProperty selectedProperty() {
return selected;
}
/// Read-only activation state.
private final ReadOnlyBooleanWrapper armed = new ReadOnlyBooleanWrapper(this, "armed") {
/// Reflects input feedback in CSS.
@Override
protected void invalidated() {
pseudoClassStateChanged(ARMED, get());
}
};
/// Returns whether the switch is armed.
public final boolean isArmed() {
return armed.get();
}
/// Returns the read-only state indicating a pending activation.
public final ReadOnlyBooleanProperty armedProperty() {
return armed.getReadOnlyProperty();
}
/// Optional action handler.
private @Nullable ObjectProperty<@Nullable EventHandler<ActionEvent>> onAction;
/// Sets or clears the action handler.
public final void setOnAction(@Nullable EventHandler<ActionEvent> handler) {
if (onAction != null || handler != null)
onActionProperty().set(handler);
}
/// Returns the action handler, or null when none is installed.
public final @Nullable EventHandler<ActionEvent> getOnAction() {
return onAction == null ? null : onAction.get();
}
/// Returns the handler property for activation events.
public final ObjectProperty<@Nullable EventHandler<ActionEvent>> onActionProperty() {
if (onAction == null) {
onAction = new SimpleObjectProperty<>(this, "onAction") {
/// Updates the action handler.
@Override
protected void invalidated() {
setEventHandler(ActionEvent.ACTION, get());
}
};
}
return onAction;
}
/// The requested track height in logical pixels.
private final StyleableDoubleProperty size = new SimpleStyleableDoubleProperty(SIZE, this, "size", 32.0) {
/// Validates the size and requests layout.
@Override
protected void invalidated() {
get();
requestLayout();
}
};
/// Sets the track height; see [#sizeProperty()].
public final void setSize(double value) {
size.set(value);
}
/// Returns the requested track height in logical pixels.
public final double getSize() {
return size.get();
}
/// Track height in logical pixels, styled by `-jfx-size`; the default is 32.
/// Scales the artwork and interaction target proportionally; nonpositive or nonfinite sizes render at the default size.
public final StyleableDoubleProperty sizeProperty() {
return size;
}
/// Optional selected-icon property.
private @Nullable BooleanProperty showSelectedIcon;
/// Sets the value of [#showSelectedIconProperty()].
public final void setShowSelectedIcon(boolean value) {
if (showSelectedIcon != null || value)
showSelectedIconProperty().set(value);
}
/// Returns whether the selected icon is shown.
public final boolean isShowSelectedIcon() {
return showSelectedIcon != null && showSelectedIcon.get();
}
/// Whether to show a check mark when selected; defaults to false.
public final BooleanProperty showSelectedIconProperty() {
if (showSelectedIcon == null)
showSelectedIcon = createOptionProperty("showSelectedIcon", false, false);
return showSelectedIcon;
}
/// Optional unselected-icon property.
private @Nullable BooleanProperty showUnselectedIcon;
/// Sets the value of [#showUnselectedIconProperty()].
public final void setShowUnselectedIcon(boolean value) {
if (showUnselectedIcon != null || value)
showUnselectedIconProperty().set(value);
}
/// Returns whether the unselected icon is shown.
public final boolean isShowUnselectedIcon() {
return showUnselectedIcon != null && showUnselectedIcon.get();
}
/// Whether to show a cross when unselected; defaults to false.
public final BooleanProperty showUnselectedIconProperty() {
if (showUnselectedIcon == null)
showUnselectedIcon = createOptionProperty("showUnselectedIcon", false, false);
return showUnselectedIcon;
}
/// Optional animation policy override.
private @Nullable BooleanProperty disableAnimation;
/// Sets the value of [#disableAnimationProperty()].
public final void setDisableAnimation(boolean value) {
if (disableAnimation != null || value == AnimationUtils.isAnimationEnabled())
disableAnimationProperty().set(value);
}
/// Returns whether animation is disabled.
public final boolean isDisableAnimation() {
return disableAnimation == null ? !AnimationUtils.isAnimationEnabled() : disableAnimation.get();
}
/// Disables animation, defaulting to the inverse of the global animation-enabled setting.
public final BooleanProperty disableAnimationProperty() {
if (disableAnimation == null)
disableAnimation = createOptionProperty("disableAnimation", !AnimationUtils.isAnimationEnabled(), true);
return disableAnimation;
}
/// Arms input feedback unless the switch is disabled or its selection is bound.
public final void arm() {
if (!isDisabled() && !selected.isBound())
armed.set(true);
}
/// Clears input feedback without firing an action.
public final void disarm() {
armed.set(false);
}
/// Toggles selection and fires one action; disabled or bound switches ignore activation.
public void fire() {
if (!isDisabled() && !selected.isBound()) {
setSelected(!isSelected());
fireEvent(new ActionEvent(this, this));
}
}
/// Creates the default switch skin.
@Override
protected Skin<?> createDefaultSkin() {
return new JFXSwitchSkin(this);
}
/// Creates a property that updates the skin, optionally settling active animations.
private BooleanProperty createOptionProperty(String name, boolean initialValue, boolean finishAnimation) {
return new SimpleBooleanProperty(this, name, initialValue) {
/// Validates the value even without a skin, so subsequent changes also reach this callback.
@Override
protected void invalidated() {
get();
if (getSkin() instanceof JFXSwitchSkin skin)
skin.updateOptions(finishAnimation);
}
};
}
/// Indicates that the switch is selected.
private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
/// Indicates a pending activation.
private static final PseudoClass ARMED = PseudoClass.getPseudoClass("armed");
/// Factory for switch CSS metadata.
private static final StyleablePropertyFactory<JFXSwitch> STYLEABLES =
new StyleablePropertyFactory<>(Control.getClassCssMetaData());
/// CSS metadata for track height.
private static final CssMetaData<JFXSwitch, Number> SIZE =
STYLEABLES.createSizeCssMetaData("-jfx-size", control -> control.size, 32.0);
/// Switch and inherited CSS metadata.
private static final @Unmodifiable List<CssMetaData<? extends Styleable, ?>> CSS_META_DATA =
List.copyOf(STYLEABLES.getCssMetaData());
/// Returns immutable CSS metadata, including inherited control properties.
public static @Unmodifiable List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return CSS_META_DATA;
}
/// Returns the metadata used to style this switch.
@Override
public @Unmodifiable List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
return getClassCssMetaData();
}
/// Exposes selection to assistive technology.
@Override
public @Nullable Object queryAccessibleAttribute(AccessibleAttribute attribute, @Nullable Object... parameters) {
return attribute == AccessibleAttribute.SELECTED ? isSelected() : super.queryAccessibleAttribute(attribute, parameters);
}
/// Handles accessible activation.
@Override
public void executeAccessibleAction(AccessibleAction action, @Nullable Object... parameters) {
if (action == AccessibleAction.FIRE)
fire();
else
super.executeAccessibleAction(action, parameters);
}
}
@@ -1,373 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import com.jfoenix.skins.JFXToggleButtonSkin;
import javafx.css.*;
import javafx.css.converter.BooleanConverter;
import javafx.css.converter.PaintConverter;
import javafx.scene.control.Skin;
import javafx.scene.control.ToggleButton;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* JFXToggleButton is the material design implementation of a toggle button.
* important CSS Selectors:
* <p>
* .jfx-toggle-button{
* -fx-toggle-color: color-value;
* -fx-untoggle-color: color-value;
* -fx-toggle-line-color: color-value;
* -fx-untoggle-line-color: color-value;
* }
* <p>
* To change the rippler color when toggled:
* <p>
* .jfx-toggle-button .jfx-rippler{
* -fx-rippler-fill: color-value;
* }
* <p>
* .jfx-toggle-button:selected .jfx-rippler{
* -fx-rippler-fill: color-value;
* }
*
* @author Shadi Shaheen
* @version 1.0
* @since 2016-03-09
*/
public class JFXToggleButton extends ToggleButton {
/**
* {@inheritDoc}
*/
public JFXToggleButton() {
initialize();
}
/**
* {@inheritDoc}
*/
@Override
protected Skin<?> createDefaultSkin() {
return new JFXToggleButtonSkin(this);
}
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
// it's up for the user to add this behavior
// toggleColor.addListener((o, oldVal, newVal) -> {
// // update line color in case not set by the user
// if(newVal instanceof Color)
// toggleLineColor.set(((Color)newVal).desaturate().desaturate().brighter());
// });
}
/***************************************************************************
* *
* styleable Properties *
* *
**************************************************************************/
/**
* Initialize the style class to 'jfx-toggle-button'.
* <p>
* This is the selector class from which CSS can be used to style
* this control.
*/
private static final String DEFAULT_STYLE_CLASS = "jfx-toggle-button";
/**
* default color used when the button is toggled
*/
private final StyleableObjectProperty<Paint> toggleColor = new SimpleStyleableObjectProperty<>(StyleableProperties.TOGGLE_COLOR,
JFXToggleButton.this,
"toggleColor",
Color.valueOf(
"#009688"));
public Paint getToggleColor() {
return toggleColor == null ? Color.valueOf("#009688") : toggleColor.get();
}
public StyleableObjectProperty<Paint> toggleColorProperty() {
return this.toggleColor;
}
public void setToggleColor(Paint color) {
this.toggleColor.set(color);
}
/**
* default color used when the button is not toggled
*/
private StyleableObjectProperty<Paint> untoggleColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNTOGGLE_COLOR,
JFXToggleButton.this,
"unToggleColor",
Color.valueOf(
"#FAFAFA"));
public Paint getUnToggleColor() {
return untoggleColor == null ? Color.valueOf("#FAFAFA") : untoggleColor.get();
}
public StyleableObjectProperty<Paint> unToggleColorProperty() {
return this.untoggleColor;
}
public void setUnToggleColor(Paint color) {
this.untoggleColor.set(color);
}
/**
* default line color used when the button is toggled
*/
private final StyleableObjectProperty<Paint> toggleLineColor = new SimpleStyleableObjectProperty<>(
StyleableProperties.TOGGLE_LINE_COLOR,
JFXToggleButton.this,
"toggleLineColor",
Color.valueOf("#77C2BB"));
public Paint getToggleLineColor() {
return toggleLineColor == null ? Color.valueOf("#77C2BB") : toggleLineColor.get();
}
public StyleableObjectProperty<Paint> toggleLineColorProperty() {
return this.toggleLineColor;
}
public void setToggleLineColor(Paint color) {
this.toggleLineColor.set(color);
}
/**
* default line color used when the button is not toggled
*/
private final StyleableObjectProperty<Paint> untoggleLineColor = new SimpleStyleableObjectProperty<>(
StyleableProperties.UNTOGGLE_LINE_COLOR,
JFXToggleButton.this,
"unToggleLineColor",
Color.valueOf("#999999"));
public Paint getUnToggleLineColor() {
return untoggleLineColor == null ? Color.valueOf("#999999") : untoggleLineColor.get();
}
public StyleableObjectProperty<Paint> unToggleLineColorProperty() {
return this.untoggleLineColor;
}
public void setUnToggleLineColor(Paint color) {
this.untoggleLineColor.set(color);
}
/**
* Default size of the toggle button.
*/
private final StyleableDoubleProperty size = new SimpleStyleableDoubleProperty(
StyleableProperties.SIZE,
JFXToggleButton.this,
"size",
10.0);
public double getSize() {
return size.get();
}
public StyleableDoubleProperty sizeProperty() {
return this.size;
}
public void setSize(double size) {
this.size.set(size);
}
/**
* Disable the visual indicator for focus
*/
private final StyleableBooleanProperty disableVisualFocus = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_VISUAL_FOCUS,
JFXToggleButton.this,
"disableVisualFocus",
false);
public final StyleableBooleanProperty disableVisualFocusProperty() {
return this.disableVisualFocus;
}
public final Boolean isDisableVisualFocus() {
return disableVisualFocus != null && this.disableVisualFocusProperty().get();
}
public final void setDisableVisualFocus(final Boolean disabled) {
this.disableVisualFocusProperty().set(disabled);
}
/**
* disable animation on button action
*/
private final StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION,
JFXToggleButton.this,
"disableAnimation",
!AnimationUtils.isAnimationEnabled());
public final StyleableBooleanProperty disableAnimationProperty() {
return this.disableAnimation;
}
public final Boolean isDisableAnimation() {
return disableAnimation != null && this.disableAnimationProperty().get();
}
public final void setDisableAnimation(final Boolean disabled) {
this.disableAnimationProperty().set(disabled);
}
private static final class StyleableProperties {
private static final CssMetaData<JFXToggleButton, Paint> TOGGLE_COLOR =
new CssMetaData<>("-jfx-toggle-color",
PaintConverter.getInstance(), Color.valueOf("#009688")) {
@Override
public boolean isSettable(JFXToggleButton control) {
return control.toggleColor == null || !control.toggleColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXToggleButton control) {
return control.toggleColorProperty();
}
};
private static final CssMetaData<JFXToggleButton, Paint> UNTOGGLE_COLOR =
new CssMetaData<>("-jfx-untoggle-color",
PaintConverter.getInstance(), Color.valueOf("#FAFAFA")) {
@Override
public boolean isSettable(JFXToggleButton control) {
return control.untoggleColor == null || !control.untoggleColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXToggleButton control) {
return control.unToggleColorProperty();
}
};
private static final CssMetaData<JFXToggleButton, Paint> TOGGLE_LINE_COLOR =
new CssMetaData<>("-jfx-toggle-line-color",
PaintConverter.getInstance(), Color.valueOf("#77C2BB")) {
@Override
public boolean isSettable(JFXToggleButton control) {
return control.toggleLineColor == null || !control.toggleLineColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXToggleButton control) {
return control.toggleLineColorProperty();
}
};
private static final CssMetaData<JFXToggleButton, Paint> UNTOGGLE_LINE_COLOR =
new CssMetaData<>("-jfx-untoggle-line-color",
PaintConverter.getInstance(), Color.valueOf("#999999")) {
@Override
public boolean isSettable(JFXToggleButton control) {
return control.untoggleLineColor == null || !control.untoggleLineColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXToggleButton control) {
return control.unToggleLineColorProperty();
}
};
private static final CssMetaData<JFXToggleButton, Number> SIZE =
new CssMetaData<>("-jfx-size",
StyleConverter.getSizeConverter(), 10.0) {
@Override
public boolean isSettable(JFXToggleButton control) {
return !control.size.isBound();
}
@Override
public StyleableProperty<Number> getStyleableProperty(JFXToggleButton control) {
return control.sizeProperty();
}
};
private static final CssMetaData<JFXToggleButton, Boolean> DISABLE_VISUAL_FOCUS =
new CssMetaData<>("-jfx-disable-visual-focus",
BooleanConverter.getInstance(), false) {
@Override
public boolean isSettable(JFXToggleButton control) {
return control.disableVisualFocus == null || !control.disableVisualFocus.isBound();
}
@Override
public StyleableBooleanProperty getStyleableProperty(JFXToggleButton control) {
return control.disableVisualFocusProperty();
}
};
private static final CssMetaData<JFXToggleButton, Boolean> DISABLE_ANIMATION =
new CssMetaData<>("-jfx-disable-animation",
BooleanConverter.getInstance(), false) {
@Override
public boolean isSettable(JFXToggleButton control) {
return control.disableAnimation == null || !control.disableAnimation.isBound();
}
@Override
public StyleableBooleanProperty getStyleableProperty(JFXToggleButton control) {
return control.disableAnimationProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables =
new ArrayList<>(ToggleButton.getClassCssMetaData());
Collections.addAll(styleables,
SIZE,
TOGGLE_COLOR,
UNTOGGLE_COLOR,
TOGGLE_LINE_COLOR,
UNTOGGLE_LINE_COLOR,
DISABLE_VISUAL_FOCUS,
DISABLE_ANIMATION
);
CHILD_STYLEABLES = Collections.unmodifiableList(styleables);
}
}
@Override
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
return getClassCssMetaData();
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.CHILD_STYLEABLES;
}
}
@@ -0,0 +1,587 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package com.jfoenix.skins;
import com.jfoenix.controls.JFXSwitch;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.animation.Transition;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.css.PseudoClass;
import javafx.css.StyleOrigin;
import javafx.css.StyleableProperty;
import javafx.event.EventHandler;
import javafx.geometry.Point2D;
import javafx.scene.control.SkinBase;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.SVGPath;
import javafx.scene.transform.Scale;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
/// Renders a Material Design 3 switch and handles input.
/// Geometry uses unscaled MD3 units and is mirrored in RTL layouts.
@NotNullByDefault
public final class JFXSwitchSkin extends SkinBase<JFXSwitch> {
/// Track width in unscaled MD3 artwork coordinates.
private static final double TRACK_WIDTH = 52;
/// Track height corresponding to the control's default `-jfx-size`.
private static final double TRACK_HEIGHT = 32;
/// Unscaled interaction target height.
private static final double TARGET_HEIGHT = 48;
/// Vertical center shared by the track, handle, state layer, and icon.
private static final double CENTER_Y = TARGET_HEIGHT / 2;
/// Handle center at the leading end of the track.
private static final double OFF_CENTER = TRACK_HEIGHT / 2;
/// Handle center at the trailing end of the track.
private static final double ON_CENTER = TRACK_WIDTH - OFF_CENTER;
/// Diameter of the unselected handle without an icon.
private static final double OFF_DIAMETER = 16;
/// Diameter of a selected handle or a handle containing an icon.
private static final double ON_DIAMETER = 24;
/// Handle diameter while pressed or dragged.
private static final double PRESSED_DIAMETER = 28;
/// Radius of the hover, focus, and pressed overlay.
private static final double STATE_LAYER_RADIUS = 20;
/// Minimum pointer movement in control pixels before a press becomes a drag.
private static final double DRAG_THRESHOLD = 3;
/// Indicates visible keyboard focus.
private static final PseudoClass VISUAL_FOCUSED = PseudoClass.getPseudoClass("visual-focused");
/// The rendered switch.
private final JFXSwitch control;
/// Unmanaged switch artwork.
private final Pane artwork = new Pane();
/// Scales artwork from its leading top corner.
private final Scale artworkScale = new Scale();
/// Rounded track with its optional outline.
private final Rectangle track = new Rectangle(0, (TARGET_HEIGHT - TRACK_HEIGHT) / 2, TRACK_WIDTH, TRACK_HEIGHT);
/// Interaction feedback behind the handle.
private final Circle stateLayer = new Circle(OFF_CENTER, CENTER_Y, STATE_LAYER_RADIUS);
/// Switch handle.
private final Circle handle = new Circle(OFF_CENTER, CENTER_Y, OFF_DIAMETER / 2);
/// Optional handle icon.
private @Nullable SVGPath icon;
/// Current logical handle center measured from the track's leading edge.
private final DoubleProperty center = new SimpleDoubleProperty(OFF_CENTER) {
/// Updates handle position.
@Override
protected void invalidated() {
layoutHandle();
}
};
/// Unscaled handle diameter.
private final DoubleProperty diameter = new SimpleDoubleProperty(OFF_DIAMETER) {
/// Updates handle size.
@Override
protected void invalidated() {
layoutHandle();
}
};
/// Optional movement and resize animation.
private @Nullable Timeline geometryTransition;
/// Animated CSS paints.
private final List<CssColor> colors = new ArrayList<>();
/// Optional shared color animation.
private @Nullable Transition colorTransition;
/// Whether a selection color update is pending.
private boolean animateColors;
/// Prevents animation writes from being interpreted as new CSS targets.
private boolean applyingColors;
/// Pointer event handler.
private final EventHandler<MouseEvent> mouseHandler = this::handleMouse;
/// Keyboard event handler.
private final EventHandler<KeyEvent> keyHandler = this::handleKey;
/// Whether a primary press is awaiting click or drag completion.
private boolean mouseDown;
/// Whether the handle is following the pointer instead of an animation.
private boolean dragging;
/// Whether a Space press is awaiting release.
private boolean keyDown;
/// Pointer position in unscaled artwork coordinates at press time.
private double pressX;
/// Handle center at press time, preserving the pointer's grab offset.
private double pressCenter;
/// Creates a skin for the switch.
public JFXSwitchSkin(JFXSwitch control) {
super(control);
this.control = control;
artwork.getStyleClass().setAll("switch-artwork");
track.getStyleClass().setAll("switch-track");
stateLayer.getStyleClass().setAll("switch-state-layer");
handle.getStyleClass().setAll("switch-handle");
track.setArcWidth(TRACK_HEIGHT);
track.setArcHeight(TRACK_HEIGHT);
artwork.setManaged(false);
artwork.setMouseTransparent(true);
artwork.getTransforms().add(artworkScale);
artwork.getChildren().setAll(track, stateLayer, handle);
for (var node : artwork.getChildren())
node.setManaged(false);
getChildren().add(artwork);
registerChangeListener(control.selectedProperty(), value -> updateSelection());
registerChangeListener(control.armedProperty(), value -> update(false));
@Nullable ReadOnlyBooleanProperty focusVisible = FXUtils.focusVisibleProperty(control);
ReadOnlyBooleanProperty focusIndicator = focusVisible != null ? focusVisible : control.focusedProperty();
registerChangeListener(focusIndicator, value ->
control.pseudoClassStateChanged(VISUAL_FOCUSED, focusIndicator.get() && !control.isPressed()));
// Match the previous toggle skin: pointer press and release both dismiss focus feedback.
registerChangeListener(control.pressedProperty(), value ->
control.pseudoClassStateChanged(VISUAL_FOCUSED, false));
Consumer<ObservableValue<?>> cancelGesture = value -> cancelGesture();
registerChangeListener(control.disabledProperty(), cancelGesture);
registerChangeListener(control.sceneProperty(), cancelGesture);
registerChangeListener(control.focusedProperty(), value -> {
if (!control.isFocused())
cancelGesture();
});
control.addEventFilter(MouseEvent.ANY, mouseHandler);
control.addEventFilter(KeyEvent.ANY, keyHandler);
control.pseudoClassStateChanged(VISUAL_FOCUSED, focusIndicator.get() && !control.isPressed());
update(true);
}
/// Updates icon and animation options, optionally settling active animations.
public void updateOptions(boolean finishAnimation) {
if (finishAnimation)
finishColors();
update(finishAnimation);
}
/// Stops animations and removes artwork, event handlers, and listeners.
@Override
public void dispose() {
control.removeEventFilter(MouseEvent.ANY, mouseHandler);
control.removeEventFilter(KeyEvent.ANY, keyHandler);
cancelGesture();
for (CssColor color : colors)
color.dispose();
control.pseudoClassStateChanged(VISUAL_FOCUSED, false);
getChildren().remove(artwork);
super.dispose();
}
/// Centers the scaled artwork within the control and starts pending color transitions.
@Override
protected void layoutChildren(double x, double y, double w, double h) {
double scale = sizeScale();
artworkScale.setX(scale);
artworkScale.setY(scale);
// Keep animation and CSS geometry in MD3 units; scale the entire artwork consistently.
artwork.resizeRelocate(x + (w - TRACK_WIDTH * scale) / 2,
y + (h - TARGET_HEIGHT * scale) / 2, TRACK_WIDTH, TARGET_HEIGHT);
layoutHandle();
// JavaFX applies CSS before layout, so all changed paints now share the same start time.
if (animateColors) {
animateColors = false;
for (CssColor color : colors) {
if (color.target != null) {
colorTransition().playFromStart();
break;
}
}
}
}
/// Reserves the scaled track width and control insets.
@Override
protected double computeMinWidth(double h, double top, double right, double bottom, double left) {
return left + TRACK_WIDTH * sizeScale() + right;
}
/// Reserves the scaled interaction target height and control insets.
@Override
protected double computeMinHeight(double w, double top, double right, double bottom, double left) {
return top + TARGET_HEIGHT * sizeScale() + bottom;
}
/// Uses the same fixed width as the minimum size.
@Override
protected double computePrefWidth(double h, double top, double right, double bottom, double left) {
return computeMinWidth(h, top, right, bottom, left);
}
/// Uses the same fixed height as the minimum size.
@Override
protected double computePrefHeight(double w, double top, double right, double bottom, double left) {
return computeMinHeight(w, top, right, bottom, left);
}
/// Limits width to the preferred size.
@Override
protected double computeMaxWidth(double h, double top, double right, double bottom, double left) {
return computePrefWidth(h, top, right, bottom, left);
}
/// Limits height to the preferred size.
@Override
protected double computeMaxHeight(double w, double top, double right, double bottom, double left) {
return computePrefHeight(w, top, right, bottom, left);
}
/// Handles clicking and dragging.
private void handleMouse(MouseEvent event) {
if (event.getEventType() == MouseEvent.MOUSE_CLICKED && event.getButton() == MouseButton.PRIMARY) {
// Activation happens on release; do not also activate the surrounding settings row.
event.consume();
return;
}
if (event.getEventType() == MouseEvent.MOUSE_PRESSED) {
if (event.getButton() != MouseButton.PRIMARY || keyDown
|| event.isShiftDown() || event.isControlDown() || event.isAltDown() || event.isMetaDown()
|| event.isMiddleButtonDown() || event.isSecondaryButtonDown())
return;
if (control.isDisabled() || control.selectedProperty().isBound())
return;
if (control.isFocusTraversable())
control.requestFocus();
@Nullable Point2D point = artwork.sceneToLocal(event.getSceneX(), event.getSceneY());
if (point != null) {
mouseDown = true;
pressX = point.getX();
pressCenter = center.get();
control.arm();
}
event.consume();
} else if (event.getEventType() == MouseEvent.MOUSE_DRAGGED && mouseDown && event.isPrimaryButtonDown()) {
@Nullable Point2D point = artwork.sceneToLocal(event.getSceneX(), event.getSceneY());
if (point == null)
return;
if (!dragging && Math.abs(point.getX() - pressX) * sizeScale() < DRAG_THRESHOLD)
return;
dragging = true;
if (geometryTransition != null)
geometryTransition.stop();
control.arm();
diameter.set(PRESSED_DIAMETER);
center.set(dragCenter(point.getX()));
event.consume();
} else if (event.getEventType() == MouseEvent.MOUSE_RELEASED && event.getButton() == MouseButton.PRIMARY) {
if (!mouseDown) {
event.consume();
return;
}
@Nullable Point2D point = dragging
? artwork.sceneToLocal(event.getSceneX(), event.getSceneY())
: control.sceneToLocal(event.getSceneX(), event.getSceneY());
if (dragging && point != null)
center.set(dragCenter(point.getX()));
boolean activate = point != null && (dragging
? (center.get() >= (OFF_CENTER + ON_CENTER) / 2) != control.isSelected()
: control.isArmed() && control.contains(point));
mouseDown = false;
dragging = false;
control.disarm();
event.consume();
if (activate)
control.fire();
// Action handlers may replace or remove the skin synchronously.
if (control.getSkin() == this)
update(false);
} else if (mouseDown && !dragging) {
if (event.getEventType() == MouseEvent.MOUSE_ENTERED)
control.arm();
else if (event.getEventType() == MouseEvent.MOUSE_EXITED)
control.disarm();
}
}
/// Clamps pointer displacement to the track while preserving the initial grab offset.
private double dragCenter(double pointerX) {
return Math.max(OFF_CENTER, Math.min(ON_CENTER, pressCenter + pointerX - pressX));
}
/// Handles Space activation and Escape cancellation.
private void handleKey(KeyEvent event) {
if (event.getCode() == KeyCode.ESCAPE && event.getEventType() == KeyEvent.KEY_PRESSED) {
if (mouseDown || keyDown) {
boolean awaitingRelease = keyDown;
cancelGesture();
// Ignore auto-repeat until the canceled Space press is released.
keyDown = awaitingRelease;
event.consume();
}
} else if (event.getCode() == KeyCode.SPACE) {
if (event.getEventType() == KeyEvent.KEY_PRESSED) {
if (event.isShiftDown() || event.isControlDown() || event.isAltDown() || event.isMetaDown())
return;
if (!keyDown) {
if (mouseDown || control.isDisabled() || control.selectedProperty().isBound())
return;
keyDown = true;
control.arm();
}
event.consume();
} else if (event.getEventType() == KeyEvent.KEY_RELEASED && keyDown) {
keyDown = false;
boolean activate = control.isArmed();
control.disarm();
event.consume();
if (activate)
control.fire();
}
}
}
/// Cancels input without changing selection.
private void cancelGesture() {
finishColors();
mouseDown = false;
keyDown = false;
dragging = false;
control.disarm();
update(true);
}
/// Updates geometry and schedules selection colors for the next CSS pass.
private void updateSelection() {
update(false);
if (control.isDisableAnimation() || control.isDisabled() || control.getScene() == null)
return;
if (colors.isEmpty()) {
colors.add(new CssColor(track.fillProperty()));
colors.add(new CssColor(handle.fillProperty()));
colors.add(new CssColor(stateLayer.fillProperty()));
colors.add(new CssColor(track.strokeProperty()));
if (icon != null)
colors.add(new CssColor(icon.fillProperty()));
}
if (colorTransition != null)
colorTransition.stop();
// A reversal before the first frame may reapply the visible paint without emitting a change event.
for (CssColor color : colors)
color.target = null;
animateColors = true;
control.requestLayout();
}
/// Updates handle geometry, with optional animation.
private void update(boolean immediate) {
if (dragging)
return;
double currentCenter = center.get();
double currentDiameter = diameter.get();
if (geometryTransition != null)
geometryTransition.stop();
boolean selected = control.isSelected();
boolean pressed = control.isArmed() && !control.isDisabled();
boolean hasIcon = selected ? control.isShowSelectedIcon() : control.isShowUnselectedIcon();
double targetDiameter = pressed ? PRESSED_DIAMETER : selected || hasIcon ? ON_DIAMETER : OFF_DIAMETER;
double targetCenter = selected ? ON_CENTER : OFF_CENTER;
updateIcon(selected, hasIcon);
if (immediate || control.isDisableAnimation() || control.getScene() == null
|| currentCenter == targetCenter && currentDiameter == targetDiameter) {
center.set(targetCenter);
diameter.set(targetDiameter);
} else {
if (geometryTransition == null)
geometryTransition = new Timeline();
// Preserve the visible geometry when a press or reversal interrupts a transition.
center.set(currentCenter);
diameter.set(currentDiameter);
geometryTransition.getKeyFrames().setAll(
new KeyFrame(Duration.ZERO,
new KeyValue(center, currentCenter),
new KeyValue(diameter, currentDiameter)),
new KeyFrame(pressed ? Motion.SHORT2 : Motion.MEDIUM1,
new KeyValue(diameter, targetDiameter, pressed ? Motion.LINEAR : Motion.STANDARD)),
new KeyFrame(Motion.MEDIUM2,
new KeyValue(center, targetCenter, Motion.EASE_OUT_BACK)));
geometryTransition.play();
}
layoutHandle();
}
/// Updates the handle icon, creating it if needed.
private void updateIcon(boolean selected, boolean visible) {
if (icon == null && visible) {
icon = new SVGPath();
icon.getStyleClass().setAll("switch-icon");
icon.setManaged(false);
// The first CSS fill should appear immediately, without a transition from default black.
icon.setFill(null);
if (!colors.isEmpty())
colors.add(new CssColor(icon.fillProperty()));
artwork.getChildren().add(icon);
}
if (icon != null) {
icon.setVisible(visible);
if (visible)
icon.setContent(selected
? "M2 8l4 4 8-8-1.4-1.4L6 9.2 3.4 6.6Z"
: "M4 2.6l4 4 4-4L13.4 4l-4 4 4 4-1.4 1.4-4-4-4 4L2.6 12l4-4-4-4Z");
}
}
/// Positions and sizes the handle and its overlays.
private void layoutHandle() {
double cx = center.get();
stateLayer.setCenterX(cx);
handle.setCenterX(cx);
handle.setRadius(diameter.get() / 2);
if (icon != null && icon.isVisible())
icon.relocate(cx - icon.getLayoutBounds().getWidth() / 2,
CENTER_Y - icon.getLayoutBounds().getHeight() / 2);
}
/// Returns the size scale, or 1 for an invalid size.
private double sizeScale() {
double size = control.getSize();
return Double.isFinite(size) && size > 0 ? size / TRACK_HEIGHT : 1;
}
/// Returns the shared color animation, creating it if needed.
private Transition colorTransition() {
if (colorTransition == null) {
colorTransition = new Transition() {
{
setCycleDuration(Motion.SHORT4);
setInterpolator(Motion.STANDARD);
}
/// Updates only paints with a pending CSS target.
@Override
protected void interpolate(double fraction) {
for (CssColor color : colors)
color.interpolate(fraction);
}
};
colorTransition.setOnFinished(event -> finishColors());
}
return colorTransition;
}
/// Stops color animation and applies pending targets.
private void finishColors() {
animateColors = false;
if (colorTransition != null)
colorTransition.stop();
for (CssColor color : colors)
color.finish();
}
/// Animates a solid paint while preserving CSS precedence.
private final class CssColor implements ChangeListener<@Nullable Paint> {
/// The animated paint property.
private final ObjectProperty<@Nullable Paint> property;
/// Most recent solid CSS target; null means no transition is pending.
private @Nullable Color target;
/// CSS origin of the target paint.
private @Nullable StyleOrigin origin;
/// Starting color.
private Color from = Color.TRANSPARENT;
/// Observes a styleable paint property.
private CssColor(ObjectProperty<@Nullable Paint> property) {
this.property = property;
property.addListener(this);
}
/// Animates selection colors and applies other paint changes immediately.
@Override
public void changed(ObservableValue<? extends @Nullable Paint> observable,
@Nullable Paint previous, @Nullable Paint next) {
if (applyingColors)
return;
// CSS may reapply the same target while the displayed value is still interpolating.
if (target != null && target.equals(next) && previous != null) {
apply(previous);
return;
}
target = null;
if (!animateColors || !(previous instanceof Color start) || !(next instanceof Color end) || start.equals(end))
return;
from = start;
target = end;
origin = ((StyleableProperty<?>) property).getStyleOrigin();
interpolate(0);
}
/// Applies an animation frame.
private void interpolate(double fraction) {
if (target != null)
apply(fraction == 1 ? target : from.interpolate(target, fraction));
}
/// Writes a CSS value while suppressing this transition's own listener.
@SuppressWarnings("unchecked")
private void apply(Paint value) {
applyingColors = true;
try {
((StyleableProperty<Paint>) property).applyStyle(origin, value);
} finally {
applyingColors = false;
}
}
/// Applies and clears the pending target.
private void finish() {
if (target != null) {
apply(target);
target = null;
}
}
/// Removes the paint listener.
private void dispose() {
property.removeListener(this);
}
}
}
@@ -1,186 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.skins;
import com.jfoenix.controls.JFXRippler;
import com.jfoenix.controls.JFXRippler.RipplerMask;
import com.jfoenix.controls.JFXRippler.RipplerPos;
import com.jfoenix.controls.JFXToggleButton;
import com.jfoenix.effects.JFXDepthManager;
import com.jfoenix.transitions.JFXAnimationTimer;
import com.jfoenix.transitions.JFXKeyFrame;
import com.jfoenix.transitions.JFXKeyValue;
import javafx.animation.Interpolator;
import javafx.geometry.Insets;
import javafx.scene.Cursor;
import javafx.scene.control.skin.ToggleButtonSkin;
import javafx.scene.layout.StackPane;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Line;
import javafx.scene.shape.StrokeLineCap;
import javafx.util.Duration;
/**
* <h1>Material Design ToggleButton Skin</h1>
*
* @author Shadi Shaheen
* @version 1.0
* @since 2016-03-09
*/
public class JFXToggleButtonSkin extends ToggleButtonSkin {
private Runnable releaseManualRippler = null;
private JFXAnimationTimer timer;
private final Circle circle;
private final Line line;
public JFXToggleButtonSkin(JFXToggleButton toggleButton) {
super(toggleButton);
double circleRadius = toggleButton.getSize();
line = new Line();
line.setStroke(getSkinnable().isSelected() ? toggleButton.getToggleLineColor() : toggleButton.getUnToggleLineColor());
line.setStartX(0);
line.setStartY(0);
line.setEndX(circleRadius * 2 + 2);
line.setEndY(0);
line.setStrokeWidth(circleRadius * 1.5);
line.setStrokeLineCap(StrokeLineCap.ROUND);
line.setSmooth(true);
circle = new Circle();
circle.setFill(getSkinnable().isSelected() ? toggleButton.getToggleColor() : toggleButton.getUnToggleColor());
circle.setCenterX(-circleRadius);
circle.setCenterY(0);
circle.setRadius(circleRadius);
circle.setSmooth(true);
JFXDepthManager.setDepth(circle, 1);
StackPane circlePane = new StackPane();
circlePane.getChildren().add(circle);
circlePane.setPadding(new Insets(circleRadius * 1.5));
JFXRippler rippler = new JFXRippler(circlePane, RipplerMask.CIRCLE, RipplerPos.BACK);
rippler.setRipplerFill(getSkinnable().isSelected() ? toggleButton.getToggleLineColor() : toggleButton.getUnToggleLineColor());
rippler.setTranslateX(computeTranslation(circleRadius, line));
final StackPane main = new StackPane();
main.getChildren().setAll(line, rippler);
main.setCursor(Cursor.HAND);
// show focus traversal effect
getSkinnable().armedProperty().addListener((o, oldVal, newVal) -> {
if (newVal) {
releaseManualRippler = rippler.createManualRipple();
} else if (releaseManualRippler != null) {
releaseManualRippler.run();
}
});
toggleButton.focusedProperty().addListener((o, oldVal, newVal) -> {
if (!toggleButton.isDisableVisualFocus()) {
if (newVal) {
if (!getSkinnable().isPressed()) {
rippler.setOverlayVisible(true);
}
} else {
rippler.setOverlayVisible(false);
}
}
});
toggleButton.pressedProperty().addListener(observable -> rippler.setOverlayVisible(false));
// add change listener to selected property
getSkinnable().selectedProperty().addListener(observable -> {
rippler.setRipplerFill(toggleButton.isSelected() ? toggleButton.getToggleLineColor() : toggleButton.getUnToggleLineColor());
if (!toggleButton.isDisableAnimation()) {
timer.reverseAndContinue();
} else {
rippler.setTranslateX(computeTranslation(circleRadius, line));
}
});
getSkinnable().setGraphic(main);
timer = new JFXAnimationTimer(
new JFXKeyFrame(Duration.millis(100),
JFXKeyValue.builder()
.setTarget(rippler.translateXProperty())
.setEndValueSupplier(() -> computeTranslation(circleRadius, line))
.setInterpolator(Interpolator.EASE_BOTH)
.setAnimateCondition(() -> !((JFXToggleButton) getSkinnable()).isDisableAnimation())
.build(),
JFXKeyValue.builder()
.setTarget(line.strokeProperty())
.setEndValueSupplier(() -> getSkinnable().isSelected() ?
((JFXToggleButton) getSkinnable()).getToggleLineColor()
: ((JFXToggleButton) getSkinnable()).getUnToggleLineColor())
.setInterpolator(Interpolator.EASE_BOTH)
.setAnimateCondition(() -> !((JFXToggleButton) getSkinnable()).isDisableAnimation())
.build(),
JFXKeyValue.builder()
.setTarget(circle.fillProperty())
.setEndValueSupplier(() -> getSkinnable().isSelected() ?
((JFXToggleButton) getSkinnable()).getToggleColor()
: ((JFXToggleButton) getSkinnable()).getUnToggleColor())
.setInterpolator(Interpolator.EASE_BOTH)
.setAnimateCondition(() -> !((JFXToggleButton) getSkinnable()).isDisableAnimation())
.build()
)
);
timer.setCacheNodes(circle, line);
registerChangeListener(toggleButton.toggleColorProperty(), observableValue -> {
if (getSkinnable().isSelected()) {
circle.setFill(((JFXToggleButton) getSkinnable()).getToggleColor());
}
});
registerChangeListener(toggleButton.unToggleColorProperty(), observableValue -> {
if (!getSkinnable().isSelected()) {
circle.setFill(((JFXToggleButton) getSkinnable()).getUnToggleColor());
}
});
registerChangeListener(toggleButton.toggleLineColorProperty(), observableValue -> {
if (getSkinnable().isSelected()) {
line.setStroke(((JFXToggleButton) getSkinnable()).getToggleLineColor());
}
});
registerChangeListener(toggleButton.unToggleColorProperty(), observableValue -> {
if (!getSkinnable().isSelected()) {
line.setStroke(((JFXToggleButton) getSkinnable()).getUnToggleLineColor());
}
});
}
private double computeTranslation(double circleRadius, Line line) {
return (getSkinnable().isSelected() ? 1 : -1) * ((line.getLayoutBounds().getWidth() / 2) - circleRadius + 2);
}
@Override
public void dispose() {
super.dispose();
timer.dispose();
timer = null;
}
}
@@ -26,8 +26,6 @@ import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
@@ -127,7 +125,6 @@ public final class Launcher extends Application {
Platform.setImplicitExit(false);
Controllers.initialize(primaryStage);
UpdateChecker.init();
primaryStage.show();
});
@@ -265,11 +262,6 @@ public final class Launcher extends Application {
}
public static void main(String[] args) {
if (UpdateHandler.processArguments(args)) {
LOG.shutdown();
return;
}
Thread.setDefaultUncaughtExceptionHandler(CRASH_REPORTER);
AsyncTaskExecutor.setUncaughtExceptionHandler(new CrashReporter(false));
@@ -33,18 +33,10 @@ public final class Metadata {
public static final int MINIMUM_REQUIRED_JAVA_VERSION = 17;
public static final int MINIMUM_SUPPORTED_JAVA_VERSION = 17;
public static final int RECOMMENDED_JAVA_VERSION = 21;
public static final String PUBLISH_URL = "https://hmcl.huangyuhui.net";
public static final String REPOSITORY_URL = "https://github.com/HMCL-dev/HMCL";
public static final String HMCL_UPDATE_URL = System.getProperty("hmcl.update_source.override", PUBLISH_URL + "/api/update_link");
public static final String MANUAL_UPDATE_URL = "https://github.com/HMCL-dev/HMCL/releases";
public static final int RECOMMENDED_JAVA_VERSION = 25;
public static final String DOCS_URL = "https://docs.hmcl.net";
public static final String CONTACT_URL = DOCS_URL + "/help.html";
public static final String CHANGELOG_URL = DOCS_URL + "/changelog/";
public static final String EULA_URL = DOCS_URL + "/eula/hmcl.html";
public static final String GROUPS_URL = "https://www.bilibili.com/opus/905435541874409529";
public static final String BUILD_CHANNEL = JarUtils.getAttribute("hmcl.version.type", "nightly");
public static final String GITHUB_SHA = JarUtils.getAttribute("hmcl.version.hash", null);
@@ -97,7 +89,7 @@ public final class Metadata {
public static @Nullable String getSuggestedJavaDownloadLink() {
if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.LOONGARCH64_OW)
return "https://www.loongnix.cn/zh/api/java/downloads-jdk21/index.html";
return "https://www.loongnix.cn/zh/api/java/downloads-jdk25/index.html";
else {
EnumSet<Architecture> supportedArchitectures;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
@@ -149,4 +141,43 @@ public final class Metadata {
return null;
}
/// Default client Server Modpack fileApi published by TDLMPack (Gitea generic package `latest`).
public static final String DEFAULT_CLIENT_FILE_API =
"https://git.nest.potatolab.uk:8443/api/packages/TDLM/generic/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;
}
}
@@ -16,11 +16,7 @@ import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.ref.SoftReference;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -364,7 +360,7 @@ public abstract class AddonProjectInfoRegistry<I extends AddonProjectInfo> {
|| ".+\\".indexOf(ch) >= 0;
}
/// Removes supported separators and pictographs from a secondary name.
/// Removes supported separators from a secondary name.
/// Returns an empty string for null, blank or unsupported names; matching is case-sensitive.
private static String cleanSubname(@Nullable String subname) {
if (StringUtils.isBlank(subname))
@@ -389,8 +385,7 @@ public abstract class AddonProjectInfoRegistry<I extends AddonProjectInfo> {
if (isSupportedSubnameChar(ch)) {
builder.appendCodePoint(ch);
} else if (!(Character.isWhitespace(ch)
|| "':_-/&()[]{}|,!?~•".indexOf(ch) >= 0
|| ch >= 0x1F300 && ch <= 0x1FAFF)) {
|| "':_-/&()[]{}|,!?~•".indexOf(ch) >= 0)) {
return "";
}
offset += Character.charCount(ch);
@@ -18,32 +18,65 @@ import org.jackhuang.hmcl.util.RegionUtils;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.util.*;
import java.util.function.Function;
import java.util.function.Supplier;
/// @author Glavo
@NotNullByDefault
public final class HMCLDownloadProvider extends DownloadProvider {
private volatile DownloadSource versionListSource = DownloadSource.DEFAULT;
private volatile DownloadSource fileSource = DownloadSource.DEFAULT;
private volatile DownloadSource versionListSource = DownloadSource.MIRROR;
private volatile DownloadSource fileSource = DownloadSource.MIRROR;
private static boolean preferMirror(DownloadSource source) {
if ("CN".equals(RegionUtils.MACHINE_REGION)) {
return source != DownloadSource.OFFICIAL;
}
return source == DownloadSource.MIRROR;
}
private static DownloadCandidates getCandidates(
DownloadSource source,
WebURL defaultUrl, WebURL bmclapiUrl
WebURL officialUrl,
List<WebURL> mirrorUrls
) {
if ("CN".equals(RegionUtils.MACHINE_REGION)) {
return switch (source) {
case DEFAULT, OFFICIAL -> DownloadCandidates.of(defaultUrl, bmclapiUrl);
case MIRROR -> DownloadCandidates.of(bmclapiUrl, defaultUrl);
};
if (preferMirror(source)) {
var candidates = new ArrayList<DownloadCandidate>(mirrorUrls.size() + 1);
candidates.addAll(spreadPrimary(mirrorUrls.stream().map(DownloadCandidate::of).toList(), officialUrl.href()));
candidates.add(DownloadCandidate.of(officialUrl));
return DownloadCandidates.of(List.copyOf(candidates));
} else {
return switch (source) {
case DEFAULT, OFFICIAL -> DownloadCandidates.of(defaultUrl);
case MIRROR -> DownloadCandidates.of(bmclapiUrl, defaultUrl);
};
var candidates = new ArrayList<DownloadCandidate>(mirrorUrls.size() + 1);
candidates.add(DownloadCandidate.of(officialUrl));
for (WebURL mirrorUrl : mirrorUrls) {
candidates.add(DownloadCandidate.of(mirrorUrl));
}
return DownloadCandidates.of(List.copyOf(candidates));
}
}
/// Rotates the first mirror among the top entries using a stable hash of the URL, so concurrent
/// file downloads spread across healthy mirrors instead of stampeding one entry point.
private static List<DownloadCandidate> spreadPrimary(List<DownloadCandidate> mirrors, String key) {
if (mirrors.size() <= 1)
return mirrors;
int top = Math.min(2, mirrors.size());
int primary = Math.floorMod(key.hashCode(), top);
if (primary == 0)
return mirrors;
var rotated = new ArrayList<DownloadCandidate>(mirrors.size());
rotated.add(mirrors.get(primary));
for (int i = 0; i < mirrors.size(); i++) {
if (i != primary)
rotated.add(mirrors.get(i));
}
return rotated;
}
private static DownloadCandidates getCandidates(DownloadSource source, List<MirrorRule> rules, List<String> urls) {
if (urls.isEmpty()) {
throw new IllegalArgumentException("urls cannot be empty");
@@ -67,7 +100,7 @@ public final class HMCLDownloadProvider extends DownloadProvider {
return DownloadCandidates.of(urls.stream().map(DownloadCandidate::of).toArray(DownloadCandidate[]::new));
}
var candidates = new ArrayList<DownloadCandidate>(urls.size() * 2);
var candidates = new ArrayList<DownloadCandidate>(urls.size() * 3);
for (String url : urls) {
DownloadCandidate candidate = DownloadCandidate.of(url);
if (candidate.url() == null) {
@@ -93,21 +126,22 @@ public final class HMCLDownloadProvider extends DownloadProvider {
return DownloadCandidates.of(List.copyOf(candidates));
}
private final String bmclRoot;
private final Supplier<@Unmodifiable List<String>> mirrorRoots;
private final List<MirrorRule> rules;
public HMCLDownloadProvider(String bmclRoot) {
this.bmclRoot = bmclRoot;
public HMCLDownloadProvider(Supplier<@Unmodifiable List<String>> mirrorRoots) {
this.mirrorRoots = Objects.requireNonNull(mirrorRoots);
this.rules = List.of(
new SimpleRule("https://bmclapi2.bangbang93.com", bmclRoot),
new SimpleRule("https://launchermeta.mojang.com", bmclRoot),
new SimpleRule("https://piston-meta.mojang.com", bmclRoot),
new SimpleRule("https://piston-data.mojang.com", bmclRoot),
new SimpleRule("https://launcher.mojang.com", bmclRoot),
new SimpleRule("https://libraries.minecraft.net", bmclRoot + "/libraries"),
new SimpleRule("http://files.minecraftforge.net/maven", bmclRoot + "/maven"),
new SimpleRule("https://files.minecraftforge.net/maven", bmclRoot + "/maven"),
new SimpleRule("https://maven.minecraftforge.net", bmclRoot + "/maven"),
new MultiRootRule("https://bmclapi2.bangbang93.com", Function.identity()),
new MultiRootRule("https://bmclapi.bangbang93.com", Function.identity()),
new MultiRootRule("https://launchermeta.mojang.com", Function.identity()),
new MultiRootRule("https://piston-meta.mojang.com", Function.identity()),
new MultiRootRule("https://piston-data.mojang.com", Function.identity()),
new MultiRootRule("https://launcher.mojang.com", Function.identity()),
new MultiRootRule("https://libraries.minecraft.net", root -> root + "/libraries"),
new MultiRootRule("http://files.minecraftforge.net/maven", root -> root + "/maven"),
new MultiRootRule("https://files.minecraftforge.net/maven", root -> root + "/maven"),
new MultiRootRule("https://maven.minecraftforge.net", root -> root + "/maven"),
(source, mirrorFirst) -> {
String neoforgedMaven = "https://maven.neoforged.net/";
String neoforgedMavenRelease = "https://maven.neoforged.net/releases/";
@@ -116,17 +150,19 @@ public final class HMCLDownloadProvider extends DownloadProvider {
return null;
var candidates = new ArrayList<DownloadCandidate>();
List<String> roots = mirrorRoots.get();
if (source.url().href().startsWith(neoforgedMavenRelease)) {
DownloadCandidate bmclapiMirror = DownloadCandidate.of(bmclRoot + "/maven/"
+ source.url().href().substring(neoforgedMavenRelease.length()));
String suffix = source.url().href().substring(neoforgedMavenRelease.length());
List<DownloadCandidate> mirrors = roots.stream()
.map(root -> DownloadCandidate.of(root + "/maven/" + suffix))
.toList();
if (mirrorFirst) {
candidates.add(bmclapiMirror);
candidates.addAll(spreadPrimary(mirrors, source.url().href()));
candidates.add(source);
} else {
candidates.add(source);
candidates.add(bmclapiMirror);
candidates.addAll(mirrors);
}
} else {
candidates.add(source);
@@ -136,11 +172,11 @@ public final class HMCLDownloadProvider extends DownloadProvider {
candidates.add(DownloadCandidate.of("https://neoforged.forgecdn.net/" + source.url().href().substring(neoforgedMaven.length())));
return candidates;
},
new SimpleRule("http://dl.liteloader.com/versions", bmclRoot + "/maven"),
new SimpleRule("https://dl.liteloader.com/versions", bmclRoot + "/maven"),
new SimpleRule("https://meta.fabricmc.net", bmclRoot + "/fabric-meta"),
new SimpleRule("https://maven.fabricmc.net", bmclRoot + "/maven"),
new SimpleRule("https://authlib-injector.yushi.moe", bmclRoot + "/mirrors/authlib-injector"),
new MultiRootRule("http://dl.liteloader.com/versions", root -> root + "/maven"),
new MultiRootRule("https://dl.liteloader.com/versions", root -> root + "/maven"),
new MultiRootRule("https://meta.fabricmc.net", root -> root + "/fabric-meta"),
new MultiRootRule("https://maven.fabricmc.net", root -> root + "/maven"),
new MultiRootRule("https://authlib-injector.yushi.moe", root -> root + "/mirrors/authlib-injector"),
new SimpleRule("https://repo1.maven.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
new SimpleRule("https://repo.maven.apache.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
new SimpleRule("https://hmcl.glavo.site/metadata/cleanroom", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom"),
@@ -155,20 +191,32 @@ public final class HMCLDownloadProvider extends DownloadProvider {
);
}
private String primaryMirrorRoot() {
List<String> roots = mirrorRoots.get();
return roots.isEmpty() ? "https://bmclapi2.bangbang93.com" : roots.get(0);
}
private List<WebURL> mirrorUrls(String suffix) {
return mirrorRoots.get().stream()
.map(root -> WebURL.parse(root + suffix))
.toList();
}
@Override
protected Task<? extends ComponentRemoteVersionList<?>> fetchVersionsAsync(GameComponentType type, @Nullable GameVersionNumber gameVersion) {
assert (gameVersion == null) == (type == GameComponentType.GAME);
String bmclRoot = primaryMirrorRoot();
switch (type) {
case FORGE -> {
Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchOfficial = ForgeRemoteVersion.fetchAsync(this, gameVersion);
DownloadSource source = versionListSource;
if (source == DownloadSource.DEFAULT || source == DownloadSource.OFFICIAL) {
if (source == DownloadSource.OFFICIAL) {
return fetchOfficial;
}
Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchBMCL = ForgeRemoteVersion.fetchBMCLAsync(this, bmclRoot, gameVersion);
if (source == DownloadSource.MIRROR) {
if (preferMirror(source)) {
return new FallbackTask<>(fetchBMCL, fetchOfficial);
} else {
return new FallbackTask<>(fetchOfficial, fetchBMCL);
@@ -178,11 +226,11 @@ public final class HMCLDownloadProvider extends DownloadProvider {
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchOfficial = NeoForgeRemoteVersion.fetchAsync(this, gameVersion);
DownloadSource source = versionListSource;
if (source == DownloadSource.DEFAULT || source == DownloadSource.OFFICIAL) {
if (source == DownloadSource.OFFICIAL) {
return fetchOfficial;
}
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchBMCL = NeoForgeRemoteVersion.fetchBMCLAsync(bmclRoot, gameVersion);
if (source == DownloadSource.MIRROR || source == DownloadSource.DEFAULT) {
if (preferMirror(source)) {
return new FallbackTask<>(fetchBMCL, fetchOfficial);
} else {
return new FallbackTask<>(fetchOfficial, fetchBMCL);
@@ -214,7 +262,7 @@ public final class HMCLDownloadProvider extends DownloadProvider {
return getCandidates(
versionListSource,
GameRemoteVersion.VERSION_MANIFEST_URL,
WebURL.parse(bmclRoot + "/mc/game/version_manifest.json")
mirrorUrls("/mc/game/version_manifest.json")
);
}
@@ -222,8 +270,8 @@ public final class HMCLDownloadProvider extends DownloadProvider {
public FabricLikeVersionListCandidates getFabricVersionListCandidates() {
DownloadSource source = versionListSource;
return new FabricLikeVersionListCandidates(
getCandidates(source, FabricRemoteVersion.LOADER_META_URL, WebURL.parse(bmclRoot + "/fabric-meta/v2/versions/loader")),
getCandidates(source, FabricRemoteVersion.GAME_META_URL, WebURL.parse(bmclRoot + "/fabric-meta/v2/versions/game"))
getCandidates(source, FabricRemoteVersion.LOADER_META_URL, mirrorUrls("/fabric-meta/v2/versions/loader")),
getCandidates(source, FabricRemoteVersion.GAME_META_URL, mirrorUrls("/fabric-meta/v2/versions/game"))
);
}
@@ -246,10 +294,11 @@ public final class HMCLDownloadProvider extends DownloadProvider {
@Override
public DownloadCandidates getAssetObjectCandidates(AssetObject assetObject) {
String location = assetObject.getLocation();
return getCandidates(
fileSource,
WebURL.parse("https://resources.download.minecraft.net/" + assetObject.getLocation()),
WebURL.parse(bmclRoot + "/assets/" + assetObject.getLocation())
WebURL.parse("https://resources.download.minecraft.net/" + location),
mirrorUrls("/assets/" + location)
);
}
@@ -298,6 +347,38 @@ public final class HMCLDownloadProvider extends DownloadProvider {
@Nullable List<DownloadCandidate> getCandidates(DownloadCandidate source, boolean mirrorFirst);
}
@NotNullByDefault
private final class MultiRootRule implements MirrorRule {
private final String source;
private final Function<String, String> rootMapper;
private MultiRootRule(String source, Function<String, String> rootMapper) {
this.source = source;
this.rootMapper = rootMapper;
}
@Override
public @Nullable List<DownloadCandidate> getCandidates(DownloadCandidate source, boolean mirrorFirst) {
if (source.url() == null || !source.url().href().startsWith(this.source))
return null;
String urlWithoutPrefix = source.url().href().substring(this.source.length());
List<DownloadCandidate> mirrors = mirrorRoots.get().stream()
.map(root -> DownloadCandidate.of(rootMapper.apply(root) + urlWithoutPrefix))
.toList();
var candidates = new ArrayList<DownloadCandidate>(mirrors.size() + 1);
if (mirrorFirst) {
candidates.addAll(spreadPrimary(mirrors, source.url().href()));
candidates.add(source);
} else {
candidates.add(source);
candidates.addAll(mirrors);
}
return candidates;
}
}
@NotNullByDefault
private record SimpleRule(String source, @Nullable String first, @Nullable String fallback) implements MirrorRule {
public SimpleRule(String source, String target) {
@@ -330,4 +411,3 @@ public final class HMCLDownloadProvider extends DownloadProvider {
}
}
}
@@ -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();
@@ -91,6 +91,12 @@ public final class HMCLGameLauncher extends DefaultLauncher {
if (gameVersion == GameVersionNumber.unknown() || gameVersion.compareTo("1.1") < 0)
return;
// These snapshots lack the external language resources needed for non-English locales.
// Selecting an unavailable language causes a NullPointerException during startup.
if (gameVersion instanceof GameVersionNumber.LegacySnapshot
&& gameVersion.compareTo("13w16a") >= 0 && gameVersion.compareTo("13w23b") <= 0)
return;
String lang = normalizedLanguageTag(locale, gameVersion);
if (lang.isEmpty())
return;
@@ -428,10 +428,11 @@ public final class LauncherHelper {
}
});
executor.start();
launchingStepsPane.startExecutor();
}
private static Task<JavaRuntime> checkGameState(HMCLGameInstance gameInstance, GameSettings.Effective setting, GameInstanceManifest manifest) {
/// Checks launch requirements, allowing required Java downloads to share this launch's execution slot.
private Task<JavaRuntime> checkGameState(HMCLGameInstance gameInstance, GameSettings.Effective setting, GameInstanceManifest manifest) {
GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, gameInstance.getVersion());
GameVersionNumber gameVersion = gameInstance.getVersion();
@@ -733,13 +734,18 @@ public final class LauncherHelper {
return task.withStage("launch.state.java");
}
private static CompletableFuture<JavaRuntime> downloadJava(GameJavaVersion javaVersion, HMCLGameRepository repository) {
/// Prompts for and downloads Java as a required child of this launch operation.
private CompletableFuture<JavaRuntime> downloadJava(GameJavaVersion javaVersion, HMCLGameRepository repository) {
CompletableFuture<JavaRuntime> future = new CompletableFuture<>();
Controllers.dialog(new MessageDialogPane.Builder(
i18n("launch.advice.require_newer_java_version", javaVersion.majorVersion()),
i18n("message.warning"),
MessageType.QUESTION)
.yesOrNo(() -> {
if (!launchingStepsPane.canStartChild()) {
future.completeExceptionally(new CancellationException());
return;
}
DownloadProvider downloadProvider = repository.getDependency().getDownloadProvider();
Controllers.taskDialog(JavaManager.getDownloadJavaTask(downloadProvider, SYSTEM_PLATFORM, javaVersion)
.whenComplete(Schedulers.javafx(), (result, exception) -> {
@@ -753,7 +759,7 @@ public final class LauncherHelper {
}
future.completeExceptionally(new CancellationException());
}
}), i18n("download.java"), it -> future.completeExceptionally(new CancellationException()));
}), i18n("download.java"), it -> future.completeExceptionally(new CancellationException()), launchingStepsPane);
}, () -> future.completeExceptionally(new CancellationException())).build());
return future;
@@ -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) {
@@ -53,7 +53,14 @@ public final class Accounts {
public static final OfflineAccountFactory FACTORY_OFFLINE = new OfflineAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER);
public static final AuthlibInjectorAccountFactory FACTORY_AUTHLIB_INJECTOR = new AuthlibInjectorAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER, Accounts::getOrCreateAuthlibInjectorServer);
public static final MicrosoftAccountFactory FACTORY_MICROSOFT = new MicrosoftAccountFactory(new MicrosoftService(OAUTH_CALLBACK));
public static final List<AccountFactory<?>> FACTORIES = List.of(FACTORY_OFFLINE, FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR);
/// Account factories offered when creating a new account from the UI.
///
/// 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<>();
@@ -26,9 +26,8 @@ import java.util.Objects;
@NotNullByDefault
@JsonSerializable
public final class AuthlibInjectorServerList extends ObservableSetting implements JsonSchemaSetting {
/// The LittleSkin Yggdrasil API endpoint bundled into newly created server lists.
/// The LittleSkin Yggdrasil API endpoint used only when recognizing an existing entry.
public static final String LITTLE_SKIN_URL = "https://littleskin.cn/api/yggdrasil/";
/// The JSON schema supported by this authlib-injector server list.
public static final JsonSchema CURRENT_SCHEMA =
new JsonSchema("authlib-injector-servers", new JsonSchema.Version(1, 0, 0));
@@ -40,17 +39,10 @@ public final class AuthlibInjectorServerList extends ObservableSetting implement
}
/// Creates the default authlib-injector server list for a newly created file.
///
/// The default list is empty; operators add their own authentication server when needed.
public static AuthlibInjectorServerList createDefault() {
AuthlibInjectorServerList result = new AuthlibInjectorServerList();
result.addLittleSkinIfAbsent();
return result;
}
/// Adds the bundled LittleSkin server when it is not already present.
public void addLittleSkinIfAbsent() {
if (getServers().stream().noneMatch(server -> LITTLE_SKIN_URL.equals(server.getUrl()))) {
getServers().add(new AuthlibInjectorServer(LITTLE_SKIN_URL));
}
return new AuthlibInjectorServerList();
}
/// The schema used by this authlib-injector server list file.
@@ -3,48 +3,27 @@
package org.jackhuang.hmcl.setting;
import javafx.beans.InvalidationListener;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.download.*;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.HMCLDownloadProvider;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.util.List;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class DownloadProviders {
private DownloadProviders() {
}
private static final String DEFAULT_BMCLAPI_ROOT = "https://bmclapi2.bangbang93.com";
private static final String BMCLAPI_ROOT;
private static final MirrorRoots MIRROR_ROOTS =
new MirrorRoots(System.getProperty("hmcl.bmclapi.override"));
static {
@Nullable String bmclOverride = System.getProperty("hmcl.bmclapi.override");
if (StringUtils.isBlank(bmclOverride)) {
BMCLAPI_ROOT = DEFAULT_BMCLAPI_ROOT;
} else {
WebURL url;
try {
url = WebURL.parseBrowserInput(bmclOverride);
} catch (Exception e) {
url = null;
LOG.warning("Invalid BMCLAPI override URL: " + bmclOverride, e);
}
private static final HMCLDownloadProvider PROVIDER = new HMCLDownloadProvider(MIRROR_ROOTS::getRankedRoots);
if (url == null) {
BMCLAPI_ROOT = DEFAULT_BMCLAPI_ROOT;
} else {
BMCLAPI_ROOT = StringUtils.removeSuffix(url.toString(), "/");
}
}
}
private static final HMCLDownloadProvider PROVIDER = new HMCLDownloadProvider(BMCLAPI_ROOT);
/// Initializes download provider settings and synchronizes download thread settings.
/// Initializes download provider settings, mirror probing, and download thread settings.
public static void init() {
InvalidationListener onChangeDownloadThreads = observable -> {
FetchTask.setDownloadExecutorConcurrency(settings().autoDownloadThreadsProperty().get()
@@ -57,13 +36,18 @@ public final class DownloadProviders {
FXUtils.onChangeAndOperate(settings().versionListSourceProperty(), PROVIDER::setVersionListSource);
FXUtils.onChangeAndOperate(settings().fileDownloadSourceProperty(), PROVIDER::setFileSource);
MIRROR_ROOTS.maybeProbeAsync();
}
/**
* Get current primary preferred download provider
*/
/// Get current primary preferred download provider
public static DownloadProvider getDownloadProvider() {
MIRROR_ROOTS.maybeProbeAsync();
return PROVIDER;
}
/// Returns the current ranked BMCLAPI-compatible mirror roots (fastest first).
public static @Unmodifiable List<String> getRankedMirrorRoots() {
return MIRROR_ROOTS.getRankedRoots();
}
}
@@ -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"));
}
@@ -152,12 +152,6 @@ public final class GameDirectoryManager {
gameDirectories.getGameDirectories().add(new GameDirectory(
DEFAULT_GAME_DIRECTORY_ID, null, CURRENT_GAME_DIRECTORY_PATH));
}
if (userGameDirectories().isNewlyCreated() && userGameDirectories().getGameDirectories().isEmpty()) {
needRebuildGameDirectories = true;
GameDirectories gameDirectories = userGameDirectories();
gameDirectories.getGameDirectories().add(new GameDirectory(
HOME_GAME_DIRECTORY_ID, null, HOME_GAME_DIRECTORY_PATH));
}
needRebuildGameDirectories |= createDefaultGameDirectoriesIfEmpty();
if (needRebuildGameDirectories) {
@@ -166,15 +160,13 @@ public final class GameDirectoryManager {
assert !mergedGameDirectories.isEmpty();
@Nullable GameDirectoryID selectedId = settings().selectedGameDirectoryProperty().get();
// Dedicated server client: always prefer the fixed workspace `.minecraft` directory.
GameDirectory currentGameDirectory = null;
if (selectedId != null) {
for (GameDirectory gameDirectory : mergedGameDirectories) {
if (gameDirectory.getId().equals(selectedId)) {
currentGameDirectory = gameDirectory;
break;
}
for (GameDirectory gameDirectory : mergedGameDirectories) {
if (DEFAULT_GAME_DIRECTORY_ID.equals(gameDirectory.getId())
|| isGameDirectoryPath(gameDirectory, CURRENT_GAME_DIRECTORY_PATH)) {
currentGameDirectory = gameDirectory;
break;
}
}
@@ -215,16 +207,13 @@ public final class GameDirectoryManager {
}
}
/// Creates the built-in game directories only when no game directory exists.
/// Creates the fixed workspace game directory when no game directory exists.
private static boolean createDefaultGameDirectoriesIfEmpty() {
if (localGameDirectories().getGameDirectories().isEmpty()
&& userGameDirectories().getGameDirectories().isEmpty()) {
localGameDirectories().getGameDirectories()
.add(new GameDirectory(
DEFAULT_GAME_DIRECTORY_ID, null, CURRENT_GAME_DIRECTORY_PATH));
userGameDirectories().getGameDirectories()
.add(new GameDirectory(
HOME_GAME_DIRECTORY_ID, null, HOME_GAME_DIRECTORY_PATH));
return true;
} else {
return false;
@@ -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";
@@ -177,32 +175,7 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
return language;
}
/// Whether preview builds are accepted by update checks.
@SerializedName("acceptPreviewUpdate")
private final BooleanProperty acceptPreviewUpdate = new SimpleBooleanProperty(false);
/// Returns the preview update opt-in property.
public BooleanProperty acceptPreviewUpdateProperty() {
return acceptPreviewUpdate;
}
/// Whether automatic update dialogs are disabled.
@SerializedName("disableAutoShowUpdateDialog")
private final BooleanProperty disableAutoShowUpdateDialog = new SimpleBooleanProperty(false);
/// Returns the automatic update dialog disable property.
public BooleanProperty disableAutoShowUpdateDialogProperty() {
return disableAutoShowUpdateDialog;
}
/// 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")
@@ -250,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.
@@ -501,7 +457,7 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
/// The selected game version list download source.
@SerializedName("versionListSource")
private final ObjectProperty<DownloadSource> versionListSource = new RawPreservingObjectProperty<>(DownloadSource.DEFAULT);
private final ObjectProperty<DownloadSource> versionListSource = new RawPreservingObjectProperty<>(DownloadSource.MIRROR);
/// Returns the selected game version list download source property.
public ObjectProperty<DownloadSource> versionListSourceProperty() {
@@ -510,7 +466,7 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
/// The selected file download source.
@SerializedName("fileDownloadSource")
private final ObjectProperty<DownloadSource> fileDownloadSource = new RawPreservingObjectProperty<>(DownloadSource.DEFAULT);
private final ObjectProperty<DownloadSource> fileDownloadSource = new RawPreservingObjectProperty<>(DownloadSource.MIRROR);
/// Returns the selected file download source property.
public ObjectProperty<DownloadSource> fileDownloadSourceProperty() {
@@ -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;
@@ -174,25 +171,6 @@ public final class LauncherState extends ObservableSetting implements JsonSchema
this.height.set(height);
}
/// The latest update version that has already prompted the user.
@SerializedName("promptedVersion")
private final StringProperty promptedVersion = new SimpleStringProperty();
/// Returns the latest prompted update version.
public @Nullable String getPromptedVersion() {
return promptedVersion.get();
}
/// Returns the latest prompted update version property.
public StringProperty promptedVersionProperty() {
return promptedVersion;
}
/// Sets the latest prompted update version.
public void setPromptedVersion(@Nullable String promptedVersion) {
this.promptedVersion.set(promptedVersion);
}
/// Tip markers that prevent repeated prompts.
@SerializedName("shownTips")
private final ObservableMap<String, Object> shownTips = FXCollections.observableHashMap();
@@ -337,7 +337,6 @@ public final class LegacyConfigMigrator {
JsonObject current = new JsonObject();
current.add(JsonSchema.PROPERTY_SCHEMA, JsonUtils.GSON.toJsonTree(UserState.CURRENT_SCHEMA, JsonSchema.class));
copyMember(legacy, current, "agreementVersion");
copyMember(legacy, current, "terracottaAgreementVersion");
copyMember(legacy, current, "platformPromptVersion");
return Objects.requireNonNull(JsonUtils.GSON.fromJson(current, UserState.class));
}
@@ -396,7 +395,6 @@ public final class LegacyConfigMigrator {
moveMember(json, state, "height");
migrateLegacyWindowContentSize(state, "width");
migrateLegacyWindowContentSize(state, "height");
moveMember(json, state, "promptedVersion");
moveMember(json, state, "shownTips");
LauncherState result = JsonUtils.GSON.fromJson(state, LauncherState.class);
@@ -427,18 +425,12 @@ public final class LegacyConfigMigrator {
if (authlibInjectorServers != null) {
servers.add("servers", authlibInjectorServers);
}
JsonElement addedLittleSkin = json.remove("addedLittleSkin");
boolean shouldAddLittleSkin = !(addedLittleSkin instanceof JsonPrimitive primitive
&& primitive.isBoolean()
&& primitive.getAsBoolean());
json.remove("addedLittleSkin");
AuthlibInjectorServerList result = JsonUtils.GSON.fromJson(servers, AuthlibInjectorServerList.class);
if (result == null) {
result = new AuthlibInjectorServerList();
}
if (shouldAddLittleSkin) {
result.addLittleSkinIfAbsent();
}
return result;
}
@@ -0,0 +1,131 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.net.HttpURLConnection;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Ranks BMCLAPI-compatible mirror roots by a lightweight latency probe.
///
/// Game downloads are many small/medium files. Racing the same file across mirrors wastes
/// volunteer OpenBMCLAPI bandwidth; ranking entry points and keeping file-level concurrency
/// (plus ordered fallback) is the better fit for that workload.
@NotNullByDefault
public final class MirrorRoots {
/// Public BMCLAPI entry points that speak the same path layout.
public static final @Unmodifiable List<String> DEFAULT_ROOTS = List.of(
"https://bmclapi2.bangbang93.com",
"https://bmclapi.bangbang93.com"
);
private static final String PROBE_PATH = "/mc/game/version_manifest.json";
private static final long REPROBE_INTERVAL_NANOS = 30L * 60L * 1_000_000_000L;
private final CopyOnWriteArrayList<String> rankedRoots = new CopyOnWriteArrayList<>(DEFAULT_ROOTS);
private final AtomicBoolean probing = new AtomicBoolean(false);
private volatile long lastProbeNanos;
/// Creates a ranker seeded with the built-in BMCLAPI roots, or a single override root.
public MirrorRoots(@Nullable String overrideRoot) {
if (StringUtils.isNotBlank(overrideRoot)) {
WebURL url;
try {
url = WebURL.parseBrowserInput(overrideRoot);
} catch (Exception e) {
url = null;
LOG.warning("Invalid BMCLAPI override URL: " + overrideRoot, e);
}
if (url != null) {
rankedRoots.clear();
rankedRoots.add(StringUtils.removeSuffix(url.toString(), "/"));
}
}
}
/// Returns the current ranked mirror roots (fastest first). Never empty.
public @Unmodifiable List<String> getRankedRoots() {
List<String> snapshot = List.copyOf(rankedRoots);
return snapshot.isEmpty() ? DEFAULT_ROOTS : snapshot;
}
/// Returns the current primary (fastest) mirror root.
public String getPrimaryRoot() {
return getRankedRoots().get(0);
}
/// Starts a background probe when none is running and the previous result is stale.
public void maybeProbeAsync() {
if (rankedRoots.size() <= 1)
return;
long now = System.nanoTime();
if (now - lastProbeNanos < REPROBE_INTERVAL_NANOS && lastProbeNanos != 0)
return;
if (!probing.compareAndSet(false, true))
return;
Schedulers.io().execute(() -> {
try {
probeAndRank();
} catch (Throwable t) {
LOG.warning("Failed to probe download mirrors", t);
} finally {
lastProbeNanos = System.nanoTime();
probing.set(false);
}
});
}
private void probeAndRank() {
List<String> candidates = List.copyOf(rankedRoots);
if (candidates.size() <= 1)
return;
record Score(String root, long nanos, boolean ok) {
}
List<Score> scores = new ArrayList<>(candidates.size());
for (String root : candidates) {
long started = System.nanoTime();
boolean ok = false;
try {
HttpURLConnection connection = NetworkUtils.createHttpConnection(root + PROBE_PATH);
connection.setInstanceFollowRedirects(true);
connection.setRequestMethod("GET");
connection.setRequestProperty("Range", "bytes=0-0");
connection.connect();
int code = connection.getResponseCode();
// Accept normal OK, partial content, or redirect-resolved success.
ok = code >= 200 && code < 400;
connection.disconnect();
} catch (Exception e) {
LOG.info("Mirror probe failed for " + root + ": " + e);
}
scores.add(new Score(root, System.nanoTime() - started, ok));
}
scores.sort(Comparator
.comparing((Score score) -> !score.ok)
.thenComparingLong(Score::nanos));
List<String> next = scores.stream().map(Score::root).toList();
if (!next.equals(candidates)) {
rankedRoots.clear();
rankedRoots.addAll(next);
LOG.info("Ranked download mirrors: " + next);
}
}
}
@@ -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) {
@@ -106,15 +106,6 @@ public final class UserState extends ObservableSetting implements JsonSchemaSett
return agreementVersion;
}
/// The accepted Terracotta agreement version.
@SerializedName("terracottaAgreementVersion")
private final IntegerProperty terracottaAgreementVersion = new SimpleIntegerProperty();
/// Returns the accepted Terracotta agreement version property.
public IntegerProperty terracottaAgreementVersionProperty() {
return terracottaAgreementVersion;
}
/// The platform prompt version shown to the user.
@SerializedName("platformPromptVersion")
private final IntegerProperty platformPromptVersion = new SimpleIntegerProperty();
@@ -1,187 +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.terracotta;
import kala.compress.archivers.tar.TarArchiveEntry;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.download.ArtifactMalformedException;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.UrlResponseInfo;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.DigestInputStream;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class TerracottaBundle {
private final Path root;
/// Candidate bundle URLs in attempt order.
private final List<WebURL> links;
private final FileDownloadTask.IntegrityCheck hash;
private final Map<String, FileDownloadTask.IntegrityCheck> files;
/// Creates a bundle using the supplied download candidates and archive/file integrity metadata.
/// The candidate list and file map are retained without copying.
public TerracottaBundle(Path root, List<WebURL> links, FileDownloadTask.IntegrityCheck hash, Map<String, FileDownloadTask.IntegrityCheck> files) {
this.root = root;
this.links = links;
this.hash = hash;
this.files = files;
}
public Task<Path> download(AbstractTerracottaProvider.DownloadContext context) {
return Task.supplyAsync(() -> Files.createTempFile("terracotta-", ".tar.gz"))
.thenComposeAsync(Schedulers.javafx(), pkg -> {
FileDownloadTask download = new FileDownloadTask(links, pkg, hash) {
@Override
protected Context getContext(@Nullable UrlResponseInfo response, boolean checkETag, @Nullable String bmclapiHash) throws IOException {
FetchTask.Context delegate = super.getContext(response, checkETag, bmclapiHash);
return new Context() {
@Override
public void withResult(boolean success) {
super.withResult(success);
delegate.withResult(success);
}
@Override
public void reset() throws IOException {
delegate.reset();
}
@Override
public void write(byte[] buffer, int offset, int len) throws IOException {
context.checkCancellation();
delegate.write(buffer, offset, len);
}
@Override
public void close() throws IOException {
if (isSuccess()) {
context.checkCancellation();
}
delegate.close();
}
};
}
};
context.bindProgress(download.progressProperty());
return download.thenSupplyAsync(() -> pkg);
});
}
public Task<?> install(Path pkg) {
return Task.runAsync(() -> {
Files.createDirectories(root);
FileUtils.cleanDirectory(root);
try (TarFileTree tree = TarFileTree.open(pkg)) {
for (Map.Entry<String, FileDownloadTask.IntegrityCheck> entry : files.entrySet()) {
String file = entry.getKey();
FileDownloadTask.IntegrityCheck check = entry.getValue();
Path path = root.resolve(file);
TarArchiveEntry archive = tree.getEntry("/" + file);
if (archive == null) {
throw new ArtifactMalformedException(String.format("Expecting %s file in terracotta bundle.", file));
}
MessageDigest digest = DigestUtils.getDigest(check.algorithm());
try (
InputStream is = tree.getInputStream(archive);
OutputStream os = new DigestOutputStream(Files.newOutputStream(path), digest)
) {
is.transferTo(os);
}
String hash = HexFormat.of().formatHex(digest.digest());
if (!check.checksum().equalsIgnoreCase(hash)) {
throw new ChecksumMismatchException(check.algorithm(), check.checksum(), hash);
}
switch (OperatingSystem.CURRENT_OS) {
case LINUX, MACOS, FREEBSD -> Files.setPosixFilePermissions(path, FileUtils.parsePosixFilePermission(archive.getMode()));
}
}
}
}).whenComplete(exception -> {
if (exception != null) {
FileUtils.deleteDirectory(root);
}
});
}
public Path locate(String file) {
FileDownloadTask.IntegrityCheck check = files.get(file);
if (check == null) {
throw new AssertionError(String.format("Expecting %s file in terracotta bundle.", file));
}
return root.resolve(file).toAbsolutePath();
}
public AbstractTerracottaProvider.Status status() throws IOException {
if (Files.exists(root) && isLocalBundleValid()) {
return AbstractTerracottaProvider.Status.READY;
}
try {
if (TerracottaMetadata.hasLegacyVersionFiles()) {
return AbstractTerracottaProvider.Status.LEGACY_VERSION;
}
} catch (IOException e) {
LOG.warning("Cannot determine whether legacy versions exist.", e);
}
return AbstractTerracottaProvider.Status.NOT_EXIST;
}
private boolean isLocalBundleValid() throws IOException { // FIXME: Make control flow clearer.
long total = 0;
byte[] buffer = new byte[8192];
for (Map.Entry<String, FileDownloadTask.IntegrityCheck> entry : files.entrySet()) {
Path path = root.resolve(entry.getKey());
FileDownloadTask.IntegrityCheck check = entry.getValue();
if (!Files.isReadable(path)) {
return false;
}
MessageDigest digest = DigestUtils.getDigest(check.algorithm());
try (InputStream is = new DigestInputStream(Files.newInputStream(path), digest)) {
int n;
while ((n = is.read(buffer)) >= 0) {
total += n;
if (total >= 50 * 1024 * 1024) { // >=50MB
return false;
}
}
}
if (!HexFormat.of().formatHex(digest.digest()).equalsIgnoreCase(check.checksum())) {
return false;
}
}
return true;
}
}
@@ -1,361 +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.terracotta;
import com.google.gson.JsonObject;
import javafx.application.Platform;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.task.DownloadException;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.InvocationDispatcher;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
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 TerracottaManager {
private TerracottaManager() {
}
private static final AtomicReference<TerracottaState> STATE_V = new AtomicReference<>(TerracottaState.Bootstrap.INSTANCE);
private static final ReadOnlyObjectWrapper<TerracottaState> STATE = new ReadOnlyObjectWrapper<>(STATE_V.getPlain());
private static final InvocationDispatcher<TerracottaState> STATE_D = InvocationDispatcher.runOn(Platform::runLater, STATE::set);
static {
Schedulers.io().execute(() -> {
try {
if (TerracottaMetadata.PROVIDER == null)
throw new IOException("Unsupported platform: " + org.jackhuang.hmcl.util.platform.Platform.CURRENT_PLATFORM);
switch (TerracottaMetadata.PROVIDER.status()) {
case NOT_EXIST -> setState(new TerracottaState.Uninitialized(false));
case LEGACY_VERSION -> setState(new TerracottaState.Uninitialized(true));
case READY -> launch(setState(new TerracottaState.Launching()), false);
}
} catch (Exception e) {
LOG.warning("Cannot initialize Terracotta.", e);
compareAndSet(TerracottaState.Bootstrap.INSTANCE, new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
}
});
}
public static ReadOnlyObjectProperty<TerracottaState> stateProperty() {
return STATE.getReadOnlyProperty();
}
private static final Thread DAEMON = Lang.thread(TerracottaManager::runBackground, "Terracotta Background Daemon", true);
@FXThread // Written in FXThread, read-only on background daemon
private static volatile boolean daemonRunning = false;
private static void runBackground() {
final long ACTIVE = TimeUnit.MILLISECONDS.toNanos(500);
final long BACKGROUND = TimeUnit.SECONDS.toMillis(15);
while (true) {
if (daemonRunning) {
LockSupport.parkNanos(ACTIVE);
} else {
long deadline = System.currentTimeMillis() + BACKGROUND;
do {
LockSupport.parkUntil(deadline);
} while (!daemonRunning && System.currentTimeMillis() < deadline - 100);
}
if (!(STATE_V.get() instanceof TerracottaState.PortSpecific state)) {
continue;
}
int port = state.port;
int index = state instanceof TerracottaState.Ready ready ? ready.index : Integer.MIN_VALUE;
TerracottaState next;
try {
TerracottaState.Ready object = HttpRequest.GET(String.format("http://127.0.0.1:%d/state", port))
.retry(5)
.getJson(TerracottaState.Ready.class);
if (object.index <= index) {
continue;
}
object.port = port;
next = object;
} catch (Exception e) {
LOG.warning("Cannot fetch state from Terracotta.", e);
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
}
compareAndSet(state, next);
}
}
@FXThread
public static void switchDaemon(boolean active) {
FXUtils.checkFxUserThread();
boolean dr = daemonRunning;
if (dr != active) {
daemonRunning = active;
if (active) {
LockSupport.unpark(DAEMON);
}
}
}
private static AbstractTerracottaProvider getProvider() {
AbstractTerracottaProvider provider = TerracottaMetadata.PROVIDER;
if (provider == null) {
throw new AssertionError("Terracotta Provider must NOT be null.");
}
return provider;
}
public static boolean isInvalidBundle(Path file) {
return !FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME);
}
@FXThread
public static TerracottaState.Preparing download() {
FXUtils.checkFxUserThread();
TerracottaState state = STATE_V.get();
if (!(state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())
) {
return null;
}
TerracottaState.Preparing preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1), true);
Task.composeAsync(() -> getProvider().download(preparing))
.thenComposeAsync(pkg -> {
if (!preparing.requestInstallFence()) {
return null;
}
return getProvider().install(pkg).thenRunAsync(() -> {
TerracottaState.Launching launching = new TerracottaState.Launching();
if (compareAndSet(preparing, launching)) {
launch(launching, true);
}
});
}).whenComplete(exception -> {
if (exception instanceof CancellationException) {
// no-op
} else if (exception instanceof DownloadException) {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.NETWORK));
} else {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
}
}).start();
return compareAndSet(state, preparing) ? preparing : null;
}
@FXThread
public static TerracottaState.Preparing install(Path bundle) {
FXUtils.checkFxUserThread();
if (isInvalidBundle(bundle)) {
return null;
}
TerracottaState state = STATE_V.get();
TerracottaState.Preparing preparing;
if (state instanceof TerracottaState.Preparing previousPreparing && previousPreparing.requestInstallFence()) {
preparing = previousPreparing;
} else if (state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable()) {
preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1), false);
} else {
return null;
}
Task.composeAsync(() -> getProvider().install(bundle))
.thenRunAsync(() -> {
TerracottaState.Launching launching = new TerracottaState.Launching();
if (compareAndSet(preparing, launching)) {
launch(launching, true);
}
})
.whenComplete(exception -> {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
}).start();
return state != preparing && compareAndSet(state, preparing) ? preparing : null;
}
@FXThread
public static TerracottaState recover() {
FXUtils.checkFxUserThread();
TerracottaState state = STATE_V.get();
if (!(state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())) {
return null;
}
try {
// FIXME: A temporary limit has been employed in TerracottaBundle#checkExisting, making
// hash check accept 50MB at most. Calling it on JavaFX should be safe.
return switch (getProvider().status()) {
case NOT_EXIST, LEGACY_VERSION -> download();
case READY -> {
TerracottaState.Launching launching = setState(new TerracottaState.Launching());
launch(launching, false);
yield launching;
}
};
} catch (RuntimeException | IOException e) {
LOG.warning("Cannot determine Terracotta state.", e);
return setState(new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
}
}
private static void launch(TerracottaState.Launching state, boolean removeLegacy) {
Task.supplyAsync(() -> {
Path path = Files.createTempDirectory(String.format("hmcl-terracotta-%d", ThreadLocalRandom.current().nextLong())).resolve("http").toAbsolutePath();
ManagedProcess process = new ManagedProcess(new ProcessBuilder(getProvider().ofCommandLine(path)));
process.pumpInputStream(SystemUtils::onLogLine);
process.pumpErrorStream(SystemUtils::onLogLine);
long exitTime = -1;
while (true) {
if (Files.exists(path)) {
JsonObject object = JsonUtils.fromNonNullJson(Files.readString(path), JsonObject.class);
return object.get("port").getAsInt();
}
if (!process.isRunning()) {
if (exitTime == -1) {
exitTime = System.currentTimeMillis();
} else if (System.currentTimeMillis() - exitTime >= 10000) {
throw new IllegalStateException(String.format("Process has exited for 10s, code = %s", process.getExitCode()));
}
}
}
}).whenComplete(Schedulers.javafx(), (port, exception) -> {
TerracottaState next;
if (exception == null) {
next = new TerracottaState.Unknown(port);
if (removeLegacy) {
TerracottaMetadata.removeLegacyVersionFiles();
}
} else {
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
}
compareAndSet(state, next);
}).start();
}
public static Task<String> exportLogs() {
if (STATE_V.get() instanceof TerracottaState.PortSpecific portSpecific) {
return new GetTask(WebURL.parse(String.format("http://127.0.0.1:%d/log?fetch=true", portSpecific.port)))
.setSignificance(Task.TaskSignificance.MINOR);
}
return Task.completed(null);
}
public static TerracottaState.Waiting setWaiting() {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
new GetTask(WebURL.parse(String.format("http://127.0.0.1:%d/state/ide", portSpecific.port)))
.setSignificance(Task.TaskSignificance.MINOR)
.start();
return new TerracottaState.Waiting(-1, -1, null);
}
return null;
}
private static String getPlayerName() {
Account account = Accounts.getSelectedAccount();
return account != null ? account.getProfileName() : i18n("terracotta.player_anonymous");
}
public static TerracottaState.HostScanning setScanning() {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
Task.supplyAsync(Schedulers.io(), TerracottaNodeList::fetch)
.thenComposeAsync(nodes -> {
List<Pair<String, String>> query = new ArrayList<>(nodes.size() + 1);
query.add(pair("player", getPlayerName()));
for (WebURL node : nodes) {
query.add(pair("public_nodes", node.toString()));
}
return new GetTask(NetworkUtils.withQuery(
"http://127.0.0.1:%d/state/scanning".formatted(portSpecific.port), query
)).setSignificance(Task.TaskSignificance.MINOR);
}).start();
return new TerracottaState.HostScanning(-1, -1, null);
}
return null;
}
public static Task<TerracottaState.GuestConnecting> setGuesting(String room) {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
return Task.supplyAsync(Schedulers.io(), TerracottaNodeList::fetch)
.thenComposeAsync(nodes -> {
ArrayList<Pair<String, String>> query = new ArrayList<>(nodes.size() + 2);
query.add(pair("room", room));
query.add(pair("player", getPlayerName()));
for (WebURL node : nodes) {
query.add(pair("public_nodes", node.toString()));
}
return new GetTask(NetworkUtils.withQuery("http://127.0.0.1:%d/state/guesting".formatted(portSpecific.port), query))
.setSignificance(Task.TaskSignificance.MINOR)
.thenSupplyAsync(() -> new TerracottaState.GuestConnecting(-1, -1, null))
.setSignificance(Task.TaskSignificance.MINOR);
});
} else {
return null;
}
}
private static <T extends TerracottaState> T setState(T value) {
if (value == null) {
throw new AssertionError();
}
STATE_V.set(value);
STATE_D.accept(value);
return value;
}
private static boolean compareAndSet(TerracottaState previous, TerracottaState next) {
if (next == null) {
throw new AssertionError();
}
if (STATE_V.compareAndSet(previous, next)) {
STATE_D.accept(next);
return true;
} else {
return false;
}
}
}
@@ -1,190 +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.terracotta;
import com.google.gson.annotations.SerializedName;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
import org.jackhuang.hmcl.terracotta.provider.GeneralProvider;
import org.jackhuang.hmcl.terracotta.provider.MacOSProvider;
import org.jackhuang.hmcl.util.RegionUtils;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OSVersion;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jackhuang.hmcl.util.versioning.VersionRange;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class TerracottaMetadata {
private TerracottaMetadata() {
}
private record Options(String version, String classifier) {
public String replace(String value) {
return value.replace("${version}", version).replace("${classifier}", classifier);
}
}
@JsonSerializable
public record Link(
@SerializedName("desc") LocalizedText description,
@SerializedName("link") String link
) {
}
@JsonSerializable
private record Package(
@SerializedName("hash") String hash,
@SerializedName("files") Map<String, String> files
) {
}
@JsonSerializable
private record Config(
@SerializedName("version_latest") String latest,
@SerializedName("packages") Map<String, Package> pkgs,
@SerializedName("downloads") List<String> downloads,
@SerializedName("downloads_CN") List<String> downloadsCN,
@SerializedName("links") List<Link> links
) {
private @Nullable TerracottaBundle resolve(Options options) {
Package pkg = pkgs.get(options.classifier);
if (pkg == null) {
return null;
}
Stream<String> stream = downloads.stream(), streamCN = downloadsCN.stream();
@Unmodifiable List<WebURL> links = ("CN".equals(RegionUtils.MACHINE_REGION) ? Stream.concat(streamCN, stream) : Stream.concat(stream, streamCN))
.map(link -> WebURL.parse(options.replace(link)))
.toList();
Map<String, FileDownloadTask.IntegrityCheck> files = pkg.files.entrySet().stream().collect(Collectors.toUnmodifiableMap(
Map.Entry::getKey,
entry -> new FileDownloadTask.IntegrityCheck("SHA-512", entry.getValue())
));
return new TerracottaBundle(
Metadata.DEPENDENCIES_DIRECTORY.resolve(options.replace("terracotta/${version}")).toAbsolutePath(),
links, new FileDownloadTask.IntegrityCheck("SHA-512", pkg.hash),
files
);
}
}
public static final AbstractTerracottaProvider PROVIDER;
public static final String PACKAGE_NAME;
public static final List<Link> PACKAGE_LINKS;
public static final String FEEDBACK_LINK = NetworkUtils.withQuery("https://docs.hmcl.net/multiplayer/feedback.html", Map.of(
"v", "v1",
"launcher_version", Metadata.VERSION
));
private static final String LATEST;
static {
Config config;
try (InputStream is = TerracottaMetadata.class.getResourceAsStream("/assets/terracotta.json")) {
config = JsonUtils.fromNonNullJsonFully(is, Config.class);
} catch (IOException e) {
throw new ExceptionInInitializerError(e);
}
LATEST = config.latest;
Options options = new Options(config.latest, OperatingSystem.CURRENT_OS.getCheckedName() + "-" + Architecture.SYSTEM_ARCH.getCheckedName());
TerracottaBundle bundle = config.resolve(options);
AbstractTerracottaProvider provider;
if (bundle == null || (provider = locateProvider(bundle, options)) == null) {
PROVIDER = null;
PACKAGE_NAME = null;
PACKAGE_LINKS = null;
} else {
PROVIDER = provider;
PACKAGE_NAME = options.replace("terracotta-${version}-${classifier}-pkg.tar.gz");
List<Link> packageLinks = config.links.stream()
.map(link -> new Link(link.description, options.replace(link.link)))
.collect(Collectors.toList());
Collections.shuffle(packageLinks);
PACKAGE_LINKS = Collections.unmodifiableList(packageLinks);
}
}
@Nullable
private static AbstractTerracottaProvider locateProvider(TerracottaBundle bundle, Options options) {
String prefix = options.replace("terracotta-${version}-${classifier}");
// FIXME: As HMCL is a cross-platform application, developers may mistakenly locate
// non-existent files in non-native platform logic without assertion errors during debugging.
return switch (OperatingSystem.CURRENT_OS) {
case WINDOWS -> {
if (!OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_10))
yield null;
yield new GeneralProvider(bundle, bundle.locate(prefix + ".exe"));
}
case LINUX, FREEBSD -> new GeneralProvider(bundle, bundle.locate(prefix));
case MACOS -> new MacOSProvider(
bundle, bundle.locate(prefix), bundle.locate(prefix + ".pkg")
);
default -> null;
};
}
public static void removeLegacyVersionFiles() {
try (DirectoryStream<Path> terracotta = collectLegacyVersionFiles()) {
if (terracotta == null)
return;
for (Path path : terracotta) {
try {
FileUtils.deleteDirectory(path);
} catch (IOException e) {
LOG.warning(String.format("Unable to remove legacy terracotta files: %s", path), e);
}
}
} catch (IOException e) {
LOG.warning("Unable to remove legacy terracotta files.", e);
}
}
public static boolean hasLegacyVersionFiles() throws IOException {
try (DirectoryStream<Path> terracotta = collectLegacyVersionFiles()) {
return terracotta != null && terracotta.iterator().hasNext();
}
}
private static @Nullable DirectoryStream<Path> collectLegacyVersionFiles() throws IOException {
Path terracottaDir = Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta");
if (Files.notExists(terracottaDir))
return null;
VersionRange<VersionNumber> range = VersionNumber.atMost(LATEST);
return Files.newDirectoryStream(terracottaDir, path -> {
String name = FileUtils.getName(path);
return !LATEST.equals(name) && range.contains(VersionNumber.asVersion(name));
});
}
}
@@ -1,90 +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.terracotta;
import com.google.gson.JsonParseException;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.util.RegionUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
import org.jackhuang.hmcl.util.gson.Validation;
import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Glavo
public final class TerracottaNodeList {
private static final String NODE_LIST_URL = "https://terracotta.glavo.site/nodes";
@JsonSerializable
private record TerracottaNode(String url, @Nullable String region) implements Validation {
@Override
public void validate() throws JsonParseException, TolerableValidationException {
Validation.requireNonNull(url, "TerracottaNode.url cannot be null");
try {
WebURL.parse(url);
} catch (IllegalArgumentException e) {
throw new JsonParseException("Invalid URL: " + url, e);
}
}
}
/// Cached node URLs, or `null` before the first fetch completes.
private static volatile @Nullable @Unmodifiable List<WebURL> list;
/// Returns the cached unmodifiable node URLs, fetching once and caching an empty list on failure.
public static @Unmodifiable List<WebURL> fetch() {
@Nullable @Unmodifiable List<WebURL> list = TerracottaNodeList.list;
if (list != null)
return list;
synchronized (TerracottaNodeList.class) {
list = TerracottaNodeList.list;
if (list != null)
return list;
try {
List<TerracottaNode> nodes = HttpRequest.GET(NODE_LIST_URL)
.getJson(JsonUtils.listTypeOf(TerracottaNode.class));
if (nodes == null) {
list = List.of();
LOG.info("No available Terracotta nodes found");
} else {
list = nodes.stream()
.filter(node -> {
if (node == null)
return false;
try {
node.validate();
} catch (Exception e) {
LOG.warning("Invalid terracotta node: " + node, e);
return false;
}
return StringUtils.isBlank(node.region) || node.region.equalsIgnoreCase(RegionUtils.MACHINE_REGION);
})
.map(it -> WebURL.parse(it.url()))
.toList();
LOG.info("Terracotta node list: " + list);
}
} catch (Exception e) {
LOG.warning("Failed to fetch terracotta node list", e);
list = List.of();
}
TerracottaNodeList.list = list;
return list;
}
}
private TerracottaNodeList() {
}
}
@@ -1,318 +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.terracotta;
import com.google.gson.JsonParseException;
import com.google.gson.annotations.SerializedName;
import javafx.beans.property.ReadOnlyDoubleProperty;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.beans.value.ObservableDoubleValue;
import org.jackhuang.hmcl.terracotta.profile.TerracottaProfile;
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
import org.jackhuang.hmcl.util.gson.JsonSubtype;
import org.jackhuang.hmcl.util.gson.JsonType;
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
import org.jackhuang.hmcl.util.gson.Validation;
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.atomic.AtomicBoolean;
public abstract sealed class TerracottaState {
protected TerracottaState() {
}
public boolean isUIFakeState() {
return false;
}
public boolean isForkOf(TerracottaState state) {
return false;
}
public static final class Bootstrap extends TerracottaState {
static final Bootstrap INSTANCE = new Bootstrap();
private Bootstrap() {
}
}
public static final class Uninitialized extends TerracottaState {
private final boolean hasLegacy;
Uninitialized(boolean hasLegacy) {
this.hasLegacy = hasLegacy;
}
public boolean hasLegacy() {
return hasLegacy;
}
}
public static final class Preparing extends TerracottaState implements AbstractTerracottaProvider.DownloadContext {
private final ReadOnlyDoubleWrapper progress;
private final AtomicBoolean hasInstallFence;
Preparing(ReadOnlyDoubleWrapper progress, boolean hasInstallFence) {
this.progress = progress;
this.hasInstallFence = new AtomicBoolean(hasInstallFence);
}
public ReadOnlyDoubleProperty progressProperty() {
return progress.getReadOnlyProperty();
}
public boolean requestInstallFence() {
return hasInstallFence.compareAndSet(true, false);
}
public boolean hasInstallFence() {
return hasInstallFence.get();
}
@Override
public void bindProgress(ObservableDoubleValue progress) {
this.progress.bind(progress);
}
@Override
public void checkCancellation() {
if (!hasInstallFence()) {
throw new CancellationException("User has installed terracotta from local archives.");
}
}
}
public static final class Launching extends TerracottaState {
Launching() {
}
}
static abstract sealed class PortSpecific extends TerracottaState {
transient int port;
protected PortSpecific(int port) {
this.port = port;
}
}
@JsonType(
property = "state",
subtypes = {
@JsonSubtype(clazz = Waiting.class, name = "waiting"),
@JsonSubtype(clazz = HostScanning.class, name = "host-scanning"),
@JsonSubtype(clazz = HostStarting.class, name = "host-starting"),
@JsonSubtype(clazz = HostOK.class, name = "host-ok"),
@JsonSubtype(clazz = GuestConnecting.class, name = "guest-connecting"),
@JsonSubtype(clazz = GuestStarting.class, name = "guest-starting"),
@JsonSubtype(clazz = GuestOK.class, name = "guest-ok"),
@JsonSubtype(clazz = Exception.class, name = "exception"),
}
)
static abstract sealed class Ready extends PortSpecific {
@SerializedName("index")
final int index;
@SerializedName("state")
private final String state;
Ready(int port, int index, String state) {
super(port);
this.index = index;
this.state = state;
}
@Override
public boolean isUIFakeState() {
return this.index == -1;
}
}
public static final class Unknown extends PortSpecific {
Unknown(int port) {
super(port);
}
}
public static final class Waiting extends Ready {
Waiting(int port, int index, String state) {
super(port, index, state);
}
}
public static final class HostScanning extends Ready {
HostScanning(int port, int index, String state) {
super(port, index, state);
}
}
public static final class HostStarting extends Ready {
HostStarting(int port, int index, String state) {
super(port, index, state);
}
}
public static final class HostOK extends Ready implements Validation {
@SerializedName("room")
private final String code;
@SerializedName("profile_index")
private final int profileIndex;
@SerializedName("profiles")
private final List<TerracottaProfile> profiles;
HostOK(int port, int index, String state, String code, int profileIndex, List<TerracottaProfile> profiles) {
super(port, index, state);
this.code = code;
this.profileIndex = profileIndex;
this.profiles = profiles;
}
@Override
public void validate() throws JsonParseException, TolerableValidationException {
if (code == null) {
throw new JsonParseException("code is null");
}
if (profiles == null) {
throw new JsonParseException("profiles is null");
}
}
public String getCode() {
return code;
}
public List<TerracottaProfile> getProfiles() {
return profiles;
}
@Override
public boolean isForkOf(TerracottaState state) {
return state instanceof HostOK hostOK && this.index - hostOK.index <= profileIndex;
}
}
public static final class GuestConnecting extends Ready {
GuestConnecting(int port, int index, String state) {
super(port, index, state);
}
}
public static final class GuestStarting extends Ready {
public enum Difficulty {
UNKNOWN,
EASIEST,
SIMPLE,
MEDIUM,
TOUGH
}
@SerializedName("difficulty")
private final Difficulty difficulty;
GuestStarting(int port, int index, String state, Difficulty difficulty) {
super(port, index, state);
this.difficulty = difficulty;
}
public Difficulty getDifficulty() {
return difficulty;
}
}
public static final class GuestOK extends Ready implements Validation {
@SerializedName("url")
private final String url;
@SerializedName("profile_index")
private final int profileIndex;
@SerializedName("profiles")
private final List<TerracottaProfile> profiles;
GuestOK(int port, int index, String state, String url, int profileIndex, List<TerracottaProfile> profiles) {
super(port, index, state);
this.url = url;
this.profileIndex = profileIndex;
this.profiles = profiles;
}
@Override
public void validate() throws JsonParseException, TolerableValidationException {
if (profiles == null) {
throw new JsonParseException("profiles is null");
}
}
public String getUrl() {
return url;
}
public List<TerracottaProfile> getProfiles() {
return profiles;
}
@Override
public boolean isForkOf(TerracottaState state) {
return state instanceof GuestOK guestOK && this.index - guestOK.index <= profileIndex;
}
}
public static final class Exception extends Ready implements Validation {
public enum Type {
PING_HOST_FAIL,
PING_HOST_RST,
GUEST_ET_CRASH,
HOST_ET_CRASH,
PING_SERVER_RST,
SCAFFOLDING_INVALID_RESPONSE
}
private static final TerracottaState.Exception.Type[] LOOKUP = Type.values();
@SerializedName("type")
private final int type;
Exception(int port, int index, String state, int type) {
super(port, index, state);
this.type = type;
}
@Override
public void validate() throws JsonParseException, TolerableValidationException {
if (type < 0 || type >= LOOKUP.length) {
throw new JsonParseException(String.format("Type must between [0, %s)", LOOKUP.length));
}
}
public Type getType() {
return LOOKUP[type];
}
}
public static final class Fatal extends TerracottaState {
public enum Type {
OS,
NETWORK,
INSTALL,
TERRACOTTA,
UNKNOWN;
}
private final Type type;
public Fatal(Type type) {
this.type = type;
}
public Type getType() {
return type;
}
public boolean isRecoverable() {
return this.type != Type.UNKNOWN;
}
}
}
@@ -1,14 +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.terracotta.profile;
import com.google.gson.annotations.SerializedName;
public enum ProfileKind {
@SerializedName("HOST")
HOST,
@SerializedName("LOCAL")
LOCAL,
@SerializedName("GUEST")
GUEST
}
@@ -1,42 +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.terracotta.profile;
import com.google.gson.annotations.SerializedName;
public final class TerracottaProfile {
@SerializedName("machine_id")
private final String machineID;
@SerializedName("name")
private final String name;
@SerializedName("vendor")
private final String vendor;
@SerializedName("kind")
private final ProfileKind type;
private TerracottaProfile(String machineID, String name, String vendor, ProfileKind type) {
this.machineID = machineID;
this.name = name;
this.vendor = vendor;
this.type = type;
}
public String getMachineID() {
return machineID;
}
public String getName() {
return name;
}
public String getVendor() {
return vendor;
}
public ProfileKind getType() {
return type;
}
}
@@ -1,48 +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.terracotta.provider;
import javafx.beans.value.ObservableDoubleValue;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.TerracottaBundle;
import org.jackhuang.hmcl.util.FXThread;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CancellationException;
public abstract class AbstractTerracottaProvider {
public enum Status {
NOT_EXIST,
LEGACY_VERSION,
READY
}
public interface DownloadContext {
@FXThread
void bindProgress(ObservableDoubleValue progress);
void checkCancellation() throws CancellationException;
}
protected final TerracottaBundle bundle;
protected AbstractTerracottaProvider(TerracottaBundle bundle) {
this.bundle = bundle;
}
public Status status() throws IOException {
return bundle.status();
}
public final Task<Path> download(DownloadContext progress) {
return bundle.download(progress);
}
public Task<?> install(Path pkg) throws IOException {
return bundle.install(pkg);
}
public abstract List<String> ofCommandLine(Path portTransfer);
}
@@ -1,22 +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.terracotta.provider;
import org.jackhuang.hmcl.terracotta.TerracottaBundle;
import java.nio.file.Path;
import java.util.List;
public final class GeneralProvider extends AbstractTerracottaProvider {
private final Path executable;
public GeneralProvider(TerracottaBundle bundle, Path executable) {
super(bundle);
this.executable = executable;
}
@Override
public List<String> ofCommandLine(Path portTransfer) {
return List.of(executable.toString(), "--hmcl", portTransfer.toString());
}
}
@@ -1,80 +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.terracotta.provider;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.TerracottaBundle;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class MacOSProvider extends AbstractTerracottaProvider {
private final Path executable, installer;
public MacOSProvider(TerracottaBundle bundle, Path executable, Path installer) {
super(bundle);
this.executable = executable;
this.installer = installer;
}
@Override
public Status status() throws IOException {
if (!Files.exists(Path.of("/Applications/terracotta.app"))) {
return Status.NOT_EXIST;
}
return bundle.status();
}
@Override
public Task<?> install(Path pkg) throws IOException {
return super.install(pkg).thenComposeAsync(() -> {
Path osascript = SystemUtils.which("osascript");
if (osascript == null) {
throw new IllegalStateException("Cannot locate 'osascript' system executable on MacOS for installing Terracotta.");
}
Path movedInstaller = Files.createTempDirectory(Metadata.HMCL_USER_HOME, "terracotta-pkg")
.toRealPath()
.resolve(FileUtils.getName(installer));
Files.copy(installer, movedInstaller, StandardCopyOption.REPLACE_EXISTING);
ManagedProcess process = new ManagedProcess(new ProcessBuilder(
osascript.toString(), "-e", String.format(
"do shell script \"installer -pkg '%s' -target /\" with prompt \"%s\" with administrator privileges",
movedInstaller, i18n("terracotta.sudo_installing")
)));
process.pumpInputStream(SystemUtils::onLogLine);
process.pumpErrorStream(SystemUtils::onLogLine);
return Task.fromCompletableFuture(process.getProcess().onExit()).thenRunAsync(() -> {
try {
FileUtils.cleanDirectory(movedInstaller.getParent());
} catch (IOException e) {
LOG.warning("Cannot remove temporary Terracotta package file.", e);
}
if (process.getExitCode() != 0) {
throw new IllegalStateException(String.format(
"Cannot install Terracotta %s: system installer exited with code %d", movedInstaller, process.getExitCode()
));
}
});
});
}
@Override
public List<String> ofCommandLine(Path path) {
return List.of(executable.toString(), "--hmcl", path.toString());
}
}
@@ -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,35 +21,33 @@ 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.animation.Motion;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.decorator.Decorator;
import org.jackhuang.hmcl.ui.download.DownloadPage;
import org.jackhuang.hmcl.ui.main.LauncherSettingsPage;
import org.jackhuang.hmcl.ui.main.RootPage;
import org.jackhuang.hmcl.ui.terracotta.TerracottaPage;
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.upgrade.UpdateChecker;
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;
@@ -61,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;
@@ -82,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;
@@ -97,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;
@@ -156,18 +131,6 @@ public final class Controllers {
return gameInstancePage;
}
/// The cached game-list page.
private static @Nullable GameListPage 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;
@@ -183,14 +146,12 @@ public final class Controllers {
/// The cached settings page.
private static @Nullable LauncherSettingsPage settingsPage;
/// Creates the settings page, loads the default preset, and attaches it for layout unless already cached.
/// Creates and prewarms the settings page unless already cached.
@FXThread
public static void prepareSettingsPage() {
if (settingsPage == null) {
LOG.info("Preparing settings page");
LauncherSettingsPage page = new LauncherSettingsPage();
page.showGameSettings(GameDirectoryManager.getSelectedRepository());
settingsPage = getDecorator().prewarmPage(page);
settingsPage = getDecorator().prewarmPage(new LauncherSettingsPage());
}
}
@@ -218,38 +179,7 @@ public final class Controllers {
return accountListPage;
}
/// The cached download page.
private static @Nullable DownloadPage downloadPage;
/// Creates and attaches the download page for a layout pulse unless already cached.
@FXThread
public static void prepareDownloadPage() {
if (downloadPage == null) {
LOG.info("Preparing download page");
downloadPage = getDecorator().prewarmPage(new DownloadPage());
}
}
/// Returns the cached download page, creating it if necessary.
@FXThread
public static DownloadPage getDownloadPage() {
if (downloadPage == null) {
downloadPage = new DownloadPage();
}
return downloadPage;
}
/// The cached Terracotta page.
private static @Nullable TerracottaPage terracottaPage;
/// Returns the cached Terracotta page, creating it if necessary.
@FXThread
public static Node getTerracottaPage() {
if (terracottaPage == null) {
terracottaPage = new TerracottaPage();
}
return terracottaPage;
}
/// Returns the initialized main-window decorator.
///
@@ -286,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)) {
@@ -304,20 +232,13 @@ public final class Controllers {
decorator = new Decorator(getRootPage(), Controllers::configureMainStage);
Scene mainScene = decorator.attachStage(stage);
getRootPage().getMainPage().showUpdateProperty().bind(UpdateChecker.checkingUpdateProperty().not().and(UpdateChecker.outdatedProperty()));
getRootPage().getMainPage().showUpdateDialogProperty().bind(
decorator.backableProperty().not()
.and(getRootPage().getMainPage().showUpdateProperty())
.and(settings().disableAutoShowUpdateDialogProperty().not())
);
// Dedicated server client: always use the fixed default cache directory.
settings().commonDirectoryTypeProperty().set(EnumCommonDirectory.DEFAULT);
if (settings().commonDirectoryTypeProperty().get() == EnumCommonDirectory.CUSTOM &&
!FileUtils.canCreateDirectory(settings().getResolvedCommonDirectory())) {
settings().commonDirectoryTypeProperty().set(EnumCommonDirectory.DEFAULT);
dialog(i18n("launcher.cache_directory.invalid"));
}
Lang.thread(JavaManager::initialize, "Search Java", true);
Lang.thread(() -> {
JavaManager.initialize();
FXUtils.runInFX(Controllers::tryEnsureRecommendedMojangJava);
}, "Search Java", true);
StyleSheets.init(mainScene);
@@ -400,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
);
}
@@ -648,36 +581,39 @@ public final class Controllers {
return pane.getCompletableFuture();
}
/// Creates and returns a progress pane without starting the executor.
/// Creates a task dialog and submits its executor to the serial business-task queue.
///
/// Requests closure when the executor stops. A null title supplies no title text.
/// The pane is connected to the executor even if no decorator exists. See [#dialog(Region)].
public static TaskExecutorDialogPane taskDialog(TaskExecutor executor, @Nullable String title, TaskCancellationAction onCancel) {
/// Submits the executor even if the pane cannot be shown, and returns the pane.
public static TaskExecutorDialogPane taskDialog(Task<?> task, @Nullable String title, TaskCancellationAction onCancel) {
return taskDialog(task, title, onCancel, null);
}
/// Creates and submits a task dialog, optionally as a required child of an executing task.
///
/// @param task the task to execute
/// @param title the dialog title, or `null`
/// @param onCancel the cancellation action
/// @param parent the executing parent dialog, or `null` to queue an independent operation
/// @return the submitted task dialog
public static TaskExecutorDialogPane taskDialog(Task<?> task, @Nullable String title, TaskCancellationAction onCancel,
@Nullable TaskExecutorDialogPane parent) {
TaskExecutor executor = task.executor();
TaskExecutorDialogPane pane = new TaskExecutorDialogPane(onCancel);
pane.setTitle(title);
pane.setExecutor(executor);
dialog(pane);
return pane;
}
/// Creates a pane using [#taskDialog(TaskExecutor, String, TaskCancellationAction)], then starts the task's executor.
///
/// Starts the executor even if the pane cannot be shown, and returns the pane.
public static TaskExecutorDialogPane taskDialog(Task<?> task, @Nullable String title, TaskCancellationAction onCancel) {
TaskExecutor executor = task.executor();
TaskExecutorDialogPane pane = taskDialog(executor, title, onCancel);
executor.start();
pane.startExecutor(parent);
return pane;
}
/// Navigates to the node using the standard navigation animation. Requires an initialized decorator.
public static void navigate(Node node) {
decorator.navigate(node, ContainerAnimations.NAVIGATION, Motion.SHORT4, Motion.EASE);
decorator.navigate(node, ContainerAnimations.NAVIGATION);
}
/// Navigates to the node using the forward animation. Requires an initialized decorator.
public static void navigateForward(Node node) {
decorator.navigate(node, ContainerAnimations.FORWARD, Motion.SHORT4, Motion.EASE);
decorator.navigate(node, ContainerAnimations.FORWARD);
}
/// Shows a toast in the main window. Requires an initialized decorator.
@@ -721,10 +657,6 @@ public final class Controllers {
public static void onHyperlinkAction(String href) {
if (href.startsWith("hmcl://")) {
switch (href) {
case "hmcl://settings/feedback":
Controllers.getSettingsPage().showFeedback();
Controllers.navigate(Controllers.getSettingsPage());
break;
case "hmcl://game/launch":
var repository = GameDirectoryManager.getSelectedRepository();
Instances.launch(repository.getSelectedInstance(), LauncherHelper::setKeep);
@@ -767,11 +699,8 @@ public final class Controllers {
onApplicationStop();
rootPage = null;
gameInstancePage = null;
gameListPage = null;
downloadPage = null;
accountListPage = null;
settingsPage = null;
terracottaPage = null;
decorator = null;
FXUtils.shutdown();
@@ -2,18 +2,13 @@
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextArea;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.countly.CrashReport;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.util.CrashReport;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -26,8 +21,6 @@ public class CrashWindow extends Stage {
Label lblCrash = new Label();
if (report.getThrowable() instanceof InternalError)
lblCrash.setText(i18n("launcher.crash.java_internal_error"));
else if (UpdateChecker.isOutdated())
lblCrash.setText(i18n("launcher.crash.hmcl_out_dated"));
else
lblCrash.setText(i18n("launcher.crash"));
lblCrash.setWrapText(true);
@@ -36,21 +29,12 @@ public class CrashWindow extends Stage {
textArea.setText(report.getDisplayText());
textArea.setEditable(false);
Button btnContact = new Button();
btnContact.setText(i18n("launcher.contact"));
btnContact.setOnAction(event -> FXUtils.openLink(Metadata.CONTACT_URL));
HBox box = new HBox();
box.setStyle("-fx-padding: 8px;");
box.getChildren().add(btnContact);
box.setAlignment(Pos.CENTER_RIGHT);
BorderPane pane = new BorderPane();
StackPane stackPane = new StackPane();
stackPane.setStyle("-fx-padding: 8px;");
stackPane.getChildren().add(lblCrash);
pane.setTop(stackPane);
pane.setCenter(textArea);
pane.setBottom(box);
Scene scene = new Scene(pane, 800, 480);
setScene(scene);
@@ -66,11 +66,25 @@ public final class DialogUtils {
show(container, content, null);
}
/// Adds content to the container's dialog stack on the JavaFX application thread.
///
/// Content already in a dialog stack is left unchanged. Content still attached
/// to a closing dialog is detached before being shown again.
///
/// @param container the container that hosts the dialog
/// @param content the content to display
/// @param onDialogCreated an optional callback that configures a newly created dialog
public static void show(StackPane container, Node content, @Nullable Consumer<JFXDialog> onDialogCreated) {
FXUtils.checkFxUserThread();
if (isShowing(content))
return;
JFXDialog dialog = (JFXDialog) container.getProperties().get(PROPERTY_DIALOG_INSTANCE);
JFXDialogPane dialogPane = (JFXDialogPane) container.getProperties().get(PROPERTY_DIALOG_PANE_INSTANCE);
// A closing dialog may retain its content until its exit animation finishes.
if (content.getParent() instanceof JFXDialogPane previousPane)
previousPane.pop(content);
@Nullable JFXDialog dialog = (JFXDialog) container.getProperties().get(PROPERTY_DIALOG_INSTANCE);
@Nullable JFXDialogPane dialogPane = (JFXDialogPane) container.getProperties().get(PROPERTY_DIALOG_PANE_INSTANCE);
if (dialog == null) {
dialog = new JFXDialog(AnimationUtils.isAnimationEnabled()
@@ -155,34 +169,47 @@ public final class DialogUtils {
queue.add(showDialogAction);
}
/// Returns whether content belongs to a dialog that has not been closed.
///
/// Content covered by another dialog is still considered shown.
public static boolean isShowing(Node content) {
FXUtils.checkFxUserThread();
return content.getProperties().containsKey(PROPERTY_PARENT_DIALOG_REF);
}
/// Removes content from its dialog stack, closing the dialog if the stack becomes empty.
///
/// Must be called on the JavaFX application thread. Closing already detached content
/// has no effect. Content may remain attached until the closing animation finishes. Its dialog
/// references are removed immediately so that it can be safely shown again.
@SuppressWarnings("unchecked")
public static void close(Node content) {
FXUtils.checkFxUserThread();
Optional.ofNullable(content.getProperties().get(PROPERTY_DIALOG_CLOSE_HANDLER))
Optional.ofNullable(content.getProperties().remove(PROPERTY_DIALOG_CLOSE_HANDLER))
.ifPresent(handler -> content.removeEventHandler(DialogCloseEvent.CLOSE, (EventHandler<DialogCloseEvent>) handler));
JFXDialogPane pane = (JFXDialogPane) content.getProperties().get(PROPERTY_PARENT_PANE_REF);
JFXDialog dialog = (JFXDialog) content.getProperties().get(PROPERTY_PARENT_DIALOG_REF);
@Nullable JFXDialogPane pane = (JFXDialogPane) content.getProperties().remove(PROPERTY_PARENT_PANE_REF);
@Nullable JFXDialog dialog = (JFXDialog) content.getProperties().remove(PROPERTY_PARENT_DIALOG_REF);
Runnable showNextDialogAction = null;
@Nullable Runnable showNextDialogAction = null;
if (dialog != null && pane != null) {
if (pane.size() == 1 && pane.peek().orElse(null) == content) {
dialog.setOnDialogClosed(e -> pane.pop(content));
dialog.close();
StackPane container = dialog.getDialogContainer();
@Nullable StackPane container = dialog.getDialogContainer();
if (container != null) {
container.getProperties().remove(PROPERTY_DIALOG_INSTANCE);
container.getProperties().remove(PROPERTY_DIALOG_PANE_INSTANCE);
container.getProperties().remove(PROPERTY_PARENT_DIALOG_REF);
container.getProperties().remove(PROPERTY_PARENT_PANE_REF);
Queue<Runnable> queue = (Queue<Runnable>) container.getProperties().get(PROPERTY_DIALOG_SHOW_LATER);
@Nullable Queue<Runnable> queue = (Queue<Runnable>) container.getProperties().get(PROPERTY_DIALOG_SHOW_LATER);
if (queue != null) {
if (!queue.isEmpty()) {
Runnable next = queue.remove();
@Nullable Runnable next = queue.remove();
if (next != null) showNextDialogAction = next;
}
if (queue.isEmpty()) {
@@ -445,14 +445,10 @@ public class GameCrashWindow extends Stage {
JFXButton logButton = FXUtils.newRaisedButton(i18n("logwindow.title"));
logButton.setOnAction(e -> showLogWindow());
JFXButton helpButton = FXUtils.newRaisedButton(i18n("help"));
helpButton.setOnAction(e -> FXUtils.openLink(Metadata.CONTACT_URL));
FXUtils.installFastTooltip(helpButton, i18n("logwindow.help"));
toolBar.setPadding(new Insets(8));
toolBar.setSpacing(8);
toolBar.getStyleClass().add("jfx-tool-bar");
toolBar.getChildren().setAll(exportButtonPane, logButton, helpButton);
toolBar.getChildren().setAll(exportButtonPane, logButton);
}
getChildren().setAll(titlePane, infoPane, moddedPane, gameDirPane, toolBar);
@@ -8,11 +8,11 @@ import javafx.collections.ObservableList;
import javafx.event.Event;
import javafx.event.EventHandler;
import javafx.scene.control.Control;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import static org.jackhuang.hmcl.ui.construct.SpinnerPane.FAILED_ACTION;
public class ListPageBase<T> extends Control implements TransitionPane.Cacheable {
/// Base control for list pages with loading and failure state.
public class ListPageBase<T> extends Control {
private final ListProperty<T> items = new SimpleListProperty<>(this, "items", FXCollections.observableArrayList());
private final BooleanProperty loading = new SimpleBooleanProperty(this, "loading", false);
private final StringProperty failedReason = new SimpleStringProperty(this, "failed");
@@ -72,6 +72,7 @@ public enum SVG {
KEYBOARD_ARROW_UP("M12 10.8 7.4 15.4 6 14 12 8 18 14 16.6 15.4 12 10.8Z"),
LANDSCAPE("M1 18l6-8 4.5 6H19L14 9.35l-2.5 3.3L10.25 11 14 6l9 12H1Zm13.025-2ZM5 16H9L7 13.325 5 16ZH9 5Z"),
LIST("M7 9V7H21V9H7ZM7 13V11H21V13H7ZM7 17V15H21V17H7ZM4 9Q3.575 9 3.2875 8.7125T3 8Q3 7.575 3.2875 7.2875T4 7Q4.425 7 4.7125 7.2875T5 8Q5 8.425 4.7125 8.7125T4 9ZM4 13Q3.575 13 3.2875 12.7125T3 12Q3 11.575 3.2875 11.2875T4 11Q4.425 11 4.7125 11.2875T5 12Q5 12.425 4.7125 12.7125T4 13ZM4 17Q3.575 17 3.2875 16.7125T3 16Q3 15.575 3.2875 15.2875T4 15Q4.425 15 4.7125 15.2875T5 16Q5 16.425 4.7125 16.7125T4 17Z"),
LIST_ALT("M8.71,16.71Q9,16.43 9,16T8.71,15.29T8,15q-0.42,0-0.71,0.29T7,16t0.29,0.71T8,17t0.71-0.29Zm0-4Q9,12.43 9,12T8.71,11.29T8,11q-0.42,0-0.71,0.29T7,12t0.29,0.71T8,13t0.71-0.29Zm0-4Q9,8.42 9,8Q9,7.57 8.71,7.29T8,7Q7.58,7 7.29,7.29T7,8Q7,8.42 7.29,8.71T8,9T8.71,8.71ZM11,17h6V15H11v2Zm0-4h6V11H11v2Zm0-4h6V7H11V9ZM5,21q-0.83,0-1.41-0.59T3,19V5Q3,4.17 3.59,3.59T5,3H19q0.83,0 1.41,0.59T21,5V19q0,0.82-0.59,1.41T19,21H5Zm0-2H19V5H5V19ZM5,5V19V5Z"),
LISTS("M2 20V16H6V20H2ZM8 20V16H22V20H8ZM2 14V10H6V14H2ZM8 14V10H22V14H8ZM2 8V4H6V8H2ZM8 8V4H22V8H8Z"),
LOCAL_CAFE("M4 21V19H20V21H4ZM8 17Q6.35 17 5.175 15.825T4 13V3H20Q20.825 3 21.4125 3.5875T22 5V8Q22 8.825 21.4125 9.4125T20 10H18V13Q18 14.65 16.825 15.825T14 17H8ZM8 15H14Q14.825 15 15.4125 14.4125T16 13V5H6V13Q6 13.825 6.5875 14.4125T8 15ZM18 8H20V5H18V8ZM8 15H6 16 8Z"),
LOCAL_CAFE_FILL("M4 21V19H20v2H4Zm4-4q-1.65 0-2.825-1.175T4 13V3H20q.825 0 1.4125.5875T22 5V8q0 .825-.5875 1.4125T20 10H18v3q0 1.65-1.175 2.825T14 17H8ZM18 8h2V5H18V8Z"),
@@ -87,6 +88,7 @@ public enum SVG {
PACKAGE("M10 9.75 12 8.75 14 9.75V5H10V9.75ZM7 17V15H12V17H7ZM5 21Q4.175 21 3.5875 20.4125T3 19V5Q3 4.175 3.5875 3.5875T5 3H19Q19.825 3 20.4125 3.5875T21 5V19Q21 19.825 20.4125 20.4125T19 21H5ZM5 5V19 5ZM5 19H19V5H16V13L12 11 8 13V5H5V19Z"),
PACKAGE2("M11 19.425V12.575L5 9.1V15.95L11 19.425ZM13 19.425 19 15.95V9.1L13 12.575V19.425ZM11 21.725 4 17.7Q3.525 17.425 3.2625 16.975T3 15.975V8.025Q3 7.475 3.2625 7.025T4 6.3L11 2.275Q11.475 2 12 2T13 2.275L20 6.3Q20.475 6.575 20.7375 7.025T21 8.025V15.975Q21 16.525 20.7375 16.975T20 17.7L13 21.725Q12.525 22 12 22T11 21.725ZM16 8.525 17.925 7.425 12 4 10.05 5.125 16 8.525ZM12 10.85 13.95 9.725 8.025 6.3 6.075 7.425 12 10.85Z"),
PACKAGE2_FILL("M11 21.725v-9.15L3 7.95v8.025q0 .55.2625 1T4 17.7l7 4.025Zm2 0L20 17.7q.475-.275.7375-.725t.2625-1V7.95l-8 4.625v9.15Zm3.975-13.75 2.95-1.725L13 2.275Q12.525 2 12 2t-1 .275L9.025 3.4l7.95 4.575ZM12 10.85l2.975-1.7L7.05 4.55l-3 1.725L12 10.85Z"),
PENDING("M8.0625 13.0625Q8.5 12.625 8.5 12t-.4375-1.0625T7 10.5t-1.0625.4375T5.5 12t.4375 1.0625T7 13.5t1.0625-.4375Zm5 0Q13.5 12.625 13.5 12t-.4375-1.0625T12 10.5t-1.0625.4375T10.5 12t.4375 1.0625T12 13.5t1.0625-.4375Zm5 0Q18.5 12.625 18.5 12t-.4375-1.0625T17 10.5t-1.0625.4375T15.5 12t.4375 1.0625T17 13.5t1.0625-.4375ZM12 22q-2.075 0-3.9-.7875T4.925 19.075q-1.35-1.35-2.1375-3.175T2 12q0-2.075.7875-3.9T4.925 4.925q1.35-1.35 3.175-2.1375T12 2q2.075 0 3.9.7875T19.075 4.925q1.35 1.35 2.1375 3.175T22 12q0 2.075-.7875 3.9T19.075 19.075q-1.35 1.35-3.175 2.1375T12 22Zm0-2q3.35 0 5.675-2.325T20 12q0-3.35-2.325-5.675T12 4Q8.65 4 6.325 6.325T4 12q0 3.35 2.325 5.675T12 20Zm0-8Z"),
PERSON("M12 12Q10.35 12 9.175 10.825T8 8Q8 6.35 9.175 5.175T12 4Q13.65 4 14.825 5.175T16 8Q16 9.65 14.825 10.825T12 12ZM4 20V17.2Q4 16.35 4.4375 15.6375T5.6 14.55Q7.15 13.775 8.75 13.3875T12 13Q13.65 13 15.25 13.3875T18.4 14.55Q19.125 14.925 19.5625 15.6375T20 17.2V20H4ZM6 18H18V17.2Q18 16.925 17.8625 16.7T17.5 16.35Q16.15 15.675 14.775 15.3375T12 15Q10.6 15 9.225 15.3375T6.5 16.35Q6.275 16.475 6.1375 16.7T6 17.2V18ZM12 10Q12.825 10 13.4125 9.4125T14 8Q14 7.175 13.4125 6.5875T12 6Q11.175 6 10.5875 6.5875T10 8Q10 8.825 10.5875 9.4125T12 10ZM12 8ZM12 18Z"),
PUBLIC("M12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM11 19.95V18Q10.175 18 9.5875 17.4125T9 16V15L4.2 10.2Q4.125 10.65 4.0625 11.1T4 12Q4 15.025 5.9875 17.3T11 19.95ZM17.9 17.4Q18.925 16.275 19.4625 14.8875T20 12Q20 9.55 18.6375 7.525T15 4.6V5Q15 5.825 14.4125 6.4125T13 7H11V9Q11 9.425 10.7125 9.7125T10 10H8V12H14Q14.425 12 14.7125 12.2875T15 13V16H16Q16.65 16 17.175 16.3875T17.9 17.4Z"),
REFRESH("M12 20Q8.65 20 6.325 17.675T4 12Q4 8.65 6.325 6.325T12 4Q13.725 4 15.3 4.7125T18 6.75V4H20V11H13V9H17.2Q16.4 7.6 15.0125 6.8T12 6Q9.5 6 7.75 7.75T6 12Q6 14.5 7.75 16.25T12 18Q13.925 18 15.475 16.9T17.65 14H19.75Q19.05 16.65 16.9 18.325T12 20Z"),
@@ -0,0 +1,74 @@
// 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 javafx.application.ConditionalFeature;
import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.input.ScrollEvent;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
/// Bridges control geometry and native skin dispatch to the shared scrolling policy.
@NotNullByDefault
abstract class ScrollAdapter {
/// Whether native skins need zero-distance input to animate overlay scrollbar visibility.
static final boolean TOUCH_SUPPORTED = Platform.isSupported(ConditionalFeature.INPUT_TOUCH);
/// Base wheel pixel multiplier scaled by the startup `hmcl.scroll.speed` preference.
private static final double WHEEL_SPEED = readWheelSpeed(
System.getProperty("hmcl.scroll.speed", System.getenv("HMCL_SCROLL_SPEED")));
/// Resolves a factor or percentage to a wheel pixel multiplier, using the default for invalid values.
static double readWheelSpeed(@Nullable String value) {
final double speedBase = 3.0;
double speedScale = 1.0;
try {
if (StringUtils.isNotBlank(value)) {
value = value.strip();
speedScale = value.endsWith("%")
? Double.parseDouble(value.substring(0, value.length() - 1)) / 100.0
: Double.parseDouble(value);
}
} catch (NumberFormatException ignored) {
}
return Double.isFinite(speedScale) && speedScale > 0
? speedBase * Math.max(speedScale, 0.0001)
: speedBase;
}
/// Coordinates animation and gestures independently of this control's geometry.
final ScrollController controller;
/// Installs common lifecycle observation before the adapter's skin hooks.
ScrollAdapter(Node node) {
controller = new ScrollController(node, this);
}
/// Returns the scrollable pixel distance available in the requested direction.
abstract double limit(boolean horizontal, double pixels);
/// Applies a bounded pixel displacement and returns the distance actually moved.
abstract double scroll(boolean horizontal, double pixels);
/// Scales platform wheel pixels consistently, preserving system settings and fractional deltas.
double wheelDelta(ScrollEvent event, boolean horizontal) {
return -(horizontal ? event.getDeltaX() : event.getDeltaY()) * WHEEL_SPEED;
}
/// Maps continuous input to pixels without applying wheel line or page scaling.
double gestureDelta(ScrollEvent event, boolean horizontal) {
return horizontal ? -event.getDeltaX() : -event.getDeltaY();
}
/// Preserves a skin's scroll lifecycle without applying its native displacement.
static ScrollEvent stationaryScroll(ScrollEvent event) {
return new ScrollEvent(event.getSource(), event.getTarget(), event.getEventType(),
event.getX(), event.getY(), event.getScreenX(), event.getScreenY(),
event.isShiftDown(), event.isControlDown(), event.isAltDown(), event.isMetaDown(),
event.isDirect(), event.isInertia(), 0, 0, 0, 0,
ScrollEvent.HorizontalTextScrollUnits.NONE, 0,
ScrollEvent.VerticalTextScrollUnits.NONE, 0, event.getTouchCount(), event.getPickResult());
}
}
@@ -0,0 +1,145 @@
// 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 javafx.scene.Node;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.input.TouchEvent;
import org.jetbrains.annotations.NotNullByDefault;
/// Routes input to immediate gesture movement or queued wheel animation.
@NotNullByDefault
final class ScrollController implements WheelScrollAnimation.Target {
/// Supplies control-specific geometry, movement, and input-axis mapping.
private final ScrollAdapter adapter;
/// Owns gesture lifetimes and nested movement accounting.
final ScrollGesture gesture;
/// Owns unapplied wheel movement and pulse interpolation.
final WheelScrollAnimation wheel;
/// Distinguishes controller writes from external position changes.
private boolean updating;
/// Observes capture without consuming input, leaving acceptance to the adapter's bubbling hooks.
ScrollController(Node node, ScrollAdapter adapter) {
this.adapter = adapter;
gesture = new ScrollGesture(node);
wheel = new WheelScrollAnimation(this);
node.addEventFilter(ScrollEvent.ANY, this::observeScroll);
node.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> interrupt());
node.addEventFilter(MouseEvent.MOUSE_RELEASED, event -> gesture.clearPanning());
node.addEventFilter(TouchEvent.TOUCH_PRESSED, event -> interrupt());
node.addEventFilter(KeyEvent.KEY_PRESSED, event -> interrupt());
node.sceneProperty().addListener(observable -> reset());
node.visibleProperty().addListener(observable -> reset());
}
/// Cancels stale animation even when a nested control consumes the new movement.
private void observeScroll(ScrollEvent event) {
gesture.observe(event);
if (event.getEventType() == ScrollEvent.SCROLL_STARTED) {
wheel.cancel();
} else if (event.getEventType() == ScrollEvent.SCROLL && wheel.isRunning()) {
if (gesture.isPrecise(event)) {
wheel.cancel();
} else {
double dx = adapter.wheelDelta(event, true);
double dy = adapter.wheelDelta(event, false);
if (Double.isFinite(dx) && Double.isFinite(dy))
wheel.cancelReversal(dx, dy);
}
}
}
/// Consumes accepted movement; unused input keeps its original bubbling route to ancestors.
void onScroll(ScrollEvent event) {
if (event.isConsumed() || event.getEventType() != ScrollEvent.SCROLL)
return;
long now = System.nanoTime();
// Unmarked fractional input still uses the wheel fallback; explicit gestures bypass animation.
boolean precise = gesture.isPrecise(event);
double dx = precise ? adapter.gestureDelta(event, true) : adapter.wheelDelta(event, true);
double dy = precise ? adapter.gestureDelta(event, false) : adapter.wheelDelta(event, false);
if (!Double.isFinite(dx) || !Double.isFinite(dy))
return;
if (!precise) {
gesture.clearPanning();
if (wheel.offer(dx, dy, now))
event.consume();
return;
}
wheel.cancel();
boolean panned = false;
if (event.isDirect() && !event.isInertia()) {
double x = gesture.panning(true);
double y = gesture.panning(false);
panned = x != 0 || y != 0;
dx -= x;
dy -= y;
}
gesture.clearPanning();
if (event.isInertia()) {
double factor = gesture.inertiaFactor(now);
if (factor == 0) {
event.consume();
return;
}
dx *= factor;
dy *= factor;
}
// Shared credit remains available when this surface cannot apply the remaining movement.
boolean moved = panned && dx == 0 && dy == 0;
moved |= scroll(true, dx) != 0;
moved |= scroll(false, dy) != 0;
if (moved) {
if (event.isInertia())
gesture.acceptInertia(now);
event.consume();
}
}
/// Supplies current bounds to the animation without changing the control's position.
@Override
public double limit(boolean horizontal, double pixels) {
return adapter.limit(horizontal, pixels);
}
/// Guards synchronous position notifications against cancelling the controller's own movement.
@Override
public double scroll(boolean horizontal, double pixels) {
if (pixels == 0)
return 0;
boolean previous = updating;
updating = true;
try {
return adapter.scroll(horizontal, pixels);
} finally {
updating = previous;
}
}
/// Cancels queued movement and prevents old native inertia from resuming after a press.
void interrupt() {
wheel.cancel();
gesture.interrupt();
}
/// Clears animation and gesture state when the scrolling surface changes.
void reset() {
wheel.cancel();
gesture.reset();
}
/// Cancels animation after an external position change, such as scrollbar dragging.
void positionChanged() {
if (!updating)
interrupt();
}
}
@@ -0,0 +1,186 @@
// 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 javafx.event.Event;
import javafx.event.EventDispatcher;
import javafx.scene.Node;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
/// Tracks continuous input, shared nested inertia, and native movement before touch recognition.
@NotNullByDefault
final class ScrollGesture {
/// Maximum native inertia tail, in nanoseconds; active gestures are never attenuated.
private static final long INERTIA_DURATION = 200_000_000L;
/// Locates ancestor gestures during capture and direct-event dispatch.
private final Node node;
/// Whether the platform has started a continuous gesture.
private boolean gesture;
/// Prevents synthesized mouse drags from repeating an active touchscreen scroll.
private boolean directGesture;
/// Horizontal native panning already included in the first direct scroll delta.
double pannedX;
/// Vertical native panning already included in the first direct scroll delta.
double pannedY;
/// Reusable displacement scope when this controller is the outermost participant.
private final Panning panning = new Panning();
/// Shared displacement for the currently dispatched direct event, or null outside dispatch.
private @Nullable Panning eventPanning;
/// Whether a press has cancelled the previous gesture's inertia.
private boolean suppressInertia;
/// Inertia timing shared with ancestor controllers for the current gesture.
private Inertia inertia = new Inertia();
/// Whether the current inertia state belongs to an ancestor controller.
private boolean inheritedInertia;
/// Shares native panning credit for the duration of each direct scroll event.
ScrollGesture(Node node) {
this.node = node;
EventDispatcher dispatcher = Objects.requireNonNull(node.getEventDispatcher());
node.setEventDispatcher((event, tail) -> {
if (!(event instanceof ScrollEvent scroll) || scroll.getEventType() != ScrollEvent.SCROLL
|| !scroll.isDirect() || scroll.isInertia())
return dispatcher.dispatchEvent(event, tail);
@Nullable Panning previous = eventPanning;
@Nullable Panning shared = null;
for (@Nullable Node parent = node.getParent(); parent != null; parent = parent.getParent()) {
@Nullable ScrollController controller = ScrollUtils.controller(parent);
if (controller != null && controller.gesture.eventPanning != null) {
shared = controller.gesture.eventPanning;
break;
}
}
if (shared == null) {
shared = previous == null ? panning : new Panning();
shared.x = 0;
shared.y = 0;
}
shared.x += pannedX;
shared.y += pannedY;
eventPanning = shared;
clearPanning();
try {
return dispatcher.dispatchEvent(event, tail);
} finally {
// Consumption by descendants must expire credit too; nested dispatch keeps its own scope.
eventPanning = previous;
}
});
}
/// Observes gesture boundaries even when a descendant consumes every movement event.
void observe(ScrollEvent event) {
if (event.getEventType() == ScrollEvent.SCROLL_STARTED) {
// Recognition can follow native mouse panning from the same touch press.
double x = pannedX;
double y = pannedY;
reset();
pannedX = x;
pannedY = y;
gesture = true;
} else if (event.getEventType() == ScrollEvent.SCROLL_FINISHED) {
gesture = false;
clearPanning();
}
// Descendants may consume the event before this controller's bubbling handler runs.
if (event.getEventType() == ScrollEvent.SCROLL && event.isDirect() && !event.isInertia())
directGesture = true;
if (event.getEventType() == ScrollEvent.SCROLL_STARTED
|| (event.getEventType() == ScrollEvent.SCROLL && !event.isInertia())) {
suppressInertia = false;
// Capture visits ancestors first, so all controllers inherit the same fresh tail.
for (@Nullable Node parent = node.getParent(); parent != null; parent = parent.getParent()) {
@Nullable ScrollController controller = ScrollUtils.controller(parent);
if (controller != null) {
inertia = controller.gesture.inertia;
inheritedInertia = true;
return;
}
}
if (inheritedInertia || inertia.start != 0)
inertia = new Inertia();
inheritedInertia = false;
}
}
/// Identifies mouse drags that would repeat a direct touchscreen scroll.
boolean isDuplicateDrag(Event event) {
return directGesture && event instanceof MouseEvent mouse
&& mouse.getEventType() == MouseEvent.MOUSE_DRAGGED && mouse.isSynthesized();
}
/// Distinguishes continuous gestures from wheels, including unmarked fractional wheel input.
boolean isPrecise(ScrollEvent event) {
return gesture || event.isDirect() || event.getTouchCount() > 0 || event.isInertia();
}
/// Returns native movement already applied by this event's participating ancestors and descendants.
double panning(boolean horizontal) {
return eventPanning == null ? 0 : horizontal ? eventPanning.x : eventPanning.y;
}
/// Returns the shared inertia attenuation, or zero when the tail must be consumed without movement.
double inertiaFactor(long now) {
double remaining = inertia.start == 0 ? 1 : 1 - (double) (now - inertia.start) / INERTIA_DURATION;
return suppressInertia || remaining <= 0 ? 0 : remaining * remaining;
}
/// Starts the common inertia clock only when a surface accepts movement.
void acceptInertia(long now) {
if (inertia.start == 0)
inertia.start = now;
}
/// Discards recognition state and prevents old native inertia from resuming after a press.
void interrupt() {
clearPanning();
suppressInertia = true;
gesture = false;
directGesture = false;
}
/// Discards native displacement that cannot belong to a later direct gesture.
void clearPanning() {
pannedX = 0;
pannedY = 0;
}
/// Clears gesture state when the scrolling surface changes.
void reset() {
interrupt();
inertia = new Inertia();
inheritedInertia = false;
}
/// Shares the native inertia age across nested scrolling surfaces.
@NotNullByDefault
private static final class Inertia {
/// Time of the first accepted inertia movement, or zero before the tail starts.
private long start;
}
/// Native displacement shared by all controllers participating in one direct scroll event.
@NotNullByDefault
private static final class Panning {
/// Horizontal content displacement already applied before gesture recognition.
private double x;
/// Vertical content displacement already applied before gesture recognition.
private double y;
}
}
@@ -0,0 +1,256 @@
// 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 javafx.beans.property.DoubleProperty;
import javafx.beans.value.ObservableValue;
import javafx.event.Event;
import javafx.event.EventDispatchChain;
import javafx.event.EventDispatcher;
import javafx.event.EventHandler;
import javafx.geometry.Bounds;
import javafx.scene.Node;
import javafx.scene.control.ScrollBar;
import javafx.scene.control.ScrollPane;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import org.jackhuang.hmcl.util.MathUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
/// Maps pixel distances onto a scroll pane's actual overflow and value ranges.
@NotNullByDefault
final class ScrollPaneAdapter extends ScrollAdapter {
/// Pane whose content and viewport determine the scroll range.
private final ScrollPane pane;
/// Current skin viewport, whose bubbling dispatch controls native scrolling.
private @Nullable Node viewport;
/// Original viewport dispatcher restored when its skin is replaced.
private @Nullable EventDispatcher viewportDispatcher;
/// Installed wrapper, retained to avoid replacing a later application dispatcher.
private @Nullable EventDispatcher viewportWrapper;
/// Stops duplicate panning after child controls have handled synthesized drags.
private final EventHandler<MouseEvent> dragHandler = event -> {
if (controller.gesture.isDuplicateDrag(event))
event.consume();
};
/// Installs bubbling on content so nested controls receive input first.
ScrollPaneAdapter(ScrollPane pane) {
super(pane);
this.pane = pane;
attach(null, pane.getContent());
attachViewport();
pane.skinProperty().addListener(observable -> attachViewport());
pane.contentProperty().addListener((ObservableValue<? extends Node> observable,
@Nullable Node oldContent, @Nullable Node newContent) -> attach(oldContent, newContent));
pane.viewportBoundsProperty().addListener((ObservableValue<? extends Bounds> observable,
@Nullable Bounds oldBounds, @Nullable Bounds newBounds) -> {
// Layout also replaces these bounds when only the content offset changes.
if (oldBounds == null || newBounds == null
|| oldBounds.getWidth() != newBounds.getWidth()
|| oldBounds.getHeight() != newBounds.getHeight())
controller.reset();
});
pane.hvalueProperty().addListener(observable -> controller.positionChanged());
pane.vvalueProperty().addListener(observable -> controller.positionChanged());
pane.addEventFilter(MouseEvent.MOUSE_DRAGGED, event -> {
if (!controller.gesture.isDuplicateDrag(event))
return;
// Empty viewport targets bypass the content's bubbling handler.
@Nullable Node content = pane.getContent();
@Nullable Node target = event.getTarget() instanceof Node n ? n : null;
while (target != null && target != content && target != pane) {
if (target instanceof ScrollBar)
return;
target = target.getParent();
}
if (content == null || target != content)
event.consume();
});
pane.addEventHandler(ScrollEvent.SCROLL, event -> {
// Viewport input was already handled; direct scrollbar input belongs to its skin.
@Nullable Node target = event.getTarget() instanceof Node n ? n : null;
while (target != null && target != pane) {
if (target == viewport || (event.isDirect() && target instanceof ScrollBar))
return;
target = target.getParent();
}
controller.onScroll(event);
});
}
/// Preserves native scrollbar visibility while unused input continues its original bubbling route.
private void attachViewport() {
controller.reset();
if (viewport != null && viewport.getEventDispatcher() == viewportWrapper)
viewport.setEventDispatcher(viewportDispatcher);
viewport = null;
viewportDispatcher = null;
viewportWrapper = null;
for (Node child : pane.getChildrenUnmodifiable()) {
if (!child.getStyleClass().contains("viewport"))
continue;
EventDispatcher dispatcher = Objects.requireNonNull(child.getEventDispatcher());
EventDispatcher wrapper = new ViewportDispatcher(dispatcher);
viewport = child;
viewportDispatcher = dispatcher;
viewportWrapper = wrapper;
child.setEventDispatcher(wrapper);
break;
}
}
/// Reuses a private tail adapter without inserting controller references into JavaFX's cached chain.
@NotNullByDefault
private final class ViewportDispatcher implements EventDispatcher, EventDispatchChain {
/// Original viewport dispatcher, including native capture filters and bubbling handlers.
private final EventDispatcher dispatcher;
/// Active child chain, cleared as soon as dispatch completes.
private @Nullable EventDispatchChain tail;
/// Original unused event to resume bubbling above the viewport.
private @Nullable ScrollEvent unused;
/// Horizontal offset after child dispatch, or NaN if the child consumed the drag.
private double panStartX;
/// Vertical offset immediately before native viewport bubbling.
private double panStartY;
/// Wraps the viewport's native dispatch while preserving its capture phase.
private ViewportDispatcher(EventDispatcher dispatcher) {
this.dispatcher = dispatcher;
}
/// Coordinates scroll and native panning with isolated state for reentrant dispatch.
@Override
public @Nullable Event dispatchEvent(Event event, EventDispatchChain tail) {
boolean panning = event instanceof MouseEvent mouse && mouse.isSynthesized()
&& mouse.getEventType() == MouseEvent.MOUSE_DRAGGED && !controller.gesture.isDuplicateDrag(mouse);
if (!panning && (!(event instanceof ScrollEvent) || event.getEventType() != ScrollEvent.SCROLL))
return dispatcher.dispatchEvent(event, tail);
if (this.tail != null)
return new ViewportDispatcher(dispatcher).dispatchEvent(event, tail);
this.tail = tail;
double previousX = controller.gesture.pannedX;
double previousY = controller.gesture.pannedY;
panStartX = Double.NaN;
try {
@Nullable Event result = dispatcher.dispatchEvent(event, this);
if (panning) {
if (!Double.isNaN(panStartX)) {
// Position listeners interrupt animation during native panning; retain its credit.
controller.gesture.pannedX = previousX + position(true) - panStartX;
controller.gesture.pannedY = previousY + position(false) - panStartY;
}
return result;
}
return unused;
} finally {
this.tail = null;
unused = null;
}
}
/// Gives descendants priority, then measures native panning or replaces native scrolling.
@Override
public @Nullable Event dispatchEvent(Event event) {
@Nullable Event result = Objects.requireNonNull(tail).dispatchEvent(event);
if (result instanceof MouseEvent) {
panStartX = position(true);
panStartY = position(false);
return result;
}
if (!(result instanceof ScrollEvent scroll))
return result;
controller.onScroll(scroll);
if (!scroll.isConsumed())
unused = scroll;
return TOUCH_SUPPORTED ? stationaryScroll(scroll) : null;
}
/// Appends a caller-supplied dispatcher while keeping this adapter around the child chain.
@Override
public EventDispatchChain append(EventDispatcher dispatcher) {
tail = Objects.requireNonNull(tail).append(dispatcher);
return this;
}
/// Prepends a caller-supplied dispatcher while keeping this adapter around the child chain.
@Override
public EventDispatchChain prepend(EventDispatcher dispatcher) {
tail = Objects.requireNonNull(tail).prepend(dispatcher);
return this;
}
}
/// Moves the handler to the current content, without retaining detached content nodes.
private void attach(@Nullable Node oldContent, @Nullable Node newContent) {
controller.reset();
if (oldContent != null)
oldContent.removeEventHandler(MouseEvent.MOUSE_DRAGGED, dragHandler);
if (newContent != null)
newContent.addEventHandler(MouseEvent.MOUSE_DRAGGED, dragHandler);
}
/// Returns overflow measured in the same layout coordinates as gesture pixel deltas.
private double overflow(boolean horizontal) {
@Nullable Node content = pane.getContent();
if (content == null)
return 0;
return Math.max(0, horizontal
? content.getLayoutBounds().getWidth() - pane.getViewportBounds().getWidth()
: content.getLayoutBounds().getHeight() - pane.getViewportBounds().getHeight());
}
/// Measures the pane's current displacement in content pixels, clamped to its overflow.
private double position(boolean horizontal) {
double min = horizontal ? pane.getHmin() : pane.getVmin();
double max = horizontal ? pane.getHmax() : pane.getVmax();
double value = horizontal ? pane.getHvalue() : pane.getVvalue();
double size = overflow(horizontal);
return max > min ? MathUtils.clamp((value - min) / (max - min) * size, 0, size) : 0;
}
/// Clamps to the distance before an edge, including non-default scroll value ranges.
@Override
double limit(boolean horizontal, double pixels) {
DoubleProperty value = horizontal ? pane.hvalueProperty() : pane.vvalueProperty();
double min = horizontal ? pane.getHmin() : pane.getVmin();
double max = horizontal ? pane.getHmax() : pane.getVmax();
double size = overflow(horizontal);
if (value.isBound() || !(max > min) || !(size > 0))
return 0;
double position = MathUtils.clamp((value.get() - min) / (max - min) * size, 0, size);
return MathUtils.clamp(pixels, -position, size - position);
}
/// Scrolls both axes independently without assuming normalized values or overflowing content.
@Override
double scroll(boolean horizontal, double pixels) {
double distance = limit(horizontal, pixels);
if (distance == 0)
return 0;
double min = horizontal ? pane.getHmin() : pane.getVmin();
double max = horizontal ? pane.getHmax() : pane.getVmax();
DoubleProperty value = horizontal ? pane.hvalueProperty() : pane.vvalueProperty();
value.set(MathUtils.clamp(value.get() + distance / overflow(horizontal) * (max - min), min, max));
return distance;
}
/// Uses shared wheel speed except for explicitly requested viewport-sized page scrolling.
@Override
double wheelDelta(ScrollEvent event, boolean horizontal) {
if (!horizontal && event.getTextDeltaYUnits() == ScrollEvent.VerticalTextScrollUnits.PAGES)
return -event.getTextDeltaY() * pane.getViewportBounds().getHeight();
return super.wheelDelta(event, horizontal);
}
}
@@ -1,269 +1,38 @@
// Copy from https://github.com/palexdev/MaterialFX/blob/c8038ce2090f5cddf923a19d79cc601db86a4d17/materialfx/src/main/java/io/github/palexdev/materialfx/utils/ScrollUtils.java
/*
* Copyright (C) 2022 Parisi Alessandro
* This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
*
* MaterialFX is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* MaterialFX is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with MaterialFX. If not, see <http://www.gnu.org/licenses/>.
*/
// 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 javafx.animation.Animation;
import javafx.animation.Animation.Status;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.event.EventHandler;
import javafx.scene.Node;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.skin.VirtualFlow;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.util.Duration;
import org.jackhuang.hmcl.util.Holder;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
/**
* Utility class for ScrollPanes.
*/
/// Installs smooth wheel scrolling while preserving native gesture and skin behavior.
@NotNullByDefault
final class ScrollUtils {
/// Identifies the controller installed on a node, making installation idempotent.
private static final Object CONTROLLER_KEY = new Object();
public enum ScrollDirection {
UP(-1), RIGHT(-1), DOWN(1), LEFT(1);
final int intDirection;
ScrollDirection(int intDirection) {
this.intDirection = intDirection;
}
public int intDirection() {
return intDirection;
}
}
private static final double DEFAULT_SPEED = 1.0;
private static final double DEFAULT_TRACK_PAD_ADJUSTMENT = 7.0;
private static final double CUTOFF_DELTA = 0.01;
/**
* Determines if the given ScrollEvent comes from a trackpad.
* <p></p>
* Although this method works in most cases, it is not very accurate.
* Since in JavaFX there's no way to tell if a ScrollEvent comes from a trackpad or a mouse
* we use this trick: I noticed that a mouse scroll has a delta of 32 (don't know if it changes depending on the device or OS)
* and trackpad scrolls have a way smaller delta. So depending on the scroll direction we check if the delta is lesser than 10
* (trackpad event) or greater(mouse event).
*
* @see ScrollEvent#getDeltaX()
* @see ScrollEvent#getDeltaY()
*/
public static boolean isTrackPad(ScrollEvent event, ScrollDirection scrollDirection) {
return switch (scrollDirection) {
case UP, DOWN -> Math.abs(event.getDeltaY()) < 10;
case LEFT, RIGHT -> Math.abs(event.getDeltaX()) < 10;
};
}
/**
* Determines the scroll direction of the given ScrollEvent.
* <p></p>
* Although this method works fine, it is not very accurate.
* In JavaFX there's no concept of scroll direction, if you try to scroll with a trackpad
* you'll notice that you can scroll in both directions at the same time, both deltaX and deltaY won't be 0.
* <p></p>
* For this method to work we assume that this behavior is not possible.
* <p></p>
* If deltaY is 0 we return LEFT or RIGHT depending on deltaX (respectively if lesser or greater than 0).
* <p>
* Else we return DOWN or UP depending on deltaY (respectively if lesser or greater than 0).
*
* @see ScrollEvent#getDeltaX()
* @see ScrollEvent#getDeltaY()
*/
public static ScrollDirection determineScrollDirection(ScrollEvent event) {
double deltaX = event.getDeltaX();
double deltaY = event.getDeltaY();
if (deltaY == 0.0) {
return deltaX < 0 ? ScrollDirection.LEFT : ScrollDirection.RIGHT;
} else {
return deltaY < 0 ? ScrollDirection.DOWN : ScrollDirection.UP;
}
}
//================================================================================
// ScrollPanes
//================================================================================
/**
* Adds a smooth scrolling effect to the given scroll pane,
* calls {@link #addSmoothScrolling(ScrollPane, double)} with a
* default speed value of 1.
*/
public static void addSmoothScrolling(ScrollPane scrollPane) {
addSmoothScrolling(scrollPane, DEFAULT_SPEED);
}
/**
* Adds a smooth scrolling effect to the given scroll pane with the given scroll speed.
* Calls {@link #addSmoothScrolling(ScrollPane, double, double)}
* with a default trackPadAdjustment of 7.
*/
public static void addSmoothScrolling(ScrollPane scrollPane, double speed) {
addSmoothScrolling(scrollPane, speed, DEFAULT_TRACK_PAD_ADJUSTMENT);
}
/**
* Adds a smooth scrolling effect to the given scroll pane with the given
* scroll speed and the given trackPadAdjustment.
* <p></p>
* The trackPadAdjustment is a value used to slow down the scrolling if a trackpad is used.
* This is kind of a workaround and it's not perfect, but at least it's way better than before.
* The default value is 7, tested up to 10, further values can cause scrolling misbehavior.
*/
public static void addSmoothScrolling(ScrollPane scrollPane, double speed, double trackPadAdjustment) {
smoothScroll(scrollPane, speed, trackPadAdjustment);
}
/// @author Glavo
public static void addSmoothScrolling(VirtualFlow<?> virtualFlow) {
addSmoothScrolling(virtualFlow, DEFAULT_SPEED);
}
/// @author Glavo
public static void addSmoothScrolling(VirtualFlow<?> virtualFlow, double speed) {
addSmoothScrolling(virtualFlow, speed, DEFAULT_TRACK_PAD_ADJUSTMENT);
}
/// @author Glavo
public static void addSmoothScrolling(VirtualFlow<?> virtualFlow, double speed, double trackPadAdjustment) {
smoothScroll(virtualFlow, speed, trackPadAdjustment);
}
private static final double[] FRICTIONS = {0.99, 0.1, 0.05, 0.04, 0.03, 0.02, 0.01, 0.04, 0.01, 0.008, 0.008, 0.008, 0.008, 0.0006, 0.0005, 0.00003, 0.00001};
private static final Duration DURATION = Duration.millis(3);
private static void smoothScroll(ScrollPane scrollPane, double speed, double trackPadAdjustment) {
final double[] derivatives = new double[FRICTIONS.length];
Timeline timeline = new Timeline();
Holder<ScrollDirection> scrollDirectionHolder = new Holder<>();
final EventHandler<MouseEvent> mouseHandler = event -> timeline.stop();
final EventHandler<ScrollEvent> scrollHandler = event -> {
if (event.getEventType() == ScrollEvent.SCROLL) {
ScrollDirection scrollDirection = determineScrollDirection(event);
scrollDirectionHolder.value = scrollDirection;
double currentSpeed = isTrackPad(event, scrollDirection) ? speed / trackPadAdjustment : speed;
derivatives[0] += scrollDirection.intDirection * currentSpeed;
if (timeline.getStatus() == Status.STOPPED) {
timeline.play();
}
event.consume();
}
};
if (scrollPane.getContent().getParent() != null) {
scrollPane.getContent().getParent().addEventFilter(MouseEvent.MOUSE_PRESSED, mouseHandler);
scrollPane.getContent().getParent().addEventHandler(ScrollEvent.ANY, scrollHandler);
}
scrollPane.getContent().parentProperty().addListener((observable, oldValue, newValue) -> {
if (oldValue != null) {
oldValue.removeEventFilter(MouseEvent.MOUSE_PRESSED, mouseHandler);
oldValue.removeEventHandler(ScrollEvent.ANY, scrollHandler);
}
if (newValue != null) {
newValue.addEventFilter(MouseEvent.MOUSE_PRESSED, mouseHandler);
newValue.addEventHandler(ScrollEvent.ANY, scrollHandler);
}
});
timeline.getKeyFrames().add(new KeyFrame(DURATION, event -> {
for (int i = 0; i < derivatives.length; i++) {
derivatives[i] *= FRICTIONS[i];
}
for (int i = 1; i < derivatives.length; i++) {
derivatives[i] += derivatives[i - 1];
}
double dy = derivatives[derivatives.length - 1];
double size;
switch (scrollDirectionHolder.value) {
case LEFT:
case RIGHT:
size = scrollPane.getContent().getLayoutBounds().getWidth();
scrollPane.setHvalue(Math.min(Math.max(scrollPane.getHvalue() + dy / size, 0), 1));
break;
case UP:
case DOWN:
size = scrollPane.getContent().getLayoutBounds().getHeight();
scrollPane.setVvalue(Math.min(Math.max(scrollPane.getVvalue() + dy / size, 0), 1));
break;
}
if (Math.abs(dy) < CUTOFF_DELTA) {
timeline.stop();
}
}));
timeline.setCycleCount(Animation.INDEFINITE);
}
/// @author Glavo
private static void smoothScroll(VirtualFlow<?> virtualFlow, double speed, double trackPadAdjustment) {
if (!virtualFlow.isVertical())
return;
final double[] derivatives = new double[FRICTIONS.length];
Timeline timeline = new Timeline();
final EventHandler<MouseEvent> mouseHandler = event -> timeline.stop();
final EventHandler<ScrollEvent> scrollHandler = event -> {
if (event.getEventType() == ScrollEvent.SCROLL) {
ScrollDirection scrollDirection = determineScrollDirection(event);
if (scrollDirection == ScrollDirection.LEFT || scrollDirection == ScrollDirection.RIGHT) {
return;
}
double currentSpeed = isTrackPad(event, scrollDirection) ? speed / trackPadAdjustment : speed;
derivatives[0] += scrollDirection.intDirection * currentSpeed;
if (timeline.getStatus() == Status.STOPPED) {
timeline.play();
}
event.consume();
}
};
virtualFlow.addEventFilter(MouseEvent.MOUSE_PRESSED, mouseHandler);
virtualFlow.addEventFilter(ScrollEvent.ANY, scrollHandler);
timeline.getKeyFrames().add(new KeyFrame(DURATION, event -> {
for (int i = 0; i < derivatives.length; i++) {
derivatives[i] *= FRICTIONS[i];
}
for (int i = 1; i < derivatives.length; i++) {
derivatives[i] += derivatives[i - 1];
}
double dy = derivatives[derivatives.length - 1];
virtualFlow.scrollPixels(dy);
if (Math.abs(dy) < CUTOFF_DELTA) {
timeline.stop();
}
}));
timeline.setCycleCount(Animation.INDEFINITE);
}
/// Prevents construction of this utility class.
private ScrollUtils() {
}
/// Installs smooth wheel scrolling and direct pixel-based gestures on the FX thread.
public static void addSmoothScrolling(ScrollPane scrollPane) {
if (controller(scrollPane) == null)
scrollPane.getProperties().put(CONTROLLER_KEY, new ScrollPaneAdapter(scrollPane).controller);
}
/// Installs scrolling on either flow orientation, preserving native cross-axis scrolling.
public static void addSmoothScrolling(VirtualFlow<?> virtualFlow) {
if (controller(virtualFlow) == null)
virtualFlow.getProperties().put(CONTROLLER_KEY, new VirtualFlowAdapter(virtualFlow).controller);
}
/// Returns an installed controller without allocating a property map on ordinary nodes.
static @Nullable ScrollController controller(Node node) {
return node.hasProperties() && node.getProperties().get(CONTROLLER_KEY) instanceof ScrollController controller
? controller : null;
}
}
@@ -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;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXSpinner;
import javafx.geometry.Insets;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.JFXHyperlink;
import org.jackhuang.hmcl.upgrade.RemoteVersion;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.nodes.Node;
import static org.jackhuang.hmcl.Metadata.CHANGELOG_URL;
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 final class UpgradeDialog extends JFXDialogLayout {
public UpgradeDialog(RemoteVersion remoteVersion, Runnable updateRunnable) {
maxWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
maxHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.7));
Label heading = new Label(i18n("update.changelog"));
heading.setPadding(new Insets(0, 0, 8, 0));
setHeading(heading);
setBody(new JFXSpinner());
String url = CHANGELOG_URL + remoteVersion.channel().channelName + ".html";
Task.supplyAsync(Schedulers.io(), () -> {
VersionNumber targetVersion = VersionNumber.asVersion(remoteVersion.version());
VersionNumber currentVersion = VersionNumber.asVersion(Metadata.VERSION);
if (targetVersion.compareTo(currentVersion) <= 0)
// Downgrade update, no need to display changelog
return null;
Document document = Jsoup.parse(WebURL.toURL(url), 30 * 1000);
Node node = document.selectFirst("h1[data-version=\"%s\"]".formatted(targetVersion));
HTMLRenderer renderer = new HTMLRenderer(uri -> {
LOG.info("Open link: " + uri);
FXUtils.openLink(uri.toString());
});
if (node == null || !"h1".equals(node.nodeName())) {
LOG.warning("Changelog not found");
Element list = new Element("ul");
list.appendElement("li").text(i18n("update.changelog.empty"));
return renderer.appendNode(list).mergeLineBreaks().render();
}
do {
if ("h1".equals(node.nodeName())) {
String changelogVersion = node.attr("data-version");
if (StringUtils.isBlank(changelogVersion) || currentVersion.compareTo(changelogVersion) >= 0) {
break;
}
}
renderer.appendNode(node);
node = node.nextSibling();
} while (node != null);
renderer.mergeLineBreaks();
return renderer.render();
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
if (result != null) {
ScrollPane scrollPane = new ScrollPane(result);
scrollPane.setFitToWidth(true);
FXUtils.smoothScrolling(scrollPane);
setBody(scrollPane);
} else {
setBody();
}
} else {
LOG.warning("Failed to load update log, trying to open it in browser");
FXUtils.openLink(url);
setBody();
}
}).start();
JFXHyperlink openInBrowser = new JFXHyperlink(i18n("web.view_in_browser"));
openInBrowser.setExternalLink(url);
JFXButton updateButton = new JFXButton(i18n("update.accept"));
updateButton.getStyleClass().add("dialog-accept");
updateButton.setOnAction(e -> updateRunnable.run());
JFXButton cancelButton = new JFXButton(i18n("button.cancel"));
cancelButton.getStyleClass().add("dialog-cancel");
cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
setActions(openInBrowser, updateButton, cancelButton);
onEscPressed(this, cancelButton::fire);
}
}
@@ -0,0 +1,415 @@
// 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 javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.ObservableList;
import javafx.event.Event;
import javafx.event.EventDispatchChain;
import javafx.event.EventDispatcher;
import javafx.event.EventHandler;
import javafx.event.WeakEventHandler;
import javafx.geometry.Orientation;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.IndexedCell;
import javafx.scene.control.ListView;
import javafx.scene.control.ScrollBar;
import javafx.scene.control.ScrollToEvent;
import javafx.scene.control.TableView;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeTableView;
import javafx.scene.control.TreeView;
import javafx.scene.control.skin.VirtualFlow;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import org.jackhuang.hmcl.util.MathUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
/// Scrolls virtualized cells along their layout axis and preserves cross-axis pixel scrolling.
@NotNullByDefault
final class VirtualFlowAdapter extends ScrollAdapter {
/// Flow providing pixel scrolling and normalized boundary positions.
private final VirtualFlow<?> flow;
/// Owning control whose navigation events interrupt queued wheel movement.
private @Nullable Control owner;
/// Cancels pending movement before native keyboard or scroll-to navigation.
private final EventHandler<Event> navigationHandler = event -> controller.interrupt();
/// Visible edge anchoring the measured offscreen interval, or -1 when invalid.
private int measuredEdge = -1;
/// Direction of the measured interval.
private int measuredDirection;
/// First index beyond the measured interval.
private int nextMeasure;
/// Combined length of measured offscreen cells; no cell references are retained.
private double measuredLength;
/// Invalidates measurements if data changes synchronously during cell updates.
private long measurementRevision;
/// Distinguishes layout requests caused by measurement from external layout changes.
private boolean measuring;
/// Observes layout changes while ignoring requests caused by the reusable measurement cell.
private final ChangeListener<Boolean> layoutListener = (observable, oldValue, value) -> {
if (!measuring)
invalidateMeasurements();
};
/// Invalidates cached sizes after data, factory, or geometry changes.
private final InvalidationListener measurementListener = observable -> {
if (observable instanceof ObservableValue<?> value)
value.getValue();
invalidateMeasurements();
};
/// Prevents externally retained item lists from retaining a detached control.
private final InvalidationListener weakMeasurementListener = new WeakInvalidationListener(measurementListener);
/// Owner's replaceable items or tree root property.
private @Nullable ObservableValue<?> dataSource;
/// Flat item list currently observed for updates, permutations, and replacements.
private @Nullable ObservableList<?> items;
/// Tree root currently observed for descendant changes.
private @Nullable TreeItem<?> root;
/// Rebinds data listeners when the owner replaces its items or root.
private final InvalidationListener dataSourceListener = observable -> bindData();
/// Invalidates tree measurements when descendants change or expand.
private final EventHandler<TreeItem.TreeModificationEvent<Object>> treeListener = event -> invalidateMeasurements();
/// Prevents externally retained tree models from retaining a detached control.
private final EventHandler<TreeItem.TreeModificationEvent<Object>> weakTreeListener = new WeakEventHandler<>(treeListener);
/// Wraps native bubbling so cell controls and nested scroll panes retain priority.
VirtualFlowAdapter(VirtualFlow<?> flow) {
super(flow);
this.flow = flow;
flow.setEventDispatcher(new FlowDispatcher(Objects.requireNonNull(flow.getEventDispatcher())));
@Nullable EventHandler<? super ScrollEvent> nativeHandler = flow.getOnScroll();
flow.setOnScroll(event -> {
// The touch skin must reveal its bars before scrollPixels can move cells.
// A zero-distance event preserves its visibility lifecycle without scrolling twice.
if (TOUCH_SUPPORTED && nativeHandler != null && !event.isInertia()) {
nativeHandler.handle(stationaryScroll(event));
}
controller.onScroll(event);
});
// Native layout revises the estimated scroll position without moving the viewport.
// Input filters handle interruptions without treating those estimates as external scrolling.
flow.verticalProperty().addListener(observable -> controller.reset());
flow.cellCountProperty().addListener(observable -> controller.reset());
flow.cellCountProperty().addListener(measurementListener);
flow.verticalProperty().addListener(measurementListener);
flow.fixedCellSizeProperty().addListener(measurementListener);
flow.cellFactoryProperty().addListener(measurementListener);
flow.layoutBoundsProperty().addListener(measurementListener);
flow.positionProperty().addListener(measurementListener);
flow.needsLayoutProperty().addListener(layoutListener);
flow.parentProperty().addListener(observable -> bindOwner());
flow.sceneProperty().addListener(observable -> bindOwner());
bindOwner();
}
/// Follows the owning control and releases its handlers when the flow is detached.
private void bindOwner() {
@Nullable Node parent = flow.getParent();
while (parent != null && !(parent instanceof Control))
parent = parent.getParent();
if (owner == parent)
return;
if (owner != null) {
owner.removeEventFilter(KeyEvent.KEY_PRESSED, navigationHandler);
owner.removeEventFilter(ScrollToEvent.ANY, navigationHandler);
owner.needsLayoutProperty().removeListener(layoutListener);
owner.getProperties().removeListener(measurementListener);
}
if (dataSource != null)
dataSource.removeListener(dataSourceListener);
owner = (Control) parent;
if (owner != null) {
owner.addEventFilter(KeyEvent.KEY_PRESSED, navigationHandler);
owner.addEventFilter(ScrollToEvent.ANY, navigationHandler);
owner.needsLayoutProperty().addListener(layoutListener);
// Native refresh operations use the control's properties to invalidate cells.
owner.getProperties().addListener(measurementListener);
}
dataSource = owner instanceof ListView<?> list ? list.itemsProperty()
: owner instanceof TableView<?> table ? table.itemsProperty()
: owner instanceof TreeView<?> tree ? tree.rootProperty()
: owner instanceof TreeTableView<?> treeTable ? treeTable.rootProperty() : null;
if (dataSource != null)
dataSource.addListener(dataSourceListener);
bindData();
}
/// Releases old data listeners and observes the current model without retaining replaced items.
private void bindData() {
if (items != null)
items.removeListener(weakMeasurementListener);
if (root != null)
root.removeEventHandler(TreeItem.treeNotificationEvent(), weakTreeListener);
@Nullable Object data = dataSource == null ? null : dataSource.getValue();
items = data instanceof ObservableList<?> list ? list : null;
root = data instanceof TreeItem<?> tree ? tree : null;
if (items != null)
items.addListener(weakMeasurementListener);
if (root != null)
root.addEventHandler(TreeItem.treeNotificationEvent(), weakTreeListener);
invalidateMeasurements();
}
/// Discards the measured interval without retaining cells or allocating per-item state.
private void invalidateMeasurements() {
measuredEdge = -1;
measurementRevision++;
}
/// Measures native capture-phase panning before descendants can change the flow themselves.
@NotNullByDefault
private final class FlowDispatcher implements EventDispatcher, EventDispatchChain {
/// Native flow dispatcher, including its panning filters.
private final EventDispatcher dispatcher;
/// Active child chain, never inserted into JavaFX's reusable dispatch chain.
private @Nullable EventDispatchChain tail;
/// Whether native panning still needs to be measured for this dispatch.
private boolean recording;
/// Index of the first visible cell before native panning, or -1 for an empty flow.
private int firstIndex;
/// Layout position of the first visible cell before native panning.
private double firstPosition;
/// Breadth scrollbar value before native panning.
private double breadthPosition;
/// Horizontal panning credit preserved across native position-change notifications.
private double previousX;
/// Vertical panning credit preserved across native position-change notifications.
private double previousY;
/// Wraps native dispatch without changing ordinary mouse or wheel input.
private FlowDispatcher(EventDispatcher dispatcher) {
this.dispatcher = dispatcher;
}
/// Suppresses duplicate drags and measures early synthesized panning with reentrant isolation.
@Override
public @Nullable Event dispatchEvent(Event event, EventDispatchChain tail) {
if (controller.gesture.isDuplicateDrag(event)) {
tail.dispatchEvent(event);
return null;
}
if (!(event instanceof MouseEvent mouse) || !mouse.isSynthesized()
|| mouse.getEventType() != MouseEvent.MOUSE_DRAGGED || !flow.isPannable())
return dispatcher.dispatchEvent(event, tail);
if (this.tail != null)
return new FlowDispatcher(dispatcher).dispatchEvent(event, tail);
@Nullable IndexedCell<?> first = flow.getFirstVisibleCell();
firstIndex = first == null ? -1 : first.getIndex();
firstPosition = first == null ? 0 : flow.isVertical() ? first.getLayoutY() : first.getLayoutX();
@Nullable ScrollBar breadth = bar(flow.isVertical());
breadthPosition = breadth == null ? 0 : breadth.getValue();
previousX = controller.gesture.pannedX;
previousY = controller.gesture.pannedY;
this.tail = tail;
recording = true;
try {
@Nullable Event result = dispatcher.dispatchEvent(event, this);
recordPanning();
return result;
} finally {
recording = false;
this.tail = null;
}
}
/// Records capture-phase displacement before dispatching to cell controls.
@Override
public @Nullable Event dispatchEvent(Event event) {
recordPanning();
return Objects.requireNonNull(tail).dispatchEvent(event);
}
/// Preserves actual pixel movement even when panning recycles variable-size cells.
private void recordPanning() {
if (!recording)
return;
recording = false;
double length = 0;
@Nullable IndexedCell<?> first = flow.getFirstVisibleCell();
if (firstIndex >= 0 && first != null) {
int index = first.getIndex();
length = firstPosition - (flow.isVertical() ? first.getLayoutY() : first.getLayoutX());
if (flow.getFixedCellSize() > 0) {
length += (index - firstIndex) * flow.getFixedCellSize();
} else {
int direction = Integer.compare(index, firstIndex);
for (int i = Math.min(index, firstIndex); i < Math.max(index, firstIndex); i++) {
IndexedCell<?> cell = flow.getCell(i);
length += direction * (flow.isVertical() ? cell.getHeight() : cell.getWidth());
}
}
}
@Nullable ScrollBar breadth = bar(flow.isVertical());
double cross = breadth == null ? 0 : breadth.getValue() - breadthPosition;
controller.gesture.pannedX = previousX + (flow.isVertical() ? cross : length);
controller.gesture.pannedY = previousY + (flow.isVertical() ? length : cross);
}
/// Appends a caller's dispatcher without adding this adapter to the shared chain.
@Override
public EventDispatchChain append(EventDispatcher dispatcher) {
tail = Objects.requireNonNull(tail).append(dispatcher);
return this;
}
/// Prepends a caller's dispatcher while retaining the capture-phase measurement boundary.
@Override
public EventDispatchChain prepend(EventDispatcher dispatcher) {
tail = Objects.requireNonNull(tail).prepend(dispatcher);
return this;
}
}
/// Finds the flow's own scrollbar on the requested axis.
private @Nullable ScrollBar bar(boolean horizontal) {
Orientation orientation = horizontal ? Orientation.HORIZONTAL : Orientation.VERTICAL;
for (Node child : flow.getChildrenUnmodifiable()) {
if (child instanceof ScrollBar bar && bar.getOrientation() == orientation)
return bar;
}
return null;
}
/// Measures the clipping surface, including platforms with overlay scrollbars.
private double viewportLength(boolean horizontal) {
for (Node child : flow.getChildrenUnmodifiable()) {
if (child.getStyleClass().contains("clipped-container"))
return horizontal ? child.getLayoutBounds().getWidth() : child.getLayoutBounds().getHeight();
}
return horizontal ? flow.getWidth() : flow.getHeight();
}
/// Bounds movement against the full fixed-size extent or the variable-size cells along its path.
@Override
double limit(boolean horizontal, double pixels) {
if (horizontal == flow.isVertical()) {
@Nullable ScrollBar bar = bar(horizontal);
@Nullable IndexedCell<?> cell = flow.getFirstVisibleCell();
if (bar == null || cell == null || bar.valueProperty().isBound())
return 0;
double extent = (horizontal ? cell.getWidth() : cell.getHeight()) - viewportLength(horizontal);
double max = bar.getMin() + Math.max(0, extent);
if (bar.maxProperty().isBound())
max = Math.min(max, bar.getMax());
return MathUtils.clamp(pixels, bar.getMin() - bar.getValue(), max - bar.getValue());
}
if (pixels == 0 || flow.getCellCount() == 0 || flow.positionProperty().isBound()
|| (pixels < 0 && flow.getPosition() <= 0)
|| (pixels > 0 && flow.getPosition() >= 1))
return 0;
@Nullable IndexedCell<?> first = flow.getFirstVisibleCell();
double size = viewportLength(horizontal);
double cellSize = flow.getFixedCellSize();
if (first != null && cellSize > 0) {
// Queued movement can reach an edge before its end cell becomes visible.
double extent = Math.max(0, flow.getCellCount() * cellSize - size);
double start = horizontal ? first.getLayoutX() : first.getLayoutY();
double position = MathUtils.clamp(first.getIndex() * cellSize - start, 0, extent);
return MathUtils.clamp(pixels, -position, extent - position);
}
@Nullable IndexedCell<?> edge = pixels < 0 ? first : flow.getLastVisibleCell();
if (edge == null)
return pixels;
double start = horizontal ? edge.getLayoutX() : edge.getLayoutY();
double length = horizontal ? edge.getWidth() : edge.getHeight();
double available = Math.max(0, pixels < 0 ? -start : start + length - size);
int direction = pixels < 0 ? -1 : 1;
int edgeIndex = edge.getIndex();
boolean reuse = measuredEdge == edgeIndex && measuredDirection == direction;
double measured = reuse ? measuredLength : 0;
int index = reuse ? nextMeasure : edgeIndex + direction;
long revision = measurementRevision;
boolean previous = measuring;
measuring = true;
try {
// Extend the previous interval instead of remeasuring the whole queued distance.
while (available + measured < Math.abs(pixels) && index >= 0 && index < flow.getCellCount()) {
IndexedCell<?> cell = flow.getCell(index);
measured += horizontal ? cell.getWidth() : cell.getHeight();
index += direction;
}
} finally {
measuring = previous;
}
if (revision == measurementRevision) {
measuredEdge = edgeIndex;
measuredDirection = direction;
nextMeasure = index;
measuredLength = measured;
}
return direction * Math.min(Math.abs(pixels), available + measured);
}
/// Applies synchronous gesture pixels through the flow or its breadth scrollbar.
@Override
double scroll(boolean horizontal, double pixels) {
if (horizontal == flow.isVertical()) {
@Nullable ScrollBar bar = bar(horizontal);
double distance = limit(horizontal, pixels);
if (bar != null && distance != 0) {
// Hidden touch bars have not necessarily received their range from layout yet.
if (bar.getValue() + distance > bar.getMax() && !bar.maxProperty().isBound())
bar.setMax(bar.getValue() + distance);
bar.setValue(bar.getValue() + distance);
}
return distance;
}
if (limit(horizontal, pixels) == 0)
return 0;
@Nullable ScrollBar bar = bar(horizontal);
// The native reveal animation starts on the next pulse; preserve the first touch delta too.
if (bar != null && !bar.isVisible() && !bar.visibleProperty().isBound())
bar.setVisible(true);
return flow.scrollPixels(pixels);
}
/// Preserves horizontal-list trackpad mapping while direct touch follows the physical finger axes.
@Override
double gestureDelta(ScrollEvent event, boolean horizontal) {
if (horizontal && !flow.isVertical() && !event.isDirect())
return -(Math.abs(event.getDeltaX()) > Math.abs(event.getDeltaY()) ? event.getDeltaX() : event.getDeltaY());
return super.gestureDelta(event, horizontal);
}
/// Uses shared wheel speed, retaining native horizontal-axis mapping and viewport-sized pages.
@Override
double wheelDelta(ScrollEvent event, boolean horizontal) {
if (!horizontal && flow.isVertical() && event.getTextDeltaYUnits() == ScrollEvent.VerticalTextScrollUnits.PAGES)
return -event.getTextDeltaY() * flow.getHeight();
if (horizontal && !flow.isVertical())
return super.wheelDelta(event, Math.abs(event.getDeltaX()) > Math.abs(event.getDeltaY()));
return super.wheelDelta(event, horizontal);
}
}
@@ -0,0 +1,207 @@
// 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 javafx.animation.AnimationTimer;
import org.jackhuang.hmcl.util.MathUtils;
import org.jetbrains.annotations.NotNullByDefault;
/// Coalesces bounded wheel distances into velocity-continuous motion applied once per animation pulse.
@NotNullByDefault
final class WheelScrollAnimation extends AnimationTimer {
/// Settling time after the last accepted wheel input, in nanoseconds.
private static final long WHEEL_DURATION = 150_000_000L;
/// Supplies pixel bounds and movement without exposing nodes, gestures, or skins.
@NotNullByDefault
interface Target {
/// Bounds a requested displacement against the current position.
double limit(boolean horizontal, double pixels);
/// Moves along one axis and returns the displacement actually applied.
double scroll(boolean horizontal, double pixels);
}
/// Receives each frame's movement and supplies current bounds.
private final Target target;
/// Horizontal queue and its current motion segment.
private final Axis xAxis = new Axis();
/// Vertical queue and its current motion segment.
private final Axis yAxis = new Axis();
/// Beginning of the current wheel interpolation, or zero while stopped.
private long wheelStart;
/// Latest time evaluated by an input or pulse; reentrant calls cannot move the trajectory backwards.
private long lastTime;
/// Invalidates an in-progress frame when synchronous callbacks change the animation's queue or lifetime.
private long revision;
/// Creates an idle animation for a pixel-based scrolling target.
WheelScrollAnimation(Target target) {
this.target = target;
}
/// Whether unapplied wheel movement is waiting for animation pulses.
boolean isRunning() {
return wheelStart != 0;
}
/// Queues additional pixels, returning false when pending movement already reserves all available space.
boolean offer(double dx, double dy, long now) {
long version = revision;
double x = target.limit(true, xAxis.pending + dx);
if (revision != version)
return false;
double y = target.limit(false, yAxis.pending + dy);
if (revision != version)
return false;
if (x == xAxis.pending && y == yAxis.pending)
return false;
revision++;
boolean running = wheelStart != 0;
long time = running ? Math.max(now, lastTime) : now;
double fraction = running ? fraction(time) : 0;
xAxis.retarget(x, fraction);
yAxis.retarget(y, fraction);
wheelStart = time;
lastTime = time;
if (!running)
start();
return true;
}
/// Cancels opposing queued pixels before a descendant can consume the new input.
void cancelReversal(double dx, double dy) {
boolean changed = xAxis.cancelReversal(dx);
changed |= yAxis.cancelReversal(dy);
if (changed)
revision++;
if (xAxis.pending == 0 && yAxis.pending == 0)
cancel();
}
/// Returns progress through the current segment with exact endpoints.
private double fraction(long now) {
return MathUtils.clamp((double) (now - wheelStart) / WHEEL_DURATION, 0, 1);
}
/// Applies the trajectory's accumulated movement, ending exactly at the accepted displacement.
@Override
public void handle(long now) {
if (wheelStart == 0)
return;
long version = ++revision;
lastTime = Math.max(now, lastTime);
double fraction = fraction(lastTime);
xAxis.advance(fraction);
yAxis.advance(fraction);
if (!apply(true, version) || !apply(false, version))
return;
if (fraction >= 1 || (xAxis.pending == 0 && yAxis.pending == 0))
cancel();
}
/// Applies one axis without overwriting queue changes made by synchronous target callbacks.
private boolean apply(boolean horizontal, long version) {
Axis axis = horizontal ? xAxis : yAxis;
double requested = axis.scheduled;
// Listeners accepting new input must see only the distance left after this movement.
axis.scheduled = 0;
axis.pending -= requested;
double actual = target.scroll(horizontal, requested);
if (revision != version)
return false;
double remaining = Math.abs(actual - requested) > 0.01 ? 0 : target.limit(horizontal, axis.pending);
if (revision != version)
return false;
axis.restrict(remaining);
return true;
}
/// Releases the pulse timer and all unapplied wheel movement.
void cancel() {
revision++;
if (wheelStart == 0)
return;
stop();
xAxis.clear();
yAxis.clear();
wheelStart = 0;
lastTime = 0;
}
/// A monotone cubic Hermite segment preserving its entry velocity and ending at rest.
@NotNullByDefault
private static final class Axis {
/// Total accepted distance not yet applied to the target, including scheduled movement.
private double pending;
/// Trajectory movement awaiting the next rendering pulse.
private double scheduled;
/// Signed distance covered by the current segment.
private double distance;
/// Entry velocity multiplied by the segment duration, in pixels.
private double initialVelocity;
/// Segment displacement already included in scheduled or rendered movement.
private double position;
/// Updates the target while carrying forward both velocity and movement between pulses.
private void retarget(double pending, double fraction) {
double velocity = 6 * distance * fraction * (1 - fraction)
+ initialVelocity * (1 - fraction) * (1 - 3 * fraction);
advance(fraction);
this.pending = pending;
scheduled = MathUtils.clamp(scheduled, Math.min(0, pending), Math.max(0, pending));
distance = pending - scheduled;
// With a zero exit velocity, this tangent bound prevents overshoot and reversal.
initialVelocity = MathUtils.clamp(velocity, Math.min(0, 3 * distance), Math.max(0, 3 * distance));
position = 0;
}
/// Evaluates motion without scrolling the control, so frequent input still coalesces per pulse.
private void advance(double fraction) {
double next = fraction == 1 ? distance
: distance * fraction * fraction * (3 - 2 * fraction)
+ initialVelocity * fraction * (1 - fraction) * (1 - fraction);
scheduled += next - position;
position = next;
}
/// Rescales remaining motion if a changing boundary reduces the accepted queue.
private void restrict(double remaining) {
if (remaining == 0) {
clear();
} else if (remaining != pending) {
double scale = remaining / pending;
distance *= scale;
initialVelocity *= scale;
position *= scale;
pending = remaining;
}
}
/// Discards both forward motion and its velocity when raw input reverses this axis.
private boolean cancelReversal(double delta) {
if (pending == 0 || delta == 0 || Math.signum(pending) == Math.signum(delta))
return false;
clear();
return true;
}
/// Releases all movement and velocity associated with this axis.
private void clear() {
pending = 0;
scheduled = 0;
distance = 0;
initialVelocity = 0;
position = 0;
}
}
}
@@ -2,250 +2,196 @@
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.account;
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.geometry.Insets;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Skin;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.Priority;
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;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
import org.jackhuang.hmcl.ui.construct.ClassTitle;
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 {
static final BooleanProperty RESTRICTED = new SimpleBooleanProperty(true);
/// Offline / authlib-injector creation is always allowed for this fork.
static final BooleanProperty RESTRICTED = new SimpleBooleanProperty(false);
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);
}
}
});
}
private final ObservableList<AccountListItem> items;
/// The account entries derived from the configured accounts.
private final @UnmodifiableView ObservableList<AccountListItem> items;
/// The account management navigation state.
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("account.manage")));
/// The configured accounts displayed in the content area.
private final ListProperty<Account> accounts = new SimpleListProperty<>(this, "accounts", FXCollections.observableArrayList());
/// The configured authentication servers displayed in the sidebar.
private final ListProperty<AuthlibInjectorServer> authServers = new SimpleListProperty<>(this, "authServers", FXCollections.observableArrayList());
private final ObjectProperty<Account> selectedAccount;
/// The selected account, or null when no entry is selected.
private final ObjectProperty<@Nullable Account> selectedAccount;
private final WeakListenerHolder holder = new WeakListenerHolder();
/// Creates the account lists, sidebar actions, and live view bindings.
public AccountListPage() {
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"));
microsoftItem.setLeftIcon(SVG.MICROSOFT);
microsoftItem.setOnAction(e -> {
if (SettingsManager.isUserGameAccountsReadOnly()) {
confirmOverwriteUserAccounts(() -> Controllers.dialog(new MicrosoftAccountLoginPane()));
} else {
Controllers.dialog(new MicrosoftAccountLoginPane());
}
});
InvalidationListener buildSidebarListener = observable -> {
getSidebar().clear();
getSidebar()
.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");
item.setLeftIcon(SVG.DRESSER);
item.setRightAction(SVG.CLOSE, () -> {
if (SettingsManager.isAuthlibInjectorServersReadOnly()) {
confirmOverwriteAuthlibInjectorServers(() -> confirmRemoveAuthlibInjectorServer(server));
} else {
confirmRemoveAuthlibInjectorServer(server);
}
});
var title = BindingMapping.of(server, AuthlibInjectorServer::getName);
item.titleProperty().bind(title);
item.setSubtitle(server.getDisplayHostUrl());
Tooltip tooltip = new Tooltip();
tooltip.textProperty().bind(Bindings.format("%s (%s)", title, server.getUrl()));
item.setOnAction(e -> openCreateAccount(Accounts.getAccountFactoryByAuthlibInjectorServer(server)));
FXUtils.installFastTooltip(item, tooltip);
getSidebar().add(item);
}
};
buildSidebarListener.invalidated(null);
WeakInvalidationListener weakBuildSidebarListener = holder.weak(buildSidebarListener);
authServers.addListener(weakBuildSidebarListener);
AdvancedListItem addAuthServerItem = new AdvancedListItem();
addAuthServerItem.getStyleClass().add("navigation-drawer-item");
addAuthServerItem.setTitle(i18n("account.injector.add"));
addAuthServerItem.setSubtitle(i18n("account.methods.authlib_injector"));
addAuthServerItem.setLeftIcon(SVG.ADD_CIRCLE);
addAuthServerItem.setOnAction(e -> {
if (SettingsManager.isAuthlibInjectorServersReadOnly()) {
confirmOverwriteAuthlibInjectorServers(
() -> Controllers.dialog(new AddAuthlibInjectorServerPane()));
} else {
Controllers.dialog(new AddAuthlibInjectorServerPane());
}
});
setSidebarBottom(addAuthServerItem);
ScrollPane scrollPane = new ScrollPane();
VBox list = new VBox();
scrollPane.setFitToWidth(true);
list.maxWidthProperty().bind(scrollPane.widthProperty());
list.setSpacing(10);
list.getStyleClass().add("card-list");
Bindings.bindContent(list.getChildren(), items);
scrollPane.setContent(list);
FXUtils.smoothScrolling(scrollPane);
setContent(scrollPane);
}
public ObjectProperty<Account> selectedAccountProperty() {
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;
}
/// Returns the configured accounts displayed by this page.
public ListProperty<Account> accountsProperty() {
return accounts;
}
/// Returns the account management navigation state.
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state.getReadOnlyProperty();
}
/// Returns the authentication servers displayed in the sidebar.
public ListProperty<AuthlibInjectorServer> authServersProperty() {
return authServers;
}
@Override
protected Skin<?> createDefaultSkin() {
return new AccountListPageSkin(this);
/// Confirms overwriting the user account files before continuing the account operation.
private static void confirmOverwriteUserAccounts(Runnable action) {
Controllers.confirmBackupAndOverwrite(i18n("account.storage.read_only"), () -> {
SettingsManager.forceOverwriteUserGameAccounts();
action.run();
});
}
private static class AccountListPageSkin extends DecoratorAnimatedPageSkin<AccountListPage> {
/// Confirms overwriting the authlib-injector server list before continuing the server operation.
private static void confirmOverwriteAuthlibInjectorServers(Runnable action) {
Controllers.confirmBackupAndOverwrite(i18n("account.injector.server.storage.read_only"), () -> {
SettingsManager.forceOverwriteAuthlibInjectorServers();
action.run();
});
}
private final ObservableList<AdvancedListItem> authServerItems;
private ChangeListener<Boolean> holder;
public AccountListPageSkin(AccountListPage skinnable) {
super(skinnable);
{
VBox boxMethods = new VBox();
{
boxMethods.getStyleClass().add("advanced-list-box-content");
FXUtils.setLimitWidth(boxMethods, 200);
AdvancedListItem microsoftItem = new AdvancedListItem();
microsoftItem.getStyleClass().add("navigation-drawer-item");
microsoftItem.setTitle(i18n("account.methods.microsoft"));
microsoftItem.setLeftIcon(SVG.MICROSOFT);
microsoftItem.setOnAction(e -> {
if (SettingsManager.isUserGameAccountsReadOnly()) {
confirmOverwriteUserAccounts(() -> Controllers.dialog(new MicrosoftAccountLoginPane()));
} else {
Controllers.dialog(new MicrosoftAccountLoginPane());
}
});
AdvancedListItem offlineItem = new AdvancedListItem();
offlineItem.getStyleClass().add("navigation-drawer-item");
offlineItem.setTitle(i18n("account.methods.offline"));
offlineItem.setLeftIcon(SVG.PERSON);
offlineItem.setOnAction(e -> {
if (SettingsManager.isUserGameAccountsReadOnly()) {
confirmOverwriteUserAccounts(() -> Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_OFFLINE)));
} else {
Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_OFFLINE));
}
});
VBox boxAuthServers = new VBox();
authServerItems = MappedObservableList.create(skinnable.authServersProperty(), server -> {
AdvancedListItem item = new AdvancedListItem();
item.getStyleClass().add("navigation-drawer-item");
item.setLeftIcon(SVG.DRESSER);
item.setOnAction(e -> {
if (SettingsManager.isUserGameAccountsReadOnly()) {
confirmOverwriteUserAccounts(() -> Controllers.dialog(new CreateAccountPane(server)));
} else {
Controllers.dialog(new CreateAccountPane(server));
}
});
item.setRightAction(SVG.CLOSE, () -> {
if (SettingsManager.isAuthlibInjectorServersReadOnly()) {
confirmOverwriteAuthlibInjectorServers(() -> confirmRemoveAuthlibInjectorServer(skinnable, server));
} else {
confirmRemoveAuthlibInjectorServer(skinnable, server);
}
});
ObservableValue<String> title = BindingMapping.of(server, AuthlibInjectorServer::getName);
item.titleProperty().bind(title);
item.setSubtitle(server.getDisplayHostUrl());
Tooltip tooltip = new Tooltip();
tooltip.textProperty().bind(Bindings.format("%s (%s)", title, server.getUrl()));
FXUtils.installFastTooltip(item, tooltip);
return item;
});
Bindings.bindContent(boxAuthServers.getChildren(), authServerItems);
ClassTitle title = new ClassTitle(i18n("account.create").toUpperCase(Locale.ROOT));
if (RESTRICTED.get()) {
VBox wrapper = new VBox(offlineItem, boxAuthServers);
wrapper.setPadding(Insets.EMPTY);
FXUtils.installFastTooltip(wrapper, i18n("account.login.restricted"));
offlineItem.setDisable(true);
boxAuthServers.setDisable(true);
boxMethods.getChildren().setAll(title, microsoftItem, wrapper);
holder = FXUtils.onWeakChange(RESTRICTED, value -> {
if (!value) {
holder = null;
offlineItem.setDisable(false);
boxAuthServers.setDisable(false);
boxMethods.getChildren().setAll(title, microsoftItem, offlineItem, boxAuthServers);
}
});
} else {
boxMethods.getChildren().setAll(title, microsoftItem, offlineItem, boxAuthServers);
}
}
AdvancedListItem addAuthServerItem = new AdvancedListItem();
{
addAuthServerItem.getStyleClass().add("navigation-drawer-item");
addAuthServerItem.setTitle(i18n("account.injector.add"));
addAuthServerItem.setSubtitle(i18n("account.methods.authlib_injector"));
addAuthServerItem.setLeftIcon(SVG.ADD_CIRCLE);
addAuthServerItem.setOnAction(e -> {
if (SettingsManager.isAuthlibInjectorServersReadOnly()) {
confirmOverwriteAuthlibInjectorServers(
() -> Controllers.dialog(new AddAuthlibInjectorServerPane()));
} else {
Controllers.dialog(new AddAuthlibInjectorServerPane());
}
});
VBox.setMargin(addAuthServerItem, new Insets(0, 0, 12, 0));
}
ScrollPane scrollPane = new ScrollPane(boxMethods);
VBox.setVgrow(scrollPane, Priority.ALWAYS);
setLeft(scrollPane, addAuthServerItem);
}
ScrollPane scrollPane = new ScrollPane();
VBox list = new VBox();
{
scrollPane.setFitToWidth(true);
list.maxWidthProperty().bind(scrollPane.widthProperty());
list.setSpacing(10);
list.getStyleClass().add("card-list");
Bindings.bindContent(list.getChildren(), skinnable.items);
scrollPane.setContent(list);
FXUtils.smoothScrolling(scrollPane);
setCenter(scrollPane);
}
}
/// Confirms overwriting the user account files before continuing the account operation.
private static void confirmOverwriteUserAccounts(Runnable action) {
Controllers.confirmBackupAndOverwrite(i18n("account.storage.read_only"), () -> {
SettingsManager.forceOverwriteUserGameAccounts();
action.run();
});
}
/// Confirms overwriting the authlib-injector server list before continuing the server operation.
private static void confirmOverwriteAuthlibInjectorServers(Runnable action) {
Controllers.confirmBackupAndOverwrite(i18n("account.injector.server.storage.read_only"), () -> {
SettingsManager.forceOverwriteAuthlibInjectorServers();
action.run();
});
}
/// Asks the user to confirm removing an authlib-injector server.
private static void confirmRemoveAuthlibInjectorServer(
AccountListPage skinnable,
AuthlibInjectorServer server) {
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> {
skinnable.authServersProperty().remove(server);
}, null);
}
/// Asks the user to confirm removing an authlib-injector server.
private void confirmRemoveAuthlibInjectorServer(AuthlibInjectorServer server) {
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> {
authServers.remove(server);
}, null);
}
}
@@ -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,17 +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);
} catch (IllegalArgumentException e) {
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;
@@ -342,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();
}
}
@@ -7,7 +7,16 @@ import javafx.scene.Node;
import javafx.scene.layout.Pane;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
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_DURATION_HALF;
import static org.jackhuang.hmcl.ui.animation.TransitionPane.DEFAULT_INTERPOLATOR;
import static org.jackhuang.hmcl.ui.animation.TransitionPane.isCacheAllowed;
/// Supplies reusable page replacement animations that honor each cache target's transition preference.
@NotNullByDefault
public enum ContainerAnimations implements TransitionPane.AnimationProducer {
NONE {
@Override
@@ -16,10 +25,10 @@ public enum ContainerAnimations implements TransitionPane.AnimationProducer {
AnimationUtils.reset(nextNode, true);
}
/// Creates an empty transition.
@Override
public Timeline animate(
Pane container, Node previousNode, Node nextNode,
Duration duration, Interpolator interpolator) {
Pane container, Node previousNode, Node nextNode) {
return new Timeline();
}
@@ -29,21 +38,20 @@ public enum ContainerAnimations implements TransitionPane.AnimationProducer {
}
},
/**
* A fade between the old and new view
*/
/// A fade between the old and new view
FADE {
/// Fades between pages while temporarily caching the incoming page.
@Override
public Timeline animate(
Pane container, Node previousNode, Node nextNode,
Duration duration, Interpolator interpolator) {
public Timeline animate(Pane container, Node previousNode, Node nextNode) {
return new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(previousNode.opacityProperty(), 1, interpolator),
new KeyValue(nextNode.opacityProperty(), 0, interpolator)),
new KeyFrame(duration,
new KeyValue(previousNode.opacityProperty(), 0, interpolator),
new KeyValue(nextNode.opacityProperty(), 1, interpolator)));
new KeyValue(nextNode.cacheProperty(), isCacheAllowed(nextNode), Interpolator.DISCRETE),
new KeyValue(previousNode.opacityProperty(), 1, DEFAULT_INTERPOLATOR),
new KeyValue(nextNode.opacityProperty(), 0, DEFAULT_INTERPOLATOR)),
new KeyFrame(DEFAULT_DURATION,
new KeyValue(nextNode.cacheProperty(), nextNode.isCache(), Interpolator.DISCRETE),
new KeyValue(previousNode.opacityProperty(), 0, DEFAULT_INTERPOLATOR),
new KeyValue(nextNode.opacityProperty(), 1, DEFAULT_INTERPOLATOR)));
}
@Override
@@ -52,9 +60,7 @@ public enum ContainerAnimations implements TransitionPane.AnimationProducer {
}
},
/**
* A swipe effect
*/
/// A swipe effect
SWIPE_LEFT {
@Override
public void init(TransitionPane container, Node previousNode, Node nextNode) {
@@ -63,16 +69,17 @@ public enum ContainerAnimations implements TransitionPane.AnimationProducer {
nextNode.setTranslateX(container.getWidth());
}
/// Slides pages left while temporarily caching the incoming page.
@Override
public Timeline animate(
Pane container, Node previousNode, Node nextNode,
Duration duration, Interpolator interpolator) {
public Timeline animate(Pane container, Node previousNode, Node nextNode) {
return new Timeline(new KeyFrame(Duration.ZERO,
new KeyValue(nextNode.translateXProperty(), container.getWidth(), interpolator),
new KeyValue(previousNode.translateXProperty(), 0, interpolator)),
new KeyFrame(duration,
new KeyValue(nextNode.translateXProperty(), 0, interpolator),
new KeyValue(previousNode.translateXProperty(), -container.getWidth(), interpolator)));
new KeyValue(nextNode.cacheProperty(), isCacheAllowed(nextNode), Interpolator.DISCRETE),
new KeyValue(nextNode.translateXProperty(), container.getWidth(), DEFAULT_INTERPOLATOR),
new KeyValue(previousNode.translateXProperty(), 0, DEFAULT_INTERPOLATOR)),
new KeyFrame(DEFAULT_DURATION,
new KeyValue(nextNode.cacheProperty(), nextNode.isCache(), Interpolator.DISCRETE),
new KeyValue(nextNode.translateXProperty(), 0, DEFAULT_INTERPOLATOR),
new KeyValue(previousNode.translateXProperty(), -container.getWidth(), DEFAULT_INTERPOLATOR)));
}
@Override
@@ -81,9 +88,7 @@ public enum ContainerAnimations implements TransitionPane.AnimationProducer {
}
},
/**
* A swipe effect
*/
/// A swipe effect
SWIPE_RIGHT {
@Override
public void init(TransitionPane container, Node previousNode, Node nextNode) {
@@ -92,16 +97,17 @@ public enum ContainerAnimations implements TransitionPane.AnimationProducer {
nextNode.setTranslateX(-container.getWidth());
}
/// Slides pages right while temporarily caching the incoming page.
@Override
public Timeline animate(
Pane container, Node previousNode, Node nextNode,
Duration duration, Interpolator interpolator) {
public Timeline animate(Pane container, Node previousNode, Node nextNode) {
return new Timeline(new KeyFrame(Duration.ZERO,
new KeyValue(nextNode.translateXProperty(), -container.getWidth(), interpolator),
new KeyValue(previousNode.translateXProperty(), 0, interpolator)),
new KeyFrame(duration,
new KeyValue(nextNode.translateXProperty(), 0, interpolator),
new KeyValue(previousNode.translateXProperty(), container.getWidth(), interpolator)));
new KeyValue(nextNode.cacheProperty(), isCacheAllowed(nextNode), Interpolator.DISCRETE),
new KeyValue(nextNode.translateXProperty(), -container.getWidth(), DEFAULT_INTERPOLATOR),
new KeyValue(previousNode.translateXProperty(), 0, DEFAULT_INTERPOLATOR)),
new KeyFrame(DEFAULT_DURATION,
new KeyValue(nextNode.cacheProperty(), nextNode.isCache(), Interpolator.DISCRETE),
new KeyValue(nextNode.translateXProperty(), 0, DEFAULT_INTERPOLATOR),
new KeyValue(previousNode.translateXProperty(), container.getWidth(), DEFAULT_INTERPOLATOR)));
}
@Override
@@ -112,25 +118,26 @@ public enum ContainerAnimations implements TransitionPane.AnimationProducer {
/// @see <a href="https://m3.material.io/styles/motion/transitions/transition-patterns">Transitions - Material Design 3</a>
FORWARD {
/// Animates forward navigation with temporary incoming-page caching.
@Override
public Timeline animate(
Pane container, Node previousNode, Node nextNode,
Duration duration, Interpolator interpolator) {
double offset = container.getWidth() > 0 ? container.getWidth() * 0.2 : 50;
public Timeline animate(Pane container, Node previousNode, Node nextNode) {
double offset = 12.0;
return new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(previousNode.translateXProperty(), 0, interpolator),
new KeyValue(previousNode.opacityProperty(), 1, interpolator),
new KeyValue(nextNode.opacityProperty(), 0, interpolator)),
new KeyFrame(duration.multiply(0.5),
new KeyValue(previousNode.translateXProperty(), -offset, interpolator),
new KeyValue(previousNode.opacityProperty(), 0, interpolator),
new KeyValue(nextNode.cacheProperty(), isCacheAllowed(nextNode), Interpolator.DISCRETE),
new KeyValue(previousNode.translateXProperty(), 0, DEFAULT_INTERPOLATOR),
new KeyValue(previousNode.opacityProperty(), 1, DEFAULT_INTERPOLATOR),
new KeyValue(nextNode.opacityProperty(), 0, DEFAULT_INTERPOLATOR)),
new KeyFrame(DEFAULT_DURATION_HALF,
new KeyValue(previousNode.translateXProperty(), -offset, DEFAULT_INTERPOLATOR),
new KeyValue(previousNode.opacityProperty(), 0, DEFAULT_INTERPOLATOR),
new KeyValue(nextNode.opacityProperty(), 0, interpolator),
new KeyValue(nextNode.translateXProperty(), offset, interpolator)),
new KeyFrame(duration,
new KeyValue(nextNode.opacityProperty(), 1, interpolator),
new KeyValue(nextNode.translateXProperty(), 0, interpolator))
new KeyValue(nextNode.opacityProperty(), 0, DEFAULT_INTERPOLATOR),
new KeyValue(nextNode.translateXProperty(), offset, DEFAULT_INTERPOLATOR)),
new KeyFrame(DEFAULT_DURATION,
new KeyValue(nextNode.cacheProperty(), nextNode.isCache(), Interpolator.DISCRETE),
new KeyValue(nextNode.opacityProperty(), 1, DEFAULT_INTERPOLATOR),
new KeyValue(nextNode.translateXProperty(), 0, DEFAULT_INTERPOLATOR))
);
}
@@ -142,25 +149,26 @@ public enum ContainerAnimations implements TransitionPane.AnimationProducer {
/// @see <a href="https://m3.material.io/styles/motion/transitions/transition-patterns">Transitions - Material Design 3</a>
BACKWARD {
/// Animates backward navigation with temporary incoming-page caching.
@Override
public Timeline animate(
Pane container, Node previousNode, Node nextNode,
Duration duration, Interpolator interpolator) {
double offset = container.getWidth() > 0 ? container.getWidth() * 0.2 : 50;
public Timeline animate(Pane container, Node previousNode, Node nextNode) {
double offset = 12.0;
return new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(previousNode.translateXProperty(), 0, interpolator),
new KeyValue(previousNode.opacityProperty(), 1, interpolator),
new KeyValue(nextNode.opacityProperty(), 0, interpolator)),
new KeyFrame(duration.multiply(0.5),
new KeyValue(previousNode.translateXProperty(), offset, interpolator),
new KeyValue(previousNode.opacityProperty(), 0, interpolator),
new KeyValue(nextNode.cacheProperty(), isCacheAllowed(nextNode), Interpolator.DISCRETE),
new KeyValue(previousNode.translateXProperty(), 0, DEFAULT_INTERPOLATOR),
new KeyValue(previousNode.opacityProperty(), 1, DEFAULT_INTERPOLATOR),
new KeyValue(nextNode.opacityProperty(), 0, DEFAULT_INTERPOLATOR)),
new KeyFrame(DEFAULT_DURATION_HALF,
new KeyValue(previousNode.translateXProperty(), offset, DEFAULT_INTERPOLATOR),
new KeyValue(previousNode.opacityProperty(), 0, DEFAULT_INTERPOLATOR),
new KeyValue(nextNode.opacityProperty(), 0, interpolator),
new KeyValue(nextNode.translateXProperty(), -offset, interpolator)),
new KeyFrame(duration,
new KeyValue(nextNode.opacityProperty(), 1, interpolator),
new KeyValue(nextNode.translateXProperty(), 0, interpolator))
new KeyValue(nextNode.opacityProperty(), 0, DEFAULT_INTERPOLATOR),
new KeyValue(nextNode.translateXProperty(), -offset, DEFAULT_INTERPOLATOR)),
new KeyFrame(DEFAULT_DURATION,
new KeyValue(nextNode.cacheProperty(), nextNode.isCache(), Interpolator.DISCRETE),
new KeyValue(nextNode.opacityProperty(), 1, DEFAULT_INTERPOLATOR),
new KeyValue(nextNode.translateXProperty(), 0, DEFAULT_INTERPOLATOR))
);
}
@@ -170,28 +178,6 @@ public enum ContainerAnimations implements TransitionPane.AnimationProducer {
}
},
/// Imitates the animation when switching tabs in the Windows 11 Settings interface
SLIDE_UP_FADE_IN {
@Override
public Timeline animate(
Pane container, Node previousNode, Node nextNode,
Duration duration, Interpolator interpolator) {
double offset = container.getHeight() > 0 ? container.getHeight() * 0.2 : 50;
return new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(previousNode.translateYProperty(), 0, interpolator),
new KeyValue(previousNode.opacityProperty(), 1, interpolator),
new KeyValue(nextNode.opacityProperty(), 0, interpolator),
new KeyValue(nextNode.translateYProperty(), offset, interpolator)),
new KeyFrame(duration.multiply(0.5),
new KeyValue(previousNode.opacityProperty(), 0, interpolator)),
new KeyFrame(duration,
new KeyValue(nextNode.opacityProperty(), 1, interpolator),
new KeyValue(nextNode.translateYProperty(), 0, interpolator))
);
}
},
NAVIGATION {
/// Also clears the horizontal offsets of the target page's navigation panels.
@Override
@@ -203,43 +189,63 @@ public enum ContainerAnimations implements TransitionPane.AnimationProducer {
}
}
/// Animates navigation panels, respecting the content's cache preference for each center panel.
/// Restores the panels' original cache states at the end.
@Override
public Animation animate(Pane container, Node previousNode, Node nextNode, Duration duration, Interpolator interpolator) {
public Animation animate(Pane container, Node previousNode, Node nextNode) {
Timeline timeline = new Timeline();
Duration halfDuration = duration.divide(2);
timeline.getKeyFrames().add(new KeyFrame(Duration.ZERO,
new KeyValue(previousNode.opacityProperty(), 1, interpolator)));
timeline.getKeyFrames().add(new KeyFrame(halfDuration,
new KeyValue(previousNode.opacityProperty(), 0, interpolator)));
new KeyValue(previousNode.opacityProperty(), 1, DEFAULT_INTERPOLATOR)));
timeline.getKeyFrames().add(new KeyFrame(DEFAULT_DURATION_HALF,
new KeyValue(previousNode.opacityProperty(), 0, DEFAULT_INTERPOLATOR)));
if (previousNode instanceof DecoratorAnimatedPage prevPage) {
Node left = prevPage.getLeft();
Node center = prevPage.getCenter();
@Nullable Node content = prevPage.getContent();
boolean cacheCenter = isCacheAllowed(center) && (content == null || isCacheAllowed(content));
timeline.getKeyFrames().add(new KeyFrame(Duration.ZERO,
new KeyValue(left.translateXProperty(), 0, interpolator),
new KeyValue(center.translateXProperty(), 0, interpolator)));
timeline.getKeyFrames().add(new KeyFrame(halfDuration,
new KeyValue(left.translateXProperty(), -30, interpolator),
new KeyValue(center.translateXProperty(), 30, interpolator)));
new KeyValue(left.cacheProperty(), isCacheAllowed(left), Interpolator.DISCRETE),
new KeyValue(center.cacheProperty(), cacheCenter, Interpolator.DISCRETE),
new KeyValue(left.translateXProperty(), 0, DEFAULT_INTERPOLATOR),
new KeyValue(center.translateXProperty(), 0, DEFAULT_INTERPOLATOR)));
timeline.getKeyFrames().add(new KeyFrame(DEFAULT_DURATION_HALF,
new KeyValue(left.translateXProperty(), -30, DEFAULT_INTERPOLATOR),
new KeyValue(center.translateXProperty(), 30, DEFAULT_INTERPOLATOR)));
timeline.getKeyFrames().add(new KeyFrame(DEFAULT_DURATION,
new KeyValue(left.cacheProperty(), left.isCache(), Interpolator.DISCRETE),
new KeyValue(center.cacheProperty(), center.isCache(), Interpolator.DISCRETE)));
}
timeline.getKeyFrames().add(new KeyFrame(Duration.ZERO,
new KeyValue(nextNode.opacityProperty(), 0, interpolator)));
timeline.getKeyFrames().add(new KeyFrame(halfDuration,
new KeyValue(nextNode.opacityProperty(), 0, interpolator)));
timeline.getKeyFrames().add(new KeyFrame(duration,
new KeyValue(nextNode.opacityProperty(), 1, interpolator)));
new KeyValue(nextNode.opacityProperty(), 0, DEFAULT_INTERPOLATOR)));
timeline.getKeyFrames().add(new KeyFrame(DEFAULT_DURATION_HALF,
new KeyValue(nextNode.opacityProperty(), 0, DEFAULT_INTERPOLATOR)));
timeline.getKeyFrames().add(new KeyFrame(DEFAULT_DURATION,
new KeyValue(nextNode.opacityProperty(), 1, DEFAULT_INTERPOLATOR)));
if (nextNode instanceof DecoratorAnimatedPage nextPage) {
Node left = nextPage.getLeft();
Node center = nextPage.getCenter();
@Nullable Node content = nextPage.getContent();
boolean cacheCenter = isCacheAllowed(center) && (content == null || isCacheAllowed(content));
timeline.getKeyFrames().add(new KeyFrame(halfDuration,
new KeyValue(left.translateXProperty(), -30, interpolator),
new KeyValue(center.translateXProperty(), 30, interpolator)));
timeline.getKeyFrames().add(new KeyFrame(duration,
new KeyValue(left.translateXProperty(), 0, interpolator),
new KeyValue(center.translateXProperty(), 0, interpolator)));
timeline.getKeyFrames().add(new KeyFrame(Duration.ZERO,
new KeyValue(left.cacheProperty(), isCacheAllowed(left), Interpolator.DISCRETE),
new KeyValue(center.cacheProperty(), cacheCenter, Interpolator.DISCRETE)));
timeline.getKeyFrames().add(new KeyFrame(DEFAULT_DURATION_HALF,
new KeyValue(left.translateXProperty(), -30, DEFAULT_INTERPOLATOR),
new KeyValue(center.translateXProperty(), 30, DEFAULT_INTERPOLATOR)));
timeline.getKeyFrames().add(new KeyFrame(DEFAULT_DURATION,
new KeyValue(left.cacheProperty(), left.isCache(), Interpolator.DISCRETE),
new KeyValue(center.cacheProperty(), center.isCache(), Interpolator.DISCRETE),
new KeyValue(left.translateXProperty(), 0, DEFAULT_INTERPOLATOR),
new KeyValue(center.translateXProperty(), 0, DEFAULT_INTERPOLATOR)));
} else {
timeline.getKeyFrames().addAll(
new KeyFrame(Duration.ZERO,
new KeyValue(nextNode.cacheProperty(), isCacheAllowed(nextNode), Interpolator.DISCRETE)),
new KeyFrame(DEFAULT_DURATION,
new KeyValue(nextNode.cacheProperty(), nextNode.isCache(), Interpolator.DISCRETE)));
}
return timeline;
@@ -6,7 +6,6 @@ import javafx.animation.Animation;
import javafx.animation.AnimationTimer;
import javafx.animation.Interpolator;
import javafx.application.Platform;
import javafx.scene.CacheHint;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.layout.Pane;
@@ -14,6 +13,7 @@ import javafx.scene.layout.StackPane;
import javafx.stage.Window;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.IdentityPropertyKey;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
@@ -22,6 +22,41 @@ import org.jetbrains.annotations.Nullable;
@NotNullByDefault
public class TransitionPane extends StackPane {
/// The default easing curve for page transitions.
public static final Interpolator DEFAULT_INTERPOLATOR = Motion.EASE_OUT_CUBIC;
/// The default duration for page transitions.
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);
/// Stores an explicit opt-out from bitmap caching during transitions.
private static final IdentityPropertyKey<Boolean> CACHE_ALLOWED_KEY = new IdentityPropertyKey<>(TransitionPane.class, "CACHE_ALLOWED_KEY");
/// Returns this node's transition cache preference, defaulting to true without allocating properties.
public static boolean isCacheAllowed(Node node) {
return !node.hasProperties() || !Boolean.FALSE.equals(node.getProperties().get(CACHE_ALLOWED_KEY));
}
/// Sets this node's transition cache preference without changing its cache property.
/// Passing true removes the override without allocating properties. Animations read the preference
/// when constructed; it is not inherited by ancestors or descendants.
public static void setCacheAllowed(Node node, boolean allowed) {
if (allowed) {
if (node.hasProperties())
node.getProperties().remove(CACHE_ALLOWED_KEY);
} else {
node.getProperties().put(CACHE_ALLOWED_KEY, Boolean.FALSE);
}
}
/// Marks pages that have completed a rendering preparation or entered their displayed state.
private static final IdentityPropertyKey<Boolean> RENDER_INITIALIZED_KEY = new IdentityPropertyKey<>(TransitionPane.class, "RENDER_INITIALIZED_KEY");
/// Keeps the first page render active while contributing negligible visible opacity.
private static final double PREPARATION_OPACITY = 0.00001;
/// The requested page, or `null` when content is absent.
private @Nullable Node currentNode;
@@ -31,7 +66,7 @@ public class TransitionPane extends StackPane {
/// The hidden preparation container, or `null` when no candidate is retained.
private @Nullable StackPane preloadPane;
/// Whether the candidate must survive the next content layout pass.
/// Whether the candidate is waiting for its first content layout pass.
private boolean preloadLayoutPending;
/// Creates an empty container that clips content to its bounds.
@@ -58,12 +93,12 @@ public class TransitionPane extends StackPane {
}
}
/// Attaches unowned content for hidden CSS and layout prewarming during scene pulses.
/// Attaches unowned content for hidden CSS and layout prewarming.
/// Replaces any previous candidate without changing the current content. The hidden
/// container uses a snapshot of this pane's content-area size and does not affect
/// its size requirements. It is retained through the next content layout pass
/// and released on a later pass. Any call to [#setContent(Node, AnimationProducer)]
/// cancels pending prewarming and releases the hidden container.
/// its size requirements. The content is detached after the layout pass.
/// Any call to [#setContent(Node, AnimationProducer)] cancels pending prewarming
/// and releases the hidden container.
/// The caller must retain the content for subsequent use. This method does not wait
/// for prewarming to finish and must be called on the JavaFX Application Thread.
///
@@ -74,14 +109,12 @@ public class TransitionPane extends StackPane {
if (content.getParent() != null || content.getScene() != null) {
throw new IllegalArgumentException("Content already belongs to a parent or scene");
}
if (preloadPane == null) {
preloadPane = new StackPane();
preloadPane.setVisible(false);
preloadPane.setManaged(false);
preloadPane.setMouseTransparent(true);
getChildren().add(0, preloadPane);
}
preloadPane.getChildren().setAll(content);
disposePreloadPane();
preloadPane = new StackPane(content);
preloadPane.setVisible(false);
preloadPane.setManaged(false);
preloadPane.setMouseTransparent(true);
getChildren().add(0, preloadPane);
preloadPane.resizeRelocate(snappedLeftInset(), snappedTopInset(),
Math.max(0, getWidth() - snappedLeftInset() - snappedRightInset()),
Math.max(0, getHeight() - snappedTopInset() - snappedBottomInset()));
@@ -90,18 +123,29 @@ public class TransitionPane extends StackPane {
return content;
}
/// Lays out displayed pages and releases a candidate retained from an earlier pass.
/// Releases the preparation container after its descendants have been laid out.
@Override
protected void layoutChildren() {
super.layoutChildren();
if (preloadPane != null && getScene() != null) {
// Descendant layout follows this method; keep a new candidate attached
// through that traversal instead of treating this callback as completion.
if (preloadLayoutPending) {
preloadLayoutPending = false;
} else {
disposePreloadPane();
}
@Nullable Scene scene = getScene();
if (preloadPane != null && scene != null && preloadLayoutPending) {
preloadLayoutPending = false;
StackPane candidate = preloadPane;
Platform.runLater(() -> {
if (preloadPane == candidate) {
disposePreloadPane();
}
});
}
}
/// Suppresses future prewarming after a page enters its displayed state in a showing window.
private static void markDisplayed(Node content) {
@Nullable Scene scene = content.getScene();
@Nullable Window window = scene == null ? null : scene.getWindow();
if (window != null && window.isShowing()
&& content.isVisible() && content.getOpacity() > 0) {
content.getProperties().put(RENDER_INITIALIZED_KEY, Boolean.TRUE);
}
}
@@ -125,24 +169,16 @@ public class TransitionPane extends StackPane {
}
}
/// Replaces the content using [Motion#SHORT4] and [Motion#EASE].
public final void setContent(Node newView, AnimationProducer transition) {
setContent(newView, transition, Motion.SHORT4);
}
/// Replaces the content using the given duration and [Motion#EASE].
public final void setContent(Node newView, AnimationProducer transition, Duration duration) {
setContent(newView, transition, duration, Motion.EASE);
}
/// Replaces the current page, cancelling any preceding transition.
/// Animated changes wait for a scene CSS and layout pass; mouse input is
/// Animated changes wait for CSS, layout, and first-display rendering preparation; mouse input is
/// suppressed while pending or running. Detaching the container or hiding
/// its window cancels the transition. Cancellation applies the end state
/// of a started animation with a finite duration and restores the target page
/// through [AnimationProducer#finish(Node)], even if playback has not started.
public void setContent(Node newView, AnimationProducer transition,
Duration duration, Interpolator interpolator) {
/// Previously undisplayed pages initialized at zero opacity render almost transparently
/// before playback, provided they are visible and their opacity is not bound. Preparation
/// preserves initialized transforms, so offscreen content may still be culled.
public void setContent(Node newView, AnimationProducer transition) {
if (activeTransition != null) {
activeTransition.stop();
}
@@ -156,26 +192,34 @@ public class TransitionPane extends StackPane {
|| window == null || !window.isShowing()) {
transition.finish(newView);
replaceContentNodes(null, newView);
markDisplayed(newView);
return;
}
replaceContentNodes(previousNode, newView);
boolean originalCache = newView.isCache();
CacheHint originalCacheHint = newView.getCacheHint();
boolean originalMouseTransparent = isMouseTransparent();
@Nullable CacheHint cacheHint = newView instanceof Cacheable cacheable
? cacheable.getCacheHint(transition) : null;
AnimationTimer timer = new AnimationTimer() {
/// Queues playback after the scene has completed CSS and layout.
private final Runnable layoutListener = this::layoutCompleted;
/// The animation created after preparation, or `null` before playback.
private @Nullable Animation animation;
/// Whether the near-transparent render still needs a subsequent scene synchronization.
private boolean renderingPending;
/// Arms a one-shot layout notification after navigation handlers return.
@Override
public void start() {
renderingPending = !newView.getProperties().containsKey(RENDER_INITIALIZED_KEY)
&& newView.isVisible() && !newView.opacityProperty().isBound()
&& newView.getOpacity() == 0;
if (renderingPending) {
newView.setOpacity(PREPARATION_OPACITY);
}
if (!canContinue()) {
return;
}
super.start();
Platform.runLater(() -> {
if (canContinue()) {
@@ -193,7 +237,18 @@ public class TransitionPane extends StackPane {
return;
}
try {
animation = transition.animate(TransitionPane.this, previousNode, newView, duration, interpolator);
if (renderingPending) {
renderingPending = false;
// This dirty state forces the next scene synchronization to wait for
// the preceding render. Only its following runLater starts the clock.
newView.setOpacity(0);
if (canContinue()) {
scene.addPostLayoutPulseListener(layoutListener);
Platform.requestNextPulse();
}
return;
}
animation = transition.animate(TransitionPane.this, previousNode, newView);
if (activeTransition != this) {
return;
}
@@ -241,11 +296,10 @@ public class TransitionPane extends StackPane {
}
animation.stop();
}
if (cacheHint != null) {
newView.setCache(originalCache);
newView.setCacheHint(originalCacheHint);
}
transition.finish(newView);
if (animation != null) {
markDisplayed(newView);
}
getChildren().removeIf(node -> node != newView && node != preloadPane);
setMouseTransparent(originalMouseTransparent);
}
@@ -257,10 +311,6 @@ public class TransitionPane extends StackPane {
if (activeTransition != timer) {
return;
}
if (cacheHint != null) {
newView.setCache(true);
newView.setCacheHint(cacheHint);
}
timer.start();
} catch (RuntimeException | Error e) {
timer.stop();
@@ -276,9 +326,10 @@ public class TransitionPane extends StackPane {
AnimationUtils.reset(nextNode, false);
}
/// Creates the transition between the prepared pages using the given timing.
Animation animate(Pane container, Node previousNode, Node nextNode,
Duration duration, Interpolator interpolator);
/// Creates the transition between the prepared pages using implementation-selected timing and interpolators.
/// Temporary settings such as caching belong in the animation's key values, with their
/// original values restored at its finite end so cancellation can apply them by jumping there.
Animation animate(Pane container, Node previousNode, Node nextNode);
/// Restores the target page to its settled state after completion or cancellation,
/// or when animation is skipped. May be called without [#init] or [#animate]
@@ -298,14 +349,4 @@ public class TransitionPane extends StackPane {
return null;
}
}
/// Marks a node as cacheable as a bitmap during animation.
public interface Cacheable {
/// @return the [cache hint][CacheHint] to use when caching this node during the given animation,
/// or `null` to not cache it.
default @Nullable CacheHint getCacheHint(AnimationProducer animationProducer) {
// https://github.com/HMCL-dev/HMCL/issues/4789
return animationProducer == ContainerAnimations.SLIDE_UP_FADE_IN ? CacheHint.SPEED : null;
}
}
}
@@ -4,6 +4,7 @@ package org.jackhuang.hmcl.ui.construct;
import javafx.beans.property.*;
import javafx.css.CssMetaData;
import javafx.css.PseudoClass;
import javafx.css.SimpleStyleableObjectProperty;
import javafx.css.Styleable;
import javafx.css.StyleableObjectProperty;
@@ -45,9 +46,9 @@ public class AdvancedListItem extends Control {
getStyleClass().add("advanced-list-item");
}
/// Fires this item's action unless disabled, without waiting for feedback or preparation.
/// Fires this item's action unless the node or main action is disabled.
public void fire() {
if (!isDisabled())
if (!isDisabled() && !isActionDisabled())
fireEvent(new ActionEvent(this, this));
}
@@ -211,6 +212,52 @@ public class AdvancedListItem extends Control {
selectedBackgroundFill.set(value);
}
/// Marks the main action as unavailable without disabling descendant controls.
private static final PseudoClass ACTION_DISABLED = PseudoClass.getPseudoClass("action-disabled");
/// The lazily allocated main-action restriction.
private @Nullable BooleanProperty actionDisabled;
/// Returns the main-action restriction, initially false. The default skin suppresses background
/// feedback and preparation and dims the text and left graphic. Right-side controls and tooltips
/// remain interactive. The inherited disable property still disables the entire subtree.
public BooleanProperty actionDisabledProperty() {
if (actionDisabled == null) {
actionDisabled = new SimpleBooleanProperty(this, "actionDisabled") {
/// Updates styling and the default skin without disabling descendant controls.
@Override
protected void invalidated() {
boolean disabled = get();
pseudoClassStateChanged(ACTION_DISABLED, disabled);
@Nullable Node graphic = getLeftGraphic();
if (graphic != null)
graphic.pseudoClassStateChanged(ACTION_DISABLED, disabled);
@Nullable Skin<?> skin = getSkin();
if (skin instanceof AdvancedListItemSkin itemSkin)
itemSkin.updateActionDisabled();
}
};
leftGraphic.addListener((var observable, @Nullable var previous, @Nullable var current) -> {
if (previous != null)
previous.pseudoClassStateChanged(ACTION_DISABLED, false);
if (current != null)
current.pseudoClassStateChanged(ACTION_DISABLED, isActionDisabled());
});
}
return actionDisabled;
}
/// Returns whether the main action is disabled without allocating its property.
public boolean isActionDisabled() {
return actionDisabled != null && actionDisabled.get();
}
/// Sets whether the main action is disabled; setting the default does not allocate a property.
public void setActionDisabled(boolean value) {
if (actionDisabled != null || value)
actionDisabledProperty().set(value);
}
/// The handler for actions originating from this item, excluding descendant controls.
private final ObjectProperty<@Nullable EventHandler<ActionEvent>> onAction = new SimpleObjectProperty<>(this, "onAction") {
/// Routes only this item's actions to the registered handler.
@@ -60,7 +60,7 @@ public class AdvancedListItemSkin extends SkinBase<AdvancedListItem> {
registerChangeListener(skinnable.activeProperty(), observable -> updateSelection(true));
registerChangeListener(skinnable.sceneProperty(), observable -> updateSelection(false));
registerChangeListener(JFXNodeUtils.treeVisibleProperty(skinnable), observable -> updateSelection(false));
setPrepareAction(skinnable.getPrepareAction());
updateActionDisabled();
}
/// Applies the control's selected background paint without changing animation progress.
@@ -97,10 +97,17 @@ public class AdvancedListItemSkin extends SkinBase<AdvancedListItem> {
selectionTransition.playFromStart();
}
/// Forwards a registration while consuming the control's reference before user code executes.
/// Updates main-action feedback and preparation without allocating the control's property.
void updateActionDisabled() {
AdvancedListItem item = getSkinnable();
container.setPressFeedbackEnabled(!item.isActionDisabled());
setPrepareAction(item.getPrepareAction());
}
/// Suspends preparation while the main action is disabled and consumes it before execution.
void setPrepareAction(@Nullable Runnable action) {
AdvancedListItem item = getSkinnable();
container.setPrepareAction(action == null ? null : () -> {
container.setPrepareAction(action == null || item.isActionDisabled() ? null : () -> {
if (item.getPrepareAction() == action) {
item.setPrepareAction(null);
action.run();
@@ -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);
}
}
@@ -10,11 +10,13 @@ import javafx.animation.Timeline;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.binding.NumberBinding;
import javafx.event.EventHandler;
import javafx.geometry.Orientation;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.control.ScrollBar;
import javafx.scene.control.Skin;
import javafx.scene.input.ScrollEvent;
import javafx.scene.layout.Region;
import javafx.scene.shape.Rectangle;
import javafx.util.Duration;
@@ -22,28 +24,48 @@ import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jackhuang.hmcl.util.MathUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
// Referenced in root.css
/// Floating scrollbar skin selected by root.css, supporting pointer drags and direct touch positioning.
@NotNullByDefault
@SuppressWarnings("unused")
public class FloatScrollBarSkin implements Skin<ScrollBar> {
private ScrollBar scrollBar;
private Region group;
/// Owning scrollbar, cleared on disposal.
private @Nullable ScrollBar scrollBar;
/// Skin root, cleared on disposal.
private @Nullable Region group;
/// Available scrollbar track excluding its end padding.
private final Rectangle track = new Rectangle();
/// Thumb whose position follows the scrollbar value.
private final Rectangle thumb = new Rectangle();
private BooleanBinding hoverOrPressed;
private Animation thumbHoverAnimation;
/// Combined pointer feedback state, retained until disposal.
private @Nullable BooleanBinding hoverOrPressed;
/// Current feedback transition, or null when no transition is retained.
private @Nullable Animation thumbHoverAnimation;
/// Removable touch handler installed on the control so thumb, track, and background targets work.
private final EventHandler<ScrollEvent> directScrollHandler = this::handleDirectScroll;
/// Finger distance from the thumb's leading edge, or NaN outside a touch grab.
private double touchGrabOffset = Double.NaN;
/// Creates track geometry, pointer handlers, and hover feedback for the scrollbar.
public FloatScrollBarSkin(final ScrollBar scrollBar) {
this.scrollBar = scrollBar;
scrollBar.setPrefHeight(1e-18);
scrollBar.setPrefWidth(1e-18);
this.group = new Region() {
Point2D dragStart;
/// Pointer position at the start of a mouse drag, or null before pressing.
@Nullable Point2D dragStart;
/// Normalized thumb position captured when a mouse drag begins.
double preDragThumbPos;
/// Width of the scrollbar's value range.
final NumberBinding range = Bindings.subtract(scrollBar.maxProperty(), scrollBar.minProperty());
/// Current normalized scrollbar value.
final NumberBinding position = Bindings.divide(Bindings.subtract(scrollBar.valueProperty(), scrollBar.minProperty()), range);
{
@@ -63,10 +85,11 @@ public class FloatScrollBarSkin implements Skin<ScrollBar> {
thumb.setOnMousePressed(me -> {
if (me.isSynthesized()) {
// touch-screen events handled by Scroll handler
captureTouchGrab(me.getSceneX(), me.getSceneY());
me.consume();
return;
}
touchGrabOffset = Double.NaN;
/*
** if max isn't greater than min then there is nothing to do here
*/
@@ -78,6 +101,10 @@ public class FloatScrollBarSkin implements Skin<ScrollBar> {
}
});
thumb.setOnTouchPressed(event -> captureTouchGrab(event.getTouchPoint().getSceneX(), event.getTouchPoint().getSceneY()));
thumb.setOnTouchReleased(event -> touchGrabOffset = Double.NaN);
thumb.setOnMouseReleased(event -> touchGrabOffset = Double.NaN);
thumb.setOnMouseDragged(me -> {
if (me.isSynthesized()) {
@@ -113,15 +140,19 @@ public class FloatScrollBarSkin implements Skin<ScrollBar> {
});
}
/// Returns track length along the scrolling axis.
private double trackLength() {
return getSkinnable().getOrientation() == Orientation.VERTICAL ? track.getHeight() : track.getWidth();
}
/// Returns thumb length along the scrolling axis.
private double thumbLength() {
return getSkinnable().getOrientation() == Orientation.VERTICAL ? thumb.getHeight() : thumb.getWidth();
}
/// Rebinds track and thumb geometry after an orientation change.
private void setup() {
touchGrabOffset = Double.NaN;
track.widthProperty().unbind();
track.heightProperty().unbind();
@@ -179,6 +210,7 @@ public class FloatScrollBarSkin implements Skin<ScrollBar> {
}
}
/// Allows horizontal tracks to fill their owner while keeping vertical tracks narrow.
@Override
protected double computeMaxWidth(double height) {
if (scrollBar.getOrientation() == Orientation.HORIZONTAL) {
@@ -188,6 +220,7 @@ public class FloatScrollBarSkin implements Skin<ScrollBar> {
return 6.0;
}
/// Allows vertical tracks to fill their owner while keeping horizontal tracks narrow.
@Override
protected double computeMaxHeight(double width) {
if (scrollBar.getOrientation() == Orientation.VERTICAL) {
@@ -198,6 +231,7 @@ public class FloatScrollBarSkin implements Skin<ScrollBar> {
}
};
scrollBar.addEventHandler(ScrollEvent.ANY, directScrollHandler);
this.hoverOrPressed = thumb.hoverProperty().or(thumb.pressedProperty());
FXUtils.onChangeAndOperate(hoverOrPressed, newValue -> {
if (thumbHoverAnimation != null) {
@@ -228,8 +262,66 @@ public class FloatScrollBarSkin implements Skin<ScrollBar> {
});
}
/// Captures the finger offset within the thumb in track coordinates.
private void captureTouchGrab(double sceneX, double sceneY) {
touchGrabOffset = Double.NaN;
@Nullable ScrollBar bar = scrollBar;
if (bar == null || !(bar.getMax() > bar.getMin()))
return;
@Nullable Point2D point = track.sceneToLocal(sceneX, sceneY);
if (point == null)
return;
boolean vertical = bar.getOrientation() == Orientation.VERTICAL;
double travel = vertical ? track.getHeight() - thumb.getHeight() : track.getWidth() - thumb.getWidth();
double fraction = (MathUtils.clamp(bar.getValue(), bar.getMin(), bar.getMax()) - bar.getMin()) / (bar.getMax() - bar.getMin());
touchGrabOffset = (vertical ? point.getY() : point.getX()) - fraction * travel;
}
/// Drags grabbed thumbs through their travel range and positions track touches absolutely.
private void handleDirectScroll(ScrollEvent event) {
if (event.getEventType() == ScrollEvent.SCROLL_FINISHED) {
touchGrabOffset = Double.NaN;
return;
}
@Nullable ScrollBar bar = scrollBar;
if (event.getEventType() != ScrollEvent.SCROLL || !event.isDirect()
|| bar == null || bar.isDisabled() || bar.valueProperty().isBound()
|| !(bar.getMax() > bar.getMin()))
return;
if (event.isInertia()) {
event.consume();
return;
}
boolean vertical = bar.getOrientation() == Orientation.VERTICAL;
double length = vertical ? track.getHeight() : track.getWidth();
double thumbLength = vertical ? thumb.getHeight() : thumb.getWidth();
if (!(length > thumbLength))
return;
@Nullable Point2D point = track.sceneToLocal(event.getSceneX(), event.getSceneY());
if (point == null)
return;
double position = vertical ? point.getY() : point.getX();
double fraction;
// Gesture recognition can pick the track after the finger has already grabbed the thumb.
if (Double.isFinite(touchGrabOffset) || event.getTarget() == thumb) {
if (Double.isNaN(touchGrabOffset))
captureTouchGrab(event.getSceneX(), event.getSceneY());
fraction = (position - touchGrabOffset) / (length - thumbLength);
} else {
fraction = position / length;
}
if (!Double.isFinite(fraction))
return;
bar.setValue(bar.getMin() + MathUtils.clamp(fraction, 0, 1) * (bar.getMax() - bar.getMin()));
event.consume();
}
/// Removes touch handling and stops feedback animation before releasing the skin's owner.
@Override
public void dispose() {
if (scrollBar != null)
scrollBar.removeEventHandler(ScrollEvent.ANY, directScrollHandler);
touchGrabOffset = Double.NaN;
if (thumbHoverAnimation != null) {
thumbHoverAnimation.stop();
thumbHoverAnimation = null;
@@ -239,13 +331,15 @@ public class FloatScrollBarSkin implements Skin<ScrollBar> {
group = null;
}
/// Returns the skin root, or null after disposal.
@Override
public Node getNode() {
public @Nullable Node getNode() {
return group;
}
/// Returns the owning scrollbar, or null after disposal.
@Override
public ScrollBar getSkinnable() {
public @Nullable ScrollBar getSkinnable() {
return scrollBar;
}
}
@@ -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;
});
}
}
@@ -3,229 +3,150 @@
package org.jackhuang.hmcl.ui.construct;
import javafx.animation.AnimationTimer;
import javafx.animation.Interpolator;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Insets;
import javafx.scene.Group;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.shape.Circle;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
/// Draws hover, focus, and static press backgrounds with transient circle waveforms.
/// All construction, property changes, and operations must occur on the JavaFX application thread.
/// The owner supplies dimensions, input states, and lifecycle notifications; this layer installs
/// no input handlers and does not own content. Waveforms share a rounded rectangular clip;
/// the layer itself retains rectangular pointer picking, including transparent corners.
/// Solid state colors use the region background; other paints use a lazily created region
/// so their intrinsic transparency is multiplied by the state opacity.
/// State opacities follow the
/// [AndroidX Material 3 state tokens](https://github.com/androidx/androidx/blob/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/StateTokens.kt).
/// Animated presses are represented by waveforms over the existing hover or focus state;
/// presses without a waveform use a single static state layer instead.
/// Draws uniform feedback behind the content of [RipplerContainer].
/// Selection adds a persistent base tint; press takes priority over focus and hover.
/// Node opacity multiplies the paint's alpha for colors, gradients, and image patterns.
/// State changes use a constant linear rate, including interrupted and reversed transitions.
/// Picking remains rectangular, including transparent corners and zero-opacity idle states.
/// All operations must occur on the JavaFX application thread.
@NotNullByDefault
public final class InteractionLayer extends Region {
/// The hover state opacity from Material 3 StateTokens v0_210.
final class InteractionLayer extends Region {
/// The hover tint opacity.
private static final double HOVER_OPACITY = 0.08;
/// The focus state opacity from Material 3 StateTokens v0_210.
private static final double FOCUS_OPACITY = 0.10;
/// The focus tint opacity.
private static final double FOCUS_OPACITY = 0.12;
/// The pressed waveform or static press opacity from Material 3 StateTokens v0_210.
private static final double PRESSED_OPACITY = 0.10;
/// The press tint opacity.
private static final double PRESSED_OPACITY = 0.12;
/// The duration of transitions within the hover opacity range, in nanoseconds.
private static final long HOVER_DURATION = (long) (Motion.SHORT2.toMillis() * 1_000_000);
/// The persistent tint added for selection, independently of pointer or focus feedback.
private static final double SELECTED_OPACITY = 0.08;
/// The duration of other state changes and ripple release, in nanoseconds.
private static final long STATE_DURATION = (long) (Motion.SHORT4.toMillis() * 1_000_000);
/// The time per unit opacity, calibrated so idle-to-hover takes 150 ms.
private static final double NANOS_PER_OPACITY = Motion.SHORT3.toMillis() * 1_000_000 / HOVER_OPACITY;
/// The duration of ripple expansion while held, in nanoseconds.
private static final long EXPANSION_DURATION = (long) (Motion.LONG1.toMillis() * 1_000_000);
/// Whether this instance schedules real pulses; deterministic tests advance it explicitly.
/// Whether the layer advances automatically.
private final boolean automaticPulses;
/// The current waveforms, including released waves that have not finished fading.
private final ArrayList<Ripple> ripples = new ArrayList<>(0);
/// The feedback paint; null hides drawing without affecting input.
private @Nullable Paint fill = Color.BLACK;
/// The lazily created waveform group; it is attached only while waveforms exist.
private @Nullable Group waveformGroup;
/// The feedback corners, with percentages resolved against this region.
private CornerRadii cornerRadii = CornerRadii.EMPTY;
/// The shared waveform clip, detached when no waveforms remain.
private @Nullable Region waveformClip;
/// The lazily created state region for paints that cannot encode an additional alpha.
private @Nullable Region paintBackground;
/// The paint last applied to the persistent state background.
private @Nullable Paint backgroundPaint;
/// The opacity last applied to the persistent state background.
private double backgroundOpacity;
/// The corner radii last applied to the persistent state background.
private CornerRadii backgroundRadii = CornerRadii.EMPTY;
/// The currently held waveform, or null for a static press or no press.
private @Nullable Ripple activeRipple;
/// Whether state changes animate, initially taken from the loaded settings.
private boolean animationEnabled = AnimationUtils.isAnimationEnabled();
/// The lazily allocated, reusable pulse source.
private @Nullable AnimationTimer timer;
/// Whether there is unfinished animation work.
private boolean running;
/// Whether the owner reports hover.
private boolean hovered;
/// Whether the owner reports visual focus.
private boolean focusActive;
/// Whether a press has started without a release or reset.
/// Whether a press is held.
private boolean pressActive;
/// The currently displayed state opacity, excluding waveforms.
private double stateOpacity;
/// Whether the owner reports persistent selection.
private boolean selected;
/// The opacity at the beginning of the current state transition.
/// The opacity at the start of the current transition.
private double startOpacity;
/// The desired state opacity.
/// The target opacity of the current transition.
private double targetOpacity;
/// The monotonic start time of the current state transition.
/// The monotonic start timestamp of the current transition.
private long stateStart;
/// Whether the state opacity has not yet reached its target.
/// Whether the target opacity has not yet been reached.
private boolean transitioning;
/// The optional owner notification for a completed or unnecessary state transition.
/// The optional callback after a transition settles; reset does not invoke it.
private @Nullable Runnable onStateSettled;
/// Sets an owner notification delivered after publishing a settled state, independently of waves.
/// Reset does not notify; disabling animation does. Assigning a listener does not invoke it.
/// Creates feedback after settings have been loaded, using their animation preference.
InteractionLayer() {
this(true);
}
/// Creates feedback with automatic pulses or explicit advancement through [#advance(long)].
InteractionLayer(boolean automaticPulses) {
this.automaticPulses = automaticPulses;
setPickOnBounds(true);
setOpacity(0);
updateBackground();
}
/// Updates the paint without restarting the current transition.
void setFill(@Nullable Paint value) {
if (fill != value) {
fill = value;
updateBackground();
}
}
/// Updates the corners without restarting the current transition.
void setCornerRadii(CornerRadii value) {
if (!cornerRadii.equals(value)) {
cornerRadii = value;
updateBackground();
}
}
/// Applies the configured paint and corners.
private void updateBackground() {
setBackground(fill == null ? null : new Background(new BackgroundFill(fill, cornerRadii, Insets.EMPTY)));
}
/// Registers a settlement callback; null clears it. Registration does not invoke the callback.
void setOnStateSettled(@Nullable Runnable listener) {
onStateSettled = listener;
}
/// Returns whether the persistent state is still transitioning, independently of waves.
/// Returns whether the feedback is still transitioning.
boolean isStateTransitioning() {
return transitioning;
}
/// Notifies the owner after state visuals have reached their current target.
/// Notifies the owner after feedback has reached its current target.
private void notifyStateSettled() {
if (onStateSettled != null)
onStateSettled.run();
}
/// Creates an empty layer with black feedback, square corners, and the animation default from
/// [AnimationUtils#isAnimationEnabled()]. Settings must have been loaded before construction.
public InteractionLayer() {
this(true);
/// Disabling animation immediately settles feedback; enabling it does not replay a transition.
void setAnimationEnabled(boolean value) {
if (animationEnabled == value)
return;
animationEnabled = value;
if (!value) {
settle();
notifyStateSettled();
}
}
/// Creates a layer with optional automatic pulses; manual timing uses the timestamped operations.
InteractionLayer(boolean automaticPulses) {
this.automaticPulses = automaticPulses;
setPickOnBounds(true);
fill.addListener(observable -> updateVisuals());
animationEnabled.addListener(observable -> {
if (!isAnimationEnabled()) {
clearRipples();
activeRipple = null;
settle();
notifyStateSettled();
}
});
}
/// The paint applied to all feedback, including existing waveforms.
private final ObjectProperty<@Nullable Paint> fill = new SimpleObjectProperty<>(this, "fill", Color.BLACK);
/// Returns the feedback paint property. Null suppresses drawing without changing input or state.
public ObjectProperty<@Nullable Paint> fillProperty() {
return fill;
}
/// Returns the paint used for both the state layer and waveforms, or null if hidden.
public @Nullable Paint getFill() {
return fill.get();
}
/// Sets the feedback paint; its intrinsic transparency multiplies the feedback opacity.
public void setFill(@Nullable Paint value) {
fill.set(value);
}
/// The corner radii shared by the state background and waveform clip.
private final ObjectProperty<@Nullable CornerRadii> cornerRadii =
new SimpleObjectProperty<>(this, "cornerRadii", CornerRadii.EMPTY) {
/// Applies changed radii to state feedback and the existing shared clip.
@Override
protected void invalidated() {
updateWaveformClip();
updateVisuals();
}
};
/// Returns the corner radii property. Null means square corners. Percentage radii are relative
/// to this layer's dimensions; JavaFX background rendering normalizes overlapping corners.
public ObjectProperty<@Nullable CornerRadii> cornerRadiiProperty() {
return cornerRadii;
}
/// Returns the configured corner radii, or null for square corners.
public @Nullable CornerRadii getCornerRadii() {
return cornerRadii.get();
}
/// Sets the radii used by state feedback and existing waveforms without restarting animation.
public void setCornerRadii(@Nullable CornerRadii value) {
cornerRadii.set(value);
}
/// Whether subsequent state changes and presses animate.
private final BooleanProperty animationEnabled = new SimpleBooleanProperty(this, "animationEnabled", AnimationUtils.isAnimationEnabled());
/// Returns the animation switch. Disabling it clears waveforms and immediately displays
/// the current state, including a held press. Enabling it does not replay a held press;
/// its static press feedback remains until release or reset.
/// Initially uses [AnimationUtils#isAnimationEnabled()]; global settings are not observed.
public BooleanProperty animationEnabledProperty() {
return animationEnabled;
}
/// Returns whether new changes animate.
public boolean isAnimationEnabled() {
return animationEnabled.get();
}
/// Enables animation for subsequent changes, or immediately settles existing feedback.
public void setAnimationEnabled(boolean value) {
animationEnabled.set(value);
}
/// Reports hover. The idle hover opacity is 8%, below focus and static press priority.
/// An animated press draws a waveform over this state without increasing the state opacity.
public void setHovered(boolean value) {
/// Reports hover, below focus and press priority.
void setHovered(boolean value) {
setHovered(value, System.nanoTime());
}
/// Reports hover at a monotonic timestamp in nanoseconds, shared with other timed operations.
/// Reports hover at a monotonic nanosecond timestamp.
void setHovered(boolean value, long now) {
if (hovered != value) {
hovered = value;
@@ -233,13 +154,12 @@ public final class InteractionLayer extends Region {
}
}
/// Reports visual focus independently of the layer's actual focus. Focus opacity is 10%,
/// above hover priority. An animated press may draw a waveform over the focus state.
public void setFocusActive(boolean value) {
/// Reports visual focus independently of the node's actual focus.
void setFocusActive(boolean value) {
setFocusActive(value, System.nanoTime());
}
/// Reports visual focus at a monotonic timestamp in nanoseconds, shared with other timed operations.
/// Reports visual focus at a monotonic nanosecond timestamp.
void setFocusActive(boolean value, long now) {
if (focusActive != value) {
focusActive = value;
@@ -247,170 +167,114 @@ public final class InteractionLayer extends Region {
}
}
/// Starts a press at finite local coordinates. With animation enabled, a waveform with
/// up to 10% paint opacity overlays the hover or focus state, without a separate press layer.
/// With animation disabled, a single 10% state layer replaces the hover or focus state.
/// Repeated calls before release or reset have no effect. Coordinates may lie outside
/// the layer; drawing remains clipped.
///
/// @throws IllegalArgumentException if either coordinate is not finite
public void press(double x, double y) {
press(x, y, System.nanoTime());
/// Reports persistent selection without changing hover, focus, or press state.
void setSelected(boolean value) {
setSelected(value, System.nanoTime());
}
/// Starts a press at a monotonic timestamp in nanoseconds; otherwise follows [#press(double, double)].
void press(double x, double y, long now) {
if (!Double.isFinite(x) || !Double.isFinite(y))
throw new IllegalArgumentException("Press coordinates must be finite");
if (pressActive)
return;
pressActive = true;
if (isAnimationEnabled()) {
Circle circle = new Circle();
circle.setManaged(false);
circle.setMouseTransparent(true);
activeRipple = new Ripple(x, y, now, circle);
ripples.add(activeRipple);
if (waveformGroup == null) {
waveformGroup = new Group();
waveformGroup.setManaged(false);
waveformGroup.setMouseTransparent(true);
waveformClip = new Region();
updateWaveformClip();
}
if (waveformGroup.getParent() == null) {
getChildren().add(waveformGroup);
waveformGroup.setClip(waveformClip);
}
waveformGroup.getChildren().add(circle);
updateGeometry();
/// Reports selection at a monotonic nanosecond timestamp.
void setSelected(boolean value, long now) {
if (selected != value) {
selected = value;
changeState(now);
}
changeState(now);
}
/// Releases the current press. Its waveform expands and fades for 200 ms when animation
/// is enabled. Repeated calls do nothing; previously released waveforms continue fading.
public void release() {
/// Starts uniform press feedback at the shared linear rate; repeated presses have no effect.
void press() {
press(System.nanoTime());
}
/// Starts a press at a monotonic nanosecond timestamp.
void press(long now) {
if (!pressActive) {
pressActive = true;
changeState(now);
}
}
/// Releases press feedback while retaining selection, hover, and focus; repeated releases do nothing.
void release() {
release(System.nanoTime());
}
/// Releases the press at a monotonic timestamp in nanoseconds, shared with other timed operations.
/// Releases a press at a monotonic nanosecond timestamp.
void release(long now) {
if (!pressActive)
return;
updateAnimations(now);
pressActive = false;
if (activeRipple != null) {
activeRipple.releaseProgress = activeRipple.progress;
activeRipple.released = true;
activeRipple.start = now;
activeRipple = null;
if (pressActive) {
pressActive = false;
changeState(now);
}
changeState(now);
}
/// Immediately clears all waveforms and the press, stops pulses, and displays the current
/// hover or focus state. Repeated calls have no additional effect.
public void reset() {
/// Cancels the press and settles selection, hover, and focus without notifying settlement.
void reset() {
pressActive = false;
activeRipple = null;
clearRipples();
settle();
}
/// Returns the persistent state opacity, leaving animated press feedback to its waveform.
/// Adds the selected base tint to the highest-priority transient state.
private double desiredOpacity() {
// A press interrupted by disabling animation stays static even if animation is re-enabled.
if (pressActive && activeRipple == null)
return PRESSED_OPACITY;
return focusActive ? FOCUS_OPACITY : hovered ? HOVER_OPACITY : 0;
double interaction = pressActive ? PRESSED_OPACITY : focusActive ? FOCUS_OPACITY : hovered ? HOVER_OPACITY : 0;
return (selected ? SELECTED_OPACITY : 0) + interaction;
}
/// Starts a transition from the currently displayed state rather than its previous target.
/// Samples the current fade and retargets it without changing the opacity rate.
private void changeState(long now) {
updateAnimations(now);
if (!isAnimationEnabled()) {
updateAnimation(now);
if (!animationEnabled) {
settle();
notifyStateSettled();
return;
}
double target = desiredOpacity();
if (target != targetOpacity) {
startOpacity = stateOpacity;
startOpacity = getOpacity();
targetOpacity = target;
stateStart = now;
transitioning = startOpacity != targetOpacity;
setTransitioning(startOpacity != targetOpacity);
}
if (transitioning)
setRunning(true);
updateVisuals();
if (!transitioning)
notifyStateSettled();
}
/// Applies the exact static state and releases the pulse source.
/// Stops pulses and displays the current state immediately.
private void settle() {
transitioning = false;
stateOpacity = targetOpacity = desiredOpacity();
setRunning(false);
updateVisuals();
setTransitioning(false);
targetOpacity = desiredOpacity();
setOpacity(targetOpacity);
}
/// Advances all animations to a monotonic timestamp in nanoseconds, then updates their nodes.
/// The timestamp must use the same time origin as input operations.
/// Package access permits deterministic tests without exposing a public timing API.
/// Advances feedback and reports completion once. Timestamps must share the input clock's origin.
void advance(long now) {
boolean wasTransitioning = transitioning;
updateAnimations(now);
updateVisuals();
updateAnimation(now);
if (wasTransitioning && !transitioning)
notifyStateSettled();
}
/// Advances logical animation state without publishing intermediate visual changes.
private void updateAnimations(long now) {
if (transitioning) {
// Hover-only fades use a uniform opacity change in both directions.
boolean hoverOnly = startOpacity <= HOVER_OPACITY && targetOpacity <= HOVER_OPACITY;
double progress = fraction(now - stateStart, hoverOnly ? HOVER_DURATION : STATE_DURATION);
Interpolator interpolator = hoverOnly ? Interpolator.LINEAR : Motion.STANDARD;
stateOpacity = progress == 0 ? startOpacity : progress == 1 ? targetOpacity
: interpolator.interpolate(startOpacity, targetOpacity, progress);
transitioning = progress < 1;
}
boolean moving = transitioning;
for (int i = ripples.size() - 1; i >= 0; i--) {
Ripple ripple = ripples.get(i);
double progress = fraction(now - ripple.start, ripple.released ? STATE_DURATION : EXPANSION_DURATION);
if (ripple.released && progress == 1) {
ripples.remove(i);
removeWaveform(ripple.circle);
continue;
}
double eased = progress == 0 ? 0 : progress == 1 ? 1 : Motion.STANDARD.interpolate(0.0, 1.0, progress);
ripple.progress = ripple.released ? ripple.releaseProgress + (1 - ripple.releaseProgress) * eased : eased;
ripple.opacity = ripple.released ? PRESSED_OPACITY * (1 - eased) : PRESSED_OPACITY;
moving |= progress < 1;
}
setRunning(moving);
}
/// Clamps elapsed time to a unit interval, including exact animation endpoints.
private static double fraction(long elapsed, long duration) {
return Math.max(0, Math.min(1, (double) elapsed / duration));
}
/// Starts or stops the single reusable pulse source when unfinished work changes.
private void setRunning(boolean value) {
if (running == value)
/// Samples the transition without notifying the owner during an input-state change.
private void updateAnimation(long now) {
if (!transitioning)
return;
running = value;
double distance = targetOpacity - startOpacity;
long elapsed = Math.max(0, now - stateStart);
// Compare rounded clock ticks so floating-point subtraction cannot delay settlement.
boolean finished = elapsed >= Math.max(1, Math.round(Math.abs(distance) * NANOS_PER_OPACITY));
setOpacity(finished ? targetOpacity : startOpacity + Math.copySign(elapsed / NANOS_PER_OPACITY, distance));
setTransitioning(!finished);
}
/// Keeps transition status and the reusable pulse source in sync.
private void setTransitioning(boolean value) {
if (transitioning == value)
return;
transitioning = value;
if (!automaticPulses)
return;
if (value) {
if (timer == null) {
timer = new AnimationTimer() {
/// Samples the system monotonic clock used by input operations.
/// Samples the same monotonic clock used by input operations.
@Override
public void handle(long now) {
advance(System.nanoTime());
@@ -422,156 +286,4 @@ public final class InteractionLayer extends Region {
timer.stop();
}
}
/// Reports whether unfinished animation work requires another pulse.
boolean isAnimating() {
return running;
}
/// Returns the current state opacity, excluding waveforms.
double stateOpacity() {
return stateOpacity;
}
/// Returns the number of held and fading waveforms.
int rippleCount() {
return ripples.size();
}
/// Updates both dimensions before adjusting backgrounds, clipping, and waveform radii.
@Override
public void resize(double width, double height) {
if (width == getWidth() && height == getHeight())
return;
super.resize(width, height);
updateGeometry();
}
/// Applies the same background geometry to the shared clip, including per-axis percentage radii.
private void updateWaveformClip() {
if (waveformClip != null)
waveformClip.setBackground(new Background(new BackgroundFill(Color.BLACK, getCornerRadii(), Insets.EMPTY)));
}
/// Updates size-dependent geometry without restarting an existing animation.
private void updateGeometry() {
if (waveformClip != null && !ripples.isEmpty())
waveformClip.resize(getWidth(), getHeight());
if (paintBackground != null)
paintBackground.resize(getWidth(), getHeight());
for (int i = 0; i < ripples.size(); i++) {
Ripple ripple = ripples.get(i);
double dx = Math.max(Math.abs(ripple.x), Math.abs(getWidth() - ripple.x));
double dy = Math.max(Math.abs(ripple.y), Math.abs(getHeight() - ripple.y));
ripple.circle.setRadius(Math.hypot(dx, dy));
}
updateVisuals();
}
/// Publishes changed state and waveform properties without replaying drawing commands.
private void updateVisuals() {
@Nullable Paint paint = getFill();
@Nullable CornerRadii radii = getCornerRadii();
updateBackground(paint, radii == null ? CornerRadii.EMPTY : radii);
if (waveformGroup != null)
waveformGroup.setVisible(paint != null && getWidth() > 0 && getHeight() > 0);
for (int i = 0; i < ripples.size(); i++) {
Ripple ripple = ripples.get(i);
Circle circle = ripple.circle;
circle.setFill(paint);
circle.setCenterX(ripple.x);
circle.setCenterY(ripple.y);
circle.setScaleX(ripple.progress);
circle.setScaleY(ripple.progress);
circle.setOpacity(ripple.opacity);
}
}
/// Updates the persistent background only when its paint, opacity, or corner radii change.
private void updateBackground(@Nullable Paint paint, CornerRadii radii) {
boolean geometryOrPaintChanged = paint != backgroundPaint || !radii.equals(backgroundRadii);
if (!geometryOrPaintChanged && stateOpacity == backgroundOpacity)
return;
backgroundPaint = paint;
backgroundOpacity = stateOpacity;
backgroundRadii = radii;
if (paintBackground != null && (paint instanceof Color || paint == null || stateOpacity == 0)) {
getChildren().remove(paintBackground);
paintBackground.setBackground(null);
}
if (paint == null || stateOpacity == 0) {
setBackground(null);
} else if (paint instanceof Color color) {
Color tinted = new Color(color.getRed(), color.getGreen(), color.getBlue(), color.getOpacity() * stateOpacity);
setBackground(new Background(new BackgroundFill(tinted, backgroundRadii, Insets.EMPTY)));
} else {
// A node opacity also supports ImagePattern without rewriting its source pixels.
setBackground(null);
if (paintBackground == null) {
paintBackground = new Region();
paintBackground.setManaged(false);
paintBackground.setMouseTransparent(true);
}
paintBackground.resize(getWidth(), getHeight());
if (geometryOrPaintChanged || paintBackground.getBackground() == null)
paintBackground.setBackground(new Background(new BackgroundFill(paint, radii, Insets.EMPTY)));
paintBackground.setOpacity(stateOpacity);
if (paintBackground.getParent() == null)
getChildren().add(0, paintBackground);
}
}
/// Detaches all waves and releases their animation state.
private void clearRipples() {
for (int i = ripples.size() - 1; i >= 0; i--)
removeWaveform(ripples.remove(i).circle);
}
/// Removes one waveform node and detaches the shared clip when its group becomes empty.
private void removeWaveform(Circle circle) {
if (waveformGroup != null) {
waveformGroup.getChildren().remove(circle);
if (waveformGroup.getChildren().isEmpty()) {
getChildren().remove(waveformGroup);
waveformGroup.setClip(null);
}
}
circle.setFill(null);
}
/// Stores animation progress alongside its waveform node until completion or cancellation.
@NotNullByDefault
private static final class Ripple {
/// The horizontal origin in local coordinates.
private final double x;
/// The vertical origin in local coordinates.
private final double y;
/// The unmanaged circle displaying this waveform.
private final Circle circle;
/// The start of the current expansion or release phase.
private long start;
/// Whether the release phase has begun.
private boolean released;
/// The fraction of the covering radius currently displayed.
private double progress;
/// The expansion fraction captured on release.
private double releaseProgress;
/// The additional paint opacity of this waveform.
private double opacity = PRESSED_OPACITY;
/// Creates an unexpanded held waveform at the supplied position and time.
private Ripple(double x, double y, long start, Circle circle) {
this.x = x;
this.y = y;
this.start = start;
this.circle = circle;
}
}
}
@@ -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
}
}
@@ -3,7 +3,8 @@
package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXToggleButton;
import com.jfoenix.controls.JFXSwitch;
import javafx.beans.InvalidationListener;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleStringProperty;
@@ -16,14 +17,11 @@ import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jetbrains.annotations.NotNullByDefault;
/// A line component that edits an inheritable boolean while showing the effective toggle state.
///
/// The override state is represented separately from the direct boolean value. The toggle always
/// reflects the effective value currently applied by the setting hierarchy.
/// A switch row that edits an inheritable boolean and displays its effective value.
@NotNullByDefault
public final class LineInheritableToggleButton extends LineButtonBase {
/// The style class applied to inheritable toggle rows.
private static final String DEFAULT_STYLE_CLASS = "line-inheritable-toggle-button";
public final class LineInheritableSwitch extends LineButtonBase {
/// The style class applied to inheritable switch rows.
private static final String DEFAULT_STYLE_CLASS = "line-inheritable-switch";
/// The pseudo class applied while the value overrides the inherited setting.
private static final PseudoClass PSEUDO_OVERRIDDEN = PseudoClass.getPseudoClass("overridden");
@@ -40,16 +38,19 @@ public final class LineInheritableToggleButton extends LineButtonBase {
/// The tooltip shown on the inheritance button.
private final Tooltip inheritTooltip;
/// The visual toggle that displays the effective value.
private final JFXToggleButton toggleButton;
/// The switch displaying the effective value.
private final JFXSwitch switchControl;
/// Creates an inheritable boolean toggle row.
public LineInheritableToggleButton() {
this.getStyleClass().addAll(DEFAULT_STYLE_CLASS, "line-toggle-button");
/// Creates an inheritable boolean switch row.
public LineInheritableSwitch() {
this.getStyleClass().addAll(DEFAULT_STYLE_CLASS, "line-switch");
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);
@@ -68,23 +69,144 @@ public final class LineInheritableToggleButton extends LineButtonBase {
event.consume();
});
this.toggleButton = new JFXToggleButton();
toggleButton.setMouseTransparent(true);
toggleButton.setSize(8);
FXUtils.setLimitHeight(toggleButton, 30);
this.switchControl = new JFXSwitch();
switchControl.accessibleTextProperty().bind(titleProperty());
switchControl.setOnAction(event -> {
// Override listeners may refresh the switch synchronously; preserve the requested value.
boolean selected = switchControl.isSelected();
event.consume();
setOverridden(true);
setRawValue(selected);
super.fire();
});
setTitleTrailing(inheritButton);
setTrailing(toggleButton);
setTrailing(switchControl);
rawValue.addListener(observable -> refresh());
overridden.addListener(observable -> refresh());
effectiveValue.addListener(observable -> refresh());
inheritAvailable.addListener(observable -> refresh());
inheritedTooltip.addListener(observable -> refresh());
overriddenTooltip.addListener(observable -> refresh());
InvalidationListener refreshListener = observable -> refresh();
rawValue.addListener(refreshListener);
overridden.addListener(refreshListener);
effectiveValue.addListener(refreshListener);
inheritAvailable.addListener(refreshListener);
inheritedTooltip.addListener(refreshListener);
overriddenTooltip.addListener(refreshListener);
refresh();
}
/// The raw value stored in this setting.
private final BooleanProperty rawValue = new SimpleBooleanProperty(this, "rawValue");
/// Sets the raw setting value.
public void setRawValue(boolean rawValue) {
rawValueProperty().set(rawValue);
}
/// Returns the raw setting value.
public boolean getRawValue() {
return rawValueProperty().get();
}
/// Returns the raw setting value.
public BooleanProperty rawValueProperty() {
return rawValue;
}
/// Whether the direct value overrides the inherited value.
private final BooleanProperty overridden = new SimpleBooleanProperty(this, "overridden");
/// Sets whether the direct value overrides the inherited value.
public void setOverridden(boolean overridden) {
overriddenProperty().set(overridden);
}
/// Returns whether the direct value overrides the inherited value.
public boolean isOverridden() {
return overriddenProperty().get();
}
/// Returns the override-state property.
public BooleanProperty overriddenProperty() {
return overridden;
}
/// The effective value displayed by the switch.
private final BooleanProperty effectiveValue = new SimpleBooleanProperty(this, "effectiveValue");
/// Sets the effective value displayed by the switch.
public void setEffectiveValue(boolean effectiveValue) {
effectiveValueProperty().set(effectiveValue);
}
/// Returns the effective value displayed by the switch.
public boolean isEffectiveValue() {
return effectiveValueProperty().get();
}
/// Returns the effective value displayed by the switch.
public BooleanProperty effectiveValueProperty() {
return effectiveValue;
}
/// Whether inherited mode can be selected.
private final BooleanProperty inheritAvailable = new SimpleBooleanProperty(this, "inheritAvailable", true);
/// Sets whether inherited mode can be selected.
public void setInheritAvailable(boolean inheritAvailable) {
inheritAvailableProperty().set(inheritAvailable);
}
/// Returns whether inherited mode can be selected.
public boolean isInheritAvailable() {
return inheritAvailableProperty().get();
}
/// Returns whether inherited mode can be selected.
public BooleanProperty inheritAvailableProperty() {
return inheritAvailable;
}
/// The tooltip displayed while inheriting the parent value.
private final StringProperty inheritedTooltip = new SimpleStringProperty(this, "inheritedTooltip", "");
/// Sets the tooltip displayed while inheriting the parent value.
public void setInheritedTooltip(String inheritedTooltip) {
inheritedTooltipProperty().set(inheritedTooltip);
}
/// Returns the tooltip displayed while inheriting the parent value.
public String getInheritedTooltip() {
return inheritedTooltipProperty().get();
}
/// Returns the tooltip displayed while inheriting the parent value.
public StringProperty inheritedTooltipProperty() {
return inheritedTooltip;
}
/// The tooltip displayed while overriding the parent value.
private final StringProperty overriddenTooltip = new SimpleStringProperty(this, "overriddenTooltip", "");
/// Sets the tooltip displayed while overriding the parent value.
public void setOverriddenTooltip(String overriddenTooltip) {
overriddenTooltipProperty().set(overriddenTooltip);
}
/// Returns the tooltip displayed while overriding the parent value.
public String getOverriddenTooltip() {
return overriddenTooltipProperty().get();
}
/// Returns the tooltip displayed while overriding the parent value.
public StringProperty overriddenTooltipProperty() {
return overriddenTooltip;
}
/// Sets the tooltip displayed on the inherit button in inherited mode.
public void setInheritTooltip(String tooltip) {
setInheritedTooltip(tooltip);
}
/// Overrides the inherited value with its inverse and fires the row action.
@Override
public void fire() {
setOverridden(true);
@@ -104,119 +226,6 @@ public final class LineInheritableToggleButton extends LineButtonBase {
inheritButton.setManaged(inheritAvailable);
inheritTooltip.setText(inherited ? getInheritedTooltip() : getOverriddenTooltip());
toggleButton.setSelected(isEffectiveValue());
}
/// The raw value stored in this setting.
private final BooleanProperty rawValue = new SimpleBooleanProperty(this, "rawValue");
/// Returns the raw setting value.
public BooleanProperty rawValueProperty() {
return rawValue;
}
/// Returns the raw setting value.
public boolean getRawValue() {
return rawValueProperty().get();
}
/// Sets the raw setting value.
public void setRawValue(boolean rawValue) {
rawValueProperty().set(rawValue);
}
/// Whether the direct value overrides the inherited value.
private final BooleanProperty overridden = new SimpleBooleanProperty(this, "overridden");
/// Returns the override-state property.
public BooleanProperty overriddenProperty() {
return overridden;
}
/// Returns whether the direct value overrides the inherited value.
public boolean isOverridden() {
return overriddenProperty().get();
}
/// Sets whether the direct value overrides the inherited value.
public void setOverridden(boolean overridden) {
overriddenProperty().set(overridden);
}
/// The effective value displayed by the toggle.
private final BooleanProperty effectiveValue = new SimpleBooleanProperty(this, "effectiveValue");
/// Returns the effective value displayed by the toggle.
public BooleanProperty effectiveValueProperty() {
return effectiveValue;
}
/// Returns the effective value displayed by the toggle.
public boolean isEffectiveValue() {
return effectiveValueProperty().get();
}
/// Sets the effective value displayed by the toggle.
public void setEffectiveValue(boolean effectiveValue) {
effectiveValueProperty().set(effectiveValue);
}
/// Whether inherited mode can be selected.
private final BooleanProperty inheritAvailable = new SimpleBooleanProperty(this, "inheritAvailable", true);
/// Returns whether inherited mode can be selected.
public BooleanProperty inheritAvailableProperty() {
return inheritAvailable;
}
/// Returns whether inherited mode can be selected.
public boolean isInheritAvailable() {
return inheritAvailableProperty().get();
}
/// Sets whether inherited mode can be selected.
public void setInheritAvailable(boolean inheritAvailable) {
inheritAvailableProperty().set(inheritAvailable);
}
/// The tooltip displayed while inheriting the parent value.
private final StringProperty inheritedTooltip = new SimpleStringProperty(this, "inheritedTooltip", "");
/// Returns the tooltip displayed while inheriting the parent value.
public StringProperty inheritedTooltipProperty() {
return inheritedTooltip;
}
/// Returns the tooltip displayed while inheriting the parent value.
public String getInheritedTooltip() {
return inheritedTooltipProperty().get();
}
/// Sets the tooltip displayed while inheriting the parent value.
public void setInheritedTooltip(String inheritedTooltip) {
inheritedTooltipProperty().set(inheritedTooltip);
}
/// The tooltip displayed while overriding the parent value.
private final StringProperty overriddenTooltip = new SimpleStringProperty(this, "overriddenTooltip", "");
/// Returns the tooltip displayed while overriding the parent value.
public StringProperty overriddenTooltipProperty() {
return overriddenTooltip;
}
/// Returns the tooltip displayed while overriding the parent value.
public String getOverriddenTooltip() {
return overriddenTooltipProperty().get();
}
/// Sets the tooltip displayed while overriding the parent value.
public void setOverriddenTooltip(String overriddenTooltip) {
overriddenTooltipProperty().set(overriddenTooltip);
}
/// Sets the tooltip displayed on the inherit button in inherited mode.
public void setInheritTooltip(String tooltip) {
setInheritedTooltip(tooltip);
switchControl.setSelected(isEffectiveValue());
}
}
@@ -0,0 +1,55 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXSwitch;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import org.jetbrains.annotations.NotNullByDefault;
/// A switch row for a boolean setting.
@NotNullByDefault
public final class LineSwitch extends LineButtonBase {
/// The style class applied to switch rows.
private static final String DEFAULT_STYLE_CLASS = "line-switch";
/// The switch that edits the selected state.
private final JFXSwitch switchControl;
/// Creates an unselected switch row.
public LineSwitch() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
this.switchControl = new JFXSwitch();
switchControl.selectedProperty().bindBidirectional(selectedProperty());
switchControl.accessibleTextProperty().bind(titleProperty());
setTrailing(switchControl);
}
/// The selected setting value.
private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected");
/// Sets the selected setting value.
public void setSelected(boolean selected) {
selectedProperty().set(selected);
}
/// Returns whether the setting is selected.
public boolean isSelected() {
return selectedProperty().get();
}
/// Returns the selected-state property bound to the switch.
public BooleanProperty selectedProperty() {
return selected;
}
/// Toggles the selected setting and fires a single row action unless the row is disabled.
@Override
public void fire() {
if (isDisabled())
return;
setSelected(!isSelected());
super.fire();
}
}
@@ -1,45 +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.construct;
import com.jfoenix.controls.JFXToggleButton;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import org.jackhuang.hmcl.ui.FXUtils;
public final class LineToggleButton extends LineButtonBase {
private static final String DEFAULT_STYLE_CLASS = "line-toggle-button";
private final JFXToggleButton toggleButton;
/// Creates a row with a directly attached toggle bound to the selected state.
public LineToggleButton() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
this.toggleButton = new JFXToggleButton();
toggleButton.selectedProperty().bindBidirectional(selectedProperty());
toggleButton.setSize(8);
FXUtils.setLimitHeight(toggleButton, 30);
setTrailing(toggleButton);
}
@Override
public void fire() {
toggleButton.fire();
super.fire();
}
private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected");
public BooleanProperty selectedProperty() {
return selected;
}
public boolean isSelected() {
return selectedProperty().get();
}
public void setSelected(boolean selected) {
selectedProperty().set(selected);
}
}
@@ -2,7 +2,6 @@
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.construct;
import javafx.animation.Interpolator;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
@@ -12,10 +11,8 @@ import javafx.event.EventHandler;
import javafx.event.EventType;
import javafx.scene.Node;
import javafx.scene.layout.Region;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.wizard.Navigation;
@@ -43,11 +40,8 @@ public class Navigator extends TransitionPane {
initialized = true;
}
/// Pushes the page using the supplied transition.
public void navigate(Node node, AnimationProducer animationProducer) {
navigate(node, animationProducer, Motion.SHORT4, Motion.EASE);
}
public void navigate(Node node, AnimationProducer animationProducer, Duration duration, Interpolator interpolator) {
FXUtils.checkFxUserThread();
if (!initialized)
@@ -67,7 +61,7 @@ public class Navigator extends TransitionPane {
node.fireEvent(navigating);
node.getProperties().put("hmcl.navigator.animation", animationProducer);
setContent(node, animationProducer, duration, interpolator);
setContent(node, animationProducer);
NavigationEvent navigated = new NavigationEvent(this, node, Navigation.NavigationDirection.NEXT, NavigationEvent.NAVIGATED);
node.fireEvent(navigated);
@@ -152,9 +146,10 @@ public class Navigator extends TransitionPane {
return stack.size();
}
/// Replaces the page and allows region content to shrink within the clipped viewport.
@Override
public void setContent(Node newView, AnimationProducer transition, Duration duration, Interpolator interpolator) {
super.setContent(newView, transition, duration, interpolator);
public void setContent(Node newView, AnimationProducer transition) {
super.setContent(newView, transition);
if (newView instanceof Region region) {
region.setMinSize(0, 0);
@@ -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() {
}
}
@@ -33,7 +33,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/// Wraps content with hover, focus, press, and ripple feedback behind it.
/// Wraps content with uniform selection, hover, focus, and press color transitions behind it.
/// Feedback covers padding without replacing the background. Corners follow the first background
/// fill of this pane, then the wrapped [Region], otherwise remaining square. Fill insets are ignored;
/// percentage radii use this pane's dimensions.
@@ -108,7 +108,7 @@ public class RipplerContainer extends StackPane {
addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
if (isBackgroundEvent(event) && event.getButton() == MouseButton.PRIMARY
&& isFeedbackAvailable() && isPressFeedbackEnabled())
interactionLayer.press(event.getX(), event.getY());
interactionLayer.press();
});
addEventHandler(MouseEvent.MOUSE_RELEASED, event -> {
if (isBackgroundEvent(event) && event.getButton() == MouseButton.PRIMARY)
@@ -181,7 +181,7 @@ public class RipplerContainer extends StackPane {
updateVisualState(false);
}
/// Updates persistent states and cancels presses when requested or feedback is unavailable.
/// Updates hover and focus and cancels unavailable presses, preserving selection.
private void updateVisualState(boolean cancelPress) {
boolean available = isFeedbackAvailable();
interactionLayer.setHovered(available && pointerInside);
@@ -284,19 +284,14 @@ public class RipplerContainer extends StackPane {
/// The registration is cleared before invocation.
///
/// Leaving before settlement postpones scheduling until another entry. Once queued, only replacement
/// or cancellation prevents invocation. Scheduling does not wait for waveforms or display presentation.
/// or cancellation prevents invocation. Scheduling does not wait for display presentation.
public void setPrepareAction(@Nullable Runnable action) {
if (action == getPrepareAction())
return;
if (preparation != null) {
interactionLayer.setOnStateSettled(null);
preparation = null;
}
if (action != null) {
preparation = new HoverPreparation(action);
interactionLayer.setOnStateSettled(preparation);
preparation = action == null ? null : new HoverPreparation(action);
interactionLayer.setOnStateSettled(preparation);
if (preparation != null)
preparation.run();
}
}
/// The CSS-configurable paint shared by all visual feedback states.
@@ -319,7 +314,7 @@ public class RipplerContainer extends StackPane {
return StyleableProperties.FEEDBACK_FILL;
}
/// Repaints existing states and waveforms with the new paint.
/// Repaints the feedback background with the new paint.
@Override
protected void invalidated() {
interactionLayer.setFill(get());
@@ -344,7 +339,7 @@ public class RipplerContainer extends StackPane {
/// Whether background presses produce visual feedback.
private final BooleanProperty pressFeedbackEnabled = new SimpleBooleanProperty(this, "pressFeedbackEnabled", true) {
/// Updates the CSS state and cancels existing feedback when disabled.
/// Updates the CSS state and clears press feedback when disabled.
@Override
protected void invalidated() {
pseudoClassStateChanged(PRESS_FEEDBACK_ENABLED, get());
@@ -354,8 +349,8 @@ public class RipplerContainer extends StackPane {
}
};
/// Returns the press feedback switch, initially true. Disabling it clears all waveforms
/// and the held press, preserving hover and focus feedback and normal event delivery.
/// Returns the press feedback switch, initially true. Disabling it clears the held press
/// and settles selection, hover, and focus feedback, preserving normal event delivery.
/// The default stylesheet applies a hand cursor only while this property is true.
/// Enabling it does not replay an existing press.
public BooleanProperty pressFeedbackEnabledProperty() {
@@ -372,7 +367,15 @@ public class RipplerContainer extends StackPane {
pressFeedbackEnabled.set(value);
}
/// Clears all held and fading waveforms and immediately restores the current hover/focus state.
/// Sets persistent selection feedback for compound controls such as tab headers.
/// Hidden or disabled containers settle immediately; selection survives transient resets.
final void setSelectedFeedback(boolean selected) {
interactionLayer.setSelected(selected);
if (!isFeedbackAvailable())
interactionLayer.reset();
}
/// Clears press feedback and immediately restores the current selection, hover, and focus.
/// Suitable for clearing transient feedback when a virtualized cell is reused.
public void resetFeedback() {
interactionLayer.reset();
@@ -429,7 +432,7 @@ public class RipplerContainer extends StackPane {
/// Reports whether CSS can assign this paint property.
@Override
public boolean isSettable(RipplerContainer styleable) {
return styleable.feedbackFill == null || !styleable.feedbackFill.isBound();
return !styleable.feedbackFill.isBound();
}
/// Returns the paint property receiving stylesheet values.
@@ -15,6 +15,7 @@ import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jetbrains.annotations.Nullable;
/// A spinner pane that can show spinner, failed reason, or content.
public class SpinnerPane extends Control {
@@ -33,7 +34,11 @@ public class SpinnerPane extends Control {
setLoading(false);
}
/// Refreshes the displayed state and its cache preference, including before skin creation.
private void updateContent() {
@Nullable Node displayedContent = content == null ? null : content.get();
TransitionPane.setCacheAllowed(this, !isLoading() && (getFailedReason() != null
|| displayedContent == null || TransitionPane.isCacheAllowed(displayedContent)));
if (getSkin() instanceof Skin skin) {
skin.updateContent();
}
@@ -185,6 +190,7 @@ public class SpinnerPane extends Control {
private StackPane failedPane;
private Label failedReasonLabel;
/// Selects the visible state and passes its cache preference to the transition target.
void updateContent() {
SpinnerPane control = getSkinnable();
@@ -193,6 +199,7 @@ public class SpinnerPane extends Control {
if (spinnerPane == null) {
spinnerPane = new StackPane(new JFXSpinner());
spinnerPane.getStyleClass().add("notice-pane");
TransitionPane.setCacheAllowed(spinnerPane, false);
}
nextContent = spinnerPane;
} else if (control.getFailedReason() != null) {
@@ -209,12 +216,13 @@ public class SpinnerPane extends Control {
contentPane = new StackPane();
}
Node content = control.getContent();
@Nullable Node content = control.getContent();
if (content != null)
contentPane.getChildren().setAll(content);
else
contentPane.getChildren().clear();
TransitionPane.setCacheAllowed(contentPane, content == null || TransitionPane.isCacheAllowed(content));
nextContent = contentPane;
}
@@ -222,6 +230,7 @@ public class SpinnerPane extends Control {
failedReasonLabel.setText(null);
}
TransitionPane.setCacheAllowed(control, TransitionPane.isCacheAllowed(nextContent));
root.setContent(nextContent, ContainerAnimations.FADE);
}
}
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.construct;
import javafx.animation.*;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
@@ -9,6 +10,8 @@ import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.scene.layout.Pane;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
@@ -17,6 +20,8 @@ import org.jetbrains.annotations.Nullable;
import java.util.Objects;
import static org.jackhuang.hmcl.ui.animation.TransitionPane.isCacheAllowed;
/// Manages tab selection, lazy content initialization, and page lifecycle callbacks.
/// An optional transition pane displays content activated by [#selectTab(TabItem)].
/// The first tab is selected when an unselected list gains tabs, without initializing
@@ -32,15 +37,15 @@ public final class TabController implements PageAware {
/// Creates a controller with the supplied tabs and no content destination.
///
/// @param tabs the initial tabs in display order, or null for an empty controller
public TabController(TabItem<?> ... tabs) {
public TabController(TabItem<?>... tabs) {
this(null, tabs);
}
/// Creates a controller with an optional destination for selected content.
///
/// @param contentPane the destination updated by [#selectTab(TabItem)], or null
/// @param tabs the initial tabs in display order, or null for an empty controller
public TabController(@Nullable TransitionPane contentPane, TabItem<?> ... tabs) {
/// @param tabs the initial tabs in display order, or null for an empty controller
public TabController(@Nullable TransitionPane contentPane, TabItem<?>... tabs) {
this.contentPane = contentPane;
this.tabs.addListener(this::tabsChanged);
this.tabs.setAll(tabs);
@@ -91,11 +96,11 @@ public final class TabController implements PageAware {
/// Exceptions from subsequent callbacks or content replacement propagate without
/// rolling back completed effects. A shown callback runs after selection is updated.
///
/// @param tab a tab belonging to [#getTabs()]
/// @param tab a tab belonging to [#getTabs()]
/// @param playAnimation whether to animate replacement of existing content
/// @throws NullPointerException if the tab is null
/// @throws NullPointerException if the tab is null
/// @throws IllegalArgumentException if the tab does not belong to this controller
/// @throws IllegalStateException if a content destination is configured but the
/// @throws IllegalStateException if a content destination is configured but the
/// tab has no content after initialization
public void selectTab(TabItem<?> tab, boolean playAnimation) {
Objects.requireNonNull(tab, "tab");
@@ -111,9 +116,10 @@ public final class TabController implements PageAware {
if (previousContent instanceof PageAware pageAware)
pageAware.onPageHidden();
if (contentPane != null) {
if (playAnimation && contentPane.getCurrentNode() != null) {
contentPane.setContent(content, ContainerAnimations.SLIDE_UP_FADE_IN,
Motion.MEDIUM4, Motion.EASE_IN_OUT_CUBIC_EMPHASIZED);
if (playAnimation && previous != null && previous != tab && contentPane.getCurrentNode() != null) {
boolean forward = tabs.indexOf(tab) > tabs.indexOf(previous);
TabAnimation animation = forward ? TabAnimation.TAB_UP : TabAnimation.TAB_DOWN;
contentPane.setContent(content, animation);
} else {
contentPane.setContent(content, ContainerAnimations.NONE);
}
@@ -174,4 +180,47 @@ public final class TabController implements PageAware {
if (tab != null && tab.isInitialized() && tab.getNode() instanceof PageAware pageAware)
pageAware.onPageHidden();
}
@NotNullByDefault
public enum TabAnimation implements TransitionPane.AnimationProducer {
/// Preserves the original slide-up and fade-in transition.
TAB_UP(1.0),
/// Mirrors the original slide-up and fade-in transition vertically.
TAB_DOWN(-1.0);
private static final Duration DURATION = Motion.MEDIUM4;
private static final Duration HALF_DURATION = DURATION.divide(2);
private final double direction;
TabAnimation(double direction) {
this.direction = direction;
}
@Override
public Animation animate(Pane container, Node previousNode, Node nextNode) {
double extent = container.getHeight();
double offset = (extent > 0 ? extent * 0.2 : 50) * direction;
var previousTranslation = previousNode.translateYProperty();
var nextTranslation = nextNode.translateYProperty();
return new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(nextNode.cacheProperty(), isCacheAllowed(nextNode), Interpolator.DISCRETE),
new KeyValue(previousTranslation, 0, Motion.EASE_IN_OUT_CUBIC_EMPHASIZED),
new KeyValue(previousNode.opacityProperty(), 1, Motion.EASE_IN_OUT_CUBIC_EMPHASIZED),
new KeyValue(nextNode.opacityProperty(), 0, Motion.EASE_IN_OUT_CUBIC_EMPHASIZED),
new KeyValue(nextTranslation, offset, Motion.EASE_IN_OUT_CUBIC_EMPHASIZED)),
new KeyFrame(HALF_DURATION,
new KeyValue(previousNode.opacityProperty(), 0, Motion.EASE_IN_OUT_CUBIC_EMPHASIZED)),
new KeyFrame(DURATION,
new KeyValue(nextNode.cacheProperty(), nextNode.isCache(), Interpolator.DISCRETE),
new KeyValue(nextNode.opacityProperty(), 1, Motion.EASE_IN_OUT_CUBIC_EMPHASIZED),
new KeyValue(nextTranslation, 0, Motion.EASE_IN_OUT_CUBIC_EMPHASIZED)));
}
@Override
public TransitionPane.AnimationProducer opposite() {
return this == TAB_UP ? TAB_DOWN : TAB_UP;
}
}
}
Loaded 100 of 316 files, more files were not shown because too many files have changed in this diff. Show more