Compare commits

...
Author SHA1 Message Date
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
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
Glavo 8047775bf8 feat: add support for virtual GPU type in AboutPage (#7158) 2026-11-01 21:54:37 +08:00
Glavo 740d3c5d80 重构 TwoLineListItem (#7153) 2026-11-01 21:48:48 +08:00
Glavo 967c326219 允许修改没有 patches 的原版实例 (#7145) 2026-11-01 21:29:48 +08:00
快乐凤爪王 fdc3500cc4 style: 为 checkstyle 添加了 RegexpSingleline 规则 (#7156) 2026-11-01 21:25:13 +08:00
Glavo 2f17995955 更新关于页面“桌面环境”条目的图标 (#7157) 2026-11-01 21:22:43 +08:00
Glavo ef311560fd Enhance SHA-1 table and update task for version detection (#7155) 2026-11-01 21:10:33 +08:00
Glavo 440b42a8b7 优化关于页面运行环境信息的排版 (#7151) 2026-11-01 15:55:40 +08:00
Glavo 8b706466e7 feat: log process PID in Launcher (#7152) 2026-11-01 15:48:28 +08:00
快乐凤爪王 8e16127845 feat: 改进 GameListCell (#7144) 2026-10-31 21:56:29 +08:00
Glavo bdbf5773a8 调整自动内存分配算法 (#7134) 2026-10-31 13:59:32 +08:00
Glavo 1ca146c615 Bump mesa-loader-windows to 26.2.6 (#7132) 2026-10-30 21:28:12 +08:00
Glavo de8ae88ee4 改进 AVF 虚拟机内的硬件检测逻辑 (#7130) 2026-10-30 17:57:27 +08:00
Glavo ba62160c76 改进 GPU 类型推断逻辑 (#7124) 2026-10-30 02:05:12 +08:00
快乐凤爪王 8e7f36c681 fix: MicrosoftAccountLoginPane 在缺失 Client ID 时禁用二维码登录 (#7119) 2026-10-29 22:22:07 +08:00
快乐凤爪王 b08f3869cf fix: 关于页面的版权条目使用 LinePane (#7122) 2026-10-29 22:01:42 +08:00
Glavo f05647fc82 支持在使用 Panthor 驱动的环境探测 Mali GPU (#7115) 2026-10-29 16:01:59 +08:00
Glavo 67e9826309 改进 CPU 拓扑探测能力 (#7113) 2026-10-29 15:18:38 +08:00
Glavo f1c9b77df5 Refactor SystemInfo and improve hardware info caching and documentation (#7112) 2026-10-29 03:09:30 +08:00
Glavo c3abfcf1d9 更新 amdgpu.ids (#7111) 2026-10-29 02:37:36 +08:00
Glavo 6632a91426 优化 GPU 信息显示逻辑 (#7109) 2026-10-28 21:55:33 +08:00
Glavo ac378ad46a 预热设置页面时预加载游戏预设 (#7105) 2026-10-28 03:38:08 +08:00
Glavo 6bbb1d157c 简化 TabItem (#7106) 2026-10-28 03:19:15 +08:00
Glavo efa1012a1c 重构 TabHeader (#7104) 2026-10-28 02:26:08 +08:00
Glavo 5058c68be8 优化窗口动画性能 (#7103) 2026-10-28 00:52:29 +08:00
鱼前三百 3612f04657 docs: 修复 withRunAsync 文档错误 (#7095) 2026-10-27 22:25:55 +08:00
快乐凤爪王 a2d3bb3b42 chore: 抑制 Decorator 中未经检查的类型转换警告 (#7098) 2026-10-27 21:50:33 +08:00
Glavo d3efc81332 为侧边栏菜单项背景色添加渐变动画 (#7101) 2026-10-27 21:22:47 +08:00
Glavo 6b1799838f 重构 RipplerContainer (#7096) 2026-10-27 20:14:05 +08:00
Glavo 5ad4880182 支持使用 CurseForge CDN 访问 NeoForge 相关文件 (#7092) 2026-10-26 23:05:08 +08:00
Glavo a3781141fe 由 TransitionPane 提供节点预热能力 (#7090) 2026-10-26 21:30:43 +08:00
Glavo ed168e14d6 清理 Controllers (#7082) 2026-10-26 21:11:06 +08:00
Glavo ee81d7cc66 使用 SPDX 文件头声明版权与开源协议 (#7091) 2026-10-26 20:15:54 +08:00
Glavo 299077d1cb 恢复使用页面预热 (#7081) 2026-10-26 02:09:11 +08:00
nekoside 7448ea90b2 fix: handle odd-length UTF-16LE GPU registry values on Windows (#7080) 2026-10-26 02:00:05 +08:00
AlexYang ac578c044f chore: update avatar for yushijinhun and update a URL (#7078) 2026-10-25 21:34:53 +08:00
快乐凤爪王 fc68029adc feat: 帮助页面添加异常提示及刷新功能 (#7047) 2026-10-25 21:32:51 +08:00
Glavo f6a92c1806 过滤模组中文名中的 Emoji (#7077) 2026-10-25 20:55:54 +08:00
Glavo 58eb452e90 在播放 UI 动画前应用 UI 布局 (#7076) 2026-10-25 19:17:39 +08:00
Wulian233 fb8a96a170 优化账户页面「上传/编辑皮肤」按钮文案 (#7050)
Close #3633
2026-10-25 19:02:55 +08:00
快乐凤爪王 ced441a830 refactor: 提取 DownloadListCell (#7075) 2026-10-25 17:30:14 +08:00
Glavo c5693c3450 清理未使用的代码与资源 (#7074) 2026-10-25 16:59:13 +08:00
鱼前三百 bc27eda220 chore(event): 移除过时且远古的进程退出事件,并将下载速度事件与全局 EventBus 解耦 (#7063) 2026-10-25 16:40:47 +08:00
Matt Van Horn 0c40dcdeed fix: honor declared modern Java requirements on legacy Linux instances (#7073) 2026-10-25 16:39:11 +08:00
Glavo 4282147042 优化设备信息的检测与显示逻辑 (#7071) 2026-10-25 16:36:49 +08:00
Glavo 0874c98d21 Fix TLS security tests on JDKs that block compromised certificates (#7065) 2026-10-25 11:42:41 +08:00
ToobLac 0c3c7dd62a chore: Bump commonmark to 0.31.0 & Update deps.json (#7056) 2026-10-25 03:04:20 +08:00
Glavo f663e5568c 在关于页面显示用户环境信息 (#7040) 2026-10-24 21:32:25 +08:00
ToobLac b8c1738f96 perf(ui): MicrosoftAccountLoginPane 在缺失 client id 时直接禁用登录按钮并显示错误提示 (#7054) 2026-10-24 19:38:51 +08:00
Glavo 41c61645e7 Detect instance manifests among multiple JSON files (#7048) 2026-10-24 18:31:52 +08:00
ToobLac 9fc697751c chore(git): 补充 schematic git attr (#7053) 2026-10-24 18:21:30 +08:00
Glavo a4db0532b9 在 default_script.csv 和 sublanguages.csv 中记录数据来源 (#7049) 2026-10-24 14:14:24 +08:00
Minecraft269andMinecraft269 8954139b5e docs: update contributor count from 130 to 140 (#7043)
Co-authored-by: Minecraft269 <Minecraft269@users.noreply.github.com>
2026-10-24 13:52:30 +08:00
Glavo b2f7e724e4 使用 IANA 时区地区映射表推断设备地区 (#7046) 2026-10-24 12:38:08 +08:00
Glavo 0d20910ea3 Block compromised eMudhra CA keys in application TLS validation (#7033) 2026-10-22 21:27:52 +08:00
Glavo 5c90299073 移除下载页面提示 (#7038) 2026-10-22 21:04:39 +08:00
Glavo fc66cbcf96 更新模组数据 (2026-10-22) (#7032) 2026-10-22 02:15:14 +08:00
Glavo e7b9a95df0 为非官方构建添加醒目标识 (#7028) 2026-10-21 21:24:41 +08:00
ToobLac acc9740fa9 perf(addon): 完善 AddonUpdatesPage (#7003) 2026-10-20 21:27:59 +08:00
鱼前三百 5789f82617 fix: 在重新检查更新的时候提示正在检查更新 (#7015)
fix: #7014
2026-10-20 21:25:24 +08:00
Glavo 8827fd06d8 优化地区检测逻辑 (#7020) 2026-10-20 18:06:54 +08:00
Glavo 583e004d71 在中国大陆地区禁用游戏下载功能 (#7017) 2026-10-20 16:37:22 +08:00
Glavo 63207affc6 更新配置文件 Json Schema (#7012) 2026-10-19 20:34:27 +08:00
Glavo 8b147bf20a Windows 平台自动使用 javaw.exe 启动游戏 (#7009) 2026-10-19 20:21:15 +08:00
鱼前三百 15754119d5 fix: 修复原生窗口透明标题栏重复绘制背景 (#7010)
fix #6959
2026-10-19 20:08:39 +08:00
快乐凤爪王 8119324a92 fix: JavaFX Alert 失败时回退到 Swing (#6918) 2026-10-18 21:15:34 +08:00
辞庐 c0ad7764b0 更新用户未购买 Minecraft 时的提示文本 (#6998) 2026-10-18 20:55:43 +08:00
辞庐 3ea054ae31 将 JFXSpinner 改为顺时针旋转 (#7000) 2026-10-18 20:52:20 +08:00
快乐凤爪王 d03ad38e24 fix: ModpackInfoPage 文件选择器关闭后不清空页面栈 (#6992) 2026-10-17 20:38:35 +08:00
快乐凤爪王 93eaee317c feat: 参考 VersionsPage 样式改进 AddonUpdatesPage (#6970) 2026-10-16 21:51:41 +08:00
ToobLac 68b3a9f076 enhance(i18n): 优化拖拽以安装本地整合包的文字提示 (#6893)
Fixes #6891
2026-10-16 21:47:09 +08:00
鱼前三百 c943e4c74f fix: 在模组/资源包文件操作失败时提示 (#6922)
fix #4314, fix #6396
2026-10-16 21:44:50 +08:00
Minecraft269 4dba96e635 chore: 在 Gitee 与 CNB Issue 模板中添加 GitHub Issue 入口 (#6967) 2026-10-16 21:19:32 +08:00
Glavo e521a1b91a Fix #6969: 修复第二次打开游戏下载页面时页面空白的问题 (#6988) 2026-10-16 21:16:18 +08:00
gunngz a7e0fe7499 Fix indeterminate checkbox rendering in modpack export wizard (#6989)
Fixes #6929
2026-10-16 21:15:44 +08:00
鱼前三百andGlavo 4e2d5cac9e chore: 转换 TaskCancellationAction 为函数式接口 (#6991)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-10-16 20:49:23 +08:00
ToobLac 452d030817 refactor(addon): Make LocalAddonFile implement Comparable (#6984) 2026-10-15 20:33:13 +08:00
ToobLac da9ec2c1e1 chore: sync translations (#6983) 2026-10-15 20:31:07 +08:00
Glavo 4ebbb400ab 重构 ModTranslations (#6987) 2026-10-15 20:29:12 +08:00
Glavo 0cfc2471a0 使用 BellSoft Full JDK 运行 GitHub Action (#6978) 2026-10-13 21:13:42 +08:00
Glavo 413a7291ec 修复 DateRangeDialog 调用 JavaFX 21 API 的问题 (#6979) 2026-10-13 20:55:15 +08:00
鱼前三百 e4893a6df9 feat: 缓存 MicrosoftAccount 的 CompleteGameProfile (#6975)
Fix #5055
2026-10-13 20:16:44 +08:00
ToobLac bb16a0b6c0 fix(addon): AddonUpdatePage 显示测试版本复选框无效 (#6976)
Fixes #6974
2026-10-13 20:16:03 +08:00
5768cd446e feat(instance): 截图管理 (#6096)
Resolves #5535

Co-authored-by: Calboot <calboot39@outlook.com>
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
Co-authored-by: CalbootOnceMore <calboot43@outlook.com>
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-10-11 21:47:13 +08:00
ToobLac 6f406cede8 enhance(addon): addon更新功能优化 (#6531)
Fixes #6940 Fixes #5865 Fixes #3841 Fixes #3705
2026-10-11 21:11:06 +08:00
快乐凤爪王 0597282463 refactor: 调整 实例管理.浏览 列表布局 (#6914) 2026-10-11 20:33:08 +08:00
ToobLac f91fbe29b4 enhance(remote-addon): 合并冗余的依赖类型 & 下载整合包时推荐版本 (#6968) 2026-10-11 20:10:08 +08:00
快乐凤爪王 cccff7effd feat: 为 Java 管理 页面添加了为空提示 (#6961) 2026-10-11 20:09:06 +08:00
Glavo 101257cae8 总是启用 zip 编码探测 (#6964) 2026-10-11 20:06:03 +08:00
快乐凤爪王 a6e74e7c19 fix: 管理已禁用的 Java 页面内容为空时返回上一页 (#6960) 2026-10-10 21:33:21 +08:00
Glavo 615ebe0311 修复安装任务对话框中部分任务分类错误的问题 (#6962) 2026-10-10 21:23:22 +08:00
Wulian233 8b3468e2d9 无更新日志时提示暂未上传更新日志 (#6957) 2026-10-09 21:49:30 +08:00
辞庐andGlavo 7f1a812373 安装整合包时 整合包名称自动去除冒号 (#6911)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-10-09 21:44:12 +08:00
ShulkerSakuraandGlavo 22823151c3 在日志中记录当前进程是否由管理员账户运行 (#4393)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-10-09 21:10:35 +08:00
Glavo c0844834cb 修复安装 NeoForge 时会有两条进度条的问题 (#6958) 2026-10-09 20:25:16 +08:00
快乐凤爪王 7921c7abb5 refactor: 在 JavaDownloadDialog 中将 版本 与 包类型 的 ComboBox 监听 distributionBox (#6889) 2026-10-08 21:08:58 +08:00
辞庐 c610519f87 优化整合包导出页面排序 & 描述 (#6895) 2026-10-08 21:07:24 +08:00
庐庐绫 bb1949f919 feat: 为 JFXComboBoxListViewSkin 添加平滑滚动效果 (#6920) 2026-10-08 21:02:09 +08:00
Zkitefly a1e946bba6 fix(csv): 修复 CSV 转义引号和换行的格式错误 (#6931) 2026-10-08 20:51:58 +08:00
辞庐 c1bf66b972 清理冗余 Liteloader 代码 (#6952) 2026-10-08 20:32:48 +08:00
ToobLac 25952aa514 chores: 转换 LocalAddonFile$Description 为记录类 (#6956) 2026-10-08 20:26:18 +08:00
辞庐 54d8d877be 仅在打包为 EXE 时设置 AppUserModelID (#6955) 2026-10-08 20:24:14 +08:00
ToobLac 662a82f21f refactor(addon): 单例化 RemoteAddonRepository (#6900) 2026-10-05 13:08:42 +08:00
Glavo 3f0fcc1382 Separate window bounds tracking from minimum size configuration (#6905) 2026-10-05 10:57:21 +08:00
gunngz 258556b22c Fix #6600: 为自定义 LWJGL 本地库路径添加目录选择按钮 (#6887) 2026-10-05 10:20:05 +08:00
辞庐 d7e3cc4473 fix: 修改游戏目录名称后不会刷新 (#6904) 2026-10-05 10:09:28 +08:00
Glavo 93d4489b94 更新模组数据 (2026-10-04) (#6941) 2026-10-05 10:09:03 +08:00
Apostrophe 77eee17d36 更新README中contributor的人数至“130人以上”,并提高图片中的数量上限 (#6921) 2026-09-30 20:35:22 +08:00
辞庐 4118fa9729 添加实例文件夹后自动选中 (#6912) 2026-09-30 20:31:38 +08:00
Glavo 83ffc91a88 重构 DownloadProvider (#6919) 2026-09-30 20:26:28 +08:00
neveler fdf883d09d 更新 Chunkbase 种子地图 (#6892) 2026-09-30 20:25:24 +08:00
Glavo 342bcc5438 Bump Gradle to 9.8.0 (#6917) 2026-09-30 20:24:09 +08:00
Glavo 59bcc7fe62 在 JavaFX27+ 环境中使用原生窗口装饰 (#6885) 2026-09-22 20:21:30 +08:00
Glavo 77b42b6f99 使用 WebURL 代替 URI (#6883) 2026-09-20 20:38:59 +08:00
辞庐 2f5594d8c1 Cleanroom 环境移除 icu4j-core-mojang 依赖 (#6886) 2026-09-17 21:57:09 +08:00
13ad50208f [Enhancement] 原理图管理优化 (#6094)
Co-authored-by: Calboot <calboot39@outlook.com>
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: CalbootOnceMore <calboot43@outlook.com>
2026-09-17 21:15:11 +08:00
Glavo 13910c24b7 Add version entry for Minecraft 26.3-rc-3 in versions.txt (#6877) 2026-09-16 21:41:56 +08:00
Glavo cc9bdb62ca Add support for Minecraft 26.3-snapshot-10+ to unofficial supported platforms (#6870) 2026-09-16 20:50:02 +08:00
Glavo baa7c816b7 将翻译 Task 名称的逻辑移动至 I18n 中 (#6868) 2026-09-15 21:55:44 +08:00
Apostrophe 090f0b9822 修复编辑实例文件夹时不修改名称就无法保存的问题 (#6866) 2026-09-15 20:30:31 +08:00
CaveNightingale 6d9ae24912 添加安装阶段对整合包可选文件的支持 (#1771) 2026-09-14 21:51:49 +08:00
Ivan Epikhin d03851b196 i18n: update Russian translation (#6859) 2026-09-14 21:13:22 +08:00
Glavo 97cf9dcf16 Refactor native library checks for improved clarity and efficiency (#6857) 2026-09-12 20:39:34 +08:00
Glavo 7bacc2bb5a Fix #6842: Include tags in TwoLineListItem preferred width (#6844) 2026-09-12 20:20:02 +08:00
Wulian233 c7637079d7 Fix #6851: 修复右键部分选项弹出的弹窗会无限叠加 (#6854)
Close #6851
2026-09-12 20:16:18 +08:00
Glavo 8f3282e9a2 Add version entries for 26.3 snapshots and release candidates (#6849) 2026-09-12 20:13:27 +08:00
Glavo 8e491862b9 对于简体中文资源使用 zh-Hans 语言标签 (#6853) 2026-09-12 14:12:24 +08:00
Glavo e0c276075c 重命名 I18N_zh.properties 和 I18N_zh_CN.properties (#6852) 2026-09-12 13:35:16 +08:00
Glavo 19ea64e14b 同步更新所有语言文件 (#6850) 2026-09-12 10:58:09 +08:00
ToobLac 5b26b8f4eb enhance(macos): 日志、游戏崩溃、HMCL崩溃窗口按下 CMD+W 时退出 (#6813) 2026-09-11 21:37:25 +08:00
NWMA-FYWFandGlavo ed7ef92ad9 feat: 添加实例列表右键编辑功能 (#6000) (#6038)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-09-11 20:59:44 +08:00
ToobLac cd95891108 [Enhancement] 启动器检查更新失败时显示提示 (#6286) 2026-09-11 20:24:13 +08:00
Burning_TNT df52bc6e81 不再支持导出 MultiMC 整合包 (#4017) 2026-09-10 21:31:27 +08:00
lcxn123 9d9fc78384 fix #6202: Validate Yggdrasil access tokens on every login (#6209) 2026-09-10 21:28:21 +08:00
bluebulletandGlavo e287f672fd Feat/curse retry and cache (#6595)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-09-10 21:02:07 +08:00
Glavo 24702dc5a0 Fix #6833: Automatically synchronize client JARs when updating game instances (#6843) 2026-09-10 10:14:11 +08:00
ToobLac 886a25a935 [Enhancement] 延后加载并缓存资源包图标 (#6730) 2026-09-09 21:00:08 +08:00
ToobLac 450504ceda [Bugfix] 修复 #6526: macOS 下 主题模式-跟随系统设置 失效 (#6555)
Fixes #6526
2026-09-09 20:58:39 +08:00
ToobLac 8767cc0e98 [Bugfix] 修复 CurseForge 搜索部分内容正确结果很靠下的问题 & 移除没有实际意义的搜索排序方式 (#6619) 2026-09-09 20:06:12 +08:00
ToobLacandGlavo 7331f619cf [Bugfix] 修复未受支持的模组加载器标签在模组版本界面不显示 (#6289)
Fixes #6280

Co-authored-by: Glavo <zjx001202@gmail.com>
2026-09-08 21:57:53 +08:00
ZZZank 7a8f931641 Fix display of mod with extra long display name (#6436) 2026-09-08 21:57:32 +08:00
辞庐 ce51dc4dd7 在修改游戏版本时清除已选的模组加载器 (#6475) 2026-09-08 21:57:16 +08:00
Glavo b97e7fbd55 Move modpack file controls into reusable tree cells (#6840) 2026-09-08 20:46:21 +08:00
辞庐andGlavo 7f578be08e 优化实例图标设置操作按钮 (#6816)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-09-08 20:33:08 +08:00
Glavo 513f53bc06 修复无法启动安装了 Forge 的 Minecraft 1.6.4 实例的问题 (#6839) 2026-09-08 20:18:25 +08:00
Glavo b2d4d36856 Implement task cleanup and abort functionality in DefaultGameBuilder (#6836)
Fix #6773
2026-09-08 16:39:06 +08:00
Glavo 9c031c5ac1 优化 ArchiveFileTree 构建性能 (#6838) 2026-09-08 16:37:31 +08:00
Glavo e2d3ac847d 打开 zip 文件时忽略 local file header (#6834) 2026-09-08 12:02:17 +08:00
Glavo 0f4152406c Bump kala-compress to 1.27.1-5 (#6837) 2026-09-08 12:02:00 +08:00
Glavo 1a258a2554 Fix #6828: 隐藏父实例缺失的实例 (#6832) 2026-09-07 21:15:16 +08:00
Glavo 3742be0aa8 Add *.jfr to .gitignore (#6835) 2026-09-07 15:54:23 +08:00
辞庐 0e34554344 转化 OptiFineVersion 为 Record (#6821) 2026-09-06 20:20:12 +08:00
Wulian233 1acfc55004 转化 CurseMetaMod 为 Record (#6822) 2026-09-06 20:18:54 +08:00
辞庐 1428183e36 简写部分 Hello Minecraft! Launcher 为 HMCL (#6826) 2026-09-06 20:16:34 +08:00
Wulian233 cbc6daf3d5 Fix #6819: 实例管理工具栏文字变小 (#6823)
Close #6819
2026-09-06 20:16:21 +08:00
辞庐 0ad180c082 fix: 无法启动新版本 Cleanroom (#6824) 2026-09-06 20:14:39 +08:00
Xirren 1da8c8d6ac 修复导出mrpack时,已禁用模组有关bug (#6830) 2026-09-06 20:14:06 +08:00
辞庐 ea232b0976 清理 DialogPane 冗余 progressBar (#6827) 2026-09-06 20:10:45 +08:00
辞庐 b4549b6f68 OAuthserver 仅在 localhost 监听端口 (#6811) 2026-09-05 20:14:46 +08:00
辞庐 4cf5dbdf66 fix: 两种方式打开的游戏选择 Popup 文字大小不一样大 (#6812)
resolves #6697
2026-09-05 20:05:16 +08:00
Glavo b179c44bec Bump kala-compress to 1.27.1-4 (#6809) 2026-09-05 00:08:32 +08:00
Wulian233 7dfc654d5d Fix #6398: Java列表无法刷新版本信息 (#6808)
Close #6398
2026-09-04 21:03:49 +08:00
ToobLac a4b930ded5 [Bugfix] 修复下载附加内容时依赖列表因文件缺失加载失败/类别标题不居中的问题 (#6532) 2026-09-04 20:51:10 +08:00
辞庐andGlavo 00cd20a6e9 简化 DefaultLauncher#getEnvVars (#6197)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-09-03 21:56:15 +08:00
Glavo 9ebf29d060 修复 1.4.7 安装 Legacy Fabric 后无法正常启动的问题 (#6807) 2026-09-03 21:16:07 +08:00
辞庐 d7d5e99185 fix: 自动安装已安装的项目会显示两份 (#6746) 2026-09-03 20:32:25 +08:00
陈梦泽 5e9ed84605 修复 GameDirectoryPage 缺失的 MessageDialogPane import (#6805) 2026-09-03 20:20:00 +08:00
ToobLac 2e01874e95 fix(ripple): 修复列表第一次点击时不显示涟漪的问题以及滚动时仍然保持涟漪的问题 (#6709)
Fixes #6703
2026-09-02 23:36:10 +08:00
辞庐 efb475879b 在选择磁盘根目录作为游戏目录时弹出警告 (#6203) 2026-09-02 21:57:19 +08:00
ToobLac cb69387151 [Chores] 移动 ModListPageSkin 到 ModListPage 内部 (#6729) 2026-09-02 20:29:55 +08:00
Glavo 70104cb9e5 重构游戏管理功能 (#6787) 2026-09-02 20:20:22 +08:00
辞庐 931fbf5181 更新 launch.advice.lwjgl_3_4_1 文案 (#6758) 2026-09-01 21:03:29 +08:00
辞庐 1d5ecb9236 使用相同逻辑提取验证服务器展示域名 (#6762) 2026-08-31 21:25:35 +08:00
辞庐 b65104e38b 优化修改实例游戏版本页标题 (#6778) 2026-08-31 21:25:25 +08:00
辞庐 8629a2bbe3 fix: 实例列表中新快照图标错误 (#6774) 2026-08-31 21:22:47 +08:00
Glavo 3142b2f894 Bump commonmark to 0.30.0 (#6793) 2026-08-31 21:20:56 +08:00
辞庐 b1759acb62 fix: 初次进入新增游戏目录页面不会自动填充 .minecraft 上级目录名称 (#6764) 2026-08-31 21:20:44 +08:00
Glavo 1363f0e70d Bump shadow plugin to 9.6.1 (#6795) 2026-08-31 21:19:26 +08:00
辞庐 45c4913b82 清理 PropertyUtils (#6763) 2026-08-31 21:15:22 +08:00
辞庐 0d47ff95e1 优化微软登录扫描二维码界面布局 (#6767) 2026-08-31 21:12:49 +08:00
Glavo 4064c5c188 Bump JUnit to 6.1.3 (#6794) 2026-08-31 21:11:18 +08:00
Glavo 56a2f75c8b Bump jsoup to 1.23.2 (#6792) 2026-08-31 21:01:46 +08:00
Glavo c4ebdeb77e Bump Gradle to 9.7.1 (#6791) 2026-08-31 21:01:22 +08:00
Glavo 13b87fff89 Bump fxsvgimage to 1.9.1 (#6790) 2026-08-31 20:52:30 +08:00
Glavo 50072799d0 Fix #6788: 修复缩放 SVG 图片时会加载 JFXPanel 类的问题 (#6789) 2026-08-31 20:48:03 +08:00
辞庐 16d45890ad 点击主页“下载”按钮时保留先前打开页面 (#6775) 2026-08-31 20:26:14 +08:00
辞庐 e0dacd0265 优化 LineSelectButton popup 副标题配色 (#6771) 2026-08-30 22:05:56 +08:00
辞庐 72071a41c9 清理 GameException (#6770) 2026-08-30 22:04:05 +08:00
辞庐 c86c19a7f9 fix: 更改模组加载器版本时下载依赖项任务未正确归类 (#6777) 2026-08-30 22:01:22 +08:00
辞庐 5475acdc9e 清理 MojangJavaRemoteVersion & MojangJavaDistribution (#6772) 2026-08-30 21:57:41 +08:00
辞庐 6daeeb0b04 清理 JsonMap (#6776) 2026-08-30 21:54:47 +08:00
辞庐 1a4ccd355d fix: 全局实例设置页面可能缺少 ”Vulkan 渲染器/驱动“选项 (#6779) 2026-08-30 21:21:01 +08:00
辞庐 204a08342d fix: LegacyFabric 实例图标异常 (#6784) 2026-08-30 21:20:02 +08:00
辞庐 ced06cb48c 清理 AuthlibInjectorDownloader (#6765) 2026-08-28 22:02:22 +08:00
辞庐 80b3883d4f 修复更换游戏组件版本对话框消息文本异常的问题 (#6757) 2026-08-28 22:01:47 +08:00
辞庐 60c7ea5a01 清理 MappedProperty (#6761) 2026-08-28 21:59:03 +08:00
辞庐 3323c6a50a 修复选择组件版本页面标题异常的问题 (#6753) 2026-08-28 21:57:48 +08:00
olivezaneandGlavo 7530f08ab1 修复 KDE Wayland 下 HMCL 不跟随缩放的问题 (#6736)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-28 21:46:26 +08:00
辞庐andGlavo 15a983de28 优化验证服务器选择样式 (#6704)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-27 21:37:15 +08:00
辞庐 5c0d4a7c7b 清理 Lang (#6707) 2026-08-27 20:49:26 +08:00
辞庐 1532247f2a feat: 支持使用系统 SDL (#6696)
closes #6452
2026-08-26 21:47:27 +08:00
Glavo 587e92543a 更新 PLATFORM.md (#6740) 2026-08-25 21:53:38 +08:00
ToobLac f0527e12b4 [Enhancement] 清理游戏实例时删除版本目录根部所有 log 文件 (#6732) 2026-08-23 20:17:34 +08:00
ToobLac b0ac4fb5d0 [Enhancement] 优先按照游戏版本排序实例 (#6731)
Fixes #4679
2026-08-23 20:17:14 +08:00
Glavo 083dbb18ad 修复安装新实例时未将 patch 合并到主 manifest 中的问题 (#6718) 2026-08-21 21:42:08 +08:00
Glavo c9585198e1 主动修剪堆内存 (#6715) 2026-08-21 21:38:41 +08:00
Glavo 0fd83e5177 修复在使用相对路径的 DefaultGameRepository 中安装实例失败的问题 (#6716) 2026-08-21 11:26:10 +08:00
辞庐 5a017ecab6 修复可通过本地安装同时安装 Cleanroom 与 Forge 的问题 (#6496) 2026-08-20 21:52:05 +08:00
辞庐 da77f52492 迁移 ModpackHelper#findMinecraftDirectoryInManuallyCreatedModpack 到 kala-compress (#6465) 2026-08-20 21:27:42 +08:00
Glavo dccfa7c9a9 将文件末尾的 CRLF 替换为 LF (#6713) 2026-08-20 21:03:12 +08:00
neveler f7bf2d468d 将 PR Checkstyle 报告同步至 Pull Request 审查评论 (#6692) 2026-08-20 20:54:32 +08:00
ToobLac a10c501d4f [Bugfix] 修复无法读取 zip 后缀名的模组的问题 (#6712) 2026-08-20 20:48:45 +08:00
Glavo 116629743e 重构 GameRepository (#6580) 2026-08-20 20:41:56 +08:00
辞庐andGlavo cfee31d339 清理未使用 i18n 键值 (#6688)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-20 20:41:33 +08:00
辞庐 3ac08d7f62 清理 FXUtils (#6706) 2026-08-19 20:29:12 +08:00
3gf8jv4dv de643becf8 fix: update modrinth category l10n for cyrillic to match upstream (#6695) 2026-08-19 20:28:41 +08:00
辞庐 edbcc1f343 优化无验证服务器提示 (#6705) 2026-08-19 19:27:06 +08:00
辞庐 91fcc4e5ef 清理未使用样式 (#6698) 2026-08-17 20:33:57 +08:00
3gf8jv4dv 34aeba1b1a fix: update modrinth category l10n for en and zh to match upstream (#6671) 2026-08-16 21:05:18 +08:00
辞庐 9a36b86cfb 简化 DownloadPage#download (#6691) 2026-08-16 21:03:26 +08:00
ApostropheandGlavo 71b4c02474 解决导出 Modrinth 整合包因大文件导致的 Out Of Memory (#6631)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-14 21:55:48 +08:00
辞庐andGlavo 45f49d079b 转化 GameVersion 为 record (#6663)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-14 21:47:11 +08:00
BANER 71d9daed16 i18n: add complete German translation (I18N_de.properties) (#6677) 2026-08-14 20:41:22 +08:00
ToobLac 5ae019ba17 fix(modloader): 修复 #6628 Cleanroom 和 LiteLoader 实例不读取子文件夹里的模组 (#6643)
Fixes #6628
2026-08-14 20:25:03 +08:00
辞庐andGlavo 770c58c2e7 优化投影管理文件夹已存在提示 (#6664)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-13 21:52:53 +08:00
ToobLac a03c11be42 fix(macos): 修复 macOS 上打开对话框后按 CMD+Q 无法退出的问题 (#6656) 2026-08-13 21:40:16 +08:00
辞庐 ca3746319f 移除 “操作被取消” toast (#6658) 2026-08-13 21:39:52 +08:00
辞庐andGlavo 56629892f8 将 ForgeInstallProfile 转化为 record (#6665)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-13 21:38:00 +08:00
BANER 5238a361cd i18n: complete Ukrainian translation (I18N_uk.properties) (#6675) 2026-08-13 20:59:33 +08:00
Glavo 0323a7f321 修复在 Linux 平台上错误的将 Intel 核显探测为独显的问题 (#6676) 2026-08-13 20:53:06 +08:00
ToobLac a3c5e8e412 [Bugfix] 修复列表中涟漪状态不更新的问题 (#6586)
Fixes #6549
2026-08-13 20:49:17 +08:00
辞庐 5c364f624a 清理 HttpRequest (#6666) 2026-08-13 20:29:58 +08:00
Glavo 22275f2c57 支持在 Linux 双显卡平台上优先使用 NVIDIA 显卡 (#6670) 2026-08-12 21:55:35 +08:00
辞庐 11e54d199c fix: .gitignore 缺少 hmcl-mod-update-list-* (#6653) 2026-08-12 20:50:20 +08:00
Glavo cf21cf4a3c 修复导出日志文件时文件为空的问题 (#6649) 2026-08-11 21:36:51 +08:00
Apostrophe a0d1fa8f38 修复异步任务 OOM 后界面假死并显示错误信息 (#6636) 2026-08-10 21:58:49 +08:00
Glavo 78b8d37891 Fix #6627: 修复安装新实例时未正确写入 patches 的问题 (#6642) 2026-08-10 21:17:58 +08:00
Glavo 0807c04572 优化 32 位平台自动分配内存策略 (#6641) 2026-08-10 19:45:23 +08:00
Glavo 8eec18450e 修复再次点击实例菜单按钮时菜单重新弹出的问题 (#6633) 2026-08-09 21:41:35 +08:00
Apostrophe 15d4874366 解决导出 MCBBS 整合包因文件过多导致的 Out Of Memory (#6630)
#6086 #6173
2026-08-09 21:22:35 +08:00
ToobLac c149401cf3 [Enhancement] enhance html spacing (#6617) 2026-08-09 20:05:28 +08:00
ToobLac ff459a5bc4 [Enhance] 模组/资源包信息对话框中 CF/MR 链接跳转到网页而非 HMCL 下载页面 (#6620) 2026-08-09 20:05:08 +08:00
Glavo 5796dad18c 改进图像类型探测功能 (#6613) 2026-08-06 19:49:33 +08:00
0443ff7cd1 [Feature] 模组等的更新日志以及 HTMLRenderer 的完善 (#6098)
Resolves #4685

Co-authored-by: Calboot <calboot39@outlook.com>
Co-authored-by: CalbootOnceMore <calboot43@outlook.com>
Co-authored-by: CiiLu <109708109+ciilu@users.noreply.github.com>
2026-08-05 21:56:48 +08:00
3gf8jv4dv 4c6fe865f5 fix(l10n): correct inaccurate text for instance isolation option (#6592)
resolves #6589
2026-08-05 20:48:03 +08:00
辞庐 1e54482f3b 禁用动画时禁用 JFXCheckbox 过渡动画 (#6583) 2026-08-05 00:59:38 +08:00
bluebullet c329f0d2e8 fix(modpack): update DefaultGameBuilder whenComplete exception check (#6593) 2026-08-04 19:34:24 +08:00
Burning_TNT af4d9feea5 修复无法正确安装 MultiMC 整合包的问题 (#6584) 2026-08-04 19:33:20 +08:00
3gf8jv4dv af71c71663 fix(l10n): correct recommend part title on addon ver. list page (#6590)
resolves #6588
2026-08-04 19:12:30 +08:00
快乐凤爪王 4675442ecd Chore: 同步 Modrinth 官方 Modded 分类的 zh 和 zh_CN 翻译 (#6604) 2026-08-04 15:09:31 +08:00
辞庐andGlavo c8929c80d9 关闭动画时跳过侧边栏图标过渡动画 (#6581)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-02 23:50:26 +08:00
ToobLac 81634b14df [Chores] 优化 addon 类型推断逻辑 (#6536) 2026-08-02 19:58:33 +08:00
ToobLac ccca396d91 [Enhancement] 在 IO 线程上清理下载缓存 (#6560) 2026-08-02 19:27:44 +08:00
Glavo 5a14449b44 重构 Decorator (#6557) 2026-08-02 00:10:40 +08:00
辞庐 612998a98f feat: [Windows] 支持自动使用高性能显卡 (#5420)
resolves #4639
2026-08-01 22:14:34 +08:00
快乐凤爪王 a50e327f70 Fix: 防止 DisabledJavaItem 的 Label 响应鼠标事件 (#6563) 2026-08-01 21:31:08 +08:00
快乐凤爪王 7aeb1642b1 Feat: 为 GameList 添加搜索无结果提示 (#6566) 2026-08-01 21:30:26 +08:00
快乐凤爪王 5fc1536d6a Refactor: Style of JavaManagementPage (#6345) 2026-08-01 21:24:27 +08:00
ToobLac 463cf2ff0f [Chores] 遗漏的 mod -> addon 重命名 (#6535) 2026-08-01 21:11:28 +08:00
Glavo 144e822a85 使用 Xvfb 运行 Gradle Action (#6570) 2026-08-01 20:59:38 +08:00
快乐凤爪王 87789beeb7 Fix: 修复 ModDownloadListPage 末尾项 Padding (#6564) 2026-08-01 20:50:06 +08:00
Glavo bb86100c54 Bump Gson to 2.14.0 (#6562) 2026-07-31 21:23:42 +08:00
Glavo 2ceca9d8cc Bump HelloNBT to 0.4.0 (#6561) 2026-07-31 21:23:24 +08:00
快乐凤爪王 d5fc365a96 Feat: Add colors for addon channel icons in DownloadPage (#6441) 2026-07-31 20:38:28 +08:00
辞庐 d5c86a8d88 清理 CurseManifestMinecraft (#6545) 2026-07-31 20:30:36 +08:00
Glavo bd6beb1369 在 Windows 平台支持将 HMCL 固定在任务栏 (#6542) 2026-07-31 20:28:38 +08:00
Glavo b1719e77bb docs: update nullability guidelines in AGENTS.md (#6559) 2026-07-31 20:23:07 +08:00
快乐凤爪王 77f0a90f10 Fix: 在 ModpackSelectionPage 中非 IOException 的异常导致崩溃 (#6551) 2026-07-31 20:19:00 +08:00
庐庐绫 ff6110ba89 fix: 背景图片非当前选项配置重启后丢失 (#6547)
close #6546
2026-07-31 20:18:00 +08:00
快乐凤爪王 e7e964b3dd Refactor: ModpackSelectionPage 文件选择器关闭后不清空页面栈 (#6550) 2026-07-31 20:14:55 +08:00
Glavo 4e29c29a7a 更新模组数据 (2026-07-21) (#6543) 2026-07-30 21:46:55 +08:00
Glavo 25ad1dbf44 修复 GameInstancePage.java 中的拼写错误 (#6533) 2026-07-29 20:38:16 +08:00
辞庐andGlavo d2e7e0d626 实例列表左侧游戏文件夹列表添加平滑滚动 (#6495)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-07-29 20:19:59 +08:00
辞庐andGlavo c68bbf2904 统一管理页面工具栏 下载 按钮文案 (#6499)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-07-29 20:17:57 +08:00
ToobLac d0fc46f930 [Bugfix] 修复导出整合包文件选择界面UI线程阻塞 (#6363)
Fixes #6354
2026-07-29 20:02:24 +08:00
Mike Lei f1b861eb21 fix(build): fix missing icon in the dock/dash of Linux DEs (#6303) 2026-07-29 19:54:33 +08:00
Glavo 5cde4ce757 不再用 Version 表示游戏实例 (#6322) 2026-07-29 18:42:58 +08:00
庐庐绫 d297417909 fix: show/hide 前先 setIconified(false) 确保窗口状态正常 (#6525)
close #6523
2026-07-28 09:40:06 +08:00
Glavo 2854eac27d 在高刷屏上自动提高动画帧率 (#6422) 2026-07-27 21:30:27 +08:00
ToobLac c9fd7c6a1c [Enhancement] 优化主题包导出对话框的交互 (#6516)
Resolves #6512
2026-07-27 21:03:54 +08:00
Glavo 0e0517ab3c 优化模组信息读取速度 (#6515) 2026-07-27 07:12:00 +08:00
ToobLacandGlavo 7a4bea10d0 [Bugfix] 修复 #6348 资源包管理处理非UTF-8编码的options.txt文件时出错 (#6351)
Fixes #6348

---------

Co-authored-by: Glavo <zjx001202@gmail.com>
2026-07-26 21:31:35 +08:00
辞庐 b87d40ffcc 优化 实例列表无版本时点击下载页展示 (#6494) 2026-07-26 20:56:41 +08:00
Glavo 1277bf8b5d 使用 kala-encoding-detector 作为编码推测库 (#6477) 2026-07-26 00:47:43 +08:00
辞庐 c2e807055a fix: 下载整合包时未设置任务名 (#6487) 2026-07-25 21:07:43 +08:00
辞庐 9021ec1960 清理模组管理页面冗余 “实例无模组加载器” 提示 (#6500) 2026-07-25 21:01:22 +08:00
辞庐 82a47e9e2d 优化实例列表界面 (#6493) 2026-07-25 21:00:42 +08:00
neveler 4841204fa0 update i18n (#6491) 2026-07-25 21:00:26 +08:00
辞庐 897f5fe11b fix: 形参化类 'Tab<?>' 的原始使用 (#6498) 2026-07-25 20:57:58 +08:00
Glavo 4657e3273e 复制实例时同步保存游戏设置 (#6492) 2026-07-24 20:39:59 +08:00
Glavo 4edea5f5b9 Add support for Minecraft 26.3-snapshot-5+ to unofficial supported platforms (#6481) 2026-07-23 19:51:00 +08:00
辞庐 36bd7d4491 修复 GameAdvancedListItem#loadVersion UI 操作可能不会 JavaFX 线程运行的问题 (#6445)
resolves #6443
2026-07-22 22:11:55 +08:00
neveler 3aa8603bd1 提取 pr-size-label 工作流中的 js 代码到独立文件 (#6458) 2026-07-22 22:06:57 +08:00
庐庐绫 ca7a61e20a fix: 修复模组列表加载时检查错误实例的模组更新 (#6478)
close #6448
2026-07-22 21:57:59 +08:00
辞庐 21afc81cf8 fix: NetwordUtils#doPost(URI u, Map<String, String> params) 未进行 URL 编码 (#6457) 2026-07-22 21:49:46 +08:00
neveler 94a0e26be5 update i18n: remove game.crash.reason.mod_repeat_installation (#6472) 2026-07-22 21:47:13 +08:00
辞庐 14e8d2d0b5 清理 VanillaInstallWizardProvider (#6474) 2026-07-22 21:46:49 +08:00
快乐凤爪王 49c408cb23 Refactor: InstallersPageSkin versionNamePane (#6349) 2026-07-22 21:45:49 +08:00
辞庐 b81c3398ac fix: 切换版本隔离设置后自动刷新其他子页面 功能失效 (#6468) 2026-07-22 13:21:58 +08:00
快乐凤爪王 2855df3693 Feat: Implement dynamic folder icon and use to game directory list (#6435) 2026-07-21 22:03:32 +08:00
快乐凤爪王 8184dc6cee Fix: Alignment for background opacity percentage label (#6450) 2026-07-21 21:55:12 +08:00
快乐凤爪王 df21559c0e Chore: Clean up duplicate key-value pairs for No results found (#6449) 2026-07-21 21:53:21 +08:00
快乐凤爪王 73ff3081f8 Feat: No results found tip for ThemePackManagementPage (#6451) 2026-07-21 21:49:52 +08:00
辞庐 fe05bb58a4 更新时 日志输出新进程命令行时 打码敏感信息 (#6456) 2026-07-21 21:30:32 +08:00
Glavo 4c9cd950e4 移除对 JavaFX 14 的兼容性 (#6467) 2026-07-21 20:40:56 +08:00
neveler 646d9a09a5 update i18n (#6466) 2026-07-21 20:38:56 +08:00
Glavo 255fe75640 Bump JavaFX to 21.0.11 on FreeBSD x86-64 (#6463) 2026-07-21 19:40:18 +08:00
neveler de15dd8e98 Remove extra whitespace (#6462) 2026-07-21 17:57:57 +08:00
Glavo 886319a6c7 避免在代码中直接引用 Controllers.getStage() (#6431) 2026-07-20 21:41:55 +08:00
辞庐 3320f6c341 fix: 整合包安装完成回调执行器不统一 (#6446) 2026-07-20 21:21:33 +08:00
快乐凤爪王 b85be78166 Feat: Add empty state placeholders for versions (#6426) 2026-07-20 21:21:06 +08:00
Glavo a34a7439d7 Clarify nullability and documentation in AGENTS.md (#6440) 2026-07-20 00:02:59 +08:00
ZZZank 591fd51600 实现游戏日志窗口开关自动换行 (#6424) 2026-07-19 21:53:57 +08:00
快乐凤爪王 c1407a9620 Fix: Resolve ModLoaderType.LEGACY_FABRIC for Modrinth (#6434) 2026-07-19 02:46:28 +08:00
Glavo e0a91579f2 更新内置主题相关译文 (#6429) 2026-07-17 21:29:51 +08:00
快乐凤爪王 b68f404c3c Refactor: Style of ModInfoDialog (#6423) 2026-07-17 21:28:02 +08:00
Glavo c1f50f9ba3 删除 Gemini 审查配置文件 (#6430) 2026-07-17 21:17:31 +08:00
辞庐 d8bb2131f2 允许拖入 nbt 文件 (#6427) 2026-07-17 21:08:45 +08:00
Glavo aca761385b Add support for Minecraft 26.3-snapshot-4+ to unofficial supported platforms (#6425) 2026-07-17 21:00:14 +08:00
3gf8jv4dv 15faccf376 chore(workflow): add pr-size-label workflow file (#6381) 2026-07-17 20:23:48 +08:00
Glavo 241c9c419c 修复 FXUtils 意外的被提前初始化的问题 (#6421) 2026-07-16 21:29:12 +08:00
辞庐 4b36f8ce03 fix: 任务窗口速度与取消未对齐 (#6140) 2026-07-16 21:28:31 +08:00
Glavo 5c0baad782 提早加载配置文件 (#6420) 2026-07-16 20:50:20 +08:00
Apostrophe 850a56405c 修复游戏 Java 路径显示滞后问题 (#6419) 2026-07-16 19:59:17 +08:00
快乐凤爪王 e8487c467b Refactor: Replace deprecated URL constructor (#6409) 2026-07-16 03:37:54 +08:00
Glavo 9866d88a16 完善游戏设置页面帮助文本 (#6415) 2026-07-15 21:15:00 +08:00
辞庐 af2d46ae89 Revert "Refactor: Remove redundant downloadSourceComboBox from ModDownloadListPageSkin" (#6410)
Reverts HMCL-dev/HMCL#6370
2026-07-15 19:56:20 +08:00
ToobLac 6f7c561ecb [Bugfix] 修复 1.21.1 安装信雅互联仍显示不支持 Fabric 模组的问题 (#6404)
Fixes #6391
2026-07-14 21:41:39 +08:00
ToobLac 93abe31acf [Chores] 处理升级到 9.6.1 后 Gradle 弃用提示 (#6405) 2026-07-14 21:29:58 +08:00
Glavo a6f8659349 Bump Gradle Wrapper Neo to 0.2.0 (#6397) 2026-07-14 00:06:56 +08:00
Glavo 8728c89093 Bump version root to 3.17 (#6392) 2026-07-13 20:34:38 +08:00
辞庐 2dccd8099d Bump authlib-injector to 1.2.8 (#6390) 2026-07-13 20:33:23 +08:00
Glavo d8a03cfed8 支持主题色跟随系统 (#6385) 2026-07-13 03:42:50 +08:00
Glavo 26a874a030 Bump Gradle to 9.6.1 (#6388) 2026-07-13 03:00:15 +08:00
Glavo 7c5c3e2fb4 使用 Gradle Wrapper Neo 代替 Gradle Wrapper (#6387) 2026-07-13 02:52:47 +08:00
Glavo 62d7b36248 添加“窗口透明”选项 (#6383) 2026-07-13 00:41:12 +08:00
Glavo dec6837528 修复 Minecraft 26.1+ 无法安装 Quilt 的问题 (#6386) 2026-07-13 00:38:52 +08:00
快乐凤爪王 3484792e81 Feat: Added status label opacity when installer item is incompatible (#6335) 2026-07-12 22:37:57 +08:00
快乐凤爪王 2b310e035d Fix: Lock AccountListItemSkin.btnRefresh width to prevent layout shifting (#6369) 2026-07-12 22:01:34 +08:00
Glavo 6e960612dd 固定默认游戏文件夹和官方启动器游戏文件夹的 ID (#6382) 2026-07-12 22:00:30 +08:00
辞庐 73a776b021 fix: 整合包安装界面高度限制失效 (#6371) 2026-07-12 21:46:46 +08:00
辞庐and3gf8jv4dv 31a2f49ed2 更新 fatal.unsupported_platform 设置指引 (#6251)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-07-12 20:49:51 +08:00
快乐凤爪王 ab268cfe48 Feat: DisplayName for JavaPackageType (#6377) 2026-07-12 20:35:32 +08:00
Glavo 721aeccfc0 修复 JFXPasswordField 导致 CPU 占用率异常增高的问题 (#6373) 2026-07-12 01:35:01 +08:00
Glavo e4348f727a 优化下载线程数设置 UI (#6374) 2026-07-12 01:34:17 +08:00
快乐凤爪王 dfc96f10da Refactor: Remove redundant downloadSourceComboBox from ModDownloadListPageSkin (#6370) 2026-07-11 21:19:37 +08:00
快乐凤爪王 a5c7fe08d8 Feat: No results found tip for ModDownloadListPage (#6368) 2026-07-11 02:37:09 +08:00
Glavo a6e0acca45 在滚动条处于按下状态时保持不透明状态 (#6366) 2026-07-10 23:57:20 +08:00
Glavo 3d312f9247 修复在 JFXComboBox 弹出窗口的滚动条上释放鼠标时也会导致弹出窗口隐藏的问题 (#6365) 2026-07-10 21:44:51 +08:00
Glavo 18dc5b69c0 Fix #6320: 修复安装新实例时,Fabric API 安装路径未遵循版本隔离策略的问题 (#6355) 2026-07-10 20:36:29 +08:00
快乐凤爪王 98af2c85b6 Feat: Animate menu button arrow counter-clockwise on popup display (#6344) 2026-07-10 20:35:39 +08:00
Glavo c4c0b1a1ce 修复自动分配内存逻辑异常的问题 (#6356) 2026-07-10 20:21:44 +08:00
辞庐 5e621ce340 HttpRequest 添加 PUT 与 DELETE 请求支持 (#6312) 2026-07-09 21:02:14 +08:00
快乐凤爪王 1d1c8dfdc9 Fix: Adjusted the size of the ModInfoListCell ResourcePackListCell icon (#6347) 2026-07-09 20:55:33 +08:00
快乐凤爪王 9b68b8825d Chore: Removed unnecessary punctuation marks of allow_auto_agent.subtitle (#6346) 2026-07-09 20:55:11 +08:00
快乐凤爪王 5c17783223 Fix: Restore subtitle when RemoteVersionListCell is reused (#6329) 2026-07-08 22:03:59 +08:00
Glavo a921be45f1 Fix #6290: GameAdvancedListItem 应等待 HMCLGameRepository 加载完成后再加载图标 (#6325) 2026-07-08 21:59:06 +08:00
辞庐 2dd1c0f85e 优化日志窗口配色 (#6313) 2026-07-08 21:54:24 +08:00
快乐凤爪王 7ba229b3e5 Fix: Adjust GameListCell spacing (#6337) 2026-07-08 21:47:49 +08:00
辞庐 59a299630b fix: #6328 MCIM 描述没有对齐 (#6333) 2026-07-08 21:46:26 +08:00
快乐凤爪王 b41b89e233 Feat: Added numbered square tag to Java runtime list cells (#6334) 2026-07-08 21:40:50 +08:00
ToobLac af137e5ff1 [Bugfix] 修复 #6338: macOS 导出整合包默认名称包含2个后缀名 (#6340)
Fixes #6338
2026-07-08 21:39:37 +08:00
Glavo 74ddbf5623 Streamline memory allocation options and improve preset management (#6330)
Fix #6307
2026-07-08 21:15:36 +08:00
快乐凤爪王 accef4a93f Fix: WorldListPage MDListCell tags (#6339) 2026-07-08 20:37:16 +08:00
快乐凤爪王 9c3a955fad Refactor: Launch pane button (#6341) 2026-07-08 20:27:35 +08:00
Glavo 1c0e506d34 Enhance renderer selection based on game version (#6316) 2026-07-05 20:42:24 +08:00
庐庐绫 6374ec0de7 fix: 修复联机核心第三方下载渠道按钮高度不一致 (#6247)
close #6245
2026-07-05 20:06:23 +08:00
快乐凤爪王 a6b3872af4 Fix: Fixed truncated authlib injector warning text (#6301) 2026-07-05 20:06:02 +08:00
辞庐 ad98a5a6d4 HMCL ProcessListener 游戏退出日志行开头添加时间 (#6314) 2026-07-05 20:04:51 +08:00
快乐凤爪王 05393af537 Fix: Adjust account item spacing (#6302) 2026-07-05 19:57:14 +08:00
Glavo 3438da2ab9 Add game version handling for graphics backend visibility (#6300) 2026-07-05 19:56:02 +08:00
辞庐 3163b465ec fix: 拷贝 uuid 按钮冗余 SpinnerPane (#6315) 2026-07-05 19:54:19 +08:00
辞庐 573dac471c 禁用密码框右键菜单的 剪切 & 复制 按钮 (#6310) 2026-07-05 19:53:43 +08:00
ToobLacandGlavo 12f54f353a [Bugfix] 修复 CF 文件下载未带有 api key 的问题 (#6297)
Fixes #6293

Co-authored-by: Glavo <zjx001202@gmail.com>
2026-07-03 21:26:58 +08:00
Glavo 246699d571 refactor: update preset management terminology for clarity (#6298) 2026-07-03 21:26:14 +08:00
Haoran Xu 3480e6974b Docs: synchronize localized README content (#6235) 2026-07-03 21:12:23 +08:00
Zkitefly 6d14351833 feat(mod): 支持读取Forge新模组元数据中的logo文件 (#6253) 2026-07-03 20:22:24 +08:00
Glavo fd0d83257d 调整旧版实例设置迁移与默认父预设解析逻辑 (#6287) 2026-07-03 20:19:20 +08:00
ToobLac 0eb1d3cf4b [Enhancement] 无论何时都显示关闭愚人节模式的按钮 (#6105)
Closes #5982
2026-07-02 21:54:39 +08:00
辞庐 f9b8dad3d2 修复安装手动打包整合包时任务名未本地化的问题 (#6248) 2026-07-02 21:50:49 +08:00
Glavo 57fbd0910e 不再将从旧版本游戏文件夹设置中迁移出的游戏预设名称设置为文件夹名称 (#6292) 2026-07-02 21:47:51 +08:00
快乐凤爪王 7767a57f50 Fix: Restrict JVM memory text fields to numeric input only (#6260) 2026-07-02 21:46:11 +08:00
快乐凤爪王 f024dba940 Fix: Dynamically lock fixedCellSize of VersionsPageSkin (#6288) 2026-07-02 21:15:03 +08:00
Wulian233 52736d3c86 导出主题时支持设置主题版本 (#6284) 2026-07-02 21:06:36 +08:00
ToobLac f75b472068 [Chores] 其他类向记录的转换 (#6277) 2026-07-02 21:03:36 +08:00
ToobLac d8e0c9e9c7 [Chore] 优化default_addon_source的中文翻译 (#6278) 2026-07-01 20:38:04 +08:00
快乐凤爪王 9601c1c1ea Fix: Aligned the height of the jfx-color-picker with the jfx-text-field (#6285) 2026-07-01 20:37:30 +08:00
ToobLacandGlavo d5c7f9bef3 [Enhancement] 导出整合包-文件选择优化 (#6115)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-06-30 21:25:29 +08:00
Zkitefly 1714ffd260 修复 Curse 整合包更新后未移除旧的模组 (#6185)
Fix #4315
2026-06-30 21:12:35 +08:00
辞庐andGlavo fa06be865c fix: #5343 陶瓦联机界面账号快速切换列表右侧滚动条错位以及列表莫名抽搐 (#6109)
fixes #5343

---------

Co-authored-by: Glavo <zjx001202@gmail.com>
2026-06-30 21:09:13 +08:00
快乐凤爪王 9dc69dbc2b Refactor: Merge the duplicate logic of PriorityComparator.of(...). (#6220) 2026-06-30 21:01:34 +08:00
Burning_TNT 43b6fdb5ee Update GameLibrariesTask.java (#6240)
Fix #6208
2026-06-30 21:00:28 +08:00
NoClassDefFoundError e6598f23de Fix #6243 修复在实例列表加载完成前切换实例文件夹导致的空指针问题 (#6244)
resolves #6243
2026-06-30 20:58:41 +08:00
Wulian233 0f51934e1c 对下载线程数输入框添加校验 (#6266) 2026-06-30 20:57:43 +08:00
辞庐andGlavo 122d474eeb fix: #5516 Java 管理无法安装部分 Java (#6249)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-06-30 20:52:29 +08:00
快乐凤爪王 6223ceedff Feat: Add name validation for preset creation and renaming (#6258) 2026-06-30 20:51:14 +08:00
ToobLac 823b74b7e5 [Enhancement] 下载界面的依赖列表采用按项目 slug 排序 (#6207)
Closes #6206
2026-06-30 20:38:45 +08:00
快乐凤爪王 31642f9185 Refactor: OfflineAccountSkinPane (#6231) 2026-06-30 20:37:47 +08:00
辞庐 c8f66a457d feat: 主题包管理界面支持拖拽导入 (#6271) 2026-06-30 20:27:48 +08:00
快乐凤爪王 65c76c53d5 Feat: Rippler for ThemePackInfoDialog (#6274) 2026-06-30 20:25:35 +08:00
Glavo 7580c6da82 重构游戏目录管理逻辑 (#6273) 2026-06-30 20:21:50 +08:00
ToobLac d8938e1fcb [Chore] auth 有关的部分类转换为记录类 (#6268) 2026-06-30 02:08:22 +08:00
辞庐 c0f699b90d 优化主题包管理页面列表项 (#6270) 2026-06-30 02:07:01 +08:00
Wulian233 3bc8d441a1 翻译模组分类的Cursed类别 (#6267) 2026-06-30 00:00:57 +08:00
辞庐 6c7681d015 fix: 主题包管理页面缺少 gray-background (#6269) 2026-06-29 23:43:35 +08:00
辞庐andGlavo 24c36c8f20 转化 AssetObject、ExtractRules 为 record (#6239)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-06-29 21:58:00 +08:00
快乐凤爪王 c023242afc Fix: Center toast message (#6262) 2026-06-29 21:56:52 +08:00
快乐凤爪王 194ed4c255 Chore: Language File (addon.curseforge addon.modrinth) (#6214) 2026-06-29 21:56:10 +08:00
Glavo 28022461f7 重构主题系统 (#6229) 2026-06-29 21:52:30 +08:00
Wulian233 55316c18bc 更新 Chunkbase 种子地图 (#6265) 2026-06-29 21:06:57 +08:00
快乐凤爪王 beb6882921 Fix: Fixed the alignment of deprecatedJvmMemorySettings memory unit (#6257) 2026-06-29 20:40:34 +08:00
快乐凤爪王 2ba56a1137 Fix: Fixed Tags disappearing on the version page (#6264) 2026-06-29 20:30:57 +08:00
快乐凤爪王 a127254341 Fix: Reduce excessive bottom margin of image container (#6263) 2026-06-29 20:26:41 +08:00
快乐凤爪王 6086e6ba6e Refactor: Removed java_permanent_generation_space.prompt (#6259) 2026-06-29 04:53:39 +08:00
庐庐绫 4baf983514 fix: Java 管理页面点击列表项时 Tag 会闪烁 (#6256)
close #6049
2026-06-29 00:43:35 +08:00
辞庐 4dc8e2a6ff 更新 关于 -> 依赖 列表 (#6250) 2026-06-28 20:25:47 +08:00
辞庐 cf9dcf04c3 清理过期 i18n 键值 (#6252) 2026-06-28 20:22:41 +08:00
ToobLac 677c29e232 [Bugfix] 允许模组文件名包含感叹号 (#6226)
Fixes #6222
2026-06-26 21:52:25 +08:00
快乐凤爪王 1d35f49af8 Fix: Fix the alignment issue of the icon in TerracottaControllerPage. (#6227) 2026-06-26 21:03:18 +08:00
Glavo 9e2f1d3151 使用 HttpURLConnection 替换 HttpClient 实现 (#6219)
#6215
2026-06-26 20:03:40 +08:00
8f9f800c51 fix(#6154): layout race in instance download page (#6155)
Fix #6154

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: CiiLu <109708109+CiiLu@users.noreply.github.com>
2026-06-25 21:54:05 +08:00
快乐凤爪王 1f8ba73bb1 Fix the alignment issue in the game download list (#6211) 2026-06-25 21:34:47 +08:00
快乐凤爪王 415fcc1ac7 Chore: Completed the .gitignore (#6217) 2026-06-25 16:43:07 +08:00
ToobLacand3gf8jv4dv da3cf3056b [Chores] Addon 有关类和翻译的整理统一 (#6176)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-06-23 21:16:54 +08:00
辞庐 d81d88d3f9 支持置顶日志窗口 (#6101) 2026-06-23 20:16:48 +08:00
辞庐 d5dee7146c 清理代码 (#6180) 2026-06-23 20:04:49 +08:00
436f664a1c [Enhancement] 不在主界面,或者已显示对话框的情况下,暂时不弹出启动器更新对话框 (#6095)
Co-authored-by: Calboot <calboot39@outlook.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: CalbootOnceMore <calboot43@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-06-21 16:25:52 +08:00
Glavo 0e04566b6c 修复无法下载 SHA-1 为空字符串的文件的问题 (#6170) 2026-06-21 15:04:29 +08:00
3gf8jv4dv f9e9939b1f chore: update versions.txt to 26.2 (#6168) 2026-06-21 15:02:05 +08:00
ToobLac 5d1e6fdc25 [Chore] CF 下载文件也使用 API Key (#6169)
Closes #6167
2026-06-21 15:01:26 +08:00
辞庐 8011eee586 统一 使用非官方构建时的提示 用词 (#6196) 2026-06-21 14:46:28 +08:00
辞庐 90eb622903 优化外置登录账号名称展示 (#6204) 2026-06-21 14:04:52 +08:00
CiiLu f0fcc4ac5e // FXThread -> @FXThread (#6195) 2026-06-19 19:52:46 +08:00
Glavo d35d1fc81e 删除过时的 PLANS.md (#6198) 2026-06-19 18:44:48 +08:00
Glavo e9a9efd0cc 重构启动器配置模型 (#6122) 2026-06-19 11:05:24 +08:00
Shadow 30acc53ee8 添加 launch.advice.cleanroom 的乌克兰语翻译 (#6188) 2026-06-16 21:38:55 +08:00
Shadow f7b1c7cd8c 修复多语言资源缺失、占位符错误与本地构建依赖路径问题 (#6183) 2026-06-16 20:46:00 +08:00
3gf8jv4dv 506f1c1d60 fix: adjust position of 26w14a in versions.txt (#6171) 2026-06-16 20:42:44 +08:00
快乐凤爪王 ca2f8a5cda Perf: Set fixed cell size for versions list (#6186) 2026-06-16 20:16:09 +08:00
Glavo 1f48b10eb9 Bump version root to 3.16 (#6172) 2026-06-14 00:56:24 +08:00
Glavo 40d21bbe24 修复无法为 MC 1.21.11 安装 Forge 的问题 (#6160) 2026-06-02 21:11:37 +08:00
Glavo d0be7efca3 将下载超时时间放宽至 30 秒 (#6151) 2026-05-30 11:50:55 +08:00
CiiLuand3gf8jv4dv 3bed03c535 设备代码流在完成授权后隐藏二维码 (#6087)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-05-29 21:08:05 +08:00
Glavo d295e606a9 修复无法用中文搜索游戏内容的问题 (#6129) 2026-05-28 20:47:41 +08:00
CiiLu 36afe32146 账户选择 popup 为已选择的账户添加底色 (#6111) 2026-05-28 20:38:36 +08:00
CiiLu 87dbbdd96f 优化陶瓦联机反馈提示 (#6120) 2026-05-28 20:35:44 +08:00
CiiLu 9b8e67b1b3 优化游戏列表 Popup 列表项大小 (#6119) 2026-05-28 20:27:12 +08:00
Glavo f5a5e6a6da Enhance nullability annotations and documentation in LocalizedText (#6149) 2026-05-28 20:19:16 +08:00
Glavo f0466c1d8e docs: remove outdated Gradle usage instructions from AGENTS.md (#6148) 2026-05-28 20:02:03 +08:00
3gf8jv4dv 903ecbb127 chore(l10n): rename Xbox to XBOX (#6130) 2026-05-27 21:03:16 +08:00
ENC_Euphony 4c6f3aeb99 fix: avoid stale versions in switch-instance popup (#6133)
Fix #6099
2026-05-27 20:05:04 +08:00
Glavo d6a4b3480f 增强 JsonUtils (#6145) 2026-05-25 20:55:37 +08:00
Glavo 5e84e5bcd6 Bump version root to 3.15 (#6144) 2026-05-25 11:05:28 +08:00
Glavo 6ce7af05b2 chore: add gradle.properties to .gitignore (#6132) 2026-05-21 21:30:48 +08:00
CiiLu 0d62a12353 fix: 列表工具栏水波纹溢出 (#6117) 2026-05-20 22:04:27 +08:00
880aab0ba5 [Feature] 资源包管理 #4980 reforged reforged reforged (#6091)
Co-authored-by: Calboot <calboot39@outlook.com>
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: CalbootOnceMore <calboot43@outlook.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-05-20 21:57:19 +08:00
Glavo 0b64e89aaa Update line breaks (#6126) 2026-05-19 12:02:05 +08:00
Wulian233 7b6379674b 在已有账户的情况下移除联机和主页的玩家名称tooltip (#6123) 2026-05-18 21:37:46 +08:00
CiiLu 55f4b0d8fc feat: 实例设置的浏览菜单中添加崩溃日志文件夹快捷打开选项 (#6110) 2026-05-18 21:26:19 +08:00
CiiLu 863c3c9c7f fix: 删除 mmc-bootstrap 后无法补全 (#6102)
resolves #6064
2026-05-18 21:23:29 +08:00
ToobLac 1f5fb983b0 [Bugfix] 修复 CurseForge 模组类别翻译错误 (#6106) 2026-05-18 21:09:35 +08:00
CiiLu 856d5ec5af fix: LineSelectButton 在页面底部时方向异常 (#6118) 2026-05-18 20:51:16 +08:00
CiiLu 20a5670876 移除主页面 “下载” 按钮的 Tooltip (#6121) 2026-05-18 20:30:17 +08:00
Glavo bdf6a58be8 在无法显示 Swing 窗口时不显示 JavaFX 下载进度对话框 (#6074) 2026-05-16 03:00:31 +08:00
Glavo 7c74dd438f Add .gitattributes for line ending normalization and file type definition (#6089) 2026-05-15 16:50:02 +08:00
Glavo 5803235407 使用 WebURL for Java 解析 URI (#6084) 2026-05-12 21:43:19 +08:00
CiiLu afbf567fef 缓解离线皮肤 CSL 输入框打一个字弹窗一个 toast “操作失败” 问题 (#5789) 2026-05-09 21:26:06 +08:00
Glavo 72b3c1981a 支持断点续传 (#4558) 2026-05-09 21:02:27 +08:00
Glavo 72ee82543b Use null-safe converter for LineSelectButton instances (#6075) 2026-05-09 08:54:14 +08:00
Glavo 996c9f4c86 检测到系统 zlib 实现为 zlib-ng 时跳过 Forge/NeoForge 安装时的哈希检测 (#6068) 2026-05-08 12:13:06 +08:00
3gf8jv4dv f8c1e97376 fix(l10n): edit Version List options on Settings > Downloads for zh-hans (#6069) 2026-05-07 21:51:20 +08:00
CiiLu f146040ff3 fix: 版本列表项目点一下 tag 就会消失 (#6067) 2026-05-06 21:02:27 +08:00
辞庐 0da28173ab 优化设置 -> 通用页面 (#6014) 2026-05-04 17:15:38 +08:00
辞庐 be822666a7 导出 MultiMC 整合包时不再写入 MaxMemAlloc (#6032) 2026-05-04 17:12:24 +08:00
辞庐 8c99ee3b0d 优化种子隐藏按钮 (#6036) 2026-05-04 17:03:11 +08:00
Zkitefly 0e3b4a7853 Update link to software name and version number location (#6039) 2026-05-04 16:59:22 +08:00
辞庐 615b1dbaae fix: #5844 整合包实例版本号在特定场景下不显示 (#6054) 2026-05-04 16:53:56 +08:00
辞庐 0fa51f830d 旁观者模式 -> 旁观模式 (#6053) 2026-05-04 15:46:11 +08:00
ShulkerSakura 007bb345d0 Fixed wrong skin review model (#6046) 2026-05-04 15:44:11 +08:00
CalbootOnceMore 46536f11d3 [Chore] Transform FileDownloadTask.IntegrityCheck into record (#6052) 2026-05-03 20:44:10 +08:00
Glavo b1232b5dc8 缩放 8mi-tech.png 到合适的大小 (#6051) 2026-05-03 14:16:12 +08:00
8Mi_Yile f8f047d67e chore: thanks新增 八蓝米科技 (#6045) 2026-05-03 13:51:37 +08:00
辞庐 86529c6f15 清理 SwingFXUtils (#6030) 2026-05-01 11:15:11 +08:00
CalbootOnceMore d63829c344 [Chore] upgrade.RemoteVersion 转换为记录类 (#6029) 2026-04-30 21:37:54 +08:00
辞庐 34359a7e86 输入框 错误提示与确认按钮间添加间距 (#6025) 2026-04-30 20:50:29 +08:00
辞庐 aa335d8365 fix: 世界管理 -> 创建备份 点击后启动器崩溃 (#6023) 2026-04-30 20:49:52 +08:00
辞庐 a565d3b7ce 移除关于页面 “版权” 项超链接 (#6026) 2026-04-30 20:36:38 +08:00
辞庐 3bff3c7f23 邀请码输入框添加非空校验 (#6024) 2026-04-30 12:50:20 +08:00
4e6e832e9c feat: 文件选择器支持选择文件和文件夹 (#5806)
Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-04-27 21:25:50 +08:00
辞庐 d6648a840e 优化微软登录未购买提示 (#5611) 2026-04-26 21:57:51 +08:00
辞庐 f8c2af716f fix: 主页面更新提示未设置鼠标指针为手形 (#6012) 2026-04-26 20:48:55 +08:00
辞庐 bd812ccb67 优化设置页更新按钮 (#6010) 2026-04-26 20:48:21 +08:00
辞庐 dad190f7e6 添加外置登录账户弹窗使用 JFXHyperLink (#6013) 2026-04-26 20:47:11 +08:00
Wulian233 1b1ba64a38 Fix 5963: 愚人节版本 Minecraft 2.0 的 Legacy Fabric 下载列表为空 (#6009) 2026-04-26 20:46:07 +08:00
辞庐 e31268c39d fix: 微软登录完成页弹窗缺少圆角 (#6006)
https://github.com/HMCL-dev/HMCL/pull/5832
2026-04-26 14:41:58 +08:00
Glavo cb9d32dc91 构建时创建 deb 包 (#5981) 2026-04-26 14:40:05 +08:00
辞庐 36568df5b1 rename NEO_FORGED to NEO_FORGE (#6008) 2026-04-26 13:55:34 +08:00
Glavo b4ac42d789 Add AGENTS.md (#5996) 2026-04-25 12:56:45 +08:00
Glavo 32aafb11a3 增强对 x86-64 转译支持状态的检测 (#5993) 2026-04-24 21:25:08 +08:00
Glavo 36dab70b7a 修复 CurseForge 源中内容不显示 Minecraft 26.x 版本的问题 (#5994) 2026-04-24 20:44:06 +08:00
Glavo ebf253b1a7 当鼠标未悬停在滚动条上时使滚动条半透明 (#5991) 2026-04-23 21:35:27 +08:00
辞庐 539dcca5ca 优化工具栏“全选”功能 (#5867) 2026-04-23 21:24:47 +08:00
neveler 659b6c2a70 优化文档中的版本声明周期示例 (#5920) 2026-04-22 22:08:39 +08:00
Glavo 3dcc7ab311 支持通过环境变量设置 HMCL 配置文件夹 (#5987) 2026-04-22 21:21:46 +08:00
辞庐 e0ccb58272 fix: 更新时不保存窗口位置 (#5819) 2026-04-22 21:12:10 +08:00
XirrenandGlavo d4ec8ebec8 对于jar路径,将感叹号视为非法字符 (#5976)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-04-22 21:05:20 +08:00
ENC_Euphony f3b21e5099 fix: place datapack in resource packs folder (#5988)
fix #5970
2026-04-22 20:49:05 +08:00
ENC_Euphony 202fd38180 fix: close Files.list stream when reading world archive root (#5986) 2026-04-21 20:36:19 +08:00
Damon LuandGlavo f5768f2f95 在 macOS 下随明暗模式设置 NSAppearance (#5778)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-04-20 23:21:36 +08:00
3gf8jv4dv 2d377d06de fix(l10n): revise Version List options on Settings > Downloads page (#5974) 2026-04-20 22:13:24 +08:00
辞庐 42d1581ba0 fix: 可输入的 combobox 内容没有与不可输入的对齐 (#5635) 2026-04-20 22:11:56 +08:00
3gf8jv4dv 995a23c2bc fix: restore tip when mod loader version list is empty (#5978) 2026-04-20 22:08:31 +08:00
Calboot 8969f55a1f [Bugfix] 使用 tomlj 代替 toml4j 以适配 TOML 标准 (#5863)
Resolves #5544
2026-04-20 21:35:47 +08:00
neveler 312a285792 HTMLRenderer 支持嵌套列表 (#5912) 2026-04-20 21:31:09 +08:00
Glavo d85b052195 更新 .gitignore 文件以忽略 codex 相关文件 (#5979) 2026-04-20 21:24:07 +08:00
GlavoandCopilot cd0d3d1b97 修复添加默认 JVM 参数时可能覆盖通过外部文件传递的参数的问题 (#5966)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-20 20:58:13 +08:00
Glavo 6ccc7df998 Fix #5862: 修复 CurseForge Addon Logo 中不包含 thumbnailUrl 时无法加载图标的问题 (#5971) 2026-04-19 22:20:04 +08:00
Glavo 73e5909a69 使用 jwebp 代替 TwelveMonkeys 加载 WebP 图像 (#5968) 2026-04-19 21:06:23 +08:00
辞庐 ade487277d 优化滚动条样式 (#5784) 2026-04-18 22:00:21 +08:00
3gf8jv4dv e755d17977 fix(l10n): revise Download Source options on Settings > Downloads page (#5644) 2026-04-18 21:57:37 +08:00
Damon Lu 292288de9e 改善启动脚本导出 (#5906)
Resolve #5200
2026-04-18 21:45:52 +08:00
Xirren 57f6dec462 整合包完整性检查忽略已升级模组 (#5918)
close #4812
2026-04-18 21:44:34 +08:00
ENC_EuphonyandGlavo 8e1a549c9d feat: modrinth loader tag aggregate display (#5964)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-04-18 21:38:20 +08:00
辞庐andgemini-code-assist[bot] 1d18eae377 fix: 下载页面部分模组 subtitle 换行 (#5637)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-17 22:20:08 +08:00
辞庐 6ef04d2fa5 对禁止取消的操作禁用取消按钮 (#5948) 2026-04-17 22:12:54 +08:00
ENC_Euphony 1778e9d916 fix: select logs outside the screen was not deselected correctly (#5955)
Fix #5884
2026-04-17 22:08:23 +08:00
Glavo dbb63e9faa 移除 ComponentSublist 内部元素的缩进 (#5956) 2026-04-17 21:24:15 +08:00
Glavo 6b1c3182eb Bump version root to 3.14 (#5961) 2026-04-17 10:42:57 +08:00
辞庐 82460ebbcb fix: 下载页面标签显示未进行 shouldDisplayCategory 判断 (#5946) 2026-04-12 21:50:40 +08:00
辞庐 3e8a9bf1c4 清理 DecoratorSkin 冗余 separator (#5947) 2026-04-12 21:50:26 +08:00
辞庐andGlavo a2db925e86 优化 MenuSeparator 颜色 (#5944)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-04-12 21:44:42 +08:00
辞庐 fe70cc3daf fix: 模组名字过长导致下载页面布局异常 (#5943) 2026-04-12 21:40:05 +08:00
gwdsirandGlavo 5991112cd8 .gitignore 增加 /.gemini/ (#5927)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-04-12 21:37:19 +08:00
辞庐 084d91c62a 增大自动安装界面加载器名称版本号间距 (#5945) 2026-04-12 21:03:01 +08:00
辞庐 4963f7a5f0 上传微软账户皮肤时校验大小 (#5949) 2026-04-12 20:57:03 +08:00
Glavo f42d5219c2 支持更多 Vulkan 渲染器 (#5940) 2026-04-12 20:41:39 +08:00
Glavo 101a9b5d5e 禁止在 LogWindow 中显示横向滚动条 (#5935) 2026-04-11 00:43:35 +08:00
Glavo 7cd79a4e2a Update versions.txt to 26.1.2 (#5934) 2026-04-10 22:49:40 +08:00
Glavoand3gf8jv4dv 30c3acfb23 增强对 Vulkan 的支持 (#5930)
1. 优化渲染器选项描述;
2. 添加 Vulkan 相关的渲染器选项。

---------

Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-04-10 22:19:51 +08:00
Glavo 31f04db555 支持将新格式快照版本号翻译为文言 (#5931) 2026-04-09 00:16:57 +08:00
Glavo 7d41f7bdd3 Add support for Minecraft 26.2-snapshot-1+ to unofficial supported platforms (#5928) 2026-04-08 01:48:50 +08:00
辞庐 8e6ac947ed fix: 安装整合包部分任务未正确归类 (#5922) 2026-04-06 20:38:07 +08:00
辞庐 20e321f502 优化原理图管理&世界管理输入校验 (#5923) 2026-04-06 20:36:49 +08:00
辞庐 00b323e507 优化 Java 下载弹窗 Spinner (#5924) 2026-04-06 20:35:43 +08:00
辞庐 333b593c8a 优化添加认证服务器 http 警告颜色 (#5925) 2026-04-06 20:31:31 +08:00
Glavo 665f6df142 Add IDEA copyright configuration files (#5919) 2026-04-06 13:12:07 +08:00
辞庐 9e16130537 fix: Alex 模型皮肤 3D 预览手臂背后有一列侧面的异常像素 (#5688) 2026-04-05 23:16:40 +08:00
Glavo 5c98d6734d 修复多次打开微软登录弹窗后报错的问题 (#5911) 2026-04-05 23:13:25 +08:00
辞庐 c3801a3445 优化下载页面依赖项展示 (#5872) 2026-04-05 23:10:53 +08:00
辞庐andGlavo 1d137a3a88 优化 ComboBox 展开图标 (#5917)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-04-05 22:57:37 +08:00
辞庐 2e8b9ae3ca fix: 长按左侧关闭返回按钮可以拖动窗口 (#5915) 2026-04-05 22:57:00 +08:00
Calboot fb750e8717 [Bugfix] 修复导入整合包界面箭头颜色错误的问题 (#5914)
Fixes #5913
2026-04-05 22:15:11 +08:00
辞庐 f9ae0902d8 fix: 安装模组未进行文件名存在检查 (#5879) 2026-04-05 20:33:08 +08:00
GlavoandBurning_TNT 8d69cbc02f Fix #5907: 修复无法为 MC 26.1.1 从官方源安装 NeoForge 的问题 (#5910)
Co-authored-by: Burning_TNT <pangyl08@163.com>
2026-04-04 22:03:54 +08:00
辞庐 90c2372c92 fix: JFXRippler 可能无法覆盖整个控件 (#5609) 2026-04-04 21:44:21 +08:00
Glavo 84c4943de5 搜索 Java 时缓存 Java 信息 (#4716) 2026-04-04 21:41:20 +08:00
3gf8jv4dv 057943b6c0 fix(i18n): clarify terms for specific world detail pages (#5486) 2026-04-04 21:22:41 +08:00
Calboot e943cb0aec [Bugfix] 修复 HMCL 在 1.20.1 neoforge 环境下将部分 forge 模组识别为 MixinExtras 的问题 (#5888)
Fixes #5329
2026-04-04 20:57:09 +08:00
Wulian233 70290e205b 优化 JFXComboBox 样式 添加 hover 背景与手型指针 (#5905) 2026-04-04 20:15:53 +08:00
Glavo a73b8c7172 Add Gemini Code Assist configuration file (#5903) 2026-04-03 23:19:08 +08:00
3gf8jv4dv a5942c8e91 chore: add LWJGL Unsafe Agent to Settings > About page (#5896) 2026-04-03 21:28:42 +08:00
Glavo dc6a97520b fix: ensure main class is not null for game instance (#5902) 2026-04-03 21:27:54 +08:00
8be3f04cc8 feat: 拖拽版本 json 安装实例 (#5730)
resolves #5459

---------

Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-03 21:23:14 +08:00
Calboot 8d85b1537d [Bugfix] 修复 HMCL 下载游戏内容时出现空游戏版本条目的问题 (#5899)
Fixes #5894
2026-04-03 20:58:24 +08:00
3gf8jv4dv b16f2a6ce6 chore: update HelloNBT's link on Settings > About page (#5895) 2026-04-03 20:45:39 +08:00
Glavo e19f175e29 优化 Java 下载 (#5859)
1. 清理代码;
2. 先将文件下载至临时文件夹,下载完毕后再移动至目标目录。
2026-04-03 20:44:10 +08:00
Glavo f3a5b2a99d 支持 26w14a (#5892) 2026-04-02 00:02:08 +08:00
neveler eac2740ea8 优化关于页组件间距使整体风格更加统一协调 (#5861) 2026-03-30 21:17:27 +08:00
a06da53ec9 修复清理游戏资源 / 库操作在 FXThread 运行的问题 (#5301)
Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-30 21:04:08 +08:00
Xirren 765ea24d22 优化手动更新URL (#5880) 2026-03-30 20:40:50 +08:00
Glavo bd24ab37f5 Bump XZ for Java to 1.12 (#5883) 2026-03-30 20:40:32 +08:00
Glavo c689272c42 Bump lwjgl-unsafe-agent to 2.0 (#5882) 2026-03-30 20:27:11 +08:00
952cc5dd37 [Feature] 允许设置游戏内容默认下载源 (#5781)
Resolves #5594

Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-03-29 22:07:20 +08:00
辞庐andGlavo 356df5d630 更新 amdgpu.ids 文件 (#5316)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-29 21:58:43 +08:00
Damon Lu 0e433c5f4b 启动时检测 Wine 环境并进行警告 (#5518) 2026-03-29 21:56:44 +08:00
Glavo a28509de85 支持加载 SVG 图片 (#5484) 2026-03-29 21:29:25 +08:00
3gf8jv4dv e45601ecda chore: unify version type and release channel in english locale (#5548) 2026-03-29 21:02:54 +08:00
辞庐andGlavo 5bbf5cbfc8 启动时对会丢失游戏数据的情况添加 5 秒确认 (#5738)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-29 21:00:44 +08:00
afb98c20ce 优化 update.note.dev 英语换行 (#5876)
Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-29 20:52:31 +08:00
辞庐 6b2bc4606b fix: JFXDialog 弹窗过程中模糊 (#5877)
resolves #5073
2026-03-29 20:43:58 +08:00
mineDiamondandGlavo 6b57cec576 feat: 为自定义颜色选择对话框添加显式的确认和取消按钮 (#5775)
close #5767

Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-29 20:43:26 +08:00
Glavo 83111b83f2 更新 launch.advice.lwjgl_3_4_1 文言文译文 (#5874) 2026-03-29 02:44:29 +08:00
Glavo 420e337c8c Update LWJGL advice message (#5873) 2026-03-29 02:18:20 +08:00
Glavoand3gf8jv4dv 028e43c959 通过 Java Agent 优化 LWJGL 3.4.1 性能 (#5870)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-03-29 01:35:07 +08:00
ef01acca6d feat: 下载页面折叠快照 Minecraft 版本 (#5601)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-27 22:58:49 +08:00
110d5f5630 更新更新渠道选择选项 (#5703)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-27 21:41:54 +08:00
neveler 94497a073d 种子地图、要塞地图、下届要塞地图和末地城地图支持 26.1 (#5860) 2026-03-27 21:06:36 +08:00
mineDiamond ff540a95ee refactor: 将 Datapack 重命名为 DataPack (#5785) 2026-03-26 21:26:18 +08:00
辞庐 ff6a32cdc3 fix: SettingsMap 缺少 toString 方法 (#5857) 2026-03-26 21:19:06 +08:00
Glavo 969644635a Bump HelloNBT to 0.3.0 (#5855) 2026-03-25 18:12:40 +08:00
Glavo a053736e95 Bump twelvemonkeys to 3.13.1 (#5854) 2026-03-25 18:07:58 +08:00
Glavo 879f82c462 Bump kala-compress to 1.27.1-3 (#5851) 2026-03-25 17:45:09 +08:00
Glavo e2d8d9f93f 更新文言文翻译 (#5845) 2026-03-24 21:39:53 +08:00
Glavo da4ed04697 Update default Java version to JAVA_25 (#5841) 2026-03-24 20:35:02 +08:00
Glavo 5d44df6b40 Fix #5822: 修复 JFXTextField 导致 GPU 占用率异常高的问题 (#5839) 2026-03-24 00:11:20 +08:00
辞庐andGlavo 114503e0bf 为游戏崩溃界面导出和启动器日志导出添加加载指示器 (#5694)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-23 22:03:16 +08:00
Calboot 8bd88aa8d8 Inject candidates for CF pack icon download (#5837) 2026-03-23 21:55:51 +08:00
Glavo cc7d1e1aae Add Minecraft 26.1 snapshot/pre/rc versions (#5833) 2026-03-23 21:54:14 +08:00
Damon Lu 56d4effda0 将“不适用于当前系统的选项”的系统支持信息移到 subtitle (#5813) 2026-03-23 21:46:13 +08:00
neveler 7c2dbe3dae 简化 JFXDialog 圆角 (#5836) 2026-03-23 21:45:23 +08:00
Calboot 99469077d2 [Enhancement] CurseForge整合包也下载图标 & 防止图标重复 (#5786) 2026-03-23 21:44:09 +08:00
neveler ec930843da JFXCustomColorPickerDialog 忽略 Alpha 通道 (#5830)
resolve #5824
2026-03-23 21:18:07 +08:00
neveler 969d314802 为 dialog 添加圆角 (#5832) 2026-03-23 21:10:54 +08:00
Glavo 3b11541b0a 更新内存状态栏 (#5829) 2026-03-23 20:33:57 +08:00
Glavo c58d5a3a81 Extract clamp methods to MathUtils (#5826) 2026-03-22 21:27:56 +08:00
Glavo cf9b4aacb2 优化 checkstyle 在缺失版权声明时的报错 (#5825) 2026-03-22 21:22:55 +08:00
Glavo e430db0cd6 使用 HelloNBT 替代 OpenNBT (#5667) 2026-03-21 22:57:52 +08:00
辞庐andGlavo 1b31e96629 feat(LogWindow): 在游戏进程结束后禁用“结束游戏进程”与“导出游戏运行栈”按钮 (#5660)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-21 21:56:51 +08:00
辞庐 5c19239ddd 优化 tag 展示逻辑 (#5702)
resolves #5677
2026-03-21 21:38:34 +08:00
Glavo c181c7825b 当 CurseForge API Key 缺失时隐藏下载源切换选项 (#5802) 2026-03-21 21:31:11 +08:00
辞庐andGlavo 80d2fefa1f 使用 checkstyle 检查文件版权声明 (#5687)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-21 21:14:05 +08:00
Glavo 8701030934 Bump version root to 3.13 (#5811) 2026-03-20 22:18:57 +08:00
mineDiamond ae4c152a8b feat: 添加新发布候选版本版本号格式支持 (#5810) 2026-03-20 21:16:06 +08:00
Glavo e436163be7 支持愚人节模式 (#5791) 2026-03-20 21:15:11 +08:00
辞庐andGlavo 9b8e65449b feat: 更新时继承所有 JVM 参数 (#5631)
Close #5612
Close #5049

Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-20 02:25:59 +08:00
Glavo fbb52f4e8a 平滑进度条 (#5805) 2026-03-19 04:07:34 +08:00
Glavo 704eb32048 优化 JFXSpinner 动画生成逻辑 (#5804) 2026-03-18 15:48:09 +08:00
Glavo 730d9b4ce8 更新 JFXSpinner 默认样式 (#5803) 2026-03-18 13:13:07 +08:00
辞庐andGlavo c7b316ecba 更新 Cleanroom Java 版本选择逻辑 (#5782)
resoloves #5780

Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-17 21:41:35 +08:00
Zkitefly 6df5a02c3f Add issue template configuration for Gitee (#5787) 2026-03-17 20:53:27 +08:00
Glavo 1b9fde3d60 使用 MCIM 作为回退下载源 (#5798) 2026-03-17 20:52:47 +08:00
Glavo f435cda6a3 更新进度条样式 (#5800) 2026-03-17 20:43:49 +08:00
Glavo 4a69fde6e6 更新 GitHub Actions (#5801) 2026-03-17 17:03:19 +08:00
Damon LuandGlavo 294a0cd7da 修复 macOS 平台上空闲内存识别问题 (#4876)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-17 04:45:51 +08:00
辞庐 e057e20b3c Fix #5295 : 实现 Curseforge 光影下载 (#5313) 2026-03-16 21:47:34 +08:00
辞庐 8c0ca7c731 调整导出游戏运行栈加载指示器大小 (#5663) 2026-03-13 22:13:58 +08:00
辞庐andGlavo 0d591a5429 优化删除垃圾桶图标 (#5664)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-13 22:12:10 +08:00
辞庐 900d7595d5 fix: 125% 缩放 Popup 模糊 (#5772) 2026-03-13 22:07:11 +08:00
辞庐andGlavo 5e18c0aad4 若背景自定义路径是文件夹 则随机在文件夹选取图片展示 (#5769)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-13 22:05:43 +08:00
Glavo f51f982f80 更新模组数据 (2026-03-12) (#5773) 2026-03-13 21:35:10 +08:00
Damon Lu 7c5430424f 修复 JFXColorPicker 到 JFXCustomColorPickerDialog 颜色信息传递问题 (#5776)
Resolve #5766
2026-03-13 21:21:36 +08:00
5909c73c48 feat: 添加"关闭启动时的更新弹窗"功能 (#4706)
Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-13 21:11:32 +08:00
3gf8jv4dv 3166c1d405 chore: update license on about page (#5620) 2026-03-11 21:39:20 +08:00
mineDiamond 27c73b4434 fix: 修复 26.1-pre-1 版本号无法归一化的问题 (#5765) 2026-03-11 20:35:00 +08:00
Glavo 705f978c43 删除 ModpackSelectionPage.onChooseRemoteFile() 中的冗余 handler.resolve() (#5728) 2026-03-09 09:40:58 +08:00
Glavo 67432bf614 Add cooldown to launch button action (#5759) 2026-03-08 22:14:40 +08:00
辞庐 378c06656d fix: 无法导入部分 Modrinth 整合包 (#5669) 2026-03-08 22:09:45 +08:00
辞庐 e2d6904269 fix: 游戏进程是 Java 11 以下时无法导出游戏运行栈 (#5662) 2026-03-08 22:09:29 +08:00
辞庐 8ef2f8be1b 修正运行路径非法字符 = 提示 (#5737) 2026-03-08 22:06:48 +08:00
辞庐 584f0f39bc 优化安装实例时自动补全模组加载器名称 (#5698) 2026-03-08 22:05:23 +08:00
辞庐 6529a3bb03 fix: 导出游戏运行栈不兼容 Tooltip 未正确展示 (#5659) 2026-03-08 22:05:01 +08:00
辞庐 0e6f43ccba feat: 支持键盘↑↓选择 LineSelectButton (#5636)
resolves #5452
2026-03-08 22:04:01 +08:00
Glavo 94b10a8b29 将 -jfx-toggle-line-color 调整为 -monet-secondary-container (#5760) 2026-03-08 21:48:03 +08:00
辞庐 dc60af6d96 移除下载页面超链接多余 tooltip (#5739) 2026-03-08 21:47:49 +08:00
辞庐andGlavo 272c5d1b57 转换 Curseforge 整合包元数据为 Record (#5625)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-08 21:35:17 +08:00
辞庐 5bfe945fd0 Bump Gradle to 9.4.0 (#5731) 2026-03-08 20:56:19 +08:00
mineDiamond 3e8a295cf2 feat: 添加新版本存档数据获取支持 (#5675)
在[26.1-snapshot-6](https://zh.minecraft.wiki/w/Java%E7%89%8826.1-snapshot-6)中,mojang对整个[存档格式](https://zh.minecraft.wiki/w/Java%E7%89%88%E5%AD%98%E6%A1%A3%E6%A0%BC%E5%BC%8F)和[存档基础数据存储格式](https://zh.minecraft.wiki/w/%E5%AD%98%E6%A1%A3%E5%9F%BA%E7%A1%80%E6%95%B0%E6%8D%AE%E5%AD%98%E5%82%A8%E6%A0%BC%E5%BC%8F)进行了非常深的破坏性变更,这导致HMCL几乎无法正确读取新版本的世界数据,本PR将添加对新版本的新格式支持

此PR与 #5419 实现的功能一致,但是使用OpenNBT。

close https://github.com/HMCL-dev/HMCL/issues/5436
2026-03-08 12:49:36 +00:00
辞庐 0d717b84c4 清理 WizardNavigationResult (#5665) 2026-03-07 21:27:17 +08:00
辞庐 13672a57ff fix: #5682 安装整合包界面文件名重复的提示显示不全 (#5691) 2026-03-07 21:25:54 +08:00
MovTery b19151a811 fix: 导出 Modrinth 整合包时,不要求填写作者 (#5710) 2026-03-07 21:18:02 +08:00
辞庐 c7d9bc696a 优化 LineSelectButton Popup Label 配色 (#5707) 2026-03-07 21:15:31 +08:00
辞庐 f426ea48fa 整合包安装页面 文件名 -> 游戏实例名 (#5692) 2026-03-07 20:52:04 +08:00
辞庐 8b9eeb68f7 修正 BAT 脚本限制为 32767 (#5697)
Close #4044
2026-03-07 20:51:09 +08:00
辞庐 413069d334 fix: LineComponent 副标题样式未正确应用 (#5706) 2026-03-07 20:46:57 +08:00
Calboot bab81da466 [Enhancement] ScrollBar 圆角 (#5716) 2026-03-07 20:43:26 +08:00
Hommam-Prom 1138d063d2 Arabic Translition Update (#5714)
15 Ramadan 1447 - 5 March 2026
By Hommam-Prom
2026-03-07 20:39:43 +08:00
Glavo 041eedb765 Bump jetbrains-annotations to 26.1.0 (#5717) 2026-03-06 18:38:52 +08:00
辞庐 48046cd416 fix: SpinnerPane 裁切元素 (#5693) 2026-03-03 21:46:44 +08:00
辞庐 9372c0903c 优化 FileSelector 按钮样式 (#5690) 2026-03-03 21:42:12 +08:00
辞庐 ee79c50dea fix: 手动取消模组更新检查会弹窗操作失败 (#5705) 2026-03-03 21:37:47 +08:00
辞庐 ef3c1b8926 fix: MicrosoftAccountLoginPane 缺少 HEADER (#5656) 2026-02-26 21:51:51 +08:00
Wulian233 ade057b819 Fix 5651: 世界管理不应展示 .zip 文件的世界 (#5652) 2026-02-26 21:50:46 +08:00
TNTyep520andGlavo 2b1de479e5 feat (版本列表): 添加 1.21.11-rc3_unobfuscated. (#5655)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-02-26 21:50:07 +08:00
辞庐 5502a4efad feat: 切换实例隔离设置后自动刷新其他子页面 (#5638) 2026-02-26 21:48:50 +08:00
Wulian233 5c36c00479 修复语言为文言时下载游戏的选择模组加载器页面的版本号未本地化的问题 (#5359) 2026-02-26 21:29:42 +08:00
辞庐 bb92b5ff8a fix: 选择角色弹窗未适配深色模式 (#5605) 2026-02-26 21:19:13 +08:00
3gf8jv4dv 8b90464d30 chore: update copyright year and attribution (#5622) 2026-02-26 21:16:15 +08:00
Calboot aaea473325 [Bugfix] Fix #5421: 修复部分滚动页面滚动时圆角消失的问题 (#5463)
Fixes #5421
2026-02-25 21:31:50 +08:00
3gf8jv4dv e8f9f093f4 chore(l10n): refine tradchinese (#5623) 2026-02-25 21:17:41 +08:00
辞庐andCopilot 9dc493dd02 清理 ToggleButton 4 创建 (#5630)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 21:17:06 +08:00
辞庐 5cf142df0e fix: message.question 未本地化 (#5633) 2026-02-24 21:45:19 +08:00
Glavo 2a6cd17a3c 重新启用 MCIM 加速 (#5030) 2026-02-24 21:21:46 +08:00
Calboot 285e361a5f [Enhancement] 下载界面简介不滚动 (#5458) 2026-02-23 22:00:52 +08:00
辞庐 0b70dc6d22 fix: 本地安装整合包报错 (#5613) 2026-02-23 21:54:56 +08:00
DimeandGlavo c8e2802373 feat: 给大部分直角图标变为圆角图标 (#5554)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-02-23 21:54:07 +08:00
Glavo 9ac32a308a FetchTask 在重试前添加等待延迟 (#5603) 2026-02-23 21:50:11 +08:00
辞庐 85cc11be1e fix: 账号列表 copy 按钮未正确应用样式 (#5607) 2026-02-23 20:47:53 +08:00
Xirrenandxirren 568fa354ee 清理OAuth Secret相关 (#5614)
Co-authored-by: xirren <xirren@outlook.com>
2026-02-23 20:45:10 +08:00
辞庐 2728be1014 清理 Decorator (#5618) 2026-02-23 20:41:24 +08:00
Glavo 388473850d Add support for Minecraft 26.1-snapshot-8+ to the linux-loongarch64 platform (#5624) 2026-02-23 16:20:35 +08:00
辞庐 1a5b19e7fe fix: JFXSpinner 样式失效 (#5617) 2026-02-23 07:39:24 +08:00
3gf8jv4dv 8d3fa105f7 docs(readme): update contributor count (#5621) 2026-02-23 03:36:48 +08:00
Glavo ed43c07d88 优化设备代码流登录流程 (#5610) 2026-02-22 13:33:53 +08:00
辞庐 586dee4f1b 清理 CrashReporter (#5599) 2026-02-21 23:13:57 +08:00
辞庐andGlavo 917d11009f 迁移微软授权代码流 client_secret 到 PKCE (#5575)
resolves #5566

Co-authored-by: Glavo <zjx001202@gmail.com>
2026-02-21 22:25:00 +08:00
Glavo 4fa07661bb 优化 MultipleSourceVersionList.refreshAsync 任务名称 (#5602) 2026-02-21 22:19:23 +08:00
辞庐 b1b90e0855 fix: 禁止取消未生效 (#5600) 2026-02-21 22:16:14 +08:00
Glavo 76370099e3 优化 GameVersionNumber.asGameVersion (#5587) 2026-02-21 09:09:27 +08:00
Glavo 2e5e21affa 修复 MultipleSourceVersionList 不会使用备用下载源的问题 (#5585) 2026-02-20 22:19:47 +08:00
Glavo cceef4f8f8 优化无法补全 JavaFX 时的报错信息 (#5586) 2026-02-20 22:16:53 +08:00
mineDiamond 68c291e23a feat: 添加新版本世界指定资源包位置支持 (#5552) 2026-02-20 21:34:10 +08:00
辞庐andGlavo 1e42034a0b fix: 光影下载被错误地显示为“支持中英文搜索” (#5558)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-02-20 21:23:52 +08:00
辞庐 53e90ea0f4 fix: 日志窗口 Ctrl+C 复制 toast 在主窗口显示 (#5581) 2026-02-20 21:21:31 +08:00
辞庐andGlavo cd989b1058 fix: 通过 cf / mr 直接安装整合包页面返回有一个多余整合包安装选择页面 (#5556)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-02-20 21:20:50 +08:00
Glavo 5bc5ae8d15 更新 JFXTreeView (#5574) 2026-02-20 21:16:15 +08:00
辞庐 c8183eaa85 fix: 打开微软对话框有一个加载圈一闪而过 (#5584) 2026-02-20 21:12:47 +08:00
Glavo ae522e0027 Add support for Minecraft 26.1-snapshot-8+ to the freebsd-x86_64/linux-arm64/linux-riscv64 platforms (#5583) 2026-02-20 13:56:42 +08:00
Glavo b6fcbb831c 更新 JFXListView (#5569) 2026-02-19 20:45:36 +08:00
Glavo f3fed74fbc 更新 JFXSlider (#5570) 2026-02-19 06:27:34 +08:00
Glavo 010abf037e 更新 JFXProgressBar 和 JFXSpinner (#5565) 2026-02-19 05:08:33 +08:00
Glavo 9202cd6d61 添加 JFXDialogEvent 源码 (#5564) 2026-02-18 21:56:56 +08:00
辞庐 e54fca4acc .gitignore 添加陶瓦日志 (#5561) 2026-02-18 20:39:02 +08:00
Glavo f6658567ea 添加 JFXCheckBox 源码 (#5560) 2026-02-17 23:51:08 +08:00
Glavo ffb1300081 添加 JFXRadioButton 源码 (#5559) 2026-02-17 23:50:50 +08:00
辞庐andFrom: zkitefly 3187c28df0 feat: 实例列表搜索 (#5242)
Co-authored-by: From: zkitefly <z18344203426@qq.com>
2026-02-17 23:27:35 +08:00
辞庐 fb0919f3fb 优化下载页面条目水波纹样式 (#5521) 2026-02-17 23:16:34 +08:00
Damon Lu b418c9d33b 为“渲染器”添加描述 (#5514) 2026-02-17 23:15:32 +08:00
CiiLuandGlavo d2a9024fc8 修复启动游戏补全文件时,任务没有正确归类的问题 (#5430)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-02-17 23:04:22 +08:00
CiiLuandGlavo 5e5d777dae fix: 安装整合包时任务弹窗可能空白 (#5550)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-02-17 22:49:55 +08:00
Xirrenandxirren e25e7c4dbd 修复微软登录报错的超链接显示 (#5549)
Co-authored-by: xirren <xirren@outlook.com>
2026-02-17 22:37:22 +08:00
Glavo 313eec4c10 添加 JFXDialogLayout 源代码 (#5546) 2026-02-17 22:36:48 +08:00
Glavo a0fc43ca1e Fix #4960: 解析 Yggdrasil 服务器地址时应当遵循重定向 (#5539) 2026-02-15 22:11:37 +08:00
小宇 b37ec8dd1f Fix #4278: 更新整合包后刷新版本号 (#5536)
fix #4278
2026-02-15 22:06:08 +08:00
CiiLu 167577b9c6 fix: 修复高分屏下新建账户 Tab 文字模糊的问题 (#5532) 2026-02-15 21:22:14 +08:00
Glavo 1c1fd8b1a4 Bump version root to 3.12 (#5545) 2026-02-15 20:31:11 +08:00
3gf8jv4dv 77a86ef963 docs: fix typos [skip ci] (#5542) 2026-02-15 20:28:09 +08:00
Glavo 32a741e2de Fix #5525: 修复 Task 进度异常的问题 (#5543) 2026-02-15 12:30:23 +08:00
CiiLu d1ead74177 fix: 微软登录完成页 svg 图标不会跟随深色模式 (#5541) 2026-02-15 02:55:57 +08:00
NoClassDefFoundErrorandGlavo 80ba1ec1a7 [Feature] 让Java下载页面可以同时下载多个Java (#5394)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-02-14 22:10:04 +08:00
Glavo 25fef6b5c2 删除 Lang.timer (#5537) 2026-02-14 22:04:04 +08:00
CiiLu 64ce1854f5 feat: 微软登录二维码生成添加 otc 参数 (#5538) 2026-02-14 22:03:23 +08:00
Glavo fb4f0be899 优化 SpinnerPane (#5533) 2026-02-14 20:45:21 +08:00
Glavo eea072cea5 更新微软账户登录对话框 (#5531) 2026-02-14 20:38:07 +08:00
3gf8jv4dv b0cd184e22 fix(l10n): replace mcedit with minecraft wiki's tutorial page (#5524)
resolves #5519
2026-02-13 22:33:48 +08:00
CiiLu 8b3e801a44 增加弹窗图标内容间距 (#5527) 2026-02-13 22:32:46 +08:00
CiiLu 04bb089f3a fix: 修复新建账号界面从微软账号切换到其他方式后下面按钮排版奇怪的问题 (#5523) 2026-02-12 20:41:17 +08:00
CiiLu 4bd0cbc88f fix: 下载游戏版本列表点击左侧图标无法触发水波纹效果 & 光标没有变成手型 (#5520) 2026-02-12 20:39:01 +08:00
CiiLu 9c4c7bd786 fix: 修复安装卡片部分区域点击无法触发水波纹效果的问题 (#5522) 2026-02-12 20:38:23 +08:00
Damon LuandGlavo f79d560605 阻止在 JFXCustomColorPickerDialog 输入非法颜色信息 (#5368)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-02-11 21:28:30 +08:00
Glavo 90e6782193 创建 SVGContainer 控件 (#5464) 2026-02-11 21:21:03 +08:00
Glavo ca071fd924 Add weak listener registration methods to WeakListenerHolder (#5509) 2026-02-10 22:42:57 +08:00
Glavo 2e26b14322 修复部分请求头中 Accept-Language 字段包含 Java 版本的问题 (#5508) 2026-02-10 22:39:22 +08:00
CiiLu b7fbebae43 feat: 使用授权代码流登录微软帐户 (#5330) 2026-02-10 02:52:52 +08:00
Glavo 33dc39e59e 修复陶瓦联机页面“导出联机日志”按钮填充异常的问题 (#5507) 2026-02-09 23:28:13 +08:00
Glavo a70b8d7baa 使用自定义 EasyTier 服务器节点 (#5504) 2026-02-09 22:54:18 +08:00
NoClassDefFoundErrorandGlavo 64821600c5 Fix #5326 修改在构建文件树时遇到重名文件夹导致读取失败的问题 (#5369)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-02-09 20:42:10 +08:00
dc993b871f feat: 无版本开始游戏不选择支持状态为“不支持”版本 (#5462)
Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-09 20:37:02 +08:00
3gf8jv4dv a0bd64ad1f docs: add HMCL_UI_SCALE to contribution guide [skip ci] (#5496) 2026-02-09 20:36:42 +08:00
mineDiamond 10df7a862b fix: 修复快速启动按钮出现在不支持的版本的bug (#5461) 2026-02-08 20:45:57 +08:00
3gf8jv4dv 9ea6a97aef fix(i18n): unify term WORLD in english (#5487) 2026-02-08 20:35:49 +08:00
Calboot dec2e4ec7a [Enhancement] NBTTreeView 自动展开 (#5467) 2026-02-08 20:29:24 +08:00
Glavo 8d504cd194 Fix #5469: 调整检查更新设置项填充大小 (#5481) 2026-02-08 20:28:05 +08:00
Glavo 2918f19482 修复 Java 管理页面中标签可能被截断的问题 (#5482) 2026-02-08 04:07:27 +08:00
CiiLuandGlavo 18f3e2870b chore(docs&l10n): highlight support for legacyfabric (#5425)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-02-07 21:58:17 +08:00
Glavo 9f2874c12b Fix #5476: 修复无法归一化版本号 "1.16.2 Pre-release 1" 的问题 (#5480) 2026-02-07 21:49:56 +08:00
Glavo 3fdf21fcd5 Fix #5457: 修复部分页面意外的显示滚动条的问题 (#5477) 2026-02-07 21:31:09 +08:00
Glavo c9e435c129 修复 ComponentSublistWrapper 被禁用时右侧图标颜色异常的问题 (#5478) 2026-02-07 21:30:42 +08:00
Glavo 26125f4002 Fix #5472: 修复无法正确识别 Minecraft 2.0 实例版本号的问题 (#5479) 2026-02-07 21:27:54 +08:00
3gf8jv4dv 93be558e84 docs: add tradchinese variant to localization guide (#5473) 2026-02-07 20:43:45 +08:00
3gf8jv4dv e1be52f0f0 docs: fix typos in simpchinese localization guide (#5475) 2026-02-07 20:43:06 +08:00
Glavo 359a90636a 避免 TwoLineListItem 中标签挤压标题 (#5474) 2026-02-07 18:58:56 +08:00
Glavo d9b31ffd18 优化 TwoLineListItem 中标签的展示方式 (#5465) 2026-02-07 18:02:20 +08:00
Glavo 4b72639969 清理 ToolbarListPageSkin (#5468) 2026-02-07 14:30:47 +08:00
CiiLuandGlavo aecbf2e476 修复 OfflineAccountSkinPane 中 cslApiField 的校验未生效的问题 (#5411)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-02-07 00:02:02 +08:00
Glavo 52d8c44320 更新 JFXSnackbar (#5451) 2026-02-07 00:01:20 +08:00
Burning_TNT 5d57052ea2 Fix #4977 Modrinth 依赖可以指向已经被删除的项目 (#5012) 2026-02-06 22:56:44 +08:00
Glavo 570e1e2029 优化 AdvancedListItem (#5460) 2026-02-06 22:48:41 +08:00
3gf8jv4dv 5b11202a9c fix(l10n): refine tradchinese text (#5410) 2026-02-06 22:40:39 +08:00
CiiLu c2d5d1b5f6 fix: 修复安装游戏时选择版本后返回已选择的版本会被清除的问题 (#5423) 2026-02-06 22:34:31 +08:00
CiiLu ef5849eba1 修复管理页面加载样式不统一的问题 (#5406) 2026-02-06 22:12:23 +08:00
CiiLu c2c542af3c 修复 1.5.2 等版本 LegacyFabric 无法安装的问题 (#5413) 2026-02-06 22:10:00 +08:00
Glavo 792412b4c7 优化 TwoLineListItem (#5447) 2026-02-06 21:47:30 +08:00
Glavo 2b8e8306d7 调整 ToolbarListPageSkin 中图标大小和填充 (#5456) 2026-02-06 17:01:03 +08:00
Glavo 84bcb01fe1 清理 SVG 相关代码 (#5450) 2026-02-06 04:21:14 +08:00
Glavo 253bdf974e 修复 SVG 图标对齐错误的问题 (#5449) 2026-02-06 03:46:03 +08:00
Glavo 5839382c64 重写 MappedObservableList (#5400) 2026-02-06 00:22:21 +08:00
Glavo ab9c45549b 使用 LineButton 代替 IconedTwoLineListItem (#5446) 2026-02-05 22:15:53 +08:00
CiiLu cb413fa40a 修复日志级别选择按钮背景颜色溢出的问题 (#5434) 2026-02-05 22:15:29 +08:00
Glavo 982c062a01 使用 LineComponent 简化代码 (#5408) 2026-02-05 21:37:51 +08:00
3gf8jv4dv 9f097149b2 docs: revise documents for tradchinese and lzh (#5402) 2026-02-05 20:44:16 +08:00
GIGN cb50c89fb9 Refine language in README_lzh.md (#5417) 2026-02-05 20:42:26 +08:00
CiiLu 7fb2b9b734 导出运行栈时附加 -e -l 参数 (#5426) 2026-02-05 20:41:03 +08:00
Glavo b933fbcd6b Fix #5437: 修复总任务数为 0 时 Task 进度计算错误的问题 (#5442) 2026-02-05 20:40:41 +08:00
辞庐 c880e4befb 移除 large-spinner-pane 样式 (#5405) 2026-02-04 22:24:47 +08:00
Glavo 575d908dba Fix #5338: 修复部分环境中进入下载页面时启动器崩溃的问题 (#5433) 2026-02-04 22:24:03 +08:00
3gf8jv4dv ecf188ad18 fix(i18n): clean up text for toolbar and related functions (#5409)
resolves #4772
2026-02-04 22:21:52 +08:00
辞庐 0b3cb9964a 修复 legacyfabricapi 可以和 forge 同时安装的问题 (#5414) 2026-02-04 22:13:30 +08:00
辞庐 c175f1c1b3 优化日志窗口无日志行背景 (#5422) 2026-02-04 22:11:12 +08:00
Glavo dbacf096c6 Fix #5431: 修复部分页面弹出菜单会被重复触发的问题 (#5432) 2026-02-04 21:56:41 +08:00
Glavo c7b9b73292 基于 HBox 重新实现 LineComponent (#5416) 2026-02-04 20:47:45 +08:00
Glavo 19079ac123 创建通用的 LineButton 控件并简化 TerracottaControllerPage (#5395) 2026-02-03 21:09:59 +08:00
3gf8jv4dv 2a66880a58 docs: fix incorrrect windows environment variable name (#5399) 2026-02-03 21:09:33 +08:00
3gf8jv4dv 8b91251337 docs: add tradchinese variant to release schedule document (#5403) 2026-02-03 21:09:01 +08:00
辞庐 0c2742a895 修复 #5292 中添加的图标错误的使用了 Rounded 图标的问题 (#5398) 2026-02-03 20:38:04 +08:00
3gf8jv4dv a8692c3893 docs: add tradchinese variant to contribution guide (#5401) 2026-02-03 20:34:15 +08:00
Glavo 6cf757e440 Bump Shadow Plugin to 9.3.1 (#5392) 2026-02-02 20:53:59 +08:00
Glavo c0f4143618 优化 ComponentList (#5385) 2026-02-02 20:45:08 +08:00
Glavo 73b30974bf Bump XZ for Java to 1.11 (#5391) 2026-02-02 20:44:33 +08:00
Glavo 879f6cfeff Bump TwelveMonkeys to 3.13.0 (#5390) 2026-02-02 20:39:54 +08:00
Luo Chen 0fc8222e63 fix(ServerModpackCompletionTask): 修复已禁用模组仍被下载的问题 (#5333) 2026-02-02 20:35:23 +08:00
Xirrenandxirren 5c2bb1cc25 PS启动脚本使用LiteralPath避免歧义 (#5388)
close #5387

Co-authored-by: xirren <xirren@outlook.com>
2026-02-01 23:06:07 +08:00
Glavo c1d0bb973a 简化 Task (#5365) 2026-02-01 19:10:01 +08:00
Glavo c45cd74b67 简化 RipplerContainer (#5363) 2026-02-01 18:40:52 +08:00
辞庐 05251cb4e3 启动时检查 Jar 路径是否包含 ! (#5272) 2026-02-01 17:17:01 +08:00
NoClassDefFoundError ffbbde9d8f Fix #5037 #5307: 修复部分列表点击后无法通过 esc 返回的问题 (#5375) 2026-02-01 16:22:26 +08:00
Damon Lu c75bcd97b2 docs: 在贡献文档中添加专门针对 macOS 的 JDK 版本检查命令 (#5165) 2026-02-01 16:19:33 +08:00
NoClassDefFoundError 0cc0b21d5f Fix #5340: 修复不透明度数值标签可能被折叠的问题 (#5374) 2026-02-01 16:12:02 +08:00
Glavo a524ba8506 清理 EventManager (#5381) 2026-02-01 16:09:31 +08:00
Glavo 17be25c036 在 Linux 平台使用 dbus-send 检测深色模式设置 (#5317) 2026-02-01 16:05:19 +08:00
Dime 5a518cec03 fix: 修复版本设置页面滚动条总是显示的问题 (#5379)
Fix 5377
2026-02-01 15:15:35 +08:00
Glavo 0adf4fcf32 在 NBTTreeView 上启用平滑滚动 (#5364) 2026-02-01 15:11:10 +08:00
Glavo c4ed6b065b 将 PseudoClass 字段声明为静态字段 (#5362) 2026-01-30 20:59:00 +08:00
Glavo 0c4805df21 使用 LinePane 简化代码 (#5360) 2026-01-30 00:04:15 +08:00
27972b987e feat: 优化世界管理与世界信息页面 (#5215)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-01-29 22:39:47 +08:00
Chen Meng fe11e5ff1e 调整渲染器名称 (#4975) 2026-01-29 21:02:08 +08:00
Glavo e3231859ea 使用 LineSelectButton 代替 JFXComboxBox (#5337) 2026-01-29 20:28:45 +08:00
Glavo 90a795b2c8 在陶瓦联机页面添加切换账号菜单 (#5270) 2026-01-27 21:18:30 +08:00
辞庐 46f7e4db66 扫描子目录时忽略 .connector 文件夹 (#5296) 2026-01-27 21:13:34 +08:00
辞庐 d956c04f2a Fix #4763 : JFXTabPane 指示器偏移 (#5308) 2026-01-27 21:04:06 +08:00
辞庐 59e147227d Fix #5293 账户列表 > 添加认证服务器,非 CJK 语言下输入地址后下一步的对话框,超级宽…… (#5299) 2026-01-27 20:56:28 +08:00
辞庐 4cbe874c81 修复下载页面模组简介只会显示一行的问题 (#5300) 2026-01-27 20:52:34 +08:00
辞庐 e9e6ef1f20 fix: 修复 JFXPopup 在 RTL 下漂移的问题 (#5328) 2026-01-27 20:42:48 +08:00
Glavo d2ae41c9ae Fix #5280: 修复部分列表页面出现横向滚动条的问题 (#5284) 2026-01-27 20:40:07 +08:00
辞庐 79a1c3af8a fix(SystemUtils) : 移除 redirectInput(ProcessBuilder.Redirect.DISCARD) (#5325) 2026-01-25 15:30:45 +08:00
辞庐 c06f898d39 将 JFXTextField 右键菜单改为 JFX 样式 (#5292) 2026-01-25 15:28:47 +08:00
3gf8jv4dv b09b915d16 chore: update issue template [skip ci] (#5322) 2026-01-25 03:28:06 +08:00
Glavo 179ccb7805 在 Windows 平台上 GameCrashWindow 标题栏应当跟随深色模式设置 (#5319) 2026-01-25 03:27:39 +08:00
辞庐 f7987a91ce 取消 Windows 7 高版本的不兼容提示 (#5304) 2026-01-24 21:45:11 +08:00
辞庐 e68b96f67d Fix #4934: 返回页面时可能卡进空白页面 (#5305) 2026-01-24 21:41:04 +08:00
辞庐 7f6379960b Fix #5291: 点击实例列表条目的中央区域无法触发 Rippler 效果 (#5310) 2026-01-24 21:19:10 +08:00
Glavo 317b92c31e 简化 SystemUtils (#5315) 2026-01-24 20:44:25 +08:00
Glavo 35e65921f9 优化深色模式支持 (#5283) 2026-01-23 22:43:58 +08:00
Glavo 95eeae4c42 导出日志时允许重名文件 (#4733) 2026-01-23 22:41:07 +08:00
Glavo 165d77df30 重构 FutureCallback (#5268) 2026-01-23 22:19:43 +08:00
Glavo 65a4b6159d 优化更新内存信息任务 (#5286) 2026-01-23 22:18:59 +08:00
辞庐 6f690d2522 修复主页公告阴影被裁切的问题 (#5245) 2026-01-23 22:14:37 +08:00
Glavo 5a34ac0d2b 更新下载页面条目样式 (#5279) 2026-01-23 21:04:43 +08:00
Glavo 0ccb352b7e 清理 ComponentListCell (#5278) 2026-01-22 22:26:42 +08:00
Glavo 6d8add6d54 修复 Unzipper 解压空 zip 文件时抛出异常的问题 (#5267) 2026-01-21 22:07:06 +08:00
Glavo 7ab22b0e07 支持右键点击侧边栏弹出切换实例菜单 (#5269) 2026-01-21 22:05:32 +08:00
Glavo 20249ec4bf 重构 SchematicsPage 以使用 ListCell 展示元素 (#5276) 2026-01-21 22:04:45 +08:00
神奇的赵二 (OrzMiku) db38b4d532 修复自动安装界面卡片上按钮无法点击的问题 (#5251) 2026-01-21 22:04:11 +08:00
Glavo ba5e63395f refactor: update CommandBuilder methods to use addAll for multiple arguments (#5275) 2026-01-21 21:26:12 +08:00
Glavo 55cbb6cd2c 对 Java 26 默认启用压缩对象头 (#5274) 2026-01-21 20:51:06 +08:00
Glavo 8aecbe89f9 优化游戏代理参数设置 (#5273) 2026-01-21 20:40:28 +08:00
Xirrenandxirren 0a0476b6d3 修复系统代理 JVM 参数格式 (#5265)
Co-authored-by: xirren <xirren@outlook.com>
2026-01-20 21:57:25 +08:00
辞庐andGlavo 38612e5d6c 提早非法路径检查 (#5259)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-01-20 21:50:34 +08:00
mineDiamond 66e75fdc9d refactor: 移除 ListPage 和 ListPageSkin (#5244) 2026-01-19 21:46:53 +08:00
神奇的赵二 (OrzMiku) 1e76733564 优化实例列表,限制宽度防止出现水平滚动条 (#5243) 2026-01-19 21:31:25 +08:00
Wulian233 370d0ce78b Forge Quilt 模组管理支持识别子文件夹 (#5196)
Close #2178 
Close #5164
2026-01-19 21:27:24 +08:00
Glavo 6f402cd218 Fix #5256: 修复使用 GIF 背景时调整背景不透明度导致启动器崩溃的问题 (#5262) 2026-01-19 21:21:23 +08:00
Calboot e778d223a7 Modrinth 默认不获取更新日志 (#5250) 2026-01-19 21:09:59 +08:00
Glavo db5aa0c869 修复 GameVersionNumber.getDefaultGameVersions() 包含未混淆版本的问题 (#5261) 2026-01-19 20:38:51 +08:00
辞庐 8077b0bae1 feat: Legacy Fabric 自动安装 (#5090) 2026-01-19 20:35:56 +08:00
辞庐 e8c6c07e6a 修复 Cleanroom 本地安装完成后会删除安装器的问题 (#5257) 2026-01-19 20:27:14 +08:00
c1396c05aa 优化 Fabric / Quilt API 安装提示 (#4792)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-16 21:01:24 +08:00
Burning_TNT 9ca95c7f17 Fix #4958: 模组检查更新 (#5029) 2026-01-16 20:55:05 +08:00
neveler fb2d53664b 支持通过拖动标题栏退出最大化 (#5172)
close #5138
2026-01-16 20:33:20 +08:00
辞庐andGlavo 6fd6022210 导出带启动器 Modrinth 整合包使用 zip 格式 & 内部改为 mrpack (#5195)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-01-15 23:53:07 +08:00
mineDiamond 83782f5628 feat: 移动并简化 VersionPage.wrap 方法 (#5218) 2026-01-15 22:00:33 +08:00
Wulian233 a19f147a6a 将 GameJavaVersion 转换为 record (#5222) 2026-01-15 21:39:33 +08:00
Glavo 85ce3d9a7a RemoteImageLoader 在图标尚未加载时应当将图像更新为占位符 (#5239) 2026-01-15 20:41:18 +08:00
Glavo 886a1f1207 将世界管理界面“生成启动脚本”按钮折叠到二级菜单 (#5198) 2026-01-15 20:39:35 +08:00
Glavo 8d6245eb38 Bump version root to 3.11 (#5238) 2026-01-15 20:33:33 +08:00
Glavo 7d76fea48d 重构 WorldListPage 以使用 ListCell 展示元素 (#5224) 2026-01-14 20:33:55 +08:00
Glavo 600bd6e9ee 修复 CurseForge 源无法搜索到内容的问题 (#5220) 2026-01-14 20:15:37 +08:00
Glavo ccc64db805 重构 JavaManagementPage 以使用 ListCell 展示元素 (#5223) 2026-01-13 21:35:34 +08:00
Glavo ea407be5f0 更新 ToolbarListPageSkin 以使用 JFXListView 展示节点 (#5221) 2026-01-13 21:08:36 +08:00
Glavo 9b9471da49 惰性初始化 JFXRippler 属性 (#5208) 2026-01-13 21:03:49 +08:00
Glavo b1402d3821 优化下载页面图标缓存功能 (#5211) 2026-01-13 20:30:14 +08:00
Xirrenandxirren 62f0d0a1c0 修复实例列表不显示实例名的问题 (#5203)
fix #5202

Co-authored-by: xirren <xirren@outlook.com>
2026-01-12 22:13:00 +08:00
Glavo 2865f2ffca 将 InstallerItem.State 迁移至 sealed 接口 (#5209) 2026-01-12 21:52:16 +08:00
Burning_TNT 3860e68caa Feature: 下载并发控制 (#5026) 2026-01-11 21:45:31 +08:00
3gf8jv4dv 74d0a89820 fix(i18n): update button text for check mod updates (#5159)
resolves #5154
2026-01-11 21:30:00 +08:00
91e4c85a28 优化 SSL 异常处理 (#5168)
fix #5115 

Co-authored-by: xirren <xirren@outlook.com>
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-01-11 21:28:54 +08:00
mineDiamond a05b24cfd7 fix: 解决数据包页面在加载超多数据包时卡顿的问题 (#5162) 2026-01-11 21:18:04 +08:00
辞庐 72e3925696 修复默认窗口大小 进入高级版本设置会向下滚动一段距离的问题 (#5197) 2026-01-11 21:14:52 +08:00
Glavo 9ea419c05b 修复未下载陶瓦联机时打开陶瓦联机页面抛出异常的问题 (#5166) 2026-01-11 21:00:35 +08:00
Glavo 3dc1631207 使用 JFXListView 显示实例列表 (#5174) 2026-01-11 20:55:17 +08:00
Glavo ab5ef6e0ff 将 FontReference 转换为 record (#5187) 2026-01-11 01:27:44 +08:00
Glavo 61113d5c08 仅在左键点击时触发 JFXRippler 波纹 (#5188) 2026-01-10 22:18:22 +08:00
辞庐 d73d7330eb 支持重置日志字体 (#5182) 2026-01-10 22:12:24 +08:00
辞庐andGlavo 45a85e006b 修复重置字体后没有遵循 hmcl.font.override HMCL_FONT 等的问题 (#5183)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-01-10 21:44:01 +08:00
ENC_Euphony 06cce5259b imp: use display categories for modrinth tag display (#5160) 2026-01-10 21:41:41 +08:00
辞庐andGlavo 3e393982e0 修复 InstallerItem 的 Rippler 没有正常显示的问题 (#5184)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-01-10 21:21:48 +08:00
辞庐 db2e2d89ee 优化 Popup 阴影 & 修复 Windows 高分屏模糊严重的问题 (#5186) 2026-01-10 21:19:08 +08:00
Glavo a3d9e29350 在资源包下载页面中隐藏不必要的标签 (#5180) 2026-01-10 20:39:15 +08:00
ENC_Euphony 682d88df02 imp: add padding for java selection list item (#5167) 2026-01-10 16:47:00 +08:00
3gf8jv4dv b5defd31ed fix: add "neo-forge" alias for modrinth modpack parsing (#5170)
resolves #5169
2026-01-10 16:46:24 +08:00
Glavo 553e67bda7 修复 JFXCheckBox 意外地显示阴影的问题 (#5179) 2026-01-10 16:17:41 +08:00
Glavo 95bb669ffc 修复 JFXButton 意外地显示阴影的问题 (#5178) 2026-01-10 16:09:53 +08:00
Glavo 505752476c 更新 README.md (#5156) 2026-01-07 22:33:03 +08:00
mineDiamond c723acfe30 feat: 允许重命名实例时使用原名 (#5137) 2026-01-07 22:32:43 +08:00
Wulian233and3gf8jv4dv 2fd7076ccf 修复第三方构建下禁用账户刷新的 tooltip 展示不支持的超链接标签 (#5133)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-01-07 22:32:10 +08:00
0705465ba4 优化账号SSL异常处理 (#5143)
Co-authored-by: xirren <xirren@outlook.com>
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-01-07 22:30:53 +08:00
mineDiamondand3gf8jv4dv 7a4649f0cc feat: 优化世界管理界面和世界信息界面 (#4823)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-01-07 22:26:17 +08:00
Glavo 8b8a62ccf1 Fix #5067: 在 MinecraftInstanceTask 中使用 kala-compress 解压文件 (#5151) 2026-01-06 22:35:33 +08:00
Glavo 243f3e7371 修复窗口重新获取焦点时 JFXRadioButton 显示阴影的问题 (#5153) 2026-01-06 22:25:59 +08:00
Glavo 4ad368560c 调整平滑滚动的截止速度 (#5150) 2026-01-06 22:15:04 +08:00
Glavo c6c302e3d6 fix: update title label to use display name directly (#5152) 2026-01-06 22:14:30 +08:00
Glavo 686f59e21b 将 Unzipper 迁移至 kala-compress (#5148) 2026-01-06 21:19:21 +08:00
Glavo f1bd4bffb7 修复无法正确识别模组的问题 (#5149) 2026-01-06 21:08:49 +08:00
ZZZank 18a8556997 feat: 更新选中 mod (#4832) 2026-01-06 21:05:46 +08:00
Glavo f10c842aa4 使用 kala-compress 读取模组元数据 (#5146) 2026-01-05 21:55:52 +08:00
Glavo 74d379a09c 修复 FetchTask 未捕获部分异常的问题 (#5144) 2026-01-05 21:51:02 +08:00
NoClassDefFoundError 3dd1d5bd62 Fix #5123: 修复字体问题导致的空行 (#5124) 2026-01-05 20:55:46 +08:00
Burning_TNT 3247b77410 重构陶瓦下载功能 并 在加入联机房间时显示 p2p 难度 (#4929) 2026-01-04 22:06:44 +08:00
neveler ce9194057a 优化组件显示效果 (#5114)
- 为字体重置操作按钮添加 tooltip
- 为游戏图标重置操作按钮添加 tooltip 并将按钮图标切换为 SVG.RESTORE
2026-01-03 20:51:46 +08:00
Glavo 04cf5eca59 使用 ByteArray 简化 MurmurHash2 (#4486) 2026-01-03 20:45:45 +08:00
Glavo 86d65e96ec Fix #4774: 安装 Modrinth 整合包时下载图标 (#5119) 2026-01-03 20:41:16 +08:00
辞庐 1262a760b8 Fix #5121 : NeoForge alpha 版本未正确归类的问题 (#5125) 2026-01-03 20:31:07 +08:00
acd41e5885 在添加游戏目录页面选择目录时自动填写名称 (#4289)
Co-authored-by: Ciilu <109708109+Ciilu@users.noreply.github.com>
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-01-03 20:19:08 +08:00
Wulian233 f22568d3e8 Fix #5126: BMCLAPI 选择 Liteloader 不支持的版本时报错 (#5128) 2026-01-03 16:44:07 +08:00
辞庐andGlavo 11d182f907 优化输入框校验 (#5129)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-01-03 16:41:40 +08:00
辞庐 e25e58cfad 修复缓存目录设置进入时默认未勾选的问题 (#5131) 2026-01-03 16:02:42 +08:00
2e4cc76d6a 优化微软登录页面 (#4794)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-02 22:06:51 +08:00
NoClassDefFoundError 02e9d807cd Fix #4938: 修复 OfflineAccountSkinPane 页面提示栏和按钮重叠的问题 (#5117) 2026-01-02 21:57:36 +08:00
Wulian233 8923be50ec 修复下载光影时对话框的标题文字错误 (#5034) 2026-01-02 21:06:59 +08:00
辞庐 a07c0d9746 Fix #5097 : 部分平台 Popup 无法通过点击侧边栏收起 (#5118) 2026-01-02 21:00:16 +08:00
3gf8jv4dv e56c4643de fix(l10n): unify term WORLD in chinese (#5058) 2026-01-02 20:48:26 +08:00
辞庐 fb942d1099 优化独立窗口弹窗 (#5060)
resolves #4942
2026-01-02 16:22:54 +08:00
neveler 3842b6df32 为不同类型的 Dialog 指定相应的默认标题 (#5079) 2026-01-02 16:15:27 +08:00
辞庐 fadd9580a2 优化打包日志逻辑 (#5108)
Fix #4587
2026-01-02 16:13:06 +08:00
辞庐 110a58b3cd 修复添加实例目录页面有一个空 Tooltip 的问题 (#5112) 2026-01-02 16:09:19 +08:00
辞庐 5a25ba6663 更新 NeoForge 版本号规则 (#5110)
Resolves #5109
2026-01-02 16:02:15 +08:00
辞庐 0013a52a5c 优化 OptionToggleButton (#5107) 2026-01-02 15:27:08 +08:00
辞庐 05d37e3911 Fix #4861 : 复制游戏实例相关问题 (#5111) 2026-01-02 15:23:32 +08:00
Glavo bc55da0720 Fix #5105: 修复按钮字体大小异常的问题 (#5106) 2026-01-01 23:04:30 +08:00
mineDiamond 57a582e2cb fix: 修复部分版本无法正确归一化的问题 (#5091) 2026-01-01 21:47:22 +08:00
neveler 7c0faf2143 更新启动按钮分隔符颜色 (#5087) 2026-01-01 21:40:28 +08:00
Glavo fe0515ed28 Fix #5068: 修复无法读取 AppliedE 模组信息的问题 (#5081) 2026-01-01 21:32:14 +08:00
Glavo 0c657ef382 修复 AccountDetailsInputPane 部分情况下未正确验证用户名的问题 (#5080) 2026-01-01 21:29:57 +08:00
辞庐 4ce6588f0c 更新 about.copyright.statement 到 2026 (#5082) 2026-01-01 20:42:01 +08:00
辞庐 d083ff9048 修复安装游戏时选择加载器版本后返回动画方向不正确的问题 (#5086) 2026-01-01 20:36:25 +08:00
neveler 54f9902849 首页切换实例菜单仅在菜单高度大于最大高度时显示垂直滚动条 (#5088) 2026-01-01 20:28:38 +08:00
辞庐 2e572d2b02 JFXSpinner 添加圆角 (#5101) 2026-01-01 20:12:00 +08:00
Glavo ac9f337d4e Fix #5048: 修复 JFXTextField 上下文菜单未使用 MD3 色彩系统的问题 (#5102) 2026-01-01 20:06:31 +08:00
neveler 078c2ad98b 优化 SettingsPage 内边距 (#5077) 2025-12-26 21:06:46 +08:00
neveler 830d9a05e7 优化不透明度滑块显示效果 (#5076) 2025-12-26 20:54:35 +08:00
Glavo 7dfeebff39 更新 placeholder 标签的颜色 (#5057) 2025-12-24 21:13:26 +08:00
mineDiamond 05a414c93f feat: 更新 NBT 图标 (#5062) 2025-12-24 21:10:59 +08:00
3gf8jv4dv 16a432125b fix: reorder items in explore menu on edit instance page (#5059)
resolves #4940
2025-12-24 21:05:59 +08:00
Glavo 573e25ecdf 修复 JFXPopup 在 HiDPI 显示器上播放动画过程中内容 Y 轴方向缩放异常的问题 (#5064) 2025-12-24 20:38:20 +08:00
Glavo 821cc6d768 简化 JFXListViewSkin (#5063) 2025-12-24 01:24:44 +08:00
Glavo c13b5e15d4 修复 JFXListViewSkin 大量触发 ListCell#updateItem 的问题 (#5056) 2025-12-24 00:41:23 +08:00
3gf8jv4dv 3eea76784d fix: correct the MB to MiB in the game crash window (#5061) 2025-12-24 00:28:28 +08:00
Calboot 22c66b2790 [Bugfix] 修复导出整合包有关以及NBT文件查看有关的界面样式及深色模式问题 (#5043)
Fixes #5042
2025-12-23 22:25:21 +08:00
mineDiamond fcabe9ca36 [Feature] [UI] 统一账户选择弹窗在列表为空时的表现 (#5054) 2025-12-23 21:52:43 +08:00
3gf8jv4dv 2af3c8f714 fix(l10n): simplify the text for settings.memory in simpchinese (#5047)
resolves https://github.com/HMCL-dev/HMCL/issues/5041
2025-12-23 21:33:34 +08:00
Glavo 253440844e 优化 JFXListView 的平滑滚动效果 (#5052) 2025-12-23 20:20:52 +08:00
Glavo cc87f6cdd1 禁用 LogWindow 的平滑滚动效果 (#5046) 2025-12-23 20:11:46 +08:00
辞庐 a77ef95ce4 取消 FXUtils#onClicked 的连点限制 (#4882) 2025-12-22 21:56:01 +08:00
Glavo 7fb7b00836 简化 GlobalConfig (#4781) 2025-12-22 21:09:56 +08:00
Glavo 3252de2f3f Update PLATFORM.md (#5045) 2025-12-22 20:48:30 +08:00
Calboot 9b0b975490 [Enhancement] 导出整合包时隐藏系统资源文件 (#4897)
Resolves #4785
2025-12-21 21:49:19 +08:00
mineDiamond 449acbc7dc feat: 优化InputDialogPane体验 (#5032) 2025-12-21 21:41:20 +08:00
神奇的赵二 (OrzMiku) 1c82e4e1e1 ModListPage 刷新时不滚动到顶部 (#5016) 2025-12-21 21:40:47 +08:00
辞庐 77cdc2108f 在非 Windows 平台也将 : 视为非法字符 (#5035) 2025-12-21 21:20:54 +08:00
neveler cc092f535b 使用 subtitle 显示重启生效提示 (#5024)
close #4956
2025-12-21 21:09:42 +08:00
Glavo 13930f8dcc Bump version root to 3.10 (#5028) 2025-12-21 20:22:22 +08:00
neveler ebad1f67ce 优化同步工作流 (#5036) 2025-12-21 16:25:45 +08:00
Glavo d3d46f6a29 Fix #5039: 修复陶瓦联机页面部分文本在深色模式下颜色异常的问题 (#5040) 2025-12-21 15:13:58 +08:00
Glavo 6f1db67e9a 优化反馈页面 (#5008)
1. 将主页左下角“官方群组”按钮调整为跳转到“反馈”页面;
2. 将反馈页面分为“官方群组”和“提交反馈”两部分
2025-12-20 20:35:41 +08:00
mineDiamond 0d7ece819d feat: 添加快速进入世界功能 (#4872)
close #2563
2025-12-20 20:33:52 +08:00
Damon Lu 7f852080cd 在 macOS 上添加启动脚本的后缀 .command (#4927) 2025-12-20 20:32:34 +08:00
neveler e0360c2b38 使用 subtitle 显示预览更新提示 (#5019) 2025-12-20 16:04:20 +08:00
Glavo 3bf0eb2a16 Fix #5014: 修复 GameCrashWindow 中 feedbackTextFlow 样式错误的问题 (#5020) 2025-12-20 16:00:42 +08:00
Glavo 84b428b57c 在 JFXCustomColorPickerDialog 中使用 HEX 作为默认 Tab (#5011) 2025-12-20 15:33:03 +08:00
neveler 41895e97e1 修复 subtitle 颜色丢失的问题 (#5018)
fix #5017
2025-12-20 15:32:34 +08:00
zimzaza4 f839dc192c 修改AccountAdvancedListItem 控件, 在 RootPage 中添加快速选择账户 (#4932) 2025-12-19 22:18:10 +08:00
Glavo 24250a6239 更新 CNB Issue 模板 (#5010) 2025-12-19 22:03:43 +08:00
Glavo a3ab2b52b6 添加 CNB Issue 模板 (#5009) 2025-12-19 21:39:00 +08:00
Glavo c4d4c6fcf3 优化镜像仓库同步功能 (#5006) 2025-12-19 16:47:12 +08:00
Glavo f77f005f88 mirror.yml 中直接传递用户名 (#5005) 2025-12-19 16:10:02 +08:00
Glavo 9ec494664c 简化镜像仓库同步 workflow (#5004) 2025-12-19 16:02:02 +08:00
Glavo dc7c883962 在低于 JavaFX 23 的情况下隐藏主题模式跟随系统选项 (#4985) 2025-12-18 21:25:07 +08:00
Glavo 567bd15997 在陶瓦联机界面启动游戏时禁用离线皮肤功能 (#4916) 2025-12-18 21:18:03 +08:00
Glavo 25c9ef0f27 修复无法解析类似 "26.1 Snapshot 1" 的版本号的问题 (#5002) 2025-12-18 21:09:07 +08:00
Glavo d45d994307 将 Release 同步至 CNB (#5000) 2025-12-18 21:08:30 +08:00
Glavo 01b7a6b95a Revert "修复 TransitionPane 动画被打断时节点状态异常的问题" (#4986)
Reverts HMCL-dev/HMCL#4957
2025-12-18 20:47:48 +08:00
神奇的赵二 (OrzMiku) a0593c03c6 支持下载 Java25 (#4990) 2025-12-18 20:46:52 +08:00
Glavo 91f05315aa Add CNB badge (#4999) 2025-12-18 20:11:06 +08:00
Burning_TNT 84b8fff37b Bump authlib-injector to 1.2.7: fix compatibility with 26.1-snapshot-1 (#4992)
Fix #4989
2025-12-17 17:05:07 -05:00
Glavo 3a9cc2b9bb 修复 CNB 同步功能 (#4995) 2025-12-18 05:57:14 +08:00
Glavo 056f30f6f5 将代码同步至 CNB (#4994) 2025-12-18 05:47:29 +08:00
辞庐 2d6e01fcf9 添加输出游戏调试日志选项 (#4144) 2025-12-14 20:49:23 +08:00
Calboot b6d95c3ebb [Enhancement] 给 TableView 添加样式 (#4854) 2025-12-12 22:00:55 +08:00
Glavo 29e40ec1a5 在 DownloadListPage 中添加图标缓存 (#4866) 2025-12-12 21:12:51 +08:00
Glavo 497789603a 在日志中记录显示器信息 (#4581) 2025-12-11 20:47:10 +08:00
Glavo 8d3cb66d17 将 TableView 中的 CheckBox 替换为 JFXCheckBox (#4964) 2025-12-11 20:41:26 +08:00
mineDiamond 0613b342f0 feat: 添加1.21.11_unobfuscated支持 (#4963) 2025-12-11 20:29:55 +08:00
Glavo 18000fae30 修复 TransitionPane 动画被打断时节点状态异常的问题 (#4957) 2025-12-09 15:32:17 +08:00
Glavo d2c985491a 合并 SettingsPage 与 SettingsView (#4955) 2025-12-08 16:45:29 +08:00
Glavo f7f0d55504 更新 RipplerContainer 的涟漪颜色 (#4953) 2025-12-08 15:17:10 +08:00
Glavo 210e127ac7 Fix #4950: 修复部分按钮波纹颜色错误的问题 (#4952) 2025-12-07 21:46:42 +08:00
Glavo 36748a3a07 Fix #4941: 更新 JFXComboBox 下拉菜单滚动条样式 (#4951) 2025-12-07 21:43:47 +08:00
Burning_TNT cf667b2b8d Fix #4759: 部分 LF/NF 模组无法正确识别 (#4945) 2025-12-07 21:13:49 +08:00
Burning_TNT a3f7a230e7 Fix #4597: 绕过 OptiFine HD U G6 pre1 与其他模组 Fingerprint 冲突的问题 (#4946) 2025-12-07 21:12:08 +08:00
辞庐 9dd8084be2 修复本地安装新版本 Forge 时未移除版本号中 forge- 前缀的问题 (#4935) 2025-12-07 21:04:18 +08:00
Glavo 9cf5a5e5db 修复从 BMCLAPI 下载部分旧文件时校验失败的问题 (#4949) 2025-12-07 20:45:02 +08:00
Glavo 8efa54dd11 更新超链接被聚焦时的边框样式 (#4948) 2025-12-07 20:44:32 +08:00
Wulian233 7902020300 Fix #4939: 修复深色模式超链接的已访问样式配色问题 (#4943) 2025-12-07 16:35:46 +08:00
Glavo e72c0bbb50 修复部分控件 Tooltip 文字颜色异常的问题 (#4947) 2025-12-07 16:15:22 +08:00
ef3df92592 feat: 资源包管理 (#4475)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-12-06 22:17:04 +08:00
Glavo 04b300f453 简化 MessageDialogPane (#4918) 2025-12-06 21:18:58 +08:00
mineDiamondandGlavo e455ce4c84 feat: 支持1.21.11-pre4/5/rc1/2_unobfuscated (#4911)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-12-06 21:03:27 +08:00
Glavo 456d13959e 更新 GameVersionNumber 解析规则 (#4917)
1. 支持 [Minecraft
新版本号方案](https://www.minecraft.net/en-us/article/minecraft-new-version-numbering-system)
2. 支持对版本号进行归一化处理。
2025-12-06 20:55:11 +08:00
Glavo 7ac22ad567 Windows 11 上日志窗口标题栏颜色应当跟随主题模式设置 (#4910) 2025-12-04 01:24:36 +08:00
neveler ed4fec6c42 在 HintPane 提示框中为图标和文本添加间距 (#4915) 2025-12-03 16:45:44 +08:00
Glavo c321453399 Fix #4900: 修复 ManagedProcess#getLines 完成后不会解除锁定的问题 (#4913) 2025-12-03 16:19:17 +08:00
Calboot aae02bef50 [Bugfix] 修复模组更新列表界面按 esc 无法正确退出的问题 (#4895)
Fixes #4894
2025-12-03 16:17:46 +08:00
Glavo 89c9a93aab 优化深色模式下日志窗口筛选按钮的颜色 (#4912) 2025-12-03 16:04:10 +08:00
neveler 74ec4aaae8 优化检查更新工作流 (#4879) 2025-12-02 21:15:43 +08:00
Glavo 669a9dfc57 优化更新弹窗的颜色 (#4903) 2025-12-02 21:14:10 +08:00
Glavo a1e75e48da 修复部分控件在深色模式下颜色异常的问题 (#4899) 2025-12-02 20:29:46 +08:00
Glavo 75292d2a52 在陶瓦联机页面向非中国内地用户添加提示框 (#4869) 2025-11-30 21:47:26 +08:00
mineDiamond 6b6dee6cfa feat: 添加对 1.21.11-pre3_unobfuscated 的支持 (#4870) 2025-11-30 21:41:36 +08:00
Calboot 6558b3b2f9 [Bugfix] #4863: macOS 上错误的 Modrinth 整合包默认文件名称 (#4864)
Fixes #4863
2025-11-30 21:27:59 +08:00
Glavo 68fc2d7cd9 修复部分控件颜色错误的问题 (#4887) 2025-11-30 21:23:39 +08:00
Glavo cda45db222 优化对 Linux MIPS64el 平台的支持 (#4886) 2025-11-30 21:23:23 +08:00
3gf8jv4dv 7325b36e8c fix(i18n): improve mod update dialog text (#4788)
fix #4773
2025-11-30 16:13:19 +08:00
Glavo 6fa6f23ad4 在 Manifest 文件中添加 Enable-Final-Field-Mutation 条目 (#4561) 2025-11-30 15:40:06 +08:00
Glavo 8351ee4094 重构下载源选择逻辑 (#4856) 2025-11-30 15:27:33 +08:00
Glavo 310a344f96 更新至 Material Design 3 颜色系统 (#4835) 2025-11-30 15:25:27 +08:00
Glavo 3b78ec9664 修复 JFXRadioButton 在 Windows 平台上中心圆点偏移的问题 (#4874) 2025-11-28 21:09:58 +08:00
Glavo cfcbe6e962 修复修改主题色后 JFXCheckBox 颜色不会及时变化的问题 (#4871) 2025-11-27 21:25:27 +08:00
Glavo fe809ad397 Fix #4860: 播放动画时将 ListPageBase 缓存为位图 (#4862) 2025-11-26 15:53:39 +08:00
Glavo 78a78f4fe7 使用 JFXColorPicker 代替 ColorPicker (#4865) 2025-11-25 21:39:39 +08:00
Glavo 6ca6429114 修复惯性滚动可能导致弹出菜单错位的问题 (#4857) 2025-11-25 15:55:12 +08:00
Glavo 4a3d330f12 Bump JUnit to 6.0.1 (#4858) 2025-11-25 15:36:31 +08:00
Glavo ac71e66fe1 修复 FetchTask 未设置 User-Agent 的问题 (#4850) 2025-11-24 20:33:24 +08:00
Glavo 3c5e4f15d9 支持自动切换游戏语言至阿拉伯语 (#4853) 2025-11-23 21:51:30 +08:00
Hommam-Prom 1c37fbc97a Arabic translation file (#4837) 2025-11-23 21:36:33 +08:00
Glavo 8823ae8ee4 将阿拉伯语列为实验性支持语言 (#4851) 2025-11-23 21:33:01 +08:00
ZZZank a7b952fc4d add @PropertyKey to i18n methods for easier translation<->key navigation (#4831) 2025-11-23 20:32:32 +08:00
Glavo 63ceb3c6c8 Bump version root to 3.9 (#4849) 2025-11-23 15:44:54 +08:00
Glavo ab6e8692ad 修复缓存异常时 FetchTask 无限循环的问题 (#4844) 2025-11-23 15:20:14 +08:00
辞庐 97ada51489 更新 amdgpu.ids (#4845) 2025-11-23 15:15:37 +08:00
Burning_TNT 8e9c2f27a1 Bump Terracotta to 0.3.14 (#4838)
* 更新 MacOS 元数据以增强本地化支持 by @burningtnt in
https://github.com/burningtnt/Terracotta/pull/69
* 重构 EasyTier 参数拼接逻辑 by @burningtnt in
https://github.com/burningtnt/Terracotta/pull/81
* 为 ET 添加 tcp://0.0.0.0:0 监听器 by @burningtnt in
https://github.com/burningtnt/Terracotta/pull/90
* 强制 ET 以 p2p_only 模式运行 by @burningtnt in
https://github.com/burningtnt/Terracotta/pull/97
* 支持联机房间码持久化 by @burningtnt in
https://github.com/burningtnt/Terracotta/pull/94
* 修复 'API c:player_profiles_list invocation failed: Host Profile is
consumed or invalid, machine_id may have conflict.' 的问题 by @burningtnt
in https://github.com/burningtnt/Terracotta/pull/98
2025-11-22 15:28:36 +08:00
mineDiamondandGlavo 8563db3654 添加对 1.21.11-pre1/2_unobfuscated 的支持 (#4829)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-11-21 22:06:51 +08:00
Glavo 2695b5c42f 使用 EdgeOne 分发 HMCL 元数据 (#4830) 2025-11-21 22:02:04 +08:00
Glavo bed87425e7 从首页打开子页面时应当打开默认标签页 (#4826) 2025-11-20 17:40:51 +08:00
Glavo d6ad1cbdf7 修复 TabHeader 相关动画问题 (#4822) 2025-11-19 15:32:18 +08:00
Glavo da5bbd2b22 播放过渡动画时缓存节点至位图 (#4815) 2025-11-18 16:35:02 +08:00
Glavo 5c726913e4 更新陶瓦联机用户须知 (#4816) 2025-11-18 16:27:00 +08:00
Glavo 02bd9e9d2d 使用低版本 Java 启动且带有 --apply-to 参数时提醒用户手动下载最新版本 HMCL (#4793) 2025-11-17 20:23:40 +08:00
辞庐 458793f0c0 fix: 修复 Windows 下全局设置中本地库路径路径分隔符显示错误的问题 (#4808) 2025-11-17 16:37:34 +08:00
Haowei Wen 0543da31cc Fix #4802: 修复复制实例时使用非法实例 ID 启动器行为异常的问题 (#4805) 2025-11-17 16:27:04 +08:00
Glavo a74dc98ce6 修复下载安装整合包时启动器崩溃的问题 (#4813) 2025-11-17 15:43:20 +08:00
Glavo 9dfc6155c4 在 JFXListView 上启用平滑滚动 (#4809) 2025-11-17 15:42:55 +08:00
Glavo e4f10d7cd8 添加更多动画效果 (#4803) 2025-11-16 21:26:38 +08:00
Glavo a21a8d5045 Bump JNA to 5.18.1 (#4799) 2025-11-15 23:22:02 +08:00
Glavo 59994bdeed Bump jsoup to 1.21.2 (#4798) 2025-11-15 23:09:33 +08:00
Glavo a95e4c65ee Bump Gradle to 9.2.0 (#4796) 2025-11-15 23:05:56 +08:00
Glavo df7783c568 修复进入世界管理页面时动画错误的问题 (#4797) 2025-11-15 23:00:09 +08:00
Glavo e5df2034fd 修复初次打开部分页面时页面闪烁的问题 (#4795) 2025-11-15 22:48:30 +08:00
mineDiamond f13d53d6da feat: 增强对数据包/资源包mcmeta的解析能力 (#4612)
close #3952
2025-11-15 21:25:48 +08:00
Glavo 75c4fb6550 优化更多界面的动画 (#4790) 2025-11-15 21:22:18 +08:00
Glavo 44869da20e 更新界面动画效果 (#4780) 2025-11-14 17:42:28 +08:00
Glavo 756dce0d6f 添加对 25w46a_unobfuscated 的支持 (#4782) 2025-11-14 17:42:20 +08:00
mineDiamond 4a4fa07db4 fix: 修复模组列表点击搜索按钮无法正确聚焦到搜索栏的问题 (#4783) 2025-11-13 21:44:00 +08:00
mineDiamondand3gf8jv4dv 17bc003698 feat: 优化数据包管理功能 (#4672)
close #4661

Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2025-11-13 16:43:17 +08:00
Damon Lu 80b2242e4d 在 macOS 下禁用最小化动画 (#4762) 2025-11-13 16:32:31 +08:00
Glavo 47ccda048c 优化游戏实例默认图标 (#4776) 2025-11-13 15:40:36 +08:00
Glavo ae144aa7b8 预加载页面以减少动画卡顿 (#4779) 2025-11-12 21:21:02 +08:00
Glavo 343c09b6ff 修复无法获取部分远古版本版本号的问题 (#4775) 2025-11-11 20:58:56 +08:00
Glavo 1bbc6ef777 更新模组数据 (2025-11-07) (#4770) 2025-11-09 20:57:10 +08:00
辞庐 f4c6a4f891 优化 Github Actions 构建速度 (#4745) 2025-11-08 21:54:15 +08:00
Glavo 733b2c90c4 Fix #4538: 删除实例失败时弹出对话框 (#4540) 2025-11-08 21:52:01 +08:00
Glavo 81b61523df 使 NetworkUtils.doGet 支持更多 URI scheme (#4768) 2025-11-08 21:51:28 +08:00
Glavo d93f4d6bbb 添加对 25w45a_unobfuscated 的支持 (#4767) 2025-11-08 21:50:16 +08:00
neveler f0a16b0cd9 使用矩阵简化更新工作流 (#4725) 2025-11-08 21:20:01 +08:00
Glavo 783cadcf77 从注册表中读取 Windows 版本信息 (#4760) 2025-11-08 20:13:38 +08:00
Glavo cbb4b0c51b 创建 ObservableSetting 辅助类 (#4719) 2025-11-02 21:33:54 +08:00
Glavo c4d9803177 更新 PLATFORM.md (#4750) 2025-11-02 20:52:47 +08:00
Glavo 2ce4fcabcb Revert #4453: 恢复对 JavaFX 14 的兼容性 (#4749) 2025-11-02 20:52:12 +08:00
3a30e5b048 修复 Terracotta 在 macOS 上的兼容性问题 (#4723)
Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-02 20:29:41 +08:00
Glavo 49b0a99ced 优化检查更新日志的方式 (#4743) 2025-11-02 20:27:25 +08:00
Wulian233 8f2c860242 更新 Chunkbase 种子版本 (#4742) 2025-11-01 22:32:50 +08:00
Glavo 8c10775de9 在 PLATFORM.md 中记录启动器对平台的支持状态 (#4737) 2025-11-01 16:46:38 +08:00
3gf8jv4dv b5bf7ce4d0 fix(i18n): update i18n for open dir button on instance mods tab (#4734) 2025-11-01 16:46:13 +08:00
Glavo bd6a6fa9b6 在不支持的平台上打开陶瓦联机页面时弹出提示 (#4735) 2025-10-31 21:07:25 +08:00
Glavo 1c0ae3918d 修复 Platform.FREEBSD_X86_64 架构错误的问题 (#4736) 2025-10-31 21:05:37 +08:00
Glavo c6ce2df731 将官方群组入口重新移动到主界面 (#4727) 2025-10-28 20:05:59 +08:00
Burning_TNT 18985c0b63 Bump Terracotta to 0.3.12 (#4724) 2025-10-27 22:09:28 +08:00
Minecraft-温迪andGlavo 322e29642d 只在未被打包为 App 时设置 macOS Dock 栏图标 (#4698)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-10-27 21:45:13 +08:00
Glavo a86e5d7bf5 Update PLATFORM.md (#4717) 2025-10-27 21:23:29 +08:00
Glavo c90eaa2937 Fix #4720: 修复 JFXListViewSkin 创建大量平台线程的问题 (#4721) 2025-10-27 21:20:32 +08:00
Glavo defacb3554 限制 ./gradlew run 运行时的默认内存占用 (#4722) 2025-10-27 20:51:51 +08:00
Glavo 0a66863272 重构 TerracottaMetadata (#4718) 2025-10-26 20:21:29 +08:00
Glavo d90df221bc 添加详细构建指南 (#4524) 2025-10-26 15:53:15 +08:00
Glavo fd7f909561 将 Localization.md 翻译至英语 (#4669) 2025-10-24 11:55:14 +08:00
neveler a921a11be6 Add tooltip to external link button (#4696) 2025-10-22 21:53:17 +08:00
Glavoand3gf8jv4dv 7fbc99f3eb 优化没有可启动的游戏时的提示 (#4697)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2025-10-22 20:45:56 +08:00
3gf8jv4dv 7e40c799e5 style(l10n): simplify update button text in mod management header (#4712) 2025-10-22 20:40:59 +08:00
Glavo f2032ab7a8 使用 Translator 格式化下载速度 (#4709) 2025-10-22 20:35:42 +08:00
GlavoandCopilot d7c6a23dbe 通过解析 IANA 语言子标签注册表增强本地化功能 (#4675)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-21 15:37:32 +08:00
Glavo 27e1e021d7 优化更新日志展示功能 (#4703)
1. 如果检测到是降级更新,则不展示更新日志;
2. 如果检测到最新更新日志和最新版本不匹配,则不显示更新日志;
3. 展示当前版本与 `nowchange`/`nowpreview` 之间的所有更新日志,而不只展示一个版本的日志。
2025-10-20 20:21:13 +08:00
Glavo 2516fb5d69 在陶瓦联机界面的启动游戏按钮中显示实例名称 (#4705) 2025-10-20 20:08:31 +08:00
Burning_TNTandGlavo d66c080dea Feature: Download Terracotta binary from Gitee, CNB, alist (sponsored by 8mi) first in China mainland. (#4700)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-10-19 21:03:32 +08:00
Glavo 94ca81035b 优化陶瓦联机的复制邀请码功能 (#4687) 2025-10-18 22:18:14 +08:00
Glavo 1ab414e702 优化 Task 的资源占用 (#4694) 2025-10-18 21:50:23 +08:00
Glavo 16f6e89e45 删除 CompletableFutureTask.resolveException (#4690) 2025-10-18 21:47:39 +08:00
Glavo 22f6bfd845 删除 VersionPage.Skin 中未使用的 BorderPane (#4695) 2025-10-18 21:21:41 +08:00
Glavo c4823b3e55 优化陶瓦联机用户须知对话框 (#4686) 2025-10-18 20:25:00 +08:00
Glavo e34ec9f801 调整陶瓦联机界面左侧按钮 (#4688) 2025-10-18 20:20:17 +08:00
Burning_TNT d2e87ba024 Bump Terracotta to 0.3.10. (#4682) 2025-10-17 20:59:10 +08:00
Glavo ffb50423af Bump version root to 3.8 (#4676) 2025-10-16 20:43:16 +08:00
1301 changed files with 154256 additions and 90224 deletions

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: QQ 群
url: https://docs.hmcl.net/groups.html
about: Hello Minecraft! Launcher 的官方 QQ 交流群。
- name: Discord 服务器
url: https://discord.gg/jVvC7HfM6U
about: Hello Minecraft! Launcher 的官方 Discord 服务器。
- name: GitHub Issue
url: https://github.com/HMCL-dev/HMCL/issues/new/choose
about: 本仓库为镜像仓库,Bug 报告与功能请求请在 GitHub 上提交。
+69
View File
@@ -0,0 +1,69 @@
# Default: normalize all text files to LF in the repository and on checkout.
# Git auto-detects binary files and leaves them untouched.
* text=auto eol=lf
# ─── Explicitly text – source & build ────────────────────────────────────────
*.java text eol=lf
*.js text eol=lf
*.kt text eol=lf
*.kts text eol=lf
*.groovy text eol=lf
*.gradle text eol=lf
# ─── Configuration & data ─────────────────────────────────────────────────────
*.properties text eol=lf
*.toml text eol=lf
*.xml text eol=lf
*.json text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.csv text eol=lf
*.html text eol=lf
*.css text eol=lf
*.txt text eol=lf
*.md text eol=lf
*.svg text eol=lf
*.MF text eol=lf
# Gradle dependency-lock files
*.lockfile text eol=lf
# ─── Scripts ──────────────────────────────────────────────────────────────────
# Unix shell scripts must always use LF so they stay executable on Linux/macOS.
*.sh text eol=lf
gradlew text eol=lf
# CI pipeline scripts
Jenkinsfile text eol=lf
# Windows batch files must keep CRLF so cmd.exe can parse them.
*.bat text eol=crlf
# ─── Binary files ─────────────────────────────────────────────────────────────
# Raster / animated images
*.png binary
*.apng binary
*.jpg binary
*.jpeg binary
*.webp binary
*.gif binary
*.ico binary
# Archives & compiled artifacts
*.jar binary
*.zip binary
*.class binary
# Cryptographic / certificate files
*.der binary
# Minecraft schematic
*.litematic binary
*.nbt binary
*.schem binary
*.schematic binary
# Compiled / intermediate build artefacts
*.bin binary
*.probe binary
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: QQ 群
url: https://docs.hmcl.net/groups.html
about: Hello Minecraft! Launcher 的官方 QQ 交流群。
- name: GitHub Issue
url: https://github.com/HMCL-dev/HMCL/issues/new/choose
about: 本仓库为镜像仓库,Bug 报告与功能请求请在 GitHub 上提交。
+4 -4
View File
@@ -9,15 +9,15 @@ body:
value: |
提交前请确认:
* 该问题确实是 **HMCL 的错误**,而**不是 Minecraft 非正常退出**,如果你的 Minecraft 非正常退出,请前往 [QQ 群](https://docs.hmcl.net/groups.html)/[Discord 服务器](https://discord.gg/jVvC7HfM6U) 获取帮助。
* 你的启动器版本是**最新的预览版本**,可以点击 [此处](https://zkitefly.github.io/HMCL-Snapshot-Update/) 下载最新预览版本。
* 该问题确实是 **HMCL 的错误**,而**不是 Minecraft 非正常退出**。如果你的 Minecraft 非正常退出,请前往 [QQ 群](https://docs.hmcl.net/groups.html)/[Discord 服务器](https://discord.gg/jVvC7HfM6U) 获取帮助。
* 你的启动器版本是**最新的预览版本**。你可以从 [GitHub Actions](https://github.com/HMCL-dev/HMCL/actions/workflows/gradle.yml?query=branch%3Amain+event%3Apush) 或 [nightly.link](https://nightly.link/HMCL-dev/HMCL/workflows/gradle/main) 下载最新预览版本。
如果你的问题并不属于上述两类,你可以选取另一种 Issue 类型,或者直接前往 [QQ 群](https://docs.hmcl.net/groups.html)/[Discord 服务器](https://discord.gg/jVvC7HfM6U) 获取帮助。
Before submitting, please confirm:
* The issue is indeed **a bug of HMCL**, not **Minecraft abnormal exit**. If your Minecraft exits abnormally, please go to the [QQ group](https://docs.hmcl.net/groups.html) or [Discord server](https://discord.gg/jVvC7HfM6U) for help.
* Your launcher is the **latest nightly build**. You can click [here](https://zkitefly.github.io/HMCL-Snapshot-Update/en) to download the latest nightly build.
* Your launcher is the **latest nightly build**. You can download the latest nightly build from [GitHub Actions](https://github.com/HMCL-dev/HMCL/actions/workflows/gradle.yml?query=branch%3Amain+event%3Apush) or [nightly.link](https://nightly.link/HMCL-dev/HMCL/workflows/gradle/main).
If your issue does not fall into the above two categories, you can choose another type of issue or directly go to the [QQ group](https://docs.hmcl.net/groups.html) or [Discord server](https://discord.gg/jVvC7HfM6U) for help.
- type: textarea
@@ -38,7 +38,7 @@ body:
attributes:
label: 启动器崩溃报告 / 启动器日志文件 | Launcher Crash Report / Launcher Log File
description: |
如果你的启动器崩溃了,请将崩溃报告填入(或将文件拖入)下方。
如果你的启动器崩溃了,请将崩溃报告填入 (或将文件拖入) 下方。
如果你的启动器没有崩溃,请在遇到问题后**不要退出启动器**,在启动器的 “设置 → 通用 → 调试” 一栏中点击 “导出启动器日志”,并将导出的日志拖到下方的输入栏中。
**请注意:启动器崩溃报告或日志文件是诊断问题的重要依据,请务必上传!**
+120
View File
@@ -0,0 +1,120 @@
const fs = require("fs");
module.exports = async ({ github, context, core }) => {
const prNumberPath = process.env.PULL_REQUEST_NUMBER_PATH;
if (!prNumberPath || !fs.existsSync(prNumberPath) || !fs.statSync(prNumberPath).isFile()) {
core.setFailed("Pull request number file not found.");
return;
}
const reportsPath = process.env.REPORTS_PATH;
if (!reportsPath || !fs.existsSync(reportsPath) || !fs.statSync(reportsPath).isFile()) {
core.setFailed("Reports file not found.");
return;
}
const PULL_REQUEST_NUMBER = parseInt(fs.readFileSync(prNumberPath, "utf8").trim(), 10);
if (isNaN(PULL_REQUEST_NUMBER)) {
core.setFailed("Failed to parse the pull request number.");
return;
}
const { owner, repo } = context.repo;
let pr;
try {
const response = await github.rest.pulls.get({
owner,
repo,
pull_number: PULL_REQUEST_NUMBER,
});
pr = response.data;
} catch (error) {
core.setFailed(`Failed to fetch PR #${PULL_REQUEST_NUMBER}: ${error.message}`);
return;
}
core.info(`Current PR state: ${pr.state}`);
if (pr.state !== "open") {
core.setFailed("The pull request is not open. Skipping comment creation.");
return;
}
const run = context.payload.workflow_run;
if (!run) {
core.setFailed("context.payload.workflow_run is undefined. Ensure this script runs on the 'workflow_run' event.");
return;
}
if (pr.head.sha !== run.head_sha) {
core.setFailed("PR head SHA does not match the workflow run head SHA. Skipping.");
return;
}
if (pr.head.ref !== run.head_branch) {
core.setFailed("PR head branch does not match the workflow run head branch. Skipping.");
return;
}
if (pr.head.repo.full_name !== run.head_repository.full_name) {
core.setFailed("PR head repository fullname does not match the workflow run head repository fullname. Skipping.");
return;
}
const comments = [];
const maxCommentCount = 10;
const reports = fs.readFileSync(reportsPath, "utf8").trim().split("\n").filter(Boolean);
for (const report of reports) {
if (comments.length >= maxCommentCount) break;
try {
const data = JSON.parse(report);
if (typeof data !== "object" || Array.isArray(data)) continue;
const { location, message, severity, code } = data;
if (typeof location !== "object" || Array.isArray(location)) continue;
if (typeof message !== "string") continue;
const severities = ["UNKNOWN_SEVERITY", "ERROR", "WARNING", "INFO", 0, 1, 2, 3];
if (!severities.includes(severity)) continue;
const severityText = typeof severity === "string" ? severity : severities[severity];
if (typeof code !== "object" || Array.isArray(code)) continue;
const path = location.path;
const line = location.range?.start?.line;
const codeText = code.value ? `[${code.value}] ` : "";
if (path && line) {
comments.push({
path,
line,
side: "RIGHT",
body: `**[${severityText}]** ${codeText}\n\n${message}`
});
}
} catch (error) {
core.warning(`Failed to parse report: ${error.message}`);
}
}
if (comments.length === 0) {
core.info("No diagnostics found to report as comments.");
return;
}
core.info(`Successfully prepared ${comments.length} comment${comments.length > 1 ? "s" : ""} to post.`);
try {
await github.rest.pulls.createReview({
owner,
repo,
pull_number: PULL_REQUEST_NUMBER,
commit_id: run.head_sha,
body: "🤖 Static analysis found the following issues (max count 10):",
event: "COMMENT",
comments: comments
});
core.info("Review comments successfully posted to the pull request.");
} catch (error) {
core.setFailed(`Failed to post review comments to GitHub: ${error.message}`);
return;
}
}
+105
View File
@@ -0,0 +1,105 @@
module.exports = async ({ github, context, core }) => {
const owner = context.repo.owner;
const repo = context.repo.repo;
const ignoredFilePatterns = [
/(?:^|\/)\.cnb\/.+\.ya?ml$/i,
/(?:^|\/)\.gitee\/.+\.ya?ml$/i,
/(?:^|\/)\.github\/.+\.ya?ml$/i,
/(?:^|\/)\.idea\/.+\.xml$/i,
/(?:^|\/)\.editorconfig$/i,
/(?:^|\/)\.gitattributes$/i,
/(?:^|\/)\.gitignore$/i,
/(?:^|\/).+\.kts$/i,
/(?:^|\/).+\.md$/i,
/(?:^|\/)LICENSE(?:_.*)?$/i,
/(?:^|\/)gradlew(?:\.bat|\.ps1)?$/i,
/(?:^|\/)gradle\/wrapper\/gradle-wrapper\.properties$/i,
/(?:^|\/)lang\/.+\.properties$/i,
/(?:^|\/)l10n\/.+\.properties$/i,
/(?:^|\/)checkstyle\.xml$/i,
/(?:^|\/).+\.(?:png|jpe?g|gif|ico|svg|json|ttf|woff2?|webp|avif|jxl|heic|mp4|webm|mp3|ogg|wav|txt|toml)$/i
];
async function processPR(issueNumber) {
const files = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: issueNumber, per_page: 100,
});
const countedFiles = [];
const ignoredFiles = [];
let totalChanges = 0;
for (const file of files) {
const ignored = ignoredFilePatterns.some(pattern => pattern.test(file.filename));
if (ignored) {
ignoredFiles.push(file.filename);
continue;
}
const fileChanges = file.changes ?? ((file.additions || 0) + (file.deletions || 0));
totalChanges += fileChanges;
countedFiles.push(`${file.filename} (${fileChanges})`);
}
let targetLabel;
if (totalChanges <= 9) targetLabel = "1+";
else if (totalChanges <= 39) targetLabel = "10+";
else if (totalChanges <= 99) targetLabel = "40+";
else if (totalChanges <= 499) targetLabel = "100+";
else if (totalChanges <= 999) targetLabel = "500+";
else if (totalChanges <= 1999) targetLabel = "1000+";
else if (totalChanges <= 4999) targetLabel = "2000+";
else targetLabel = "5000+";
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner, repo, issue_number: issueNumber, per_page: 100,
});
const sizeLabels = currentLabels.map(label => label.name).filter(name => /^\d+\+$/.test(name));
for (const name of sizeLabels) {
if (name !== targetLabel) {
await github.rest.issues.removeLabel({
owner, repo, issue_number: issueNumber, name,
}).catch(() => { });
}
}
if (!currentLabels.some(label => label.name === targetLabel)) {
await github.rest.issues.addLabels({
owner, repo, issue_number: issueNumber, labels: [targetLabel],
});
}
core.info(`Pull request #${issueNumber} has ${totalChanges} counted changed lines, applied ${targetLabel}.`);
core.info(`Ignored files: ${ignoredFiles.length ? ignoredFiles.join(", ") : "none"}`);
core.info(`Counted files: ${countedFiles.length ? countedFiles.join(", ") : "none"}`);
}
if (context.eventName === "workflow_dispatch") {
core.info("Manual trigger detected. Fetching all open pull requests...");
const openPRs = await github.paginate(github.rest.pulls.list, {
owner, repo, state: "open", per_page: 100,
});
const unlabelledPRs = openPRs.filter(pr => {
return !pr.labels.some(label => /^\d+\+$/.test(label.name));
});
core.info(`Found ${openPRs.length} open pull request(s), ${unlabelledPRs.length} of them lack a size label.`);
for (const pr of unlabelledPRs) {
core.info(`----------------------------------------`);
core.info(`Processing unlabelled pull request #${pr.number}: ${pr.title}`);
await processPR(pr.number);
}
} else {
const pr = context.payload.pull_request;
if (!pr) {
core.info("No pull request found in payload, skipping.");
return;
}
await processPR(pr.number);
}
}
-25
View File
@@ -1,25 +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@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
java-package: 'jdk+fx'
- name: Check Codes
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel --stacktrace
-119
View File
@@ -1,119 +0,0 @@
name: Check Update
on:
workflow_dispatch:
# schedule:
# - cron: '30 * * * *'
permissions:
contents: write
jobs:
dev-check-update:
if: ${{ github.repository_owner == 'HMCL-dev' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '25'
- name: Fetch tags
run: git fetch --all --tags
- name: Fetch last version
run: ./gradlew checkUpdateDev --no-daemon --info --stacktrace
- name: Check for existing tags
run: if [ -z "$(git tag -l "$HMCL_TAG_NAME")" ]; then echo "continue=true" >> $GITHUB_ENV; fi
- name: Download artifacts
if: ${{ env.continue == 'true' }}
run: |
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256"
- name: Generate release note
if: ${{ env.continue == 'true' }}
run: |
echo "The full changelogs can be found on the website: https://docs.hmcl.net/changelog/dev.html" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
echo "*Notice: changelogs are written in Chinese.*" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
echo "| File Name | SHA-256 Checksum |" >> RELEASE_NOTE
echo "| --- | --- |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE
env:
GH_DOWNLOAD_BASE_URL: https://github.com/HMCL-dev/HMCL/releases/download
- name: Create release
if: ${{ env.continue == 'true' }}
uses: softprops/action-gh-release@v2
with:
body_path: RELEASE_NOTE
files: |
HMCL-${{ env.HMCL_VERSION }}.exe
HMCL-${{ env.HMCL_VERSION }}.jar
HMCL-${{ env.HMCL_VERSION }}.sh
target_commitish: ${{ env.HMCL_COMMIT_SHA }}
name: ${{ env.HMCL_TAG_NAME }}
tag_name: ${{ env.HMCL_TAG_NAME }}
prerelease: true
stable-check-update:
if: ${{ github.repository_owner == 'HMCL-dev' }}
needs: dev-check-update
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '25'
- name: Fetch tags
run: git fetch --all --tags
- name: Fetch last version
run: ./gradlew checkUpdateStable --no-daemon --info --stacktrace
- name: Check for existing tags
run: if ! git tag -l | grep -q "$HMCL_TAG_NAME"; then echo "continue=true" >> $GITHUB_ENV; fi
- name: Download artifacts
if: ${{ env.continue == 'true' }}
run: |
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256"
- name: Generate release note
if: ${{ env.continue == 'true' }}
run: |
echo "**This version is a stable version.**" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
echo "The full changelogs can be found on the website: https://docs.hmcl.net/changelog/stable.html" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
echo "*Notice: changelogs are written in Chinese.*" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
echo "| File Name | SHA-256 Checksum |" >> RELEASE_NOTE
echo "| --- | --- |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE
env:
GH_DOWNLOAD_BASE_URL: https://github.com/HMCL-dev/HMCL/releases/download
- name: Create release
if: ${{ env.continue == 'true' }}
uses: softprops/action-gh-release@v2
with:
body_path: RELEASE_NOTE
files: |
HMCL-${{ env.HMCL_VERSION }}.exe
HMCL-${{ env.HMCL_VERSION }}.jar
HMCL-${{ env.HMCL_VERSION }}.sh
target_commitish: ${{ env.HMCL_COMMIT_SHA }}
name: ${{ env.HMCL_TAG_NAME }}
tag_name: ${{ env.HMCL_TAG_NAME }}
prerelease: true
-21
View File
@@ -1,21 +0,0 @@
name: Sync to Gitee
on:
push
jobs:
run:
if: ${{ github.repository_owner == 'HMCL-dev' }}
runs-on: ubuntu-latest
steps:
- name: Mirror GitHub to Gitee
uses: Yikun/hub-mirror-action@v1.4
with:
src: github/HMCL-dev
dst: gitee/huanghongxun
static_list: 'HMCL'
force_update: true
debug: true
dst_key: ${{ secrets.GITEE_SYNC_BOT_PRIVATE_KEY }}
dst_token: ${{ secrets.GITEE_SYNC_BOT_TOKEN }}
cache_path: /github/workspace/hub-mirror-cache
+54 -18
View File
@@ -1,50 +1,86 @@
name: Java CI
# Pushes to main and pull requests only. A branch with an open pull request used to build twice
# (once for the push, once for the pull request), which kept the runners queued.
on:
push:
branches:
- main
tags:
- '*'
pull_request:
paths-ignore:
- '**.md'
# A newer run for the same pull request or branch replaces the older one.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v4
uses: actions/setup-java@v6
with:
distribution: 'zulu'
distribution: 'liberica'
java-version: '17'
java-package: 'jdk+fx'
- name: Build with Gradle
run: ./gradlew build --no-daemon
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-cleanup: never
# 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 }}
MICROSOFT_AUTH_SECRET: ${{ secrets.MICROSOFT_AUTH_SECRET }}
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
- name: Get short SHA
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
- name: Upload JAR
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: HMCL-${{ env.SHORT_SHA }}-jar
path: |
HMCL/build/libs/HMCL-*.jar
HMCL/build/libs/HMCL-*.jar.sha256
path: HMCL/build/libs/HMCL-*.jar
archive: false
- name: Upload EXE
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: HMCL-${{ env.SHORT_SHA }}-exe
path: |
HMCL/build/libs/HMCL-*.exe
HMCL/build/libs/HMCL-*.exe.sha256
path: HMCL/build/libs/HMCL-*.exe
archive: false
- name: Upload SH
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: HMCL-${{ env.SHORT_SHA }}-sh
path: |
HMCL/build/libs/HMCL-*.sh
HMCL/build/libs/HMCL-*.sh.sha256
path: HMCL/build/libs/HMCL-*.sh
archive: false
- name: Upload DEB
uses: actions/upload-artifact@v7
with:
name: HMCL-${{ env.SHORT_SHA }}-deb
path: HMCL/build/libs/HMCL-*.deb
archive: false
+33
View File
@@ -0,0 +1,33 @@
name: Mirror Repository
on:
workflow_dispatch:
push:
concurrency:
group: mirror-repository
cancel-in-progress: true
jobs:
mirror:
strategy:
fail-fast: false
matrix:
include:
- name: Gitee
repo: gitee.com/huanghongxun/HMCL
user: 'hmcl-sync'
token: 'GITEE_SYNC_TOKEN'
- name: CNB
repo: cnb.cool/HMCL-dev/HMCL
user: 'cnb'
token: 'CNB_SYNC_TOKEN'
name: Mirror to ${{ matrix.name }}
if: ${{ github.repository == 'HMCL-dev/HMCL' }}
runs-on: ubuntu-latest
steps:
- name: Mirror GitHub to ${{ matrix.name }}
run: |
git clone --mirror "https://github.com/${{ github.repository }}.git" -- repo
cd repo
git push -f --prune "https://${{ matrix.user }}:${{ secrets[matrix.token] }}@${{ matrix.repo }}.git" "refs/heads/*:refs/heads/*" "refs/tags/*:refs/tags/*"
+20
View File
@@ -0,0 +1,20 @@
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:
workflow_dispatch:
permissions:
contents: read
issues: write
pull-requests: write
jobs:
label:
runs-on: ubuntu-latest
if: false
steps:
- name: Disabled
run: echo "pr-size-label is disabled on this fork"
+136
View File
@@ -0,0 +1,136 @@
name: Create Release
on:
workflow_dispatch:
# schedule:
# - cron: '30 * * * *'
permissions:
contents: write
jobs:
check-update:
if: ${{ github.repository_owner == 'HMCL-dev' }}
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
- channel: dev
task: checkUpdateDev
- channel: stable
task: checkUpdateStable
runs-on: ubuntu-latest
name: check-update-${{ matrix.channel }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '25'
- name: Fetch last version
run: ./gradlew ${{ matrix.task }} --no-daemon --info --stacktrace
- name: Check for existing tags
run: if [ -z "$(git tag -l "$HMCL_TAG_NAME")" ]; then echo "continue=true" >> $GITHUB_ENV; fi
- name: Download artifacts
if: ${{ env.continue == 'true' }}
run: |
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.deb"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.deb.sha256"
- name: Verify artifacts
if: ${{ env.continue == 'true' }}
run: |
export JAR_SHA256=$(cat HMCL-$HMCL_VERSION.jar.sha256 | tr -d '\n')
export EXE_SHA256=$(cat HMCL-$HMCL_VERSION.exe.sha256 | tr -d '\n')
export SH_SHA256=$(cat HMCL-$HMCL_VERSION.sh.sha256 | tr -d '\n')
export DEB_SHA256=$(cat HMCL-$HMCL_VERSION.deb.sha256 | tr -d '\n')
echo "$JAR_SHA256 HMCL-$HMCL_VERSION.jar" | sha256sum -c
echo "$EXE_SHA256 HMCL-$HMCL_VERSION.exe" | sha256sum -c
echo "$SH_SHA256 HMCL-$HMCL_VERSION.sh" | sha256sum -c
echo "$DEB_SHA256 HMCL-$HMCL_VERSION.deb" | sha256sum -c
- name: Generate release note
if: ${{ env.continue == 'true' }}
run: |
# GitHub Release Note
echo "&nbsp;**[Changelog](https://docs.hmcl.net/changelog/${{ matrix.channel }}.html#HMCL-$HMCL_VERSION)** (Chinese)" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
echo "| File | SHA-256 Checksum |" >> RELEASE_NOTE
echo "| --- | --- |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.deb]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.deb) | \`$(cat HMCL-$HMCL_VERSION.deb.sha256)\` |" >> RELEASE_NOTE
# CNB Release Note
echo "[更新日志](https://docs.hmcl.net/changelog/${{ matrix.channel }}.html#HMCL-$HMCL_VERSION)" >> CNB_RELEASE_NOTE
echo "" >> CNB_RELEASE_NOTE
echo "| 文件 | SHA-256 校验码 |" >> CNB_RELEASE_NOTE
echo "| :--- | --- |" >> CNB_RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.exe]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> CNB_RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.jar]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> CNB_RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.sh]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> CNB_RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.deb]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.deb) | \`$(cat HMCL-$HMCL_VERSION.deb.sha256)\` |" >> CNB_RELEASE_NOTE
env:
GH_DOWNLOAD_BASE_URL: https://github.com/${{ github.repository }}/releases/download
CNB_DOWNLOAD_BASE_URL: https://cnb.cool/HMCL-dev/HMCL/-/releases/download
- name: Create GitHub release
if: ${{ env.continue == 'true' }}
run: |
gh release create "${{ env.HMCL_TAG_NAME }}" \
"HMCL-${{ env.HMCL_VERSION }}.exe" \
"HMCL-${{ env.HMCL_VERSION }}.jar" \
"HMCL-${{ env.HMCL_VERSION }}.sh" \
"HMCL-${{ env.HMCL_VERSION }}.deb" \
--target "${{ env.HMCL_COMMIT_SHA }}" \
--title "${{ env.HMCL_TAG_NAME }}" \
--notes-file RELEASE_NOTE \
--prerelease
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install git-cnb
if: ${{ env.continue == 'true' }}
run: go install "cnb.cool/looc/git-cnb@$GIT_CNB_VERSION"
env:
GIT_CNB_VERSION: '1.1.2'
- name: Create CNB release
if: ${{ env.continue == 'true' }}
run: |
echo "Uploading tags to CNB"
git fetch --tags
git push "https://cnb:${{ secrets.CNB_SYNC_TOKEN }}@cnb.cool/$CNB_REPO.git" "$HMCL_TAG_NAME"
echo "Creating CNB release"
~/go/bin/git-cnb release create \
--repo "$CNB_REPO" \
--tag "$HMCL_TAG_NAME" \
--name "HMCL $HMCL_VERSION" \
--body "$(cat CNB_RELEASE_NOTE)" \
--prerelease true
echo "Uploading HMCL-$HMCL_VERSION.jar"
~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.jar"
echo "Uploading HMCL-$HMCL_VERSION.exe"
~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.exe"
echo "Uploading HMCL-$HMCL_VERSION.sh"
~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.sh"
echo "Uploading HMCL-$HMCL_VERSION.deb"
~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.deb"
env:
CNB_TOKEN: ${{ secrets.CNB_SYNC_TOKEN }}
CNB_REPO: HMCL-dev/HMCL
+20 -1
View File
@@ -4,7 +4,10 @@
hs_err_pid*
*.hprof
*.jfr
.gradle
gradle.properties
*.lck
*.1
@@ -15,10 +18,16 @@ hs_err_pid*
NVIDIA
minecraft-exported-crash-info*
hmcl-exported-logs-*
terracotta-log-*
hmcl-mod-update-list-*
*.hmcl-theme
/.java/
/.local/
/.cache/
# IANA Language Subtag Registry
language-subtag-registry
# gradle build
/build/
/HMCL/build/
@@ -39,6 +48,7 @@ hmcl-exported-logs-*
# eclipse
/bin/
/HMCL/bin/
/HMCLBoot/bin/
/HMCLCore/bin/
/minecraft/libraries/HMCLTransformerDiscoveryService/bin/
/minecraft/libraries/HMCLMultiMCBootstrap/bin/
@@ -60,4 +70,13 @@ hmcl-exported-logs-*
# test
/hmcl.json
/.hmcl.json
/.hmcl/
/.hmcl/
# AI
/.gemini/
!/.gemini/config.yaml
.codex
/.gradle-*/
# Managed Java downloaded by the launcher when it is run from the repository root
/java/
+6
View File
@@ -0,0 +1,6 @@
<component name="CopyrightManager">
<copyright>
<option name="notice" value="SPDX-FileCopyrightText: Copyright (C) &amp;#36;today.year The HMCL Authors&#10;SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL" />
<option name="myName" value="HMCL" />
</copyright>
</component>
+9
View File
@@ -0,0 +1,9 @@
<component name="CopyrightManager">
<settings default="HMCL">
<LanguageOptions name="JAVA">
<option name="fileTypeOverride" value="3" />
<option name="addBlankAfter" value="false" />
<option name="block" value="false" />
</LanguageOptions>
</settings>
</component>
+25
View File
@@ -0,0 +1,25 @@
# Java Code Style Requirements
These rules apply to all Java code written or modified in this repository.
## Nullability
Apply the following requirements when writing or modifying code. Do not use them as code-review criteria.
- Every class declared in a newly added Java source file must be annotated with JetBrains Annotations `@NotNullByDefault`.
- When writing or modifying Java code, any type, field, parameter, return value, local variable, or generic type argument that may be `null` must be explicitly annotated with `@Nullable`.
- Nullability in code being written or modified must never be implicit.
## Immutability
- Immutable arrays and collections must be explicitly annotated with JetBrains Annotations `@Unmodifiable` or `@UnmodifiableView` as appropriate.
- For arrays, use type-use syntax such as `String @Unmodifiable []`.
## Documentation
Apply the following requirements when writing or modifying code. Do not use them as code-review criteria.
- Every class, field, and method must have documentation.
- Documentation must use `///` Markdown-style Javadoc comments.
- Keep documentation accurate and specific to the actual behavior, constraints, and side effects.
- Add concise implementation comments inside complex logic whenever they materially improve readability or explain non-obvious behavior.
+122 -56
View File
@@ -1,11 +1,16 @@
import org.jackhuang.hmcl.gradle.addon.FormatJsonFile
import org.jackhuang.hmcl.gradle.addon.UpdateAddonProjectInfos
import org.jackhuang.hmcl.gradle.ci.GitHubActionUtils
import org.jackhuang.hmcl.gradle.ci.JenkinsUtils
import org.jackhuang.hmcl.gradle.l10n.CheckTranslations
import org.jackhuang.hmcl.gradle.l10n.CreateLanguageList
import org.jackhuang.hmcl.gradle.l10n.CreateLocaleNamesResourceBundle
import org.jackhuang.hmcl.gradle.l10n.SyncTranslations
import org.jackhuang.hmcl.gradle.l10n.UpsideDownTranslate
import org.jackhuang.hmcl.gradle.mod.ParseModDataTask
import org.jackhuang.hmcl.gradle.pack.CreateDeb
import org.jackhuang.hmcl.gradle.pack.ReleaseType
import org.jackhuang.hmcl.gradle.utils.PropertiesUtils
import org.jackhuang.hmcl.gradle.utils.OfficialBuildValueSource
import java.net.URI
import java.nio.file.FileSystems
import java.nio.file.Files
@@ -27,7 +32,6 @@ val versionType = System.getenv("VERSION_TYPE") ?: if (isOfficial) "nightly" els
val versionRoot = System.getenv("VERSION_ROOT") ?: projectConfig.getProperty("versionRoot") ?: "3"
val microsoftAuthId = System.getenv("MICROSOFT_AUTH_ID") ?: ""
val microsoftAuthSecret = System.getenv("MICROSOFT_AUTH_SECRET") ?: ""
val curseForgeApiKey = System.getenv("CURSEFORGE_API_KEY") ?: ""
val launcherExe = System.getenv("HMCL_LAUNCHER_EXE") ?: ""
@@ -50,20 +54,27 @@ if (buildNumber != null) {
}
}
val embedResources by configurations.registering
val embedResources = configurations.register("embedResources")
dependencies {
implementation(project(":HMCLCore"))
implementation(project(":HMCLBoot"))
implementation("libs:JFoenix")
implementation(libs.twelvemonkeys.imageio.webp)
implementation(libs.jwebp)
implementation(libs.fxsvgimage)
implementation(libs.java.info)
implementation(libs.monet.fx)
implementation(libs.nayuki.qrcodegen)
implementation(libs.uuid.tools)
testImplementation(libs.jimfs)
if (launcherExe.isBlank()) {
implementation(libs.hmclauncher)
}
embedResources(libs.authlib.injector)
embedResources(libs.lwjgl.unsafe.agent)
}
fun digest(algorithm: String, bytes: ByteArray): ByteArray = MessageDigest.getInstance(algorithm).digest(bytes)
@@ -117,10 +128,6 @@ tasks.checkstyleMain {
exclude("**/org/jackhuang/hmcl/ui/image/apng/**")
}
tasks.compileJava {
options.compilerArgs.add("--add-exports=java.base/jdk.internal.loader=ALL-UNNAMED")
}
val addOpens = listOf(
"java.base/java.lang",
"java.base/java.lang.reflect",
@@ -128,42 +135,48 @@ val addOpens = listOf(
"javafx.base/com.sun.javafx.binding",
"javafx.base/com.sun.javafx.event",
"javafx.base/com.sun.javafx.runtime",
"javafx.base/javafx.beans.property",
"javafx.graphics/javafx.css",
"javafx.graphics/javafx.stage",
"javafx.graphics/javafx.scene",
"javafx.graphics/com.sun.glass.ui",
"javafx.graphics/com.sun.javafx.stage",
"javafx.graphics/com.sun.javafx.util",
"javafx.graphics/com.sun.prism",
"javafx.controls/com.sun.javafx.scene.control",
"javafx.controls/com.sun.javafx.scene.control.behavior",
"javafx.graphics/com.sun.javafx.tk.quantum",
"javafx.controls/javafx.scene.control",
"javafx.controls/javafx.scene.control.skin",
"jdk.attach/sun.tools.attach",
)
val hmclProperties = buildList {
add("hmcl.version" to project.version.toString())
add("hmcl.add-opens" to addOpens.joinToString(" "))
System.getenv("GITHUB_SHA")?.let {
add("hmcl.version.hash" to it)
}
add("hmcl.version.type" to versionType)
add("hmcl.microsoft.auth.id" to microsoftAuthId)
add("hmcl.microsoft.auth.secret" to microsoftAuthSecret)
add("hmcl.curseforge.apikey" to curseForgeApiKey)
add("hmcl.authlib-injector.version" to libs.authlib.injector.get().version!!)
tasks.compileJava {
options.compilerArgs.addAll(addOpens.map { "--add-exports=$it=ALL-UNNAMED" })
}
val hmclPropertiesFile = layout.buildDirectory.file("hmcl.properties")
val createPropertiesFile by tasks.registering {
outputs.file(hmclPropertiesFile)
hmclProperties.forEach { (k, v) -> inputs.property(k, v) }
val createPropertiesFile = tasks.register<WriteProperties>("createPropertiesFile") {
destinationFile.set(layout.buildDirectory.file("hmcl.properties"))
encoding = "UTF-8"
doLast {
val targetFile = hmclPropertiesFile.get().asFile
targetFile.parentFile.mkdir()
targetFile.bufferedWriter().use {
for ((k, v) in hmclProperties) {
it.write("$k=$v\n")
}
}
}
property("hmcl.version", project.version.toString())
property("hmcl.add-opens", addOpens.joinToString(" "))
System.getenv("GITHUB_SHA")?.let { property("hmcl.version.hash", it) }
property("hmcl.version.type", versionType)
property("hmcl.microsoft.auth.id", microsoftAuthId)
property("hmcl.curseforge.apikey", curseForgeApiKey)
property("hmcl.authlib-injector.version", libs.authlib.injector.get().version!!)
property("hmcl.lwjgl-unsafe-agent.version", libs.lwjgl.unsafe.agent.get().version!!)
property("hmcl.official", providers.of(OfficialBuildValueSource::class) {
parameters.publicKeyFile.set(layout.projectDirectory.file("src/main/resources/assets/hmcl_signature_publickey.der"))
parameters.privateKeyLocation.set(providers.environmentVariable("HMCL_SIGNATURE_KEY"))
// Pin the official public key independently of the replaceable resource file.
// Under HMCL’s license agreement, you **MUST** change the software name or the version number in
// a reasonable way to distinguish it from the original version.
// You may modify this hash if and only if you have changed the software name or version number
// in accordance with the license requirements.
parameters.expectedSha256.set("36aadcfde8626b9d4c7a74facb3fdfda47e703c205a673fbe6a56b1d419f8b8e")
})
}
tasks.jar {
@@ -185,7 +198,7 @@ tasks.shadowJar {
exclude("META-INF/services/javax.imageio.spi.ImageInputStreamSpi")
listOf(
"aix-*", "sunos-*", "openbsd-*", "dragonflybsd-*", "freebsd-*", "linux-*", "darwin-*",
"aix-*", "sunos-*", "openbsd-*", "dragonflybsd-*", "freebsd-*", "linux-*",
"*-ppc", "*-ppc64le", "*-s390x", "*-armel",
).forEach { exclude("com/sun/jna/$it/**") }
@@ -197,12 +210,13 @@ tasks.shadowJar {
}
manifest.attributes(
"Created-By" to "Copyright(c) 2013-2025 huangyuhui.",
"Created-By" to "Copyright(c) 2013-2026 The HMCL Authors",
"Implementation-Version" to project.version.toString(),
"Main-Class" to "org.jackhuang.hmcl.Main",
"Multi-Release" to "true",
"Add-Opens" to addOpens.joinToString(" "),
"Enable-Native-Access" to "ALL-UNNAMED"
"Enable-Native-Access" to "ALL-UNNAMED",
"Enable-Final-Field-Mutation" to "ALL-UNNAMED",
)
if (launcherExe.isNotBlank()) {
@@ -218,13 +232,12 @@ tasks.shadowJar {
}
tasks.processResources {
dependsOn(createPropertiesFile)
dependsOn(upsideDownTranslate)
dependsOn(createLocaleNamesResourceBundle)
dependsOn(createLanguageList)
into("assets/") {
from(hmclPropertiesFile)
from(createPropertiesFile.flatMap { it.destinationFile })
from(embedResources)
}
@@ -233,22 +246,25 @@ tasks.processResources {
from(upsideDownTranslate.map { it.outputFile })
from(createLocaleNamesResourceBundle.map { it.outputDirectory })
}
}
val makeExecutables by tasks.registering {
fun artifactFile(ext: String) = jarPath.resolveSibling(jarPath.nameWithoutExtension + '.' + ext)
val makeExecutables = tasks.register("makeExecutables") {
val extensions = listOf("exe", "sh")
dependsOn(tasks.jar)
inputs.file(jarPath)
outputs.files(extensions.map { File(jarPath.parentFile, jarPath.nameWithoutExtension + '.' + it) })
outputs.files(extensions.map { artifactFile(it) })
doLast {
val jarContent = jarPath.readBytes()
ZipFile(jarPath).use { zipFile ->
for (extension in extensions) {
val output = File(jarPath.parentFile, jarPath.nameWithoutExtension + '.' + extension)
val output = artifactFile(extension)
val entry = zipFile.getEntry("assets/HMCLauncher.$extension")
?: throw GradleException("HMCLauncher.$extension not found")
@@ -263,8 +279,32 @@ val makeExecutables by tasks.registering {
}
}
val makeDeb = tasks.register("makeDeb", CreateDeb::class) {
dependsOn(makeExecutables)
val debFile = layout.file(provider { artifactFile("deb") })
val debChannel = when (versionType) {
"stable" -> ReleaseType.STABLE
"dev" -> ReleaseType.DEVELOPMENT
else -> ReleaseType.NIGHTLY
}
version.set(project.version.toString())
releaseType.set(debChannel)
launcherClassName.set("org.jackhuang.hmcl.Launcher")
appShFile.set(layout.file(provider { artifactFile("sh") }))
iconFile.set(layout.projectDirectory.file("image/hmcl.png"))
outputFile.set(debFile)
doLast {
createChecksum(debFile.get().asFile)
}
}
tasks.build {
dependsOn(makeExecutables)
dependsOn(makeDeb)
}
fun parseToolOptions(options: String?): MutableList<String> {
@@ -317,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") {
@@ -335,7 +380,7 @@ tasks.register<JavaExec>("run") {
classpath = files(jarPath)
workingDir = rootProject.rootDir
val vmOptions = parseToolOptions(System.getenv("HMCL_JAVA_OPTS"))
val vmOptions = parseToolOptions(System.getenv("HMCL_JAVA_OPTS") ?: "-Xmx1g")
if (vmOptions.none { it.startsWith("-Dhmcl.offline.auth.restricted=") })
vmOptions += "-Dhmcl.offline.auth.restricted=false"
@@ -355,48 +400,69 @@ tasks.register<JavaExec>("run") {
}
}
// Check Translations
tasks.register<CheckTranslations>("checkTranslations") {
val dir = layout.projectDirectory.dir("src/main/resources/assets/lang")
englishFile.set(dir.file("I18N.properties"))
simplifiedChineseFile.set(dir.file("I18N_zh_CN.properties"))
traditionalChineseFile.set(dir.file("I18N_zh.properties"))
simplifiedChineseFile.set(dir.file("I18N_zh_Hans.properties"))
traditionalChineseFile.set(dir.file("I18N_zh_Hant.properties"))
classicalChineseFile.set(dir.file("I18N_lzh.properties"))
}
// l10n
tasks.register<SyncTranslations>("syncTranslations") {
group = "localization"
description = "Synchronizes translation keys and blank lines with I18N.properties."
val dir = layout.projectDirectory.dir("src/main/resources/assets/lang")
sourceFile.set(dir.file("I18N.properties"))
translationFiles.from(fileTree(dir) { include("I18N_*.properties") })
}
val generatedDir = layout.buildDirectory.dir("generated")
val upsideDownTranslate by tasks.registering(UpsideDownTranslate::class) {
val upsideDownTranslate = tasks.register<UpsideDownTranslate>("upsideDownTranslate") {
inputFile.set(layout.projectDirectory.file("src/main/resources/assets/lang/I18N.properties"))
outputFile.set(generatedDir.map { it.file("generated/i18n/I18N_en_Qabs.properties") })
}
val createLanguageList by tasks.registering(CreateLanguageList::class) {
val createLanguageList = tasks.register<CreateLanguageList>("createLanguageList") {
resourceBundleDir.set(layout.projectDirectory.dir("src/main/resources/assets/lang"))
resourceBundleBaseName.set("I18N")
additionalLanguages.set(listOf("en-Qabs"))
outputFile.set(generatedDir.map { it.file("languages.json") })
}
val createLocaleNamesResourceBundle by tasks.registering(CreateLocaleNamesResourceBundle::class) {
dependsOn(createLanguageList)
val createLocaleNamesResourceBundle =
tasks.register<CreateLocaleNamesResourceBundle>("createLocaleNamesResourceBundle") {
dependsOn(createLanguageList)
languagesFile.set(createLanguageList.flatMap { it.outputFile })
outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") })
}
languagesFile.set(createLanguageList.flatMap { it.outputFile })
outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") })
}
// mcmod data
tasks.register<ParseModDataTask>("parseModData") {
inputFile.set(layout.projectDirectory.file("mod.json"))
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/mod_data.txt"))
tasks.register<FormatJsonFile>("formatModJson") {
file.set(layout.projectDirectory.file("mod.json"))
}
tasks.register<ParseModDataTask>("parseModPackData") {
inputFile.set(layout.projectDirectory.file("modpack.json"))
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/modpack_data.txt"))
tasks.register<FormatJsonFile>("formatModpackJson") {
file.set(layout.projectDirectory.file("modpack.json"))
}
tasks.register<UpdateAddonProjectInfos>("updateModProjectInfos") {
inputFile.set(layout.projectDirectory.file("mod.json"))
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/addon/mod.csv"))
projectType.set(UpdateAddonProjectInfos.AddonProjectType.MOD)
}
tasks.register<UpdateAddonProjectInfos>("updateModpackProjectInfos") {
inputFile.set(layout.projectDirectory.file("modpack.json"))
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/addon/modpack.csv"))
projectType.set(UpdateAddonProjectInfos.AddonProjectType.MODPACK)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,129 @@
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package com.jfoenix.controls;
import com.jfoenix.converters.ButtonTypeConverter;
import com.jfoenix.skins.JFXButtonSkin;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.css.CssMetaData;
import javafx.css.SimpleStyleableObjectProperty;
import javafx.css.Styleable;
import javafx.css.StyleableObjectProperty;
import javafx.css.StyleableProperty;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.Control;
import javafx.scene.control.Labeled;
import javafx.scene.control.Skin;
import javafx.scene.paint.Paint;
public class JFXButton extends Button {
private static final String DEFAULT_STYLE_CLASS = "jfx-button";
private List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
public JFXButton() {
this.initialize();
}
public JFXButton(String text) {
super(text);
this.initialize();
}
public JFXButton(String text, Node graphic) {
super(text, graphic);
this.initialize();
}
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
}
protected Skin<?> createDefaultSkin() {
return new JFXButtonSkin(this);
}
private final ObjectProperty<Paint> ripplerFill = new SimpleObjectProperty<>(this, "ripplerFill", null);
public final ObjectProperty<Paint> ripplerFillProperty() {
return this.ripplerFill;
}
public final Paint getRipplerFill() {
return this.ripplerFillProperty().get();
}
public final void setRipplerFill(Paint ripplerFill) {
this.ripplerFillProperty().set(ripplerFill);
}
private final StyleableObjectProperty<ButtonType> buttonType = new SimpleStyleableObjectProperty<>(
JFXButton.StyleableProperties.BUTTON_TYPE, this, "buttonType", JFXButton.ButtonType.FLAT);
public ButtonType getButtonType() {
return this.buttonType == null ? JFXButton.ButtonType.FLAT : this.buttonType.get();
}
public StyleableObjectProperty<ButtonType> buttonTypeProperty() {
return this.buttonType;
}
public void setButtonType(ButtonType type) {
this.buttonType.set(type);
}
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
if (this.STYLEABLES == null) {
List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
styleables.addAll(getClassCssMetaData());
styleables.addAll(Labeled.getClassCssMetaData());
this.STYLEABLES = List.copyOf(styleables);
}
return this.STYLEABLES;
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return JFXButton.StyleableProperties.CHILD_STYLEABLES;
}
protected void layoutChildren() {
super.layoutChildren();
this.setNeedsLayout(false);
}
public enum ButtonType {
FLAT,
RAISED;
}
private static final class StyleableProperties {
private static final CssMetaData<JFXButton, ButtonType> BUTTON_TYPE;
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
static {
BUTTON_TYPE = new CssMetaData<>("-jfx-button-type", ButtonTypeConverter.getInstance(), JFXButton.ButtonType.FLAT) {
public boolean isSettable(JFXButton control) {
return control.buttonType == null || !control.buttonType.isBound();
}
public StyleableProperty<ButtonType> getStyleableProperty(JFXButton control) {
return control.buttonTypeProperty();
}
};
List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
Collections.addAll(styleables, BUTTON_TYPE);
CHILD_STYLEABLES = List.copyOf(styleables);
}
}
}
@@ -0,0 +1,169 @@
/*
* 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.JFXCheckBoxSkin;
import javafx.css.*;
import javafx.css.converter.PaintConverter;
import javafx.scene.control.CheckBox;
import javafx.scene.control.Skin;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/// JFXCheckBox is the material design implementation of a checkbox.
/// it shows ripple effect and a custom selection animation.
///
/// @author Shadi Shaheen
/// @version 1.0
/// @since 2016-03-09
public class JFXCheckBox extends CheckBox {
public JFXCheckBox(String text) {
super(text);
initialize();
}
public JFXCheckBox() {
initialize();
}
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
}
@Override
protected Skin<?> createDefaultSkin() {
return new JFXCheckBoxSkin(this);
}
/***************************************************************************
* *
* Stylesheet Handling *
* *
**************************************************************************/
/// Initialize the style class to 'jfx-check-box'.
///
/// This is the selector class from which CSS can be used to style
/// this control.
private static final String DEFAULT_STYLE_CLASS = "jfx-check-box";
/// checkbox color property when selected
private StyleableObjectProperty<Paint> checkedColor;
private static final Color DEFAULT_CHECKED_COLOR = Color.valueOf("#0F9D58");
public StyleableObjectProperty<Paint> checkedColorProperty() {
if (checkedColor == null) {
checkedColor = new SimpleStyleableObjectProperty<>(StyleableProperties.CHECKED_COLOR,
JFXCheckBox.this,
"checkedColor",
DEFAULT_CHECKED_COLOR);
}
return this.checkedColor;
}
public Paint getCheckedColor() {
return checkedColor == null ? DEFAULT_CHECKED_COLOR : checkedColor.get();
}
public void setCheckedColor(Paint color) {
this.checkedColor.set(color);
}
/**
* checkbox color property when not selected
*/
private StyleableObjectProperty<Paint> unCheckedColor;
private static final Color DEFAULT_UNCHECKED_COLOR = Color.valueOf("#5A5A5A");
public StyleableObjectProperty<Paint> unCheckedColorProperty() {
if (unCheckedColor == null) {
unCheckedColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNCHECKED_COLOR,
JFXCheckBox.this,
"unCheckedColor",
DEFAULT_UNCHECKED_COLOR);
}
return this.unCheckedColor;
}
public Paint getUnCheckedColor() {
return unCheckedColor == null ? DEFAULT_UNCHECKED_COLOR : unCheckedColor.get();
}
public void setUnCheckedColor(Paint color) {
this.unCheckedColor.set(color);
}
private static final class StyleableProperties {
private static final CssMetaData<JFXCheckBox, Paint> CHECKED_COLOR =
new CssMetaData<>("-jfx-checked-color",
PaintConverter.getInstance(), DEFAULT_CHECKED_COLOR) {
@Override
public boolean isSettable(JFXCheckBox control) {
return control.checkedColor == null || !control.checkedColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXCheckBox control) {
return control.checkedColorProperty();
}
};
private static final CssMetaData<JFXCheckBox, Paint> UNCHECKED_COLOR =
new CssMetaData<>("-jfx-unchecked-color",
PaintConverter.getInstance(), DEFAULT_UNCHECKED_COLOR) {
@Override
public boolean isSettable(JFXCheckBox control) {
return control.unCheckedColor == null || !control.unCheckedColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXCheckBox control) {
return control.unCheckedColorProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables =
new ArrayList<>(CheckBox.getClassCssMetaData());
Collections.addAll(styleables,
CHECKED_COLOR,
UNCHECKED_COLOR
);
CHILD_STYLEABLES = List.copyOf(styleables);
}
}
@Override
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
return getClassCssMetaData();
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.CHILD_STYLEABLES;
}
}
@@ -0,0 +1,86 @@
// 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.JFXCheckBoxSkin;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.CheckBoxTreeItem;
import javafx.scene.layout.HBox;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
/// Displays a [CheckBoxTreeItem] with a material-design checkbox.
///
/// The checkbox's selected and indeterminate states are bidirectionally bound to
/// the current tree item. Reusing or clearing the cell removes the old bindings.
/// Installing a checkbox skin or switching tree items applies the selection state
/// immediately, without playing a selection animation.
/// Ordinary tree items retain the display provided by [JFXTreeCell].
/// Empty cells and `null` values display neither content nor a checkbox.
///
/// @param <T> the type of value stored in each tree item
@NotNullByDefault
public class JFXCheckTreeCell<T> extends JFXTreeCell<T> {
/// The checkbox bound to the currently displayed checkable tree item.
private final JFXCheckBox checkBox = new JFXCheckBox();
/// Places the checkbox before the graphic supplied by the superclass.
private final HBox graphic = new HBox();
/// The tree item whose selection properties are currently bound, if any.
private @Nullable CheckBoxTreeItem<?> boundTreeItem;
/// Creates an empty cell whose checkbox toggles the current tree item's selection.
public JFXCheckTreeCell() {
checkBox.setAllowIndeterminate(false);
checkBox.setFocusTraversable(false);
graphic.setAlignment(Pos.CENTER_LEFT);
graphic.setPickOnBounds(false);
checkBox.skinProperty().addListener(observable -> finishSelectionAnimation());
treeItemProperty().addListener(observable -> updateDisplay(getItem(), isEmpty()));
}
/// Finishes animations from the previous item and immediately displays the current state.
private void finishSelectionAnimation() {
if (checkBox.getSkin() instanceof JFXCheckBoxSkin skin) {
skin.finishSelectionAnimation();
}
}
/// Adds the checkbox to the inherited display and updates its selection bindings.
@Override
protected void updateDisplay(@Nullable T item, boolean empty) {
// Release graphics before the superclass reuses or reparents them.
graphic.getChildren().clear();
super.updateDisplay(item, empty);
@Nullable CheckBoxTreeItem<?> treeItem = !empty && item != null
&& getTreeItem() instanceof CheckBoxTreeItem<?> checkTreeItem ? checkTreeItem : null;
if (boundTreeItem != treeItem) {
if (boundTreeItem != null) {
checkBox.selectedProperty().unbindBidirectional(boundTreeItem.selectedProperty());
checkBox.indeterminateProperty().unbindBidirectional(boundTreeItem.indeterminateProperty());
}
boundTreeItem = treeItem;
if (treeItem != null) {
checkBox.selectedProperty().bindBidirectional(treeItem.selectedProperty());
checkBox.indeterminateProperty().bindBidirectional(treeItem.indeterminateProperty());
} else {
checkBox.setSelected(false);
checkBox.setIndeterminate(false);
}
finishSelectionAnimation();
}
if (treeItem != null) {
@Nullable Node itemGraphic = getGraphic();
graphic.getChildren().add(checkBox);
if (itemGraphic != null) {
graphic.getChildren().add(itemGraphic);
}
setGraphic(graphic);
}
}
}
@@ -0,0 +1,60 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import com.jfoenix.utils.JFXNodeUtils;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
/**
* JFXClippedPane is a StackPane that clips its content if exceeding the pane bounds.
*
* @author Shadi Shaheen
* @version 1.0
* @since 2018-06-02
*/
public class JFXClippedPane extends StackPane {
private final Region clip = new Region();
public JFXClippedPane() {
super();
init();
}
public JFXClippedPane(Node... children) {
super(children);
init();
}
private void init() {
setClip(clip);
clip.setBackground(new Background(new BackgroundFill(Color.BLACK, new CornerRadii(2), Insets.EMPTY)));
backgroundProperty().addListener(observable -> JFXNodeUtils.updateBackground(getBackground(), clip));
}
@Override
protected void layoutChildren() {
super.layoutChildren();
clip.resizeRelocate(0, 0, getWidth(), getHeight());
}
}
@@ -0,0 +1,145 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import com.jfoenix.skins.JFXColorPickerSkin;
import javafx.css.CssMetaData;
import javafx.css.SimpleStyleableBooleanProperty;
import javafx.css.Styleable;
import javafx.css.StyleableBooleanProperty;
import javafx.css.converter.BooleanConverter;
import javafx.scene.control.ColorPicker;
import javafx.scene.control.Skin;
import javafx.scene.paint.Color;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* JFXColorPicker is the metrial design implementation of color picker.
*
* @author Shadi Shaheen
* @version 1.0
* @since 2016-03-09
*/
public class JFXColorPicker extends ColorPicker {
/**
* {@inheritDoc}
*/
public JFXColorPicker() {
initialize();
}
/**
* {@inheritDoc}
*/
public JFXColorPicker(Color color) {
super(color);
initialize();
}
/**
* {@inheritDoc}
*/
@Override
protected Skin<?> createDefaultSkin() {
return new JFXColorPickerSkin(this);
}
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
}
/**
* Initialize the style class to 'jfx-color-picker'.
* <p>
* This is the selector class from which CSS can be used to style
* this control.
*/
private static final String DEFAULT_STYLE_CLASS = "jfx-color-picker";
private double[] preDefinedColors = null;
public double[] getPreDefinedColors() {
return preDefinedColors;
}
public void setPreDefinedColors(double[] preDefinedColors) {
this.preDefinedColors = preDefinedColors;
}
/**
* disable animation on button action
*/
private final StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION,
JFXColorPicker.this,
"disableAnimation",
false);
public final StyleableBooleanProperty disableAnimationProperty() {
return this.disableAnimation;
}
public final Boolean isDisableAnimation() {
return disableAnimation != null && this.disableAnimationProperty().get();
}
public final void setDisableAnimation(final Boolean disabled) {
this.disableAnimationProperty().set(disabled);
}
private static final class StyleableProperties {
private static final CssMetaData<JFXColorPicker, Boolean> DISABLE_ANIMATION =
new CssMetaData<JFXColorPicker, Boolean>("-jfx-disable-animation",
BooleanConverter.getInstance(), false) {
@Override
public boolean isSettable(JFXColorPicker control) {
return control.disableAnimation == null || !control.disableAnimation.isBound();
}
@Override
public StyleableBooleanProperty getStyleableProperty(JFXColorPicker control) {
return control.disableAnimationProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables =
new ArrayList<>(ColorPicker.getClassCssMetaData());
Collections.addAll(styleables, 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,357 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import com.jfoenix.converters.base.NodeConverter;
import com.jfoenix.skins.JFXComboBoxListViewSkin;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.ObservableList;
import javafx.css.*;
import javafx.css.converter.BooleanConverter;
import javafx.css.converter.PaintConverter;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.util.StringConverter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu;
/**
* JFXComboBox is the material design implementation of a combobox.
*
* @author Shadi Shaheen
* @version 1.0
* @since 2016-03-09
*/
public class JFXComboBox<T> extends ComboBox<T> {
/**
* {@inheritDoc}
*/
public JFXComboBox() {
initialize();
}
/**
* {@inheritDoc}
*/
public JFXComboBox(ObservableList<T> items) {
super(items);
initialize();
}
private void initialize() {
getStyleClass().add(DEFAULT_STYLE_CLASS);
this.setCellFactory(listView -> new JFXListCell<>() {
@Override
public void updateItem(T item, boolean empty) {
super.updateItem(item, empty);
updateDisplayText(this, item, empty);
}
});
// had to refactor the code out of the skin class to allow
// customization of the button cell
this.setButtonCell(new ListCell<>() {
{
// fixed clearing the combo box value is causing
// java prompt text to be shown because the button cell is not updated
JFXComboBox.this.valueProperty().addListener(observable -> {
if (JFXComboBox.this.getValue() == null) {
updateItem(null, true);
}
});
}
@Override
protected void updateItem(T item, boolean empty) {
updateDisplayText(this, item, empty);
this.setVisible(item != null || !empty);
}
});
useJFXContextMenu(editorProperty().get());
}
/**
* {@inheritDoc}
*/
@Override
protected Skin<?> createDefaultSkin() {
return new JFXComboBoxListViewSkin<T>(this);
}
/**
* Initialize the style class to 'jfx-combo-box'.
* <p>
* This is the selector class from which CSS can be used to style
* this control.
*/
private static final String DEFAULT_STYLE_CLASS = "jfx-combo-box";
/***************************************************************************
* *
* Node Converter Property *
* *
**************************************************************************/
/**
* Converts the user-typed input (when the ComboBox is
* {@link #editableProperty() editable}) to an object of type T, such that
* the input may be retrieved via the {@link #valueProperty() value} property.
*/
public ObjectProperty<NodeConverter<T>> nodeConverterProperty() {
return nodeConverter;
}
private ObjectProperty<NodeConverter<T>> nodeConverter = new SimpleObjectProperty<>(this, "nodeConverter",
JFXComboBox.<T>defaultNodeConverter());
public final void setNodeConverter(NodeConverter<T> value) {
nodeConverterProperty().set(value);
}
public final NodeConverter<T> getNodeConverter() {
return nodeConverterProperty().get();
}
private static <T> NodeConverter<T> defaultNodeConverter() {
return new NodeConverter<T>() {
@Override
public Node toNode(T object) {
if (object == null) {
return null;
}
StackPane selectedValueContainer = new StackPane();
selectedValueContainer.getStyleClass().add("combo-box-selected-value-container");
selectedValueContainer.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, null, null)));
Label selectedValueLabel = object instanceof Label ? new Label(((Label) object).getText()) : new Label(
object.toString());
selectedValueLabel.setTextFill(Color.BLACK);
selectedValueContainer.getChildren().add(selectedValueLabel);
StackPane.setAlignment(selectedValueLabel, Pos.CENTER_LEFT);
StackPane.setMargin(selectedValueLabel, new Insets(0, 0, 0, 5));
return selectedValueContainer;
}
@SuppressWarnings("unchecked")
@Override
public T fromNode(Node node) {
return (T) node;
}
@Override
public String toString(T object) {
if (object == null) {
return null;
}
if (object instanceof Label) {
return ((Label) object).getText();
}
return object.toString();
}
};
}
private boolean updateDisplayText(ListCell<T> cell, T item, boolean empty) {
if (empty) {
// create empty cell
if (cell == null) {
return true;
}
cell.setGraphic(null);
cell.setText(null);
return true;
} else if (item instanceof Node) {
Node currentNode = cell.getGraphic();
Node newNode = (Node) item;
// create a node from the selected node of the listview
// using JFXComboBox {@link #nodeConverterProperty() NodeConverter})
NodeConverter<T> nc = this.getNodeConverter();
Node node = nc == null ? null : nc.toNode(item);
if (currentNode == null || !currentNode.equals(newNode)) {
cell.setText(null);
cell.setGraphic(node == null ? newNode : node);
}
return node == null;
} else {
// run item through StringConverter if it isn't null
StringConverter<T> c = this.getConverter();
String s = item == null ? this.getPromptText() : (c == null ? item.toString() : c.toString(item));
cell.setText(s);
cell.setGraphic(null);
return s == null || s.isEmpty();
}
}
/***************************************************************************
* *
* styleable Properties *
* *
**************************************************************************/
/**
* set true to show a float the prompt text when focusing the field
*/
private StyleableBooleanProperty labelFloat = new SimpleStyleableBooleanProperty(StyleableProperties.LABEL_FLOAT,
JFXComboBox.this,
"lableFloat",
false);
public final StyleableBooleanProperty labelFloatProperty() {
return this.labelFloat;
}
public final boolean isLabelFloat() {
return this.labelFloatProperty().get();
}
public final void setLabelFloat(final boolean labelFloat) {
this.labelFloatProperty().set(labelFloat);
}
/**
* default color used when the field is unfocused
*/
private StyleableObjectProperty<Paint> unFocusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNFOCUS_COLOR,
JFXComboBox.this,
"unFocusColor",
Color.rgb(77,
77,
77));
public Paint getUnFocusColor() {
return unFocusColor == null ? Color.rgb(77, 77, 77) : unFocusColor.get();
}
public StyleableObjectProperty<Paint> unFocusColorProperty() {
return this.unFocusColor;
}
public void setUnFocusColor(Paint color) {
this.unFocusColor.set(color);
}
/**
* default color used when the field is focused
*/
private StyleableObjectProperty<Paint> focusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.FOCUS_COLOR,
JFXComboBox.this,
"focusColor",
Color.valueOf("#4059A9"));
public Paint getFocusColor() {
return focusColor == null ? Color.valueOf("#4059A9") : focusColor.get();
}
public StyleableObjectProperty<Paint> focusColorProperty() {
return this.focusColor;
}
public void setFocusColor(Paint color) {
this.focusColor.set(color);
}
private final static class StyleableProperties {
private static final CssMetaData<JFXComboBox<?>, Paint> UNFOCUS_COLOR = new CssMetaData<JFXComboBox<?>, Paint>(
"-jfx-unfocus-color",
PaintConverter.getInstance(),
Color.valueOf("#A6A6A6")) {
@Override
public boolean isSettable(JFXComboBox<?> control) {
return control.unFocusColor == null || !control.unFocusColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXComboBox<?> control) {
return control.unFocusColorProperty();
}
};
private static final CssMetaData<JFXComboBox<?>, Paint> FOCUS_COLOR = new CssMetaData<JFXComboBox<?>, Paint>(
"-jfx-focus-color",
PaintConverter.getInstance(),
Color.valueOf("#3f51b5")) {
@Override
public boolean isSettable(JFXComboBox<?> control) {
return control.focusColor == null || !control.focusColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXComboBox<?> control) {
return control.focusColorProperty();
}
};
private static final CssMetaData<JFXComboBox<?>, Boolean> LABEL_FLOAT = new CssMetaData<JFXComboBox<?>, Boolean>(
"-jfx-label-float",
BooleanConverter.getInstance(),
false) {
@Override
public boolean isSettable(JFXComboBox<?> control) {
return control.labelFloat == null || !control.labelFloat.isBound();
}
@Override
public StyleableBooleanProperty getStyleableProperty(JFXComboBox<?> control) {
return control.labelFloatProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(
Control.getClassCssMetaData());
Collections.addAll(styleables, UNFOCUS_COLOR, FOCUS_COLOR, LABEL_FLOAT);
CHILD_STYLEABLES = Collections.unmodifiableList(styleables);
}
}
// inherit the styleable properties from parent
private List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
@Override
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
if (STYLEABLES == null) {
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(
Control.getClassCssMetaData());
styleables.addAll(getClassCssMetaData());
styleables.addAll(Control.getClassCssMetaData());
STYLEABLES = Collections.unmodifiableList(styleables);
}
return STYLEABLES;
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.CHILD_STYLEABLES;
}
}
@@ -0,0 +1,531 @@
/*
* 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.controls.events.JFXDialogEvent;
import com.jfoenix.converters.DialogTransitionConverter;
import com.jfoenix.effects.JFXDepthManager;
import com.jfoenix.transitions.CachedTransition;
import javafx.animation.*;
import javafx.beans.DefaultProperty;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ObjectPropertyBase;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.css.*;
import javafx.event.Event;
import javafx.event.EventHandler;
import javafx.geometry.Pos;
import javafx.scene.CacheHint;
import javafx.scene.Node;
import javafx.scene.SnapshotParameters;
import javafx.scene.image.ImageView;
import javafx.scene.image.WritableImage;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.animation.Motion;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/// Note: for JFXDialog to work properly, the root node **MUST**
/// be of type [StackPane]
///
/// @author Shadi Shaheen
/// @version 1.0
/// @since 2016-03-09
@DefaultProperty(value = "content")
public class JFXDialog extends StackPane {
private static final double INITIAL_SCALE = 0.8;
// public static enum JFXDialogLayout{PLAIN, HEADING, ACTIONS, BACKDROP};
public enum DialogTransition {
CENTER, NONE
}
private StackPane contentHolder;
private double offsetX = 0;
private double offsetY = 0;
private StackPane dialogContainer;
private Region content;
private Transition animation;
private final EventHandler<? super MouseEvent> closeHandler = e -> close();
/// creates empty JFXDialog control with CENTER animation type
public JFXDialog() {
this(null, null, DialogTransition.CENTER);
}
/// creates empty JFXDialog control with a specified animation type
public JFXDialog(DialogTransition transition) {
this(null, null, transition);
}
/// creates JFXDialog control with a specified animation type, the animation type
/// can be one of the following:
///
/// - CENTER
/// - TOP
/// - RIGHT
/// - BOTTOM
/// - LEFT
///
/// @param dialogContainer is the parent of the dialog, it
/// @param content the content of dialog
/// @param transitionType the animation type
public JFXDialog(StackPane dialogContainer, Region content, DialogTransition transitionType) {
initialize();
setContent(content);
setDialogContainer(dialogContainer);
this.transitionType.set(transitionType);
// init change listeners
initChangeListeners();
}
/// creates JFXDialog control with a specified animation type that
/// is closed when clicking on the overlay, the animation type
/// can be one of the following:
///
/// - CENTER
/// - TOP
/// - RIGHT
/// - BOTTOM
/// - LEFT
public JFXDialog(StackPane dialogContainer, Region content, DialogTransition transitionType, boolean overlayClose) {
setOverlayClose(overlayClose);
initialize();
setContent(content);
setDialogContainer(dialogContainer);
this.transitionType.set(transitionType);
// init change listeners
initChangeListeners();
}
private void initChangeListeners() {
overlayCloseProperty().addListener((o, oldVal, newVal) -> {
if (newVal) {
this.addEventHandler(MouseEvent.MOUSE_PRESSED, closeHandler);
} else {
this.removeEventHandler(MouseEvent.MOUSE_PRESSED, closeHandler);
}
});
}
private void initialize() {
this.setVisible(false);
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
this.transitionType.addListener((o, oldVal, newVal) -> {
animation = getShowAnimation(transitionType.get());
});
contentHolder = new StackPane();
JFXDepthManager.setDepth(contentHolder, 4);
contentHolder.setPickOnBounds(false);
// ensure stackpane is never resized beyond it's preferred size
contentHolder.setMaxSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE);
this.getChildren().add(contentHolder);
this.getStyleClass().add("jfx-dialog-overlay-pane");
StackPane.setAlignment(contentHolder, Pos.CENTER);
this.setBackground(new Background(new BackgroundFill(Color.rgb(0, 0, 0, 0.1), null, null)));
// close the dialog if clicked on the overlay pane
if (overlayClose.get()) {
this.addEventHandler(MouseEvent.MOUSE_PRESSED, closeHandler);
}
// prevent propagating the events to overlay pane
contentHolder.addEventHandler(MouseEvent.ANY, Event::consume);
}
/***************************************************************************
* *
* Setters / Getters *
* *
**************************************************************************/
/// @return the dialog container
public StackPane getDialogContainer() {
return dialogContainer;
}
/// set the dialog container
/// Note: the dialog container must be StackPane, its the container for the dialog to be shown in.
public void setDialogContainer(StackPane dialogContainer) {
if (dialogContainer != null) {
this.dialogContainer = dialogContainer;
// FIXME: need to be improved to consider only the parent boundary
offsetX = dialogContainer.getBoundsInLocal().getWidth();
offsetY = dialogContainer.getBoundsInLocal().getHeight();
animation = getShowAnimation(transitionType.get());
}
}
/// @return dialog content node
public Region getContent() {
return content;
}
/// set the content of the dialog
public void setContent(Region content) {
if (content != null) {
this.content = content;
this.content.setPickOnBounds(false);
contentHolder.getChildren().setAll(content);
}
}
/// indicates whether the dialog will close when clicking on the overlay or not
private final BooleanProperty overlayClose = new SimpleBooleanProperty(true);
public final BooleanProperty overlayCloseProperty() {
return this.overlayClose;
}
public final boolean isOverlayClose() {
return this.overlayCloseProperty().get();
}
public final void setOverlayClose(final boolean overlayClose) {
this.overlayCloseProperty().set(overlayClose);
}
/// if sets to true, the content of dialog container will be cached and replaced with an image
/// when displaying the dialog (better performance).
/// this is recommended if the content behind the dialog will not change during the showing
/// period
private final BooleanProperty cacheContainer = new SimpleBooleanProperty(false);
public boolean isCacheContainer() {
return cacheContainer.get();
}
public BooleanProperty cacheContainerProperty() {
return cacheContainer;
}
public void setCacheContainer(boolean cacheContainer) {
this.cacheContainer.set(cacheContainer);
}
/// it will show the dialog in the specified container
public void show(StackPane dialogContainer) {
this.setDialogContainer(dialogContainer);
showDialog();
}
private ArrayList<Node> tempContent;
/**
* show the dialog inside its parent container
*/
public void show() {
this.setDialogContainer(dialogContainer);
showDialog();
}
private void showDialog() {
if (dialogContainer == null) {
throw new RuntimeException("ERROR: JFXDialog container is not set!");
}
if (isCacheContainer()) {
tempContent = new ArrayList<>(dialogContainer.getChildren());
SnapshotParameters snapShotparams = new SnapshotParameters();
snapShotparams.setFill(Color.TRANSPARENT);
WritableImage temp = dialogContainer.snapshot(snapShotparams,
new WritableImage((int) dialogContainer.getWidth(),
(int) dialogContainer.getHeight()));
ImageView tempImage = new ImageView(temp);
tempImage.setCache(true);
tempImage.setCacheHint(CacheHint.SPEED);
dialogContainer.getChildren().setAll(tempImage, this);
} else {
//prevent error if opening an already opened dialog
dialogContainer.getChildren().remove(this);
tempContent = null;
dialogContainer.getChildren().add(this);
}
if (animation != null) {
animation.play();
} else {
setVisible(true);
setOpacity(1);
Event.fireEvent(JFXDialog.this, new JFXDialogEvent(JFXDialogEvent.OPENED));
}
}
/**
* close the dialog
*/
public void close() {
if (animation != null) {
animation.setRate(-2);
animation.play();
animation.setOnFinished(e -> {
closeDialog();
});
} else {
setOpacity(0);
setVisible(false);
closeDialog();
}
}
private void closeDialog() {
resetProperties();
Event.fireEvent(JFXDialog.this, new JFXDialogEvent(JFXDialogEvent.CLOSED));
if (tempContent == null) {
dialogContainer.getChildren().remove(this);
} else {
dialogContainer.getChildren().setAll(tempContent);
}
}
/***************************************************************************
* *
* Transitions *
* *
**************************************************************************/
private Transition getShowAnimation(DialogTransition transitionType) {
Transition animation = null;
if (contentHolder != null) {
animation = switch (transitionType) {
case CENTER -> {
contentHolder.setScaleX(INITIAL_SCALE);
contentHolder.setScaleY(INITIAL_SCALE);
yield new CenterTransition();
}
case NONE -> {
contentHolder.setScaleX(1);
contentHolder.setScaleY(1);
contentHolder.setTranslateX(0);
contentHolder.setTranslateY(0);
yield null;
}
};
}
if (animation != null) {
animation.setOnFinished(finish ->
Event.fireEvent(JFXDialog.this, new JFXDialogEvent(JFXDialogEvent.OPENED)));
}
return animation;
}
private void resetProperties() {
this.setVisible(false);
contentHolder.setTranslateX(0);
contentHolder.setTranslateY(0);
contentHolder.setScaleX(1);
contentHolder.setScaleY(1);
}
private final class CenterTransition extends CachedTransition {
private static final Interpolator INTERPOLATOR = Motion.EMPHASIZED_DECELERATE;
CenterTransition() {
super(contentHolder, new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(contentHolder.scaleXProperty(), INITIAL_SCALE, INTERPOLATOR),
new KeyValue(contentHolder.scaleYProperty(), INITIAL_SCALE, INTERPOLATOR),
new KeyValue(JFXDialog.this.visibleProperty(), false, Motion.LINEAR)
),
new KeyFrame(Duration.millis(10),
new KeyValue(JFXDialog.this.visibleProperty(), true, Motion.LINEAR),
new KeyValue(JFXDialog.this.opacityProperty(), 0, INTERPOLATOR)
),
new KeyFrame(Motion.EXTRA_LONG4,
new KeyValue(contentHolder.scaleXProperty(), 1, INTERPOLATOR),
new KeyValue(contentHolder.scaleYProperty(), 1, INTERPOLATOR),
new KeyValue(JFXDialog.this.visibleProperty(), true, Motion.LINEAR),
new KeyValue(JFXDialog.this.opacityProperty(), 1, INTERPOLATOR)
))
);
// reduce the number to increase the shifting , increase number to reduce shifting
setCycleDuration(Duration.seconds(0.4));
setDelay(Duration.ZERO);
}
@Override
protected void starting() {
}
@Override
protected void stopping() {
}
}
/***************************************************************************
* *
* Stylesheet Handling *
* *
**************************************************************************/
/// Initialize the style class to 'jfx-dialog'.
///
/// This is the selector class from which CSS can be used to style
/// this control.
private static final String DEFAULT_STYLE_CLASS = "jfx-dialog";
/// dialog transition type property, it can be one of the following:
///
/// - CENTER
/// - TOP
/// - RIGHT
/// - BOTTOM
/// - LEFT
/// - NONE
private final StyleableObjectProperty<DialogTransition> transitionType = new SimpleStyleableObjectProperty<>(
StyleableProperties.DIALOG_TRANSITION,
JFXDialog.this,
"dialogTransition",
DialogTransition.CENTER);
public DialogTransition getTransitionType() {
return transitionType == null ? DialogTransition.CENTER : transitionType.get();
}
public StyleableObjectProperty<DialogTransition> transitionTypeProperty() {
return this.transitionType;
}
public void setTransitionType(DialogTransition transition) {
this.transitionType.set(transition);
}
private static final class StyleableProperties {
private static final CssMetaData<JFXDialog, DialogTransition> DIALOG_TRANSITION =
new CssMetaData<JFXDialog, DialogTransition>("-jfx-dialog-transition",
DialogTransitionConverter.getInstance(),
DialogTransition.CENTER) {
@Override
public boolean isSettable(JFXDialog control) {
return control.transitionType == null || !control.transitionType.isBound();
}
@Override
public StyleableProperty<DialogTransition> getStyleableProperty(JFXDialog control) {
return control.transitionTypeProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables =
new ArrayList<>(StackPane.getClassCssMetaData());
Collections.addAll(styleables,
DIALOG_TRANSITION
);
CHILD_STYLEABLES = Collections.unmodifiableList(styleables);
}
}
@Override
public List<CssMetaData<? extends Styleable, ?>> getCssMetaData() {
return getClassCssMetaData();
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.CHILD_STYLEABLES;
}
/***************************************************************************
* *
* Custom Events *
* *
**************************************************************************/
private final ObjectProperty<EventHandler<? super JFXDialogEvent>> onDialogClosedProperty = new ObjectPropertyBase<EventHandler<? super JFXDialogEvent>>() {
@Override
protected void invalidated() {
setEventHandler(JFXDialogEvent.CLOSED, get());
}
@Override
public Object getBean() {
return JFXDialog.this;
}
@Override
public String getName() {
return "onClosed";
}
};
/**
* Defines a function to be called when the dialog is closed.
* Note: it will be triggered after the close animation is finished.
*/
public ObjectProperty<EventHandler<? super JFXDialogEvent>> onDialogClosedProperty() {
return onDialogClosedProperty;
}
public void setOnDialogClosed(EventHandler<? super JFXDialogEvent> handler) {
onDialogClosedProperty().set(handler);
}
public EventHandler<? super JFXDialogEvent> getOnDialogClosed() {
return onDialogClosedProperty().get();
}
private final ObjectProperty<EventHandler<? super JFXDialogEvent>> onDialogOpenedProperty = new ObjectPropertyBase<>() {
@Override
protected void invalidated() {
setEventHandler(JFXDialogEvent.OPENED, get());
}
@Override
public Object getBean() {
return JFXDialog.this;
}
@Override
public String getName() {
return "onOpened";
}
};
/**
* Defines a function to be called when the dialog is opened.
* Note: it will be triggered after the show animation is finished.
*/
public ObjectProperty<EventHandler<? super JFXDialogEvent>> onDialogOpenedProperty() {
return onDialogOpenedProperty;
}
public void setOnDialogOpened(EventHandler<? super JFXDialogEvent> handler) {
onDialogOpenedProperty().set(handler);
}
public EventHandler<? super JFXDialogEvent> getOnDialogOpened() {
return onDialogOpenedProperty().get();
}
}
@@ -0,0 +1,104 @@
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package com.jfoenix.controls;
import java.util.List;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.scene.Node;
import javafx.scene.layout.FlowPane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
public class JFXDialogLayout extends StackPane {
private final StackPane heading = new StackPane();
private final StackPane body = new StackPane();
private final FlowPane actions = new FlowPane() {
protected double computeMinWidth(double height) {
if (this.getContentBias() == Orientation.HORIZONTAL) {
double maxPref = 0.0;
for (Node child : this.getChildren()) {
if (child.isManaged()) {
maxPref = Math.max(maxPref, child.minWidth(-1.0));
}
}
Insets insets = this.getInsets();
return insets.getLeft() + this.snapSizeX(maxPref) + insets.getRight();
} else {
return this.computePrefWidth(height);
}
}
protected double computeMinHeight(double width) {
if (this.getContentBias() == Orientation.VERTICAL) {
double maxPref = 0.0;
for (Node child : this.getChildren()) {
if (child.isManaged()) {
maxPref = Math.max(maxPref, child.minHeight(-1.0));
}
}
Insets insets = this.getInsets();
return insets.getTop() + this.snapSizeY(maxPref) + insets.getBottom();
} else {
return this.computePrefHeight(width);
}
}
};
private static final String DEFAULT_STYLE_CLASS = "jfx-dialog-layout";
public JFXDialogLayout() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
VBox layout = new VBox();
this.heading.getStyleClass().add("jfx-layout-heading");
this.heading.getStyleClass().add("title");
this.body.getStyleClass().add("jfx-layout-body");
this.body.prefHeightProperty().bind(this.prefHeightProperty());
this.body.prefWidthProperty().bind(this.prefWidthProperty());
this.actions.getStyleClass().add("jfx-layout-actions");
layout.getChildren().setAll(this.heading, this.body, this.actions);
this.getChildren().add(layout);
}
public ObservableList<Node> getHeading() {
return this.heading.getChildren();
}
public void setHeading(Node... titleContent) {
this.heading.getChildren().setAll(titleContent);
}
public ObservableList<Node> getBody() {
return this.body.getChildren();
}
public void setBody(Node... body) {
this.body.getChildren().setAll(body);
}
public ObservableList<Node> getActions() {
return this.actions.getChildren();
}
public void setActions(Node... actions) {
this.actions.getChildren().setAll(actions);
}
public void setActions(List<? extends Node> actions) {
this.actions.getChildren().setAll(actions);
}
}
@@ -0,0 +1,274 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import com.jfoenix.utils.JFXNodeUtils;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.Region;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Shape;
import javafx.util.Duration;
import java.util.Set;
/// material design implementation of ListCell
///
/// By default, JFXListCell will try to create a graphic node for the cell,
/// to override it you need to set graphic to null in [#updateItem(Object, boolean)] method.
///
/// NOTE: passive nodes (Labels and Shapes) will be set to mouse transparent in order to
/// show the ripple effect upon clicking , to change this behavior you can override the
/// method {[#makeChildrenTransparent()]
///
/// @author Shadi Shaheen
/// @version 1.0
/// @since 2016-03-09
public class JFXListCell<T> extends ListCell<T> {
protected JFXRippler cellRippler = new JFXRippler(this) {
@Override
protected Node getMask() {
Region clip = new Region();
JFXNodeUtils.updateBackground(JFXListCell.this.getBackground(), clip);
double width = control.getLayoutBounds().getWidth();
double height = control.getLayoutBounds().getHeight();
clip.resize(width, height);
return clip;
}
@Override
protected void positionControl(Node control) {
// do nothing
}
};
protected Node cellContent;
private Rectangle clip;
private Timeline gapAnimation;
private boolean playExpandAnimation = false;
private boolean selectionChanged = false;
/**
* {@inheritDoc}
*/
public JFXListCell() {
initialize();
initListeners();
}
/**
* init listeners to update the vertical gap / selection animation
*/
private void initListeners() {
listViewProperty().addListener((listObj, oldList, newList) -> {
if (newList instanceof JFXListView<?> listView) {
listView.currentVerticalGapProperty().addListener((o, oldVal, newVal) -> {
cellRippler.rippler.setClip(null);
if (newVal.doubleValue() != 0) {
playExpandAnimation = true;
getListView().requestLayout();
} else {
// fake expand state
double gap = clip.getY() * 2;
gapAnimation = new Timeline(
new KeyFrame(Duration.millis(240),
new KeyValue(this.translateYProperty(),
-gap / 2 - (gap * (getIndex())),
Interpolator.EASE_BOTH)
));
gapAnimation.play();
gapAnimation.setOnFinished((finish) -> {
requestLayout();
Platform.runLater(() -> getListView().requestLayout());
});
}
});
selectedProperty().addListener((o, oldVal, newVal) -> {
if (newVal) {
selectionChanged = true;
}
});
}
});
}
@Override
protected void layoutChildren() {
super.layoutChildren();
cellRippler.resizeRelocate(0, 0, getWidth(), getHeight());
double gap = getGap();
if (clip == null) {
clip = new Rectangle(0, gap / 2, getWidth(), getHeight() - gap);
setClip(clip);
} else {
if (gap != 0) {
if (playExpandAnimation || selectionChanged) {
// fake list collapse state
if (playExpandAnimation) {
this.setTranslateY(-gap / 2 + (-gap * (getIndex())));
clip.setY(gap / 2);
clip.setHeight(getHeight() - gap);
gapAnimation = new Timeline(new KeyFrame(Duration.millis(240),
new KeyValue(this.translateYProperty(),
0,
Interpolator.EASE_BOTH)));
playExpandAnimation = false;
} else if (selectionChanged) {
clip.setY(0);
clip.setHeight(getHeight());
gapAnimation = new Timeline(
new KeyFrame(Duration.millis(240),
new KeyValue(clip.yProperty(), gap / 2, Interpolator.EASE_BOTH),
new KeyValue(clip.heightProperty(), getHeight() - gap, Interpolator.EASE_BOTH)
));
}
playExpandAnimation = false;
selectionChanged = false;
gapAnimation.play();
} else {
if (gapAnimation != null) {
gapAnimation.stop();
}
this.setTranslateY(0);
clip.setY(gap / 2);
clip.setHeight(getHeight() - gap);
}
} else {
this.setTranslateY(0);
clip.setY(0);
clip.setHeight(getHeight());
}
clip.setX(0);
clip.setWidth(getWidth());
}
if (!getChildren().contains(cellRippler)) {
makeChildrenTransparent();
getChildren().add(0, cellRippler);
cellRippler.rippler.clear();
}
}
/**
* this method is used to set some nodes in cell content as mouse transparent nodes
* so clicking on them will trigger the ripple effect.
*/
protected void makeChildrenTransparent() {
for (Node child : getChildren()) {
if (child instanceof Label) {
Set<Node> texts = child.lookupAll("Text");
for (Node text : texts) {
text.setMouseTransparent(true);
}
} else if (child instanceof Shape) {
child.setMouseTransparent(true);
}
}
}
/**
* {@inheritDoc}
*/
@Override
protected void updateItem(T item, boolean empty) {
T oldItem = getItem();
boolean oldEmpty = isEmpty();
if (oldItem != item || oldEmpty != empty) cellRippler.releaseRippleImmediately();
super.updateItem(item, empty);
if (empty) {
setText(null);
setGraphic(null);
// remove empty (Trailing cells)
setMouseTransparent(true);
setStyle("-fx-background-color:TRANSPARENT;");
} else {
setMouseTransparent(false);
setStyle(null);
if (item instanceof Node newNode) {
setText(null);
Node currentNode = getGraphic();
if (currentNode == null || !currentNode.equals(newNode)) {
cellContent = newNode;
cellRippler.rippler.cacheRippleClip(false);
// build the Cell node
// RIPPLER ITEM : in case if the list item has its own rippler bind the list rippler and item rippler properties
if (newNode instanceof JFXRippler newRippler) {
// build cell container from exisiting rippler
cellRippler.ripplerFillProperty().bind(newRippler.ripplerFillProperty());
cellRippler.maskTypeProperty().bind(newRippler.maskTypeProperty());
cellRippler.positionProperty().bind(newRippler.positionProperty());
cellContent = newRippler.getControl();
}
((Region) cellContent).setMaxHeight(cellContent.prefHeight(-1));
setGraphic(cellContent);
}
} else {
setText(item == null ? "null" : item.toString());
setGraphic(null);
}
// show cell tooltip if it's toggled in JFXListView
if (getListView() instanceof JFXListView<?> listView && listView.isShowTooltip()) {
if (item instanceof Label label) {
setTooltip(new Tooltip(label.getText()));
} else if (getText() != null) {
setTooltip(new Tooltip(getText()));
}
}
}
}
// Stylesheet Handling *
/**
* Initialize the style class to 'jfx-list-cell'.
* <p>
* This is the selector class from which CSS can be used to style
* this control.
*/
private static final String DEFAULT_STYLE_CLASS = "jfx-list-cell";
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
this.setPadding(new Insets(8, 12, 8, 12));
}
@Override
protected double computePrefHeight(double width) {
double gap = getGap();
return super.computePrefHeight(width) + gap;
}
private double getGap() {
return (getListView() instanceof JFXListView<?> listView)
? (listView.isExpanded() ? listView.currentVerticalGapProperty().get() : 0)
: 0;
}
}
@@ -0,0 +1,258 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import com.jfoenix.skins.JFXListViewSkin;
import javafx.beans.property.*;
import javafx.css.*;
import javafx.css.converter.BooleanConverter;
import javafx.css.converter.SizeConverter;
import javafx.scene.control.ListView;
import javafx.scene.control.Skin;
import javafx.scene.input.MouseEvent;
import java.util.*;
/// Material design implementation of List View
///
/// @author Shadi Shaheen
/// @version 1.0
/// @since 2016-03-09
public class JFXListView<T> extends ListView<T> {
/**
* {@inheritDoc}
*/
public JFXListView() {
this.setCellFactory(listView -> new JFXListCell<>());
initialize();
}
/**
* {@inheritDoc}
*/
@Override
protected Skin<?> createDefaultSkin() {
return new JFXListViewSkin<>(this);
}
private IntegerProperty depth;
public IntegerProperty depthProperty() {
if (depth == null) {
depth = new SimpleIntegerProperty(this, "depth", 0);
}
return depth;
}
public int getDepth() {
return depth != null ? depth.get() : 0;
}
public void setDepth(int depth) {
depthProperty().set(depth);
}
private DoubleProperty currentVerticalGap;
DoubleProperty currentVerticalGapProperty() {
if (currentVerticalGap == null) {
currentVerticalGap = new SimpleDoubleProperty(this, "currentVerticalGap");
}
return currentVerticalGap;
}
private void updateVerticalGap() {
if (isExpanded()) {
currentVerticalGapProperty().set(verticalGap.get());
} else {
currentVerticalGapProperty().set(0);
}
}
/*
* this only works if the items were labels / strings
*/
private BooleanProperty showTooltip;
public final BooleanProperty showTooltipProperty() {
if (showTooltip == null) {
showTooltip = new SimpleBooleanProperty(this, "showTooltip", false);
}
return this.showTooltip;
}
public final boolean isShowTooltip() {
return showTooltip != null && showTooltip.get();
}
public final void setShowTooltip(final boolean showTooltip) {
this.showTooltipProperty().set(showTooltip);
}
/***************************************************************************
* *
* Stylesheet Handling *
* *
**************************************************************************/
/// Initialize the style class to 'jfx-list-view'.
///
/// This is the selector class from which CSS can be used to style
/// this control.
private static final String DEFAULT_STYLE_CLASS = "jfx-list-view";
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
}
/**
* propagate mouse events to the parent node ( e.g. to allow dragging while clicking on the list)
*/
public void propagateMouseEventsToParent() {
this.addEventHandler(MouseEvent.ANY, e -> {
e.consume();
this.getParent().fireEvent(e);
});
}
private StyleableDoubleProperty verticalGap;
public StyleableDoubleProperty verticalGapProperty() {
if (this.verticalGap == null) {
this.verticalGap = new StyleableDoubleProperty(0.0) {
@Override
public Object getBean() {
return JFXListView.this;
}
@Override
public String getName() {
return "verticalGap";
}
@Override
public CssMetaData<? extends Styleable, Number> getCssMetaData() {
return StyleableProperties.VERTICAL_GAP;
}
@Override
protected void invalidated() {
updateVerticalGap();
}
};
}
return this.verticalGap;
}
public Double getVerticalGap() {
return verticalGap == null ? 0.0 : verticalGap.get();
}
public void setVerticalGap(Double gap) {
verticalGapProperty().set(gap);
}
private StyleableBooleanProperty expanded;
public StyleableBooleanProperty expandedProperty() {
if (expanded == null) {
expanded = new StyleableBooleanProperty(false) {
@Override
public Object getBean() {
return JFXListView.this;
}
@Override
public String getName() {
return "expanded";
}
@Override
public CssMetaData<? extends Styleable, Boolean> getCssMetaData() {
return StyleableProperties.EXPANDED;
}
@Override
protected void invalidated() {
updateVerticalGap();
}
};
}
return this.expanded;
}
public Boolean isExpanded() {
return expanded != null && expanded.get();
}
public void setExpanded(Boolean expanded) {
expandedProperty().set(expanded);
}
private static final class StyleableProperties {
private static final CssMetaData<JFXListView<?>, Number> VERTICAL_GAP =
new CssMetaData<>("-jfx-vertical-gap",
SizeConverter.getInstance(), 0) {
@Override
public boolean isSettable(JFXListView<?> control) {
return control.verticalGap == null || !control.verticalGap.isBound();
}
@Override
public StyleableDoubleProperty getStyleableProperty(JFXListView<?> control) {
return control.verticalGapProperty();
}
};
private static final CssMetaData<JFXListView<?>, Boolean> EXPANDED =
new CssMetaData<>("-jfx-expanded",
BooleanConverter.getInstance(), false) {
@Override
public boolean isSettable(JFXListView<?> control) {
// it's only settable if the List is not shown yet
return control.getHeight() == 0 && (control.expanded == null || !control.expanded.isBound());
}
@Override
public StyleableBooleanProperty getStyleableProperty(JFXListView<?> control) {
return control.expandedProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables =
new ArrayList<>(ListView.getClassCssMetaData());
Collections.addAll(styleables, VERTICAL_GAP, EXPANDED);
CHILD_STYLEABLES = List.copyOf(styleables);
}
}
@Override
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
return getClassCssMetaData();
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.CHILD_STYLEABLES;
}
}
@@ -0,0 +1,299 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import com.jfoenix.skins.JFXPasswordFieldSkin;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.css.*;
import javafx.css.converter.BooleanConverter;
import javafx.css.converter.PaintConverter;
import javafx.scene.control.Control;
import javafx.scene.control.PasswordField;
import javafx.scene.control.Skin;
import javafx.scene.control.TextField;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu;
/**
* JFXPasswordField is the material design implementation of a password Field.
*
* @author Shadi Shaheen
* @version 1.0
* @since 2016-03-09
*/
public class JFXPasswordField extends PasswordField {
/**
* {@inheritDoc}
*/
public JFXPasswordField() {
initialize();
}
/**
* {@inheritDoc}
*/
@Override
protected Skin<?> createDefaultSkin() {
return new JFXPasswordFieldSkin(this);
}
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
useJFXContextMenu(this);
}
/// Prevents the legacy JFoenix skin from continuously requesting another layout pass.
// https://github.com/HMCL-dev/HMCL/issues/5822
// TODO: This method may no longer be needed after we update JFXTextFieldSkin
@Override
protected void layoutChildren() {
super.layoutChildren();
this.setNeedsLayout(false);
}
/**
* Initialize the style class to 'jfx-password-field'.
* <p>
* This is the selector class from which CSS can be used to style
* this control.
*/
private static final String DEFAULT_STYLE_CLASS = "jfx-password-field";
/***************************************************************************
* *
* Properties *
* *
**************************************************************************/
/**
* holds the current active validator on the password field in case of validation error
*/
private ReadOnlyObjectWrapper<ValidatorBase> activeValidator = new ReadOnlyObjectWrapper<>();
public ValidatorBase getActiveValidator() {
return activeValidator == null ? null : activeValidator.get();
}
public ReadOnlyObjectProperty<ValidatorBase> activeValidatorProperty() {
return this.activeValidator.getReadOnlyProperty();
}
/**
* list of validators that will validate the password value upon calling
* {{@link #validate()}
*/
private ObservableList<ValidatorBase> validators = FXCollections.observableArrayList();
public ObservableList<ValidatorBase> getValidators() {
return validators;
}
public void setValidators(ValidatorBase... validators) {
this.validators.addAll(validators);
}
/**
* validates the password value using the list of validators provided by the user
* {{@link #setValidators(ValidatorBase...)}
*
* @return true if the value is valid else false
*/
public boolean validate() {
for (ValidatorBase validator : validators) {
if (validator.getSrcControl() == null) {
validator.setSrcControl(this);
}
validator.validate();
if (validator.getHasErrors()) {
activeValidator.set(validator);
return false;
}
}
activeValidator.set(null);
return true;
}
public void resetValidation() {
pseudoClassStateChanged(ValidatorBase.PSEUDO_CLASS_ERROR, false);
activeValidator.set(null);
}
/***************************************************************************
* *
* styleable Properties *
* *
**************************************************************************/
/**
* set true to show a float the prompt text when focusing the field
*/
private StyleableBooleanProperty labelFloat = new SimpleStyleableBooleanProperty(StyleableProperties.LABEL_FLOAT, JFXPasswordField.this, "lableFloat", false);
public final StyleableBooleanProperty labelFloatProperty() {
return this.labelFloat;
}
public final boolean isLabelFloat() {
return this.labelFloatProperty().get();
}
public final void setLabelFloat(final boolean labelFloat) {
this.labelFloatProperty().set(labelFloat);
}
/**
* default color used when the field is unfocused
*/
private StyleableObjectProperty<Paint> unFocusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNFOCUS_COLOR, JFXPasswordField.this, "unFocusColor", Color.rgb(77, 77, 77));
public Paint getUnFocusColor() {
return unFocusColor == null ? Color.rgb(77, 77, 77) : unFocusColor.get();
}
public StyleableObjectProperty<Paint> unFocusColorProperty() {
return this.unFocusColor;
}
public void setUnFocusColor(Paint color) {
this.unFocusColor.set(color);
}
/**
* default color used when the field is focused
*/
private StyleableObjectProperty<Paint> focusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.FOCUS_COLOR, JFXPasswordField.this, "focusColor", Color.valueOf("#4059A9"));
public Paint getFocusColor() {
return focusColor == null ? Color.valueOf("#4059A9") : focusColor.get();
}
public StyleableObjectProperty<Paint> focusColorProperty() {
return this.focusColor;
}
public void setFocusColor(Paint color) {
this.focusColor.set(color);
}
/**
* disable animation on validation
*/
private StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION, JFXPasswordField.this, "disableAnimation", false);
public final StyleableBooleanProperty disableAnimationProperty() {
return this.disableAnimation;
}
public final Boolean isDisableAnimation() {
return disableAnimation != null && this.disableAnimationProperty().get();
}
public final void setDisableAnimation(final Boolean disabled) {
this.disableAnimationProperty().set(disabled);
}
private final static class StyleableProperties {
private static final CssMetaData<JFXPasswordField, Paint> UNFOCUS_COLOR = new CssMetaData<JFXPasswordField, Paint>("-jfx-unfocus-color", PaintConverter.getInstance(), Color.valueOf("#A6A6A6")) {
@Override
public boolean isSettable(JFXPasswordField control) {
return control.unFocusColor == null || !control.unFocusColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXPasswordField control) {
return control.unFocusColorProperty();
}
};
private static final CssMetaData<JFXPasswordField, Paint> FOCUS_COLOR = new CssMetaData<JFXPasswordField, Paint>("-jfx-focus-color", PaintConverter.getInstance(), Color.valueOf("#3f51b5")) {
@Override
public boolean isSettable(JFXPasswordField control) {
return control.focusColor == null || !control.focusColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXPasswordField control) {
return control.focusColorProperty();
}
};
private static final CssMetaData<JFXPasswordField, Boolean> LABEL_FLOAT = new CssMetaData<JFXPasswordField, Boolean>("-jfx-label-float", BooleanConverter.getInstance(), false) {
@Override
public boolean isSettable(JFXPasswordField control) {
return control.labelFloat == null || !control.labelFloat.isBound();
}
@Override
public StyleableBooleanProperty getStyleableProperty(JFXPasswordField control) {
return control.labelFloatProperty();
}
};
private static final CssMetaData<JFXPasswordField, Boolean> DISABLE_ANIMATION = new CssMetaData<JFXPasswordField, Boolean>("-fx-disable-animation", BooleanConverter.getInstance(), false) {
@Override
public boolean isSettable(JFXPasswordField control) {
return control.disableAnimation == null || !control.disableAnimation.isBound();
}
@Override
public StyleableBooleanProperty getStyleableProperty(JFXPasswordField control) {
return control.disableAnimationProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
Collections.addAll(styleables, UNFOCUS_COLOR, FOCUS_COLOR, LABEL_FLOAT, DISABLE_ANIMATION);
CHILD_STYLEABLES = Collections.unmodifiableList(styleables);
}
}
// inherit the styleable properties from parent
private List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
@Override
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
if (STYLEABLES == null) {
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
styleables.addAll(getClassCssMetaData());
styleables.addAll(TextField.getClassCssMetaData());
STYLEABLES = Collections.unmodifiableList(styleables);
}
return STYLEABLES;
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.CHILD_STYLEABLES;
}
}
@@ -0,0 +1,188 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import com.jfoenix.skins.JFXPopupSkin;
import javafx.application.Platform;
import javafx.beans.DefaultProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.NodeOrientation;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.PopupControl;
import javafx.scene.control.Skin;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
import javafx.stage.Window;
/// JFXPopup is the material design implementation of a popup.
///
/// @author Shadi Shaheen
/// @version 2.0
/// @since 2017-03-01
@DefaultProperty(value = "popupContent")
public class JFXPopup extends PopupControl {
public enum PopupHPosition {
RIGHT,
LEFT;
public PopupHPosition getOpposite() {
return (this == RIGHT) ? LEFT : RIGHT;
}
}
public enum PopupVPosition {
TOP, BOTTOM
}
/// Creates empty popup.
public JFXPopup() {
this(null);
}
/// creates popup with a specified container and content
///
/// @param content the node that will be shown in the popup
public JFXPopup(Region content) {
setPopupContent(content);
initialize();
}
private void initialize() {
this.setAutoFix(false);
this.setAutoHide(true);
this.setHideOnEscape(true);
this.setConsumeAutoHidingEvents(false);
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
}
@Override
protected Skin<?> createDefaultSkin() {
return new JFXPopupSkin(this);
}
/***************************************************************************
* *
* Setters / Getters *
* *
**************************************************************************/
private final ObjectProperty<Region> popupContent = new SimpleObjectProperty<>(new Pane());
public final ObjectProperty<Region> popupContentProperty() {
return this.popupContent;
}
public final Region getPopupContent() {
return this.popupContentProperty().get();
}
public final void setPopupContent(final Region popupContent) {
this.popupContentProperty().set(popupContent);
}
/***************************************************************************
* *
* Public API *
* *
**************************************************************************/
/// show the popup using the default position
public void show(Node node) {
this.show(node, PopupVPosition.TOP, PopupHPosition.LEFT, 0, 0);
}
/// show the popup according to the specified position
///
/// @param vAlign can be TOP/BOTTOM
/// @param hAlign can be LEFT/RIGHT
public void show(Node node, PopupVPosition vAlign, PopupHPosition hAlign) {
this.show(node, vAlign, hAlign, 0, 0);
}
/// show the popup according to the specified position with a certain offset
///
/// @param vAlign can be TOP/BOTTOM
/// @param hAlign can be LEFT/RIGHT
/// @param initOffsetX on the x-axis
/// @param initOffsetY on the y-axis
public void show(Node node, PopupVPosition vAlign, PopupHPosition hAlign, double initOffsetX, double initOffsetY) {
show(node, vAlign, hAlign, initOffsetX, initOffsetY, false);
}
public void show(Node node, PopupVPosition vAlign, PopupHPosition hAlign, double initOffsetX, double initOffsetY, boolean attachToNode) {
if (!isShowing()) {
Scene scene = node.getScene();
if (scene == null || scene.getWindow() == null) {
throw new IllegalStateException("Can not show popup. The node must be attached to a scene/window.");
}
Window parent = scene.getWindow();
final Point2D origin = node.localToScene(0, 0);
boolean isRTL = node.getEffectiveNodeOrientation() == NodeOrientation.RIGHT_TO_LEFT;
double anchorX = parent.getX() + scene.getX() + origin.getX() + (hAlign == PopupHPosition.RIGHT ? ((Region) node).getWidth() : 0);
double anchorY = parent.getY() + origin.getY() + scene.getY() + (vAlign == PopupVPosition.BOTTOM ? ((Region) node).getHeight() : 0);
if (attachToNode)
this.show(node, anchorX, anchorY);
else
this.show(parent, anchorX, anchorY);
((JFXPopupSkin) getSkin()).reset(vAlign, isRTL ? hAlign.getOpposite() : hAlign, isRTL ? -initOffsetX : initOffsetX, initOffsetY);
Platform.runLater(() -> ((JFXPopupSkin) getSkin()).animate());
}
}
public void show(Window window, double x, double y, PopupVPosition vAlign, PopupHPosition hAlign, double initOffsetX, double initOffsetY) {
if (!isShowing()) {
if (window == null) {
throw new IllegalStateException("Can not show popup. The node must be attached to a scene/window.");
}
Window parent = window;
final double anchorX = parent.getX() + x + initOffsetX;
final double anchorY = parent.getY() + y + initOffsetY;
this.show(parent, anchorX, anchorY);
((JFXPopupSkin) getSkin()).reset(vAlign, hAlign, initOffsetX, initOffsetY);
Platform.runLater(() -> ((JFXPopupSkin) getSkin()).animate());
}
}
@Override
public void hide() {
super.hide();
((JFXPopupSkin) getSkin()).init();
}
/***************************************************************************
* *
* Stylesheet Handling *
* *
**************************************************************************/
/// Initialize the style class to 'jfx-popup'.
///
/// This is the selector class from which CSS can be used to style
/// this control.
private static final String DEFAULT_STYLE_CLASS = "jfx-popup";
}
@@ -0,0 +1,66 @@
/*
* 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.JFXProgressBarSkin;
import javafx.scene.control.ProgressBar;
import javafx.scene.control.Skin;
/// JFXProgressBar is the material design implementation of a progress bar.
///
/// @author Shadi Shaheen
/// @version 1.0
/// @since 2016-03-09
public class JFXProgressBar extends ProgressBar {
/// Initialize the style class to 'jfx-progress-bar'.
///
/// This is the selector class from which CSS can be used to style
/// this control.
private static final String DEFAULT_STYLE_CLASS = "jfx-progress-bar";
public JFXProgressBar() {
initialize();
}
public JFXProgressBar(double progress) {
super(progress);
initialize();
}
@Override
protected Skin<?> createDefaultSkin() {
return new JFXProgressBarSkin(this);
}
private boolean smoothProgress = true;
public boolean isSmoothProgress() {
return smoothProgress;
}
public void setSmoothProgress(boolean smoothProgress) {
this.smoothProgress = smoothProgress;
}
private void initialize() {
setPrefWidth(200);
getStyleClass().add(DEFAULT_STYLE_CLASS);
}
}
@@ -0,0 +1,160 @@
/*
* 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.JFXRadioButtonSkin;
import javafx.css.*;
import javafx.css.converter.ColorConverter;
import javafx.scene.control.RadioButton;
import javafx.scene.control.Skin;
import javafx.scene.paint.Color;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/// JFXRadioButton is the material design implementation of a radio button.
///
/// @author Bashir Elias & Shadi Shaheen
/// @version 1.0
/// @since 2016-03-09
public class JFXRadioButton extends RadioButton {
public JFXRadioButton(String text) {
super(text);
initialize();
}
public JFXRadioButton() {
initialize();
}
@Override
protected Skin<?> createDefaultSkin() {
return new JFXRadioButtonSkin(this);
}
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
}
/// Initialize the style class to 'jfx-radio-button'.
///
/// This is the selector class from which CSS can be used to style
/// this control.
private static final String DEFAULT_STYLE_CLASS = "jfx-radio-button";
/// default color used when the radio button is selected
private StyleableObjectProperty<Color> selectedColor;
private static final Color DEFAULT_SELECTED_COLOR = Color.valueOf("#0F9D58");
public final StyleableObjectProperty<Color> selectedColorProperty() {
if (selectedColor == null) {
selectedColor = new SimpleStyleableObjectProperty<>(StyleableProperties.SELECTED_COLOR,
JFXRadioButton.this,
"selectedColor",
DEFAULT_SELECTED_COLOR);
}
return this.selectedColor;
}
public final Color getSelectedColor() {
return selectedColor == null ? DEFAULT_SELECTED_COLOR : this.selectedColorProperty().get();
}
public final void setSelectedColor(final Color selectedColor) {
this.selectedColorProperty().set(selectedColor);
}
/// default color used when the radio button is not selected
private StyleableObjectProperty<Color> unSelectedColor;
private static final Color DEFAULT_UNSELECTED_COLOR = Color.valueOf("#5A5A5A");
public final StyleableObjectProperty<Color> unSelectedColorProperty() {
if (unSelectedColor == null) {
unSelectedColor = new SimpleStyleableObjectProperty<>(
StyleableProperties.UNSELECTED_COLOR,
JFXRadioButton.this,
"unSelectedColor",
DEFAULT_UNSELECTED_COLOR);
}
return this.unSelectedColor;
}
public final Color getUnSelectedColor() {
return unSelectedColor == null ? DEFAULT_UNSELECTED_COLOR : this.unSelectedColorProperty().get();
}
public final void setUnSelectedColor(final Color unSelectedColor) {
this.unSelectedColorProperty().set(unSelectedColor);
}
private static final class StyleableProperties {
private static final CssMetaData<JFXRadioButton, Color> SELECTED_COLOR =
new CssMetaData<JFXRadioButton, Color>("-jfx-selected-color",
ColorConverter.getInstance(), DEFAULT_SELECTED_COLOR) {
@Override
public boolean isSettable(JFXRadioButton control) {
return control.selectedColor == null || !control.selectedColor.isBound();
}
@Override
public StyleableProperty<Color> getStyleableProperty(JFXRadioButton control) {
return control.selectedColorProperty();
}
};
private static final CssMetaData<JFXRadioButton, Color> UNSELECTED_COLOR =
new CssMetaData<JFXRadioButton, Color>("-jfx-unselected-color",
ColorConverter.getInstance(), DEFAULT_UNSELECTED_COLOR) {
@Override
public boolean isSettable(JFXRadioButton control) {
return control.unSelectedColor == null || !control.unSelectedColor.isBound();
}
@Override
public StyleableProperty<Color> getStyleableProperty(JFXRadioButton control) {
return control.unSelectedColorProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables =
new ArrayList<>(RadioButton.getClassCssMetaData());
Collections.addAll(styleables,
SELECTED_COLOR,
UNSELECTED_COLOR
);
CHILD_STYLEABLES = List.copyOf(styleables);
}
}
@Override
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
return getClassCssMetaData();
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.CHILD_STYLEABLES;
}
}
@@ -0,0 +1,834 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import com.jfoenix.converters.RipplerMaskTypeConverter;
import com.jfoenix.utils.JFXNodeUtils;
import javafx.animation.*;
import javafx.beans.DefaultProperty;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.css.*;
import javafx.css.converter.BooleanConverter;
import javafx.css.converter.PaintConverter;
import javafx.css.converter.SizeConverter;
import javafx.geometry.Bounds;
import javafx.scene.CacheHint;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Shape;
import javafx.util.Duration;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* JFXRippler is the material design implementation of a ripple effect.
* the ripple effect can be applied to any node in the scene. JFXRippler is
* a {@link StackPane} container that holds a specified node (control node) and a ripple generator.
* <p>
* UPDATE NOTES:
* - fireEventProgrammatically(Event) method has been removed as the ripple controller is
* the control itself, so you can trigger manual ripple by firing mouse event on the control
* instead of JFXRippler
*
* @author Shadi Shaheen
* @version 1.0
* @since 2016-03-09
*/
@DefaultProperty(value = "control")
public class JFXRippler extends StackPane {
public enum RipplerPos {
FRONT, BACK
}
public enum RipplerMask {
CIRCLE, RECT, FIT
}
protected RippleGenerator rippler;
protected Pane ripplerPane;
protected Node control;
protected static final double RIPPLE_MAX_RADIUS = 300;
private static final Interpolator RIPPLE_INTERPOLATOR = Interpolator.SPLINE(0.0825,
0.3025,
0.0875,
0.9975); //0.1, 0.54, 0.28, 0.95);
private boolean forceOverlay = false;
/// creates empty rippler node
public JFXRippler() {
this(null, RipplerMask.RECT, RipplerPos.FRONT);
}
/// creates a rippler for the specified control
public JFXRippler(Node control) {
this(control, RipplerMask.RECT, RipplerPos.FRONT);
}
/// creates a rippler for the specified control
///
/// @param pos can be either FRONT/BACK (position the ripple effect infront of or behind the control)
public JFXRippler(Node control, RipplerPos pos) {
this(control, RipplerMask.RECT, pos);
}
/// creates a rippler for the specified control and apply the specified mask to it
///
/// @param mask can be either rectangle/cricle
public JFXRippler(Node control, RipplerMask mask) {
this(control, mask, RipplerPos.FRONT);
}
/// creates a rippler for the specified control, mask and position.
///
/// @param mask can be either rectangle/cricle
/// @param pos can be either FRONT/BACK (position the ripple effect infront of or behind the control)
public JFXRippler(Node control, RipplerMask mask, RipplerPos pos) {
initialize();
setMaskType(mask);
setPosition(pos);
createRippleUI();
setControl(control);
// listen to control position changed
position.addListener(observable -> updateControlPosition());
setPickOnBounds(false);
setCache(true);
setCacheHint(CacheHint.SPEED);
setCacheShape(true);
}
protected final void createRippleUI() {
// create rippler panels
rippler = new RippleGenerator();
ripplerPane = new StackPane();
ripplerPane.setMouseTransparent(true);
ripplerPane.getChildren().add(rippler);
getChildren().add(ripplerPane);
}
/***************************************************************************
* *
* Setters / Getters *
* *
**************************************************************************/
public void setControl(Node control) {
if (control != null) {
this.control = control;
// position control
positionControl(control);
// add control listeners to generate / release ripples
initControlListeners();
}
}
// Override this method to create JFXRippler for a control outside the ripple
protected void positionControl(Node control) {
if (this.position.get() == RipplerPos.BACK) {
getChildren().add(control);
} else {
getChildren().add(0, control);
}
}
protected void updateControlPosition() {
if (this.position.get() == RipplerPos.BACK) {
ripplerPane.toBack();
} else {
ripplerPane.toFront();
}
}
public Node getControl() {
return control;
}
// methods that can be changed by extending the rippler class
/// generate the clipping mask
///
/// @return the mask node
protected Node getMask() {
double borderWidth = ripplerPane.getBorder() != null ? ripplerPane.getBorder().getInsets().getTop() : 0;
Bounds bounds = control.getBoundsInParent();
double width = control.getLayoutBounds().getWidth();
double height = control.getLayoutBounds().getHeight();
double diffMinX = Math.abs(control.getBoundsInLocal().getMinX() - control.getLayoutBounds().getMinX());
double diffMinY = Math.abs(control.getBoundsInLocal().getMinY() - control.getLayoutBounds().getMinY());
double diffMaxX = Math.abs(control.getBoundsInLocal().getMaxX() - control.getLayoutBounds().getMaxX());
double diffMaxY = Math.abs(control.getBoundsInLocal().getMaxY() - control.getLayoutBounds().getMaxY());
Node mask;
switch (getMaskType()) {
case RECT:
mask = new Rectangle(bounds.getMinX() + diffMinX - snappedLeftInset(),
bounds.getMinY() + diffMinY - snappedTopInset(),
width - 2 * borderWidth,
height - 2 * borderWidth); // -0.1 to prevent resizing the anchor pane
break;
case CIRCLE:
double radius = Math.min((width / 2) - 2 * borderWidth, (height / 2) - 2 * borderWidth);
mask = new Circle((bounds.getMinX() + diffMinX + bounds.getMaxX() - diffMaxX) / 2 - snappedLeftInset(),
(bounds.getMinY() + diffMinY + bounds.getMaxY() - diffMaxY) / 2 - snappedTopInset(),
radius,
Color.BLUE);
break;
case FIT:
mask = new Region();
if (control instanceof Shape) {
((Region) mask).setShape((Shape) control);
} else if (control instanceof Region) {
((Region) mask).setShape(((Region) control).getShape());
JFXNodeUtils.updateBackground(((Region) control).getBackground(), (Region) mask);
}
mask.resize(width, height);
mask.relocate(bounds.getMinX() + diffMinX, bounds.getMinY() + diffMinY);
break;
default:
mask = new Rectangle(bounds.getMinX() + diffMinX - snappedLeftInset(),
bounds.getMinY() + diffMinY - snappedTopInset(),
width - 2 * borderWidth,
height - 2 * borderWidth); // -0.1 to prevent resizing the anchor pane
break;
}
return mask;
}
/**
* compute the ripple radius
*
* @return the ripple radius size
*/
protected double computeRippleRadius(double centerX, double centerY) {
double dx = Math.max(centerX, control.getLayoutBounds().getWidth() - centerX);
double dy = Math.max(centerY, control.getLayoutBounds().getHeight() - centerY);
return (Math.sqrt(dx * dx + dy * dy) / 0.9) + 2;
}
protected void setOverLayBounds(Rectangle overlay) {
overlay.setWidth(control.getLayoutBounds().getWidth());
overlay.setHeight(control.getLayoutBounds().getHeight());
}
/**
* init mouse listeners on the control
*/
protected void initControlListeners() {
// if the control got resized the overlay rect must be rest
control.layoutBoundsProperty().addListener(observable -> resetRippler());
if (getChildren().contains(control)) {
control.boundsInParentProperty().addListener(observable -> resetRippler());
}
control.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
if (event.getButton() == MouseButton.PRIMARY)
createRipple(event.getX(), event.getY());
});
// create fade out transition for the ripple
control.addEventHandler(MouseEvent.MOUSE_RELEASED, event -> {
if (event.getButton() == MouseButton.PRIMARY)
releaseRipple();
});
}
/**
* creates Ripple effect
*/
protected void createRipple(double x, double y) {
if (!isRipplerDisabled()) {
rippler.setGeneratorCenterX(x);
rippler.setGeneratorCenterY(y);
rippler.createRipple();
}
}
protected void releaseRipple() {
rippler.releaseRipple();
}
public void releaseRippleImmediately() {
rippler.releaseRippleImmediately();
}
/**
* creates Ripple effect in the center of the control
*
* @return a runnable to release the ripple when needed
*/
public Runnable createManualRipple() {
if (!isRipplerDisabled()) {
rippler.setGeneratorCenterX(control.getLayoutBounds().getWidth() / 2);
rippler.setGeneratorCenterY(control.getLayoutBounds().getHeight() / 2);
rippler.createRipple();
return () -> {
// create fade out transition for the ripple
releaseRipple();
};
}
return () -> {
};
}
/// show/hide the ripple overlay
///
/// @param forceOverlay used to hold the overlay after ripple action
public void setOverlayVisible(boolean visible, boolean forceOverlay) {
this.forceOverlay = forceOverlay;
setOverlayVisible(visible);
}
/// show/hide the ripple overlay
/// NOTE: setting overlay visibility to false will reset forceOverlay to false
public void setOverlayVisible(boolean visible) {
if (visible) {
showOverlay();
} else {
forceOverlay = false;
hideOverlay();
}
}
/**
* this method will be set to private in future versions of JFoenix,
* user the method {@link #setOverlayVisible(boolean)}
*/
public void showOverlay() {
if (rippler.overlayRect != null) {
rippler.overlayRect.outAnimation.stop();
}
rippler.createOverlay();
rippler.overlayRect.inAnimation.play();
}
public void hideOverlay() {
if (!forceOverlay) {
if (rippler.overlayRect != null) {
rippler.overlayRect.inAnimation.stop();
}
if (rippler.overlayRect != null) {
rippler.overlayRect.outAnimation.play();
}
} else {
System.err.println("Ripple Overlay is forced!");
}
}
/**
* Generates ripples on the screen every 0.3 seconds or whenever
* the createRipple method is called. Ripples grow and fade out
* over 0.6 seconds
*/
protected final class RippleGenerator extends Group {
private double generatorCenterX = 0;
private double generatorCenterY = 0;
private OverLayRipple overlayRect;
private final AtomicBoolean generating = new AtomicBoolean(false);
private boolean cacheRipplerClip = false;
private boolean resetClip = false;
private final Queue<Ripple> ripplesQueue = new LinkedList<>();
RippleGenerator() {
// improve in performance, by preventing
// redrawing the parent when the ripple effect is triggered
this.setManaged(false);
this.setCache(true);
this.setCacheHint(CacheHint.SPEED);
}
void createRipple() {
if (!generating.getAndSet(true)) {
// create overlay once then change its color later
createOverlay();
if (this.getClip() == null || (getChildren().size() == 1 && !cacheRipplerClip) || resetClip) {
this.setClip(getMask());
}
this.resetClip = false;
// create the ripple effect
final Ripple ripple = new Ripple(generatorCenterX, generatorCenterY);
getChildren().add(ripple);
ripplesQueue.add(ripple);
// animate the ripple
overlayRect.outAnimation.stop();
overlayRect.inAnimation.play();
ripple.inAnimation.play();
}
}
private void releaseRipple() {
Ripple ripple = ripplesQueue.poll();
if (ripple != null) {
ripple.inAnimation.stop();
ripple.outAnimation = new Timeline(
new KeyFrame(Duration.millis(Math.min(800, (0.9 * 500) / ripple.getScaleX()))
, ripple.outKeyValues));
ripple.outAnimation.setOnFinished((event) -> getChildren().remove(ripple));
ripple.outAnimation.play();
if (generating.getAndSet(false)) {
if (overlayRect != null) {
overlayRect.inAnimation.stop();
if (!forceOverlay) {
overlayRect.outAnimation.play();
}
}
}
}
}
private void releaseRippleImmediately() {
Ripple ripple = ripplesQueue.poll();
if (ripple != null) {
getChildren().remove(ripple);
if (generating.getAndSet(false)) {
if (overlayRect != null) {
overlayRect.inAnimation.stop();
if (!forceOverlay) {
overlayRect.setOpacity(0D);
}
}
}
}
}
void cacheRippleClip(boolean cached) {
cacheRipplerClip = cached;
}
void createOverlay() {
if (overlayRect == null) {
overlayRect = new OverLayRipple();
overlayRect.setClip(getMask());
getChildren().add(0, overlayRect);
overlayRect.fillProperty().bind(Bindings.createObjectBinding(() -> {
if (getRipplerFill() instanceof Color fill) {
return new Color(fill.getRed(),
fill.getGreen(),
fill.getBlue(),
0.2);
} else {
return Color.TRANSPARENT;
}
}, ripplerFillProperty()));
}
}
void setGeneratorCenterX(double generatorCenterX) {
this.generatorCenterX = generatorCenterX;
}
void setGeneratorCenterY(double generatorCenterY) {
this.generatorCenterY = generatorCenterY;
}
private final class OverLayRipple extends Rectangle {
// Overlay ripple animations
Animation inAnimation = new Timeline(new KeyFrame(Duration.millis(300),
new KeyValue(opacityProperty(), 1, Interpolator.EASE_IN)));
Animation outAnimation = new Timeline(new KeyFrame(Duration.millis(300),
new KeyValue(opacityProperty(), 0, Interpolator.EASE_OUT)));
OverLayRipple() {
super();
setOverLayBounds(this);
this.getStyleClass().add("jfx-rippler-overlay");
// update initial position
if (JFXRippler.this.getChildrenUnmodifiable().contains(control)) {
double diffMinX = Math.abs(control.getBoundsInLocal().getMinX() - control.getLayoutBounds().getMinX());
double diffMinY = Math.abs(control.getBoundsInLocal().getMinY() - control.getLayoutBounds().getMinY());
Bounds bounds = control.getBoundsInParent();
this.setX(bounds.getMinX() + diffMinX - snappedLeftInset());
this.setY(bounds.getMinY() + diffMinY - snappedTopInset());
}
// set initial attributes
setOpacity(0);
setCache(true);
setCacheHint(CacheHint.SPEED);
setCacheShape(true);
setManaged(false);
}
}
private final class Ripple extends Circle {
KeyValue[] outKeyValues;
Animation outAnimation = null;
Animation inAnimation = null;
private Ripple(double centerX, double centerY) {
super(centerX,
centerY,
getRipplerRadius() == Region.USE_COMPUTED_SIZE ?
computeRippleRadius(centerX, centerY) : getRipplerRadius(), null);
setCache(true);
setCacheHint(CacheHint.SPEED);
setCacheShape(true);
setManaged(false);
setSmooth(true);
KeyValue[] inKeyValues = new KeyValue[isRipplerRecenter() ? 4 : 2];
outKeyValues = new KeyValue[isRipplerRecenter() ? 5 : 3];
inKeyValues[0] = new KeyValue(scaleXProperty(), 0.9, RIPPLE_INTERPOLATOR);
inKeyValues[1] = new KeyValue(scaleYProperty(), 0.9, RIPPLE_INTERPOLATOR);
outKeyValues[0] = new KeyValue(this.scaleXProperty(), 1, RIPPLE_INTERPOLATOR);
outKeyValues[1] = new KeyValue(this.scaleYProperty(), 1, RIPPLE_INTERPOLATOR);
outKeyValues[2] = new KeyValue(this.opacityProperty(), 0, RIPPLE_INTERPOLATOR);
if (isRipplerRecenter()) {
double dx = (control.getLayoutBounds().getWidth() / 2 - centerX) / 1.55;
double dy = (control.getLayoutBounds().getHeight() / 2 - centerY) / 1.55;
inKeyValues[2] = outKeyValues[3] = new KeyValue(translateXProperty(),
Math.signum(dx) * Math.min(Math.abs(dx),
this.getRadius() / 2),
RIPPLE_INTERPOLATOR);
inKeyValues[3] = outKeyValues[4] = new KeyValue(translateYProperty(),
Math.signum(dy) * Math.min(Math.abs(dy),
this.getRadius() / 2),
RIPPLE_INTERPOLATOR);
}
inAnimation = new Timeline(new KeyFrame(Duration.ZERO,
new KeyValue(scaleXProperty(),
0,
RIPPLE_INTERPOLATOR),
new KeyValue(scaleYProperty(),
0,
RIPPLE_INTERPOLATOR),
new KeyValue(translateXProperty(),
0,
RIPPLE_INTERPOLATOR),
new KeyValue(translateYProperty(),
0,
RIPPLE_INTERPOLATOR),
new KeyValue(opacityProperty(),
1,
RIPPLE_INTERPOLATOR)
), new KeyFrame(Duration.millis(600), inKeyValues));
setScaleX(0);
setScaleY(0);
if (getRipplerFill() instanceof Color fill) {
Color circleColor = new Color(fill.getRed(),
fill.getGreen(),
fill.getBlue(),
0.3);
setStroke(circleColor);
setFill(circleColor);
} else {
setStroke(getRipplerFill());
setFill(getRipplerFill());
}
}
}
public void clear() {
getChildren().clear();
rippler.overlayRect = null;
generating.set(false);
}
}
private void resetOverLay() {
if (rippler.overlayRect != null) {
rippler.overlayRect.inAnimation.stop();
final RippleGenerator.OverLayRipple oldOverlay = rippler.overlayRect;
rippler.overlayRect.outAnimation.setOnFinished((finish) -> rippler.getChildren().remove(oldOverlay));
rippler.overlayRect.outAnimation.play();
rippler.overlayRect = null;
}
}
private void resetClip() {
this.rippler.resetClip = true;
}
protected void resetRippler() {
resetOverLay();
resetClip();
}
/***************************************************************************
* *
* Stylesheet Handling *
* *
**************************************************************************/
/**
* Initialize the style class to 'jfx-rippler'.
* <p>
* This is the selector class from which CSS can be used to style
* this control.
*/
private static final String DEFAULT_STYLE_CLASS = "jfx-rippler";
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
}
/**
* the ripple recenter property, by default it's false.
* if true the ripple effect will show gravitational pull to the center of its control
*/
private StyleableBooleanProperty ripplerRecenter;
public boolean isRipplerRecenter() {
return ripplerRecenter != null && ripplerRecenter.get();
}
public StyleableBooleanProperty ripplerRecenterProperty() {
if (this.ripplerRecenter == null) {
this.ripplerRecenter = new SimpleStyleableBooleanProperty(
StyleableProperties.RIPPLER_RECENTER,
JFXRippler.this,
"ripplerRecenter",
false);
}
return this.ripplerRecenter;
}
public void setRipplerRecenter(boolean recenter) {
ripplerRecenterProperty().set(recenter);
}
/**
* the ripple radius size, by default it will be automatically computed.
*/
private StyleableDoubleProperty ripplerRadius;
public double getRipplerRadius() {
return ripplerRadius == null ? Region.USE_COMPUTED_SIZE : ripplerRadius.get();
}
public StyleableDoubleProperty ripplerRadiusProperty() {
if (this.ripplerRadius == null) {
this.ripplerRadius = new SimpleStyleableDoubleProperty(
StyleableProperties.RIPPLER_RADIUS,
JFXRippler.this,
"ripplerRadius",
Region.USE_COMPUTED_SIZE);
}
return this.ripplerRadius;
}
public void setRipplerRadius(double radius) {
ripplerRadiusProperty().set(radius);
}
private static final Color DEFAULT_RIPPLER_FILL = Color.rgb(0, 200, 255);
/**
* the default color of the ripple effect
*/
private StyleableObjectProperty<Paint> ripplerFill;
public Paint getRipplerFill() {
return ripplerFill == null ? DEFAULT_RIPPLER_FILL : ripplerFill.get();
}
public StyleableObjectProperty<Paint> ripplerFillProperty() {
if (this.ripplerFill == null) {
this.ripplerFill = new SimpleStyleableObjectProperty<>(StyleableProperties.RIPPLER_FILL,
JFXRippler.this,
"ripplerFill",
DEFAULT_RIPPLER_FILL);
}
return this.ripplerFill;
}
public void setRipplerFill(Paint color) {
ripplerFillProperty().set(color);
}
/// mask property used for clipping the rippler.
/// can be either CIRCLE/RECT
private StyleableObjectProperty<RipplerMask> maskType;
public RipplerMask getMaskType() {
return maskType == null ? RipplerMask.RECT : maskType.get();
}
public StyleableObjectProperty<RipplerMask> maskTypeProperty() {
if (this.maskType == null) {
this.maskType = new SimpleStyleableObjectProperty<>(
StyleableProperties.MASK_TYPE,
JFXRippler.this,
"maskType",
RipplerMask.RECT);
}
return this.maskType;
}
public void setMaskType(RipplerMask type) {
if (this.maskType != null || type != RipplerMask.RECT)
maskTypeProperty().set(type);
}
/**
* the ripple disable, by default it's false.
* if true the ripple effect will be hidden
*/
private StyleableBooleanProperty ripplerDisabled;
public boolean isRipplerDisabled() {
return ripplerDisabled != null && ripplerDisabled.get();
}
public StyleableBooleanProperty ripplerDisabledProperty() {
if (this.ripplerDisabled == null) {
this.ripplerDisabled = new SimpleStyleableBooleanProperty(
StyleableProperties.RIPPLER_DISABLED,
JFXRippler.this,
"ripplerDisabled",
false);
}
return this.ripplerDisabled;
}
public void setRipplerDisabled(boolean disabled) {
ripplerDisabledProperty().set(disabled);
}
/**
* indicates whether the ripple effect is infront of or behind the node
*/
protected ObjectProperty<RipplerPos> position = new SimpleObjectProperty<>();
public void setPosition(RipplerPos pos) {
this.position.set(pos);
}
public RipplerPos getPosition() {
return position == null ? RipplerPos.FRONT : position.get();
}
public ObjectProperty<RipplerPos> positionProperty() {
return this.position;
}
private static final class StyleableProperties {
private static final CssMetaData<JFXRippler, Boolean> RIPPLER_RECENTER =
new CssMetaData<>("-jfx-rippler-recenter",
BooleanConverter.getInstance(), false) {
@Override
public boolean isSettable(JFXRippler control) {
return control.ripplerRecenter == null || !control.ripplerRecenter.isBound();
}
@Override
public StyleableProperty<Boolean> getStyleableProperty(JFXRippler control) {
return control.ripplerRecenterProperty();
}
};
private static final CssMetaData<JFXRippler, Boolean> RIPPLER_DISABLED =
new CssMetaData<>("-jfx-rippler-disabled",
BooleanConverter.getInstance(), false) {
@Override
public boolean isSettable(JFXRippler control) {
return control.ripplerDisabled == null || !control.ripplerDisabled.isBound();
}
@Override
public StyleableProperty<Boolean> getStyleableProperty(JFXRippler control) {
return control.ripplerDisabledProperty();
}
};
private static final CssMetaData<JFXRippler, Paint> RIPPLER_FILL =
new CssMetaData<>("-jfx-rippler-fill",
PaintConverter.getInstance(), DEFAULT_RIPPLER_FILL) {
@Override
public boolean isSettable(JFXRippler control) {
return control.ripplerFill == null || !control.ripplerFill.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXRippler control) {
return control.ripplerFillProperty();
}
};
private static final CssMetaData<JFXRippler, Number> RIPPLER_RADIUS =
new CssMetaData<>("-jfx-rippler-radius",
SizeConverter.getInstance(), Region.USE_COMPUTED_SIZE) {
@Override
public boolean isSettable(JFXRippler control) {
return control.ripplerRadius == null || !control.ripplerRadius.isBound();
}
@Override
public StyleableProperty<Number> getStyleableProperty(JFXRippler control) {
return control.ripplerRadiusProperty();
}
};
private static final CssMetaData<JFXRippler, RipplerMask> MASK_TYPE =
new CssMetaData<>("-jfx-mask-type",
RipplerMaskTypeConverter.getInstance(), RipplerMask.RECT) {
@Override
public boolean isSettable(JFXRippler control) {
return control.maskType == null || !control.maskType.isBound();
}
@Override
public StyleableProperty<RipplerMask> getStyleableProperty(JFXRippler control) {
return control.maskTypeProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables =
new ArrayList<>(StackPane.getClassCssMetaData());
Collections.addAll(styleables,
RIPPLER_RECENTER,
RIPPLER_RADIUS,
RIPPLER_FILL,
MASK_TYPE,
RIPPLER_DISABLED
);
STYLEABLES = Collections.unmodifiableList(styleables);
}
}
@Override
public List<CssMetaData<? extends Styleable, ?>> getCssMetaData() {
return getClassCssMetaData();
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.STYLEABLES;
}
}
@@ -0,0 +1,180 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import com.jfoenix.converters.IndicatorPositionConverter;
import com.jfoenix.skins.JFXSliderSkin;
import javafx.beans.binding.StringBinding;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.css.*;
import javafx.scene.control.Skin;
import javafx.scene.control.Slider;
import javafx.util.Callback;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/// JFXSlider is the material design implementation of a slider.
///
/// @author Bashir Elias & Shadi Shaheen
/// @version 1.0
/// @since 2016-03-09
public class JFXSlider extends Slider {
public JFXSlider() {
super(0, 100, 50);
initialize();
}
public JFXSlider(double min, double max, double value) {
super(min, max, value);
initialize();
}
@Override
protected Skin<?> createDefaultSkin() {
return new JFXSliderSkin(this);
}
private void initialize() {
getStyleClass().add(DEFAULT_STYLE_CLASS);
}
public enum IndicatorPosition {
LEFT, RIGHT
}
/***************************************************************************
* *
* Properties *
* *
**************************************************************************/
/// String binding factory for the slider value.
/// Sets a custom string for the value text (by default, it shows the value rounded to the nearest whole number).
///
///
/// For example, to have the value displayed as a percentage (assuming the slider has a range of (0, 100)):
/// ```java
/// JFXSlider mySlider = ...
/// mySlider.setValueFactory(slider ->
/// Bindings.createStringBinding(
/// () -> ((int) slider.getValue()) + "%",
/// slider.valueProperty()
/// )
/// );
/// ```
///
/// NOTE: might be replaced later with a call back to create the animated thumb node
///
/// @param callback a callback to create the string value binding
private ObjectProperty<Callback<JFXSlider, StringBinding>> valueFactory;
public final ObjectProperty<Callback<JFXSlider, StringBinding>> valueFactoryProperty() {
if (valueFactory == null) {
valueFactory = new SimpleObjectProperty<>(this, "valueFactory");
}
return valueFactory;
}
/// @return the current slider value factory
public final Callback<JFXSlider, StringBinding> getValueFactory() {
return valueFactory == null ? null : valueFactory.get();
}
/// sets custom string binding for the slider text value
///
/// @param valueFactory a callback to create the string value binding
public final void setValueFactory(final Callback<JFXSlider, StringBinding> valueFactory) {
this.valueFactoryProperty().set(valueFactory);
}
/***************************************************************************
* *
* Stylesheet Handling *
* *
**************************************************************************/
/// Initialize the style class to 'jfx-slider'.
///
/// This is the selector class from which CSS can be used to style
/// this control.
private static final String DEFAULT_STYLE_CLASS = "jfx-slider";
/// indicates the position of the slider indicator, can be
/// either LEFT or RIGHT
private StyleableObjectProperty<IndicatorPosition> indicatorPosition;
public StyleableObjectProperty<IndicatorPosition> indicatorPositionProperty() {
if (indicatorPosition == null) {
indicatorPosition = new SimpleStyleableObjectProperty<>(
StyleableProperties.INDICATOR_POSITION,
JFXSlider.this,
"indicatorPosition",
IndicatorPosition.LEFT);
}
return this.indicatorPosition;
}
public IndicatorPosition getIndicatorPosition() {
return indicatorPosition == null ? IndicatorPosition.LEFT : indicatorPosition.get();
}
public void setIndicatorPosition(IndicatorPosition pos) {
indicatorPositionProperty().set(pos);
}
private static final class StyleableProperties {
private static final CssMetaData<JFXSlider, IndicatorPosition> INDICATOR_POSITION = new CssMetaData<>(
"-jfx-indicator-position",
IndicatorPositionConverter.getInstance(),
IndicatorPosition.LEFT) {
@Override
public boolean isSettable(JFXSlider control) {
return control.indicatorPosition == null || !control.indicatorPosition.isBound();
}
@Override
public StyleableProperty<IndicatorPosition> getStyleableProperty(JFXSlider control) {
return control.indicatorPositionProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(
Slider.getClassCssMetaData());
Collections.addAll(styleables, INDICATOR_POSITION);
CHILD_STYLEABLES = List.copyOf(styleables);
}
}
@Override
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
return getClassCssMetaData();
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.CHILD_STYLEABLES;
}
}
@@ -0,0 +1,421 @@
/*
* 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 javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.PauseTransition;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.WeakChangeListener;
import javafx.css.PseudoClass;
import javafx.event.Event;
import javafx.event.EventType;
import javafx.geometry.Bounds;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.animation.Motion;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicBoolean;
/// "Snackbars provide brief messages about app processes at the bottom of the screen"
/// (<a href="https://material.io/design/components/snackbars.html#">Material Design Guidelines</a>).
///
/// To show a snackbar you need to
/// <ol>
/// - Have a [Pane] (snackbarContainer) to show the snackbar on top of. Register it in
/// [the JFXSnackbar constructor][#JFXSnackbar(Pane)] or using the [#registerSnackbarContainer(Pane)] method.
/// - Have or create a [JFXSnackbar]. - Having one snackbar where you pass all your
/// [SnackbarEvents][JFXSnackbar.SnackbarEvent] will ensure that the [enqueuemethod][JFXSnackbar#enqueue(SnackbarEvent)] works as intended.
///
/// - Have something to show in the snackbar. A [JFXSnackbarLayout] is nice and pretty,
/// but any arbitrary [Node] will do.
/// - Create a [SnackbarEvent][JFXSnackbar.SnackbarEvent] specifying the contents and the
/// duration.
/// </ol>
///
/// Finally, with all those things prepared, show your snackbar using
/// [snackbar.enqueue(snackbarEvent);][JFXSnackbar#enqueue(SnackbarEvent)].
///
/// It's most convenient to create functions to do most of this (creating the layout and event) with the default
/// settings; that way all you need to do to show a snackbar is specify the message or just the message and the duration.
///
/// @see <a href="https://material.io/design/components/snackbars.html#"> The Material Design Snackbar</a>
public class JFXSnackbar extends Group {
private static final String DEFAULT_STYLE_CLASS = "jfx-snackbar";
private Pane snackbarContainer;
private final ChangeListener<? super Number> sizeListener = (o, oldVal, newVal) -> refreshPopup();
private final WeakChangeListener<? super Number> weakSizeListener = new WeakChangeListener<>(sizeListener);
private final AtomicBoolean processingQueue = new AtomicBoolean(false);
private final ConcurrentLinkedQueue<SnackbarEvent> eventQueue = new ConcurrentLinkedQueue<>();
private final ConcurrentHashMap.KeySetView<Object, Boolean> eventsSet = ConcurrentHashMap.newKeySet();
private final Pane content;
private PseudoClass activePseudoClass = null;
private PauseTransition pauseTransition;
/// This constructor assumes that you will eventually call the [#registerSnackbarContainer(Pane)] method before
/// calling the [#enqueue(SnackbarEvent)] method. Otherwise, how will the snackbar know where to show itself?
///
///
/// "Snackbars provide brief messages about app processes at the bottom of the screen"
/// (<a href="https://material.io/design/components/snackbars.html#">Material Design Guidelines</a>).
///
/// To show a snackbar you need to
///
/// - Have a [Pane] (snackbarContainer) to show the snackbar on top of. Register it in
/// [the JFXSnackbar constructor][#JFXSnackbar(Pane)] or using the [#registerSnackbarContainer(Pane)] method.
/// - Have or create a [JFXSnackbar]. - Having one snackbar where you pass all your
/// [SnackbarEvents][JFXSnackbar.SnackbarEvent] will ensure that the [enqueuemethod][JFXSnackbar#enqueue(SnackbarEvent)] works as intended.
/// - Have something to show in the snackbar. A [JFXSnackbarLayout] is nice and pretty,
/// but any arbitrary [Node] will do.
/// - Create a [SnackbarEvent][JFXSnackbar.SnackbarEvent] specifying the contents and the
/// duration.
///
/// Finally, with all those things prepared, show your snackbar using
/// [snackbar.enqueue(snackbarEvent);][JFXSnackbar#enqueue(SnackbarEvent)].
///
public JFXSnackbar() {
this(null);
}
/// "Snackbars provide brief messages about app processes at the bottom of the screen"
/// (<a href="https://material.io/design/components/snackbars.html#">Material Design Guidelines</a>).
///
/// To show a snackbar you need to
///
/// - Have a [Pane] (snackbarContainer) to show the snackbar on top of. Register it in
/// [the JFXSnackbar constructor][#JFXSnackbar(Pane)] or using the [#registerSnackbarContainer(Pane)] method.
/// - Have or create a [JFXSnackbar]. - Having one snackbar where you pass all your
/// [SnackbarEvents][JFXSnackbar.SnackbarEvent] will ensure that the [enqueuemethod][JFXSnackbar#enqueue(SnackbarEvent)] works as intended.
/// - Have something to show in the snackbar. A [JFXSnackbarLayout] is nice and pretty,
/// but any arbitrary [Node] will do.
/// - Create a [SnackbarEvent][JFXSnackbar.SnackbarEvent] specifying the contents and the
/// duration.
///
/// Finally, with all those things prepared, show your snackbar using
/// [snackbar.enqueue(snackbarEvent);][JFXSnackbar#enqueue(SnackbarEvent)].
///
/// @param snackbarContainer where the snackbar will appear. Using a single snackbar instead of many, will ensure that
/// the [#enqueue(SnackbarEvent)] method works correctly.
public JFXSnackbar(Pane snackbarContainer) {
initialize();
content = new StackPane();
content.getStyleClass().add("jfx-snackbar-content");
//wrap the content in a group so that the content is managed inside its own container
//but the group is not managed in the snackbarContainer so it does not affect any layout calculations
getChildren().add(content);
setManaged(false);
setVisible(false);
// register the container before resizing it
registerSnackbarContainer(snackbarContainer);
// resize the popup if its layout has been changed
layoutBoundsProperty().addListener((o, oldVal, newVal) -> refreshPopup());
addEventHandler(SnackbarEvent.SNACKBAR, this::enqueue);
}
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
}
// Setters / Getters
public Pane getPopupContainer() {
return snackbarContainer;
}
public void setPrefWidth(double width) {
content.setPrefWidth(width);
}
public double getPrefWidth() {
return content.getPrefWidth();
}
// Public API
public void registerSnackbarContainer(Pane snackbarContainer) {
if (snackbarContainer != null) {
if (this.snackbarContainer != null) {
//since listeners are added the container should be properly registered/unregistered
throw new IllegalArgumentException("Snackbar Container already set");
}
this.snackbarContainer = snackbarContainer;
this.snackbarContainer.getChildren().add(this);
this.snackbarContainer.heightProperty().addListener(weakSizeListener);
this.snackbarContainer.widthProperty().addListener(weakSizeListener);
}
}
public void unregisterSnackbarContainer(Pane snackbarContainer) {
if (snackbarContainer != null) {
if (this.snackbarContainer == null) {
throw new IllegalArgumentException("Snackbar Container not set");
}
this.snackbarContainer.getChildren().remove(this);
this.snackbarContainer.heightProperty().removeListener(weakSizeListener);
this.snackbarContainer.widthProperty().removeListener(weakSizeListener);
this.snackbarContainer = null;
}
}
private void show(SnackbarEvent event) {
content.getChildren().setAll(event.getContent());
openAnimation = getTimeline(event.getTimeout());
if (event.getPseudoClass() != null) {
activePseudoClass = event.getPseudoClass();
content.pseudoClassStateChanged(activePseudoClass, true);
}
openAnimation.play();
}
private Timeline openAnimation = null;
private Timeline getTimeline(Duration timeout) {
Timeline animation;
animation = new Timeline(
new KeyFrame(
Duration.ZERO,
e -> {
this.toBack();
this.setVisible(false);
},
new KeyValue(this.translateYProperty(), this.getLayoutBounds().getHeight(), Motion.EASE),
new KeyValue(this.opacityProperty(), 0, Motion.EASE)
),
new KeyFrame(
Duration.millis(10),
e -> {
this.toFront();
this.setVisible(true);
}
),
new KeyFrame(Duration.millis(300),
new KeyValue(this.opacityProperty(), 1, Motion.EASE),
new KeyValue(this.translateYProperty(), 0, Motion.EASE)
)
);
animation.setCycleCount(1);
pauseTransition = Duration.INDEFINITE.equals(timeout) ? null : new PauseTransition(timeout);
if (pauseTransition != null) {
animation.setOnFinished(finish -> {
pauseTransition.setOnFinished(done -> {
pauseTransition = null;
eventsSet.remove(currentEvent);
currentEvent = eventQueue.peek();
close();
});
pauseTransition.play();
});
}
return animation;
}
public void close() {
if (openAnimation != null) {
openAnimation.stop();
}
if (this.isVisible()) {
Timeline closeAnimation = new Timeline(
new KeyFrame(
Duration.ZERO,
e -> this.toFront(),
new KeyValue(this.opacityProperty(), 1, Motion.EASE),
new KeyValue(this.translateYProperty(), 0, Motion.EASE)
),
new KeyFrame(
Duration.millis(290),
e -> this.setVisible(true)
),
new KeyFrame(Duration.millis(300),
e -> {
this.toBack();
this.setVisible(false);
},
new KeyValue(this.translateYProperty(), this.getLayoutBounds().getHeight(), Motion.EASE),
new KeyValue(this.opacityProperty(), 0, Motion.EASE)
)
);
closeAnimation.setCycleCount(1);
closeAnimation.setOnFinished(e -> {
resetPseudoClass();
processSnackbar();
});
closeAnimation.play();
}
}
private SnackbarEvent currentEvent = null;
public SnackbarEvent getCurrentEvent() {
return currentEvent;
}
/**
* Shows {@link SnackbarEvent SnackbarEvents} one by one. The next event will be shown after the current event's duration.
*
* @param event the {@link SnackbarEvent event} to put in the queue.
*/
public void enqueue(SnackbarEvent event) {
synchronized (this) {
if (!eventsSet.contains(event)) {
eventsSet.add(event);
eventQueue.offer(event);
} else if (currentEvent == event && pauseTransition != null) {
pauseTransition.playFromStart();
}
}
if (processingQueue.compareAndSet(false, true)) {
Platform.runLater(() -> {
currentEvent = eventQueue.poll();
if (currentEvent != null) {
show(currentEvent);
}
});
}
}
private void resetPseudoClass() {
if (activePseudoClass != null) {
content.pseudoClassStateChanged(activePseudoClass, false);
activePseudoClass = null;
}
}
private void processSnackbar() {
currentEvent = eventQueue.poll();
if (currentEvent != null) {
eventsSet.remove(currentEvent);
show(currentEvent);
} else {
//The enqueue method and this listener should be executed sequentially on the FX Thread so there
//should not be a race condition
processingQueue.getAndSet(false);
}
}
private void refreshPopup() {
if (snackbarContainer == null) {
return;
}
Bounds contentBound = this.getLayoutBounds();
double offsetX = Math.ceil(snackbarContainer.getWidth() / 2) - Math.ceil(contentBound.getWidth() / 2);
double offsetY = snackbarContainer.getHeight() - contentBound.getHeight();
this.setLayoutX(offsetX);
this.setLayoutY(offsetY);
}
///////////////////////////////////////////////////////////////////////////
// Event API
///////////////////////////////////////////////////////////////////////////
/// Specifies _what_ and _how long_ to show a [JFXSnackbar].
///
/// The _what_ can be any arbitrary [Node]; the [JFXSnackbarLayout] is a great choice.
///
/// The _how long_ is specified in the form of a [javafx.util.Duration][javafx.util.Duration], not to be
/// confused with the [java.time.Duration].
public static class SnackbarEvent extends Event {
public static final EventType<SnackbarEvent> SNACKBAR = new EventType<>(Event.ANY, "SNACKBAR");
/// The amount of time the snackbar will show for, if not otherwise specified.
///
/// It's 1.5 seconds.
public static Duration DEFAULT_DURATION = Duration.seconds(1.5);
private final Node content;
private final PseudoClass pseudoClass;
private final Duration timeout;
/// Creates a [SnackbarEvent] with the [default duration][#DEFAULT_DURATION] and no pseudoClass.
///
/// @param content what you want shown in the snackbar; a [JFXSnackbarLayout] is a great choice.
public SnackbarEvent(Node content) {
this(content, DEFAULT_DURATION, null);
}
/// Creates a [SnackbarEvent] with the [default duration][#DEFAULT_DURATION]; you specify the contents and
/// pseudoClass.
///
/// @param content what you want shown in the snackbar; a [JFXSnackbarLayout] is a great choice.
public SnackbarEvent(Node content, PseudoClass pseudoClass) {
this(content, DEFAULT_DURATION, pseudoClass);
}
/// Creates a SnackbarEvent with no pseudoClass; you specify the contents and duration.
/// pseudoClass.
///
/// @param content what you want shown in the snackbar; a [JFXSnackbarLayout] is a great choice.
/// @param timeout the amount of time you want the snackbar to show for.
public SnackbarEvent(Node content, Duration timeout) {
this(content, timeout, null);
}
/// Creates a SnackbarEvent; you specify the contents, duration and pseudoClass.
///
/// If you don't need so much customization, try one of the other constructors.
///
/// @param content what you want shown in the snackbar; a [JFXSnackbarLayout] is a great choice.
/// @param timeout the amount of time you want the snackbar to show for.
public SnackbarEvent(Node content, Duration timeout, PseudoClass pseudoClass) {
super(SNACKBAR);
this.content = content;
this.pseudoClass = pseudoClass;
this.timeout = timeout;
}
public Node getContent() {
return content;
}
public PseudoClass getPseudoClass() {
return pseudoClass;
}
public Duration getTimeout() {
return timeout;
}
@Override
@SuppressWarnings("unchecked")
public EventType<? extends SnackbarEvent> getEventType() {
return (EventType<? extends SnackbarEvent>) super.getEventType();
}
public boolean isPersistent() {
return Duration.INDEFINITE.equals(getTimeout());
}
}
}
@@ -0,0 +1,112 @@
/*
* 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 javafx.beans.binding.Bindings;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.StackPane;
/// JFXSnackbarLayout default layout for snackbar content
///
/// @author Shadi Shaheen
/// @version 1.0
/// @since 2018-11-16
public class JFXSnackbarLayout extends BorderPane {
private final Label toast;
private JFXButton action;
private final StackPane actionContainer;
public JFXSnackbarLayout(String message) {
this(message, null, null);
}
public JFXSnackbarLayout(String message, String actionText, EventHandler<ActionEvent> actionHandler) {
initialize();
toast = new Label();
toast.setMinWidth(Control.USE_PREF_SIZE);
toast.getStyleClass().add("jfx-snackbar-toast");
toast.setWrapText(true);
toast.setText(message);
StackPane toastContainer = new StackPane(toast);
toastContainer.setPadding(new Insets(20));
actionContainer = new StackPane();
actionContainer.setVisible(false);
actionContainer.setManaged(false);
toast.prefWidthProperty().bind(Bindings.createDoubleBinding(() -> {
if (getPrefWidth() == -1) {
return getPrefWidth();
}
double actionWidth = actionContainer.isVisible() ? actionContainer.getWidth() : 0.0;
return prefWidthProperty().get() - actionWidth;
}, prefWidthProperty(), actionContainer.widthProperty(), actionContainer.visibleProperty()));
setLeft(toastContainer);
setRight(actionContainer);
if (actionText != null) {
action = new JFXButton();
action.setText(actionText);
action.setOnAction(actionHandler);
action.setMinWidth(Control.USE_PREF_SIZE);
action.setButtonType(JFXButton.ButtonType.FLAT);
action.getStyleClass().add("jfx-snackbar-action");
// actions will be added upon showing the snackbar if needed
actionContainer.getChildren().add(action);
if (!actionText.isEmpty()) {
actionContainer.setPadding(new Insets(0, 10, 0, 0));
action.setVisible(true);
actionContainer.setVisible(true);
actionContainer.setManaged(true);
// to force updating the layout bounds
action.setText("");
action.setText(actionText);
action.setOnAction(actionHandler);
} else {
actionContainer.setVisible(false);
actionContainer.setManaged(false);
action.setVisible(false);
}
}
}
private static final String DEFAULT_STYLE_CLASS = "jfx-snackbar-layout";
public String getToast() {
return toast.getText();
}
public void setToast(String toast) {
this.toast.setText(toast);
}
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
}
}
@@ -0,0 +1,138 @@
/*
* 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.JFXSpinnerSkin;
import javafx.css.CssMetaData;
import javafx.css.SimpleStyleableDoubleProperty;
import javafx.css.Styleable;
import javafx.css.StyleableDoubleProperty;
import javafx.css.converter.SizeConverter;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.control.Skin;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
/// JFXSpinner is the material design implementation of a loading spinner.
///
/// @author Bashir Elias & Shadi Shaheen
/// @version 1.0
/// @since 2016-03-09
public class JFXSpinner extends ProgressIndicator {
public static final double INDETERMINATE_PROGRESS = ProgressIndicator.INDETERMINATE_PROGRESS;
public JFXSpinner() {
this(INDETERMINATE_PROGRESS);
}
public JFXSpinner(double progress) {
super(progress);
init();
}
private void init() {
getStyleClass().add(DEFAULT_STYLE_CLASS);
}
@Override
protected Skin<?> createDefaultSkin() {
return new JFXSpinnerSkin(this);
}
/***************************************************************************
* *
* Stylesheet Handling *
* *
**************************************************************************/
/// Initialize the style class to 'jfx-spinner'.
///
/// This is the selector class from which CSS can be used to style
/// this control.
private static final String DEFAULT_STYLE_CLASS = "jfx-spinner";
private static final double DEFAULT_RADIUS = 16.0;
/**
* specifies the radius of the spinner node, by default it's set to `16.0`
*/
private StyleableDoubleProperty radius;
public final StyleableDoubleProperty radiusProperty() {
if (this.radius == null) {
this.radius = new SimpleStyleableDoubleProperty(StyleableProperties.RADIUS,
JFXSpinner.this,
"radius",
DEFAULT_RADIUS);
}
return this.radius;
}
public final double getRadius() {
return radius != null ? radius.get() : DEFAULT_RADIUS;
}
public final void setRadius(final double radius) {
this.radiusProperty().set(radius);
}
public double getStartingAngle() {
return 360 - ThreadLocalRandom.current().nextDouble() * 720;
}
private static final class StyleableProperties {
private static final CssMetaData<JFXSpinner, Number> RADIUS =
new CssMetaData<>("-jfx-radius",
SizeConverter.getInstance(), DEFAULT_RADIUS) {
@Override
public boolean isSettable(JFXSpinner control) {
return control.radius == null || !control.radius.isBound();
}
@Override
public StyleableDoubleProperty getStyleableProperty(JFXSpinner control) {
return control.radiusProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables =
new ArrayList<>(ProgressIndicator.getClassCssMetaData());
Collections.addAll(styleables, RADIUS);
CHILD_STYLEABLES = List.copyOf(styleables);
}
}
@Override
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
return getClassCssMetaData();
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.CHILD_STYLEABLES;
}
}
@@ -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);
}
}
@@ -0,0 +1,297 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import com.jfoenix.skins.JFXTextAreaSkin;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.css.*;
import javafx.css.converter.BooleanConverter;
import javafx.css.converter.PaintConverter;
import javafx.scene.control.Control;
import javafx.scene.control.Skin;
import javafx.scene.control.TextArea;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu;
/**
* JFXTextArea is the material design implementation of a text area.
*
* @author Shadi Shaheen
* @version 1.0
* @since 2016-03-09
*/
public class JFXTextArea extends TextArea {
/**
* Initialize the style class to 'jfx-text-field'.
* <p>
* This is the selector class from which CSS can be used to style
* this control.
*/
private static final String DEFAULT_STYLE_CLASS = "jfx-text-area";
/**
* {@inheritDoc}
*/
public JFXTextArea() {
initialize();
}
/**
* {@inheritDoc}
*/
public JFXTextArea(String text) {
super(text);
initialize();
}
/**
* {@inheritDoc}
*/
@Override
protected Skin<?> createDefaultSkin() {
return new JFXTextAreaSkin(this);
}
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
if ("dalvik".equalsIgnoreCase(System.getProperty("java.vm.name"))) {
this.setStyle("-fx-skin: \"com.jfoenix.android.skins.JFXTextAreaSkinAndroid\";");
}
useJFXContextMenu(this);
}
/***************************************************************************
* *
* Properties *
* *
**************************************************************************/
/**
* holds the current active validator on the text area in case of validation error
*/
private ReadOnlyObjectWrapper<ValidatorBase> activeValidator = new ReadOnlyObjectWrapper<>();
public ValidatorBase getActiveValidator() {
return activeValidator == null ? null : activeValidator.get();
}
public ReadOnlyObjectProperty<ValidatorBase> activeValidatorProperty() {
return this.activeValidator.getReadOnlyProperty();
}
/**
* list of validators that will validate the text value upon calling
* {{@link #validate()}
*/
private ObservableList<ValidatorBase> validators = FXCollections.observableArrayList();
public ObservableList<ValidatorBase> getValidators() {
return validators;
}
public void setValidators(ValidatorBase... validators) {
this.validators.addAll(validators);
}
/**
* validates the text value using the list of validators provided by the user
* {{@link #setValidators(ValidatorBase...)}
*
* @return true if the value is valid else false
*/
public boolean validate() {
for (ValidatorBase validator : validators) {
if (validator.getSrcControl() == null) {
validator.setSrcControl(this);
}
validator.validate();
if (validator.getHasErrors()) {
activeValidator.set(validator);
return false;
}
}
activeValidator.set(null);
return true;
}
public void resetValidation() {
pseudoClassStateChanged(ValidatorBase.PSEUDO_CLASS_ERROR, false);
activeValidator.set(null);
}
/***************************************************************************
* *
* styleable Properties *
* *
**************************************************************************/
/**
* set true to show a float the prompt text when focusing the field
*/
private StyleableBooleanProperty labelFloat = new SimpleStyleableBooleanProperty(StyleableProperties.LABEL_FLOAT, JFXTextArea.this, "lableFloat", false);
public final StyleableBooleanProperty labelFloatProperty() {
return this.labelFloat;
}
public final boolean isLabelFloat() {
return this.labelFloatProperty().get();
}
public final void setLabelFloat(final boolean labelFloat) {
this.labelFloatProperty().set(labelFloat);
}
/**
* default color used when the text area is unfocused
*/
private StyleableObjectProperty<Paint> unFocusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNFOCUS_COLOR, JFXTextArea.this, "unFocusColor", Color.rgb(77, 77, 77));
public Paint getUnFocusColor() {
return unFocusColor == null ? Color.rgb(77, 77, 77) : unFocusColor.get();
}
public StyleableObjectProperty<Paint> unFocusColorProperty() {
return this.unFocusColor;
}
public void setUnFocusColor(Paint color) {
this.unFocusColor.set(color);
}
/**
* default color used when the text area is focused
*/
private StyleableObjectProperty<Paint> focusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.FOCUS_COLOR, JFXTextArea.this, "focusColor", Color.valueOf("#4059A9"));
public Paint getFocusColor() {
return focusColor == null ? Color.valueOf("#4059A9") : focusColor.get();
}
public StyleableObjectProperty<Paint> focusColorProperty() {
return this.focusColor;
}
public void setFocusColor(Paint color) {
this.focusColor.set(color);
}
/**
* disable animation on validation
*/
private StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION, JFXTextArea.this, "disableAnimation", false);
public final StyleableBooleanProperty disableAnimationProperty() {
return this.disableAnimation;
}
public final Boolean isDisableAnimation() {
return disableAnimation != null && this.disableAnimationProperty().get();
}
public final void setDisableAnimation(final Boolean disabled) {
this.disableAnimationProperty().set(disabled);
}
private final static class StyleableProperties {
private static final CssMetaData<JFXTextArea, Paint> UNFOCUS_COLOR = new CssMetaData<JFXTextArea, Paint>("-jfx-unfocus-color", PaintConverter.getInstance(), Color.rgb(77, 77, 77)) {
@Override
public boolean isSettable(JFXTextArea control) {
return control.unFocusColor == null || !control.unFocusColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXTextArea control) {
return control.unFocusColorProperty();
}
};
private static final CssMetaData<JFXTextArea, Paint> FOCUS_COLOR = new CssMetaData<JFXTextArea, Paint>("-jfx-focus-color", PaintConverter.getInstance(), Color.valueOf("#4059A9")) {
@Override
public boolean isSettable(JFXTextArea control) {
return control.focusColor == null || !control.focusColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXTextArea control) {
return control.focusColorProperty();
}
};
private static final CssMetaData<JFXTextArea, Boolean> LABEL_FLOAT = new CssMetaData<JFXTextArea, Boolean>("-jfx-label-float", BooleanConverter.getInstance(), false) {
@Override
public boolean isSettable(JFXTextArea control) {
return control.labelFloat == null || !control.labelFloat.isBound();
}
@Override
public StyleableBooleanProperty getStyleableProperty(JFXTextArea control) {
return control.labelFloatProperty();
}
};
private static final CssMetaData<JFXTextArea, Boolean> DISABLE_ANIMATION = new CssMetaData<JFXTextArea, Boolean>("-jfx-disable-animation", BooleanConverter.getInstance(), false) {
@Override
public boolean isSettable(JFXTextArea control) {
return control.disableAnimation == null || !control.disableAnimation.isBound();
}
@Override
public StyleableBooleanProperty getStyleableProperty(JFXTextArea control) {
return control.disableAnimationProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
Collections.addAll(styleables, UNFOCUS_COLOR, FOCUS_COLOR, LABEL_FLOAT, DISABLE_ANIMATION);
CHILD_STYLEABLES = Collections.unmodifiableList(styleables);
}
}
// inherit the styleable properties from parent
private List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
@Override
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
if (STYLEABLES == null) {
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
styleables.addAll(getClassCssMetaData());
styleables.addAll(TextArea.getClassCssMetaData());
STYLEABLES = Collections.unmodifiableList(styleables);
}
return STYLEABLES;
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.CHILD_STYLEABLES;
}
}
@@ -0,0 +1,307 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import com.jfoenix.skins.JFXTextFieldSkin;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.css.*;
import javafx.css.converter.BooleanConverter;
import javafx.css.converter.PaintConverter;
import javafx.scene.control.Control;
import javafx.scene.control.Skin;
import javafx.scene.control.TextField;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu;
/**
* JFXTextField is the material design implementation of a text Field.
*
* @author Shadi Shaheen
* @version 1.0
* @since 2016-03-09
*/
public class JFXTextField extends TextField {
/**
* Initialize the style class to 'jfx-text-field'.
* <p>
* This is the selector class from which CSS can be used to style
* this control.
*/
private static final String DEFAULT_STYLE_CLASS = "jfx-text-field";
/**
* {@inheritDoc}
*/
public JFXTextField() {
initialize();
}
/**
* {@inheritDoc}
*/
public JFXTextField(String text) {
super(text);
initialize();
}
/**
* {@inheritDoc}
*/
@Override
protected Skin<?> createDefaultSkin() {
return new JFXTextFieldSkin(this);
}
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
if ("dalvik".equalsIgnoreCase(System.getProperty("java.vm.name"))) {
this.setStyle("-fx-skin: \"com.jfoenix.android.skins.JFXTextFieldSkinAndroid\";");
}
useJFXContextMenu(this);
}
// https://github.com/HMCL-dev/HMCL/issues/5822
// TODO: This method may no longer be needed after we update JFXTextFieldSkin
@Override
protected void layoutChildren() {
super.layoutChildren();
this.setNeedsLayout(false);
}
/***************************************************************************
* *
* Properties *
* *
**************************************************************************/
/**
* holds the current active validator on the text field in case of validation error
*/
private ReadOnlyObjectWrapper<ValidatorBase> activeValidator = new ReadOnlyObjectWrapper<>();
public ValidatorBase getActiveValidator() {
return activeValidator == null ? null : activeValidator.get();
}
public ReadOnlyObjectProperty<ValidatorBase> activeValidatorProperty() {
return this.activeValidator.getReadOnlyProperty();
}
/**
* list of validators that will validate the text value upon calling
* {{@link #validate()}
*/
private ObservableList<ValidatorBase> validators = FXCollections.observableArrayList();
public ObservableList<ValidatorBase> getValidators() {
return validators;
}
public void setValidators(ValidatorBase... validators) {
this.validators.addAll(validators);
}
/**
* validates the text value using the list of validators provided by the user
* {{@link #setValidators(ValidatorBase...)}
*
* @return true if the value is valid else false
*/
public boolean validate() {
for (ValidatorBase validator : validators) {
if (validator.getSrcControl() == null) {
validator.setSrcControl(this);
}
validator.validate();
if (validator.getHasErrors()) {
activeValidator.set(validator);
return false;
}
}
activeValidator.set(null);
return true;
}
public void resetValidation() {
pseudoClassStateChanged(ValidatorBase.PSEUDO_CLASS_ERROR, false);
activeValidator.set(null);
}
/***************************************************************************
* *
* styleable Properties *
* *
**************************************************************************/
/**
* set true to show a float the prompt text when focusing the field
*/
private StyleableBooleanProperty labelFloat = new SimpleStyleableBooleanProperty(StyleableProperties.LABEL_FLOAT, JFXTextField.this, "lableFloat", false);
public final StyleableBooleanProperty labelFloatProperty() {
return this.labelFloat;
}
public final boolean isLabelFloat() {
return this.labelFloatProperty().get();
}
public final void setLabelFloat(final boolean labelFloat) {
this.labelFloatProperty().set(labelFloat);
}
/**
* default color used when the field is unfocused
*/
private StyleableObjectProperty<Paint> unFocusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNFOCUS_COLOR, JFXTextField.this, "unFocusColor", Color.rgb(77, 77, 77));
public Paint getUnFocusColor() {
return unFocusColor == null ? Color.rgb(77, 77, 77) : unFocusColor.get();
}
public StyleableObjectProperty<Paint> unFocusColorProperty() {
return this.unFocusColor;
}
public void setUnFocusColor(Paint color) {
this.unFocusColor.set(color);
}
/**
* default color used when the field is focused
*/
private StyleableObjectProperty<Paint> focusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.FOCUS_COLOR, JFXTextField.this, "focusColor", Color.valueOf("#4059A9"));
public Paint getFocusColor() {
return focusColor == null ? Color.valueOf("#4059A9") : focusColor.get();
}
public StyleableObjectProperty<Paint> focusColorProperty() {
return this.focusColor;
}
public void setFocusColor(Paint color) {
this.focusColor.set(color);
}
/**
* disable animation on validation
*/
private StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION, JFXTextField.this, "disableAnimation", false);
public final StyleableBooleanProperty disableAnimationProperty() {
return this.disableAnimation;
}
public final Boolean isDisableAnimation() {
return disableAnimation != null && this.disableAnimationProperty().get();
}
public final void setDisableAnimation(final Boolean disabled) {
this.disableAnimationProperty().set(disabled);
}
private final static class StyleableProperties {
private static final CssMetaData<JFXTextField, Paint> UNFOCUS_COLOR = new CssMetaData<JFXTextField, Paint>("-jfx-unfocus-color", PaintConverter.getInstance(), Color.valueOf("#A6A6A6")) {
@Override
public boolean isSettable(JFXTextField control) {
return control.unFocusColor == null || !control.unFocusColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXTextField control) {
return control.unFocusColorProperty();
}
};
private static final CssMetaData<JFXTextField, Paint> FOCUS_COLOR = new CssMetaData<JFXTextField, Paint>("-jfx-focus-color", PaintConverter.getInstance(), Color.valueOf("#3f51b5")) {
@Override
public boolean isSettable(JFXTextField control) {
return control.focusColor == null || !control.focusColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXTextField control) {
return control.focusColorProperty();
}
};
private static final CssMetaData<JFXTextField, Boolean> LABEL_FLOAT = new CssMetaData<JFXTextField, Boolean>("-jfx-label-float", BooleanConverter.getInstance(), false) {
@Override
public boolean isSettable(JFXTextField control) {
return control.labelFloat == null || !control.labelFloat.isBound();
}
@Override
public StyleableBooleanProperty getStyleableProperty(JFXTextField control) {
return control.labelFloatProperty();
}
};
private static final CssMetaData<JFXTextField, Boolean> DISABLE_ANIMATION = new CssMetaData<JFXTextField, Boolean>("-jfx-disable-animation", BooleanConverter.getInstance(), false) {
@Override
public boolean isSettable(JFXTextField control) {
return control.disableAnimation == null || !control.disableAnimation.isBound();
}
@Override
public StyleableBooleanProperty getStyleableProperty(JFXTextField control) {
return control.disableAnimationProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
Collections.addAll(styleables, UNFOCUS_COLOR, FOCUS_COLOR, LABEL_FLOAT, DISABLE_ANIMATION);
CHILD_STYLEABLES = Collections.unmodifiableList(styleables);
}
}
// inherit the styleable properties from parent
private List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
@Override
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
if (STYLEABLES == null) {
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
styleables.addAll(getClassCssMetaData());
styleables.addAll(TextField.getClassCssMetaData());
STYLEABLES = Collections.unmodifiableList(styleables);
}
return STYLEABLES;
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.CHILD_STYLEABLES;
}
}
@@ -0,0 +1,153 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import com.jfoenix.utils.JFXNodeUtils;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.TreeCell;
import javafx.scene.control.TreeItem;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import org.jetbrains.annotations.Nullable;
import java.lang.ref.WeakReference;
/// JFXTreeCell is simple material design implementation of a tree cell.
///
/// @author Shadi Shaheen
/// @version 1.0
/// @since 2017-02-15
public class JFXTreeCell<T> extends TreeCell<T> {
protected JFXRippler cellRippler = new JFXRippler(this) {
@Override
protected Node getMask() {
Region clip = new Region();
JFXNodeUtils.updateBackground(JFXTreeCell.this.getBackground(), clip);
double width = control.getLayoutBounds().getWidth();
double height = control.getLayoutBounds().getHeight();
clip.resize(width, height);
return clip;
}
@Override
protected void positionControl(Node control) {
// do nothing
}
};
private HBox hbox;
private final StackPane selectedPane = new StackPane();
private final InvalidationListener treeItemGraphicInvalidationListener = observable -> updateDisplay(getItem(),
isEmpty());
private final WeakInvalidationListener weakTreeItemGraphicListener = new WeakInvalidationListener(
treeItemGraphicInvalidationListener);
private WeakReference<TreeItem<T>> treeItemRef;
public JFXTreeCell() {
selectedPane.getStyleClass().add("selection-bar");
selectedPane.setBackground(new Background(new BackgroundFill(Color.RED, CornerRadii.EMPTY, Insets.EMPTY)));
selectedPane.setPrefWidth(3);
selectedPane.setMouseTransparent(true);
selectedProperty().addListener((o, oldVal, newVal) -> selectedPane.setVisible(newVal ? true : false));
final InvalidationListener treeItemInvalidationListener = observable -> {
TreeItem<T> oldTreeItem = treeItemRef == null ? null : treeItemRef.get();
if (oldTreeItem != null) {
oldTreeItem.graphicProperty().removeListener(weakTreeItemGraphicListener);
}
TreeItem<T> newTreeItem = getTreeItem();
if (newTreeItem != null) {
newTreeItem.graphicProperty().addListener(weakTreeItemGraphicListener);
treeItemRef = new WeakReference<>(newTreeItem);
}
};
treeItemProperty().addListener(treeItemInvalidationListener);
if (getTreeItem() != null) {
getTreeItem().graphicProperty().addListener(weakTreeItemGraphicListener);
}
}
@Override
protected void layoutChildren() {
super.layoutChildren();
if (!getChildren().contains(selectedPane)) {
getChildren().add(0, cellRippler);
cellRippler.rippler.clear();
getChildren().add(0, selectedPane);
}
cellRippler.resizeRelocate(0, 0, getWidth(), getHeight());
cellRippler.releaseRipple();
selectedPane.resizeRelocate(0, 0, selectedPane.prefWidth(-1), getHeight());
selectedPane.setVisible(isSelected());
}
/// Updates the text and graphic for the current value and tree item.
///
/// @implSpec Called when the cell value or the current tree item's graphic changes.
/// Subclasses may override this method to decorate the default display.
///
/// @param item the cell value, or `null` to clear the display
/// @param empty whether the cell has no item
protected void updateDisplay(@Nullable T item, boolean empty) {
if (item == null || empty) {
hbox = null;
setText(null);
setGraphic(null);
} else {
@Nullable TreeItem<T> treeItem = getTreeItem();
if (treeItem != null && treeItem.getGraphic() != null) {
if (item instanceof Node) {
setText(null);
if (hbox == null) {
hbox = new HBox(3);
}
hbox.getChildren().setAll(treeItem.getGraphic(), (Node) item);
setGraphic(hbox);
} else {
hbox = null;
setText(item.toString());
setGraphic(treeItem.getGraphic());
}
} else {
hbox = null;
if (item instanceof Node) {
setText(null);
setGraphic((Node) item);
} else {
setText(item.toString());
setGraphic(null);
}
}
}
}
@Override
protected void updateItem(T item, boolean empty) {
super.updateItem(item, empty);
updateDisplay(item, empty);
setMouseTransparent(item == null || empty);
}
}
@@ -0,0 +1,48 @@
/*
* 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 javafx.scene.control.TreeItem;
import javafx.scene.control.TreeView;
/// JFXTreeView is the material design implementation of a TreeView
/// with expand/collapse animation and selection indicator.
///
/// @author Shadi Shaheen
/// @version 1.0
/// @since 2017-02-15
public class JFXTreeView<T> extends TreeView<T> {
private static final String DEFAULT_STYLE_CLASS = "jfx-tree-view";
public JFXTreeView() {
init();
}
public JFXTreeView(TreeItem<T> root) {
super(root);
init();
}
private void init() {
this.setCellFactory((view) -> new JFXTreeCell<>());
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
}
}
@@ -0,0 +1,46 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls.behavior;
import com.sun.javafx.scene.control.behavior.ComboBoxBaseBehavior;
import javafx.scene.control.ComboBoxBase;
import javafx.scene.control.PopupControl;
/**
* @author Shadi Shaheen
* @version 2.0
* @since 2017-10-05
*/
public class JFXGenericPickerBehavior<T> extends ComboBoxBaseBehavior<T> {
public JFXGenericPickerBehavior(ComboBoxBase<T> var1) {
super(var1);
}
public void onAutoHide(PopupControl var1) {
if (!var1.isShowing() && this.getNode().isShowing()) {
this.getNode().hide();
}
if (!this.getNode().isShowing()) {
super.onAutoHide(var1);
}
}
}
@@ -0,0 +1,80 @@
/*
* 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.datamodels.treetable;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.control.TreeTableColumn;
/// data model that is used in JFXTreeTableView, it's used to implement
/// the grouping feature.
///
/// **Note:** the data object used in JFXTreeTableView **must** extends this class
///
/// @param <T> is the concrete object of the Tree table
/// @author Shadi Shaheen
/// @version 1.0
/// @since 2016-03-09
public class RecursiveTreeObject<T> {
/// grouped children objects
private ObservableList<T> children = FXCollections.observableArrayList();
public ObservableList<T> getChildren() {
return children;
}
public void setChildren(ObservableList<T> children) {
this.children = children;
}
/// Whether or not the object is grouped by a specified tree table column
ObjectProperty<TreeTableColumn<T, ?>> groupedColumn = new SimpleObjectProperty<>();
public final ObjectProperty<TreeTableColumn<T, ?>> groupedColumnProperty() {
return this.groupedColumn;
}
public final TreeTableColumn<T, ?> getGroupedColumn() {
return this.groupedColumnProperty().get();
}
public final void setGroupedColumn(final TreeTableColumn<T, ?> groupedColumn) {
this.groupedColumnProperty().set(groupedColumn);
}
/// the value that must be shown when grouped
ObjectProperty<Object> groupedValue = new SimpleObjectProperty<>();
public final ObjectProperty<Object> groupedValueProperty() {
return this.groupedValue;
}
public final Object getGroupedValue() {
return this.groupedValueProperty().get();
}
public final void setGroupedValue(final Object groupedValue) {
this.groupedValueProperty().set(groupedValue);
}
}
@@ -0,0 +1,55 @@
/*
* 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.events;
import javafx.event.Event;
import javafx.event.EventType;
import java.io.Serial;
/// JFXDialog events, used exclusively by the following methods:
///
/// - [com.jfoenix.controls.JFXDialog#show()]
/// - [com.jfoenix.controls.JFXDialog#close()]
///
/// @author Shadi Shaheen
/// @version 1.0
/// @since 2016-03-09
public class JFXDialogEvent extends Event {
@Serial
private static final long serialVersionUID = 1L;
/// This event occurs when a JFXDialog is closed, no longer visible to the user
/// ( after the exit animation ends )
public static final EventType<JFXDialogEvent> CLOSED = new EventType<>(Event.ANY, "JFX_DIALOG_CLOSED");
/// This event occurs when a JFXDialog is opened, visible to the user
/// ( after the entrance animation ends )
public static final EventType<JFXDialogEvent> OPENED = new EventType<>(Event.ANY, "JFX_DIALOG_OPENED");
/// Construct a new JFXDialog `Event` with the specified event type
///
/// @param eventType the event type
public JFXDialogEvent(EventType<? extends Event> eventType) {
super(eventType);
}
}
@@ -0,0 +1,122 @@
/*
* Copyright (c) 2016 JFoenix
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.jfoenix.effects;
import javafx.scene.Node;
import javafx.scene.effect.BlurType;
import javafx.scene.effect.DropShadow;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
/**
* it will create a shadow effect for a given node and a specified depth level.
* depth levels are {0,1,2,3,4,5}
*
* @author Shadi Shaheen
* @version 1.0
* @since 2016-03-09
*/
public final class JFXDepthManager {
private JFXDepthManager() {
throw new AssertionError();
}
private static final DropShadow[] depth = new DropShadow[] {
new DropShadow(BlurType.GAUSSIAN, Color.TRANSPARENT, 0, 0, 0, 0),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.12), 4, 0, 0, 0),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.16), 8, 0, 0, 4),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.20), 12, 0, 0, 4),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.24), 16, 0, 0, 4),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.28), 20, 0, 0, 8)
};
/**
* this method is used to add shadow effect to the node,
* however the shadow is not real ( gets affected with node transformations)
* <p>
* use {@link #createMaterialNode(Node, int)} instead to generate a real shadow
*/
public static void setDepth(Node control, int level) {
level = level < 0 ? 0 : level;
level = level > 5 ? 5 : level;
control.setEffect(new DropShadow(BlurType.GAUSSIAN,
depth[level].getColor(),
depth[level].getRadius(),
depth[level].getSpread(),
depth[level].getOffsetX(),
depth[level].getOffsetY()));
}
public static int getLevels() {
return depth.length;
}
public static DropShadow getShadowAt(int level) {
return depth[level];
}
/**
* this method will generate a new container node that prevent
* control transformation to be applied to the shadow effect
* (which makes it looks as a real shadow)
*/
public static Node createMaterialNode(Node control, int level) {
Node container = new Pane(control) {
@Override
protected double computeMaxWidth(double height) {
return computePrefWidth(height);
}
@Override
protected double computeMaxHeight(double width) {
return computePrefHeight(width);
}
@Override
protected double computePrefWidth(double height) {
return control.prefWidth(height);
}
@Override
protected double computePrefHeight(double width) {
return control.prefHeight(width);
}
};
container.getStyleClass().add("depth-container");
container.setPickOnBounds(false);
level = level < 0 ? 0 : level;
level = level > 5 ? 5 : level;
container.setEffect(new DropShadow(BlurType.GAUSSIAN,
depth[level].getColor(),
depth[level].getRadius(),
depth[level].getSpread(),
depth[level].getOffsetX(),
depth[level].getOffsetY()));
return container;
}
public static void pop(Node control) {
control.setEffect(new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.26), 5, 0.05, 0, 1));
}
}
@@ -0,0 +1,222 @@
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package com.jfoenix.skins;
import com.jfoenix.adapters.skins.ButtonSkin;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXRippler;
import com.jfoenix.effects.JFXDepthManager;
import com.jfoenix.transitions.CachedTransition;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.animation.Transition;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.effect.DropShadow;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Shape;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.FXUtils;
public class JFXButtonSkin extends ButtonSkin {
private final StackPane buttonContainer = new StackPane();
private final JFXRippler buttonRippler = new JFXRippler(new StackPane()) {
protected Node getMask() {
StackPane mask = new StackPane();
mask.shapeProperty().bind(JFXButtonSkin.this.buttonContainer.shapeProperty());
mask.backgroundProperty().bind(Bindings.createObjectBinding(() -> new Background(
new BackgroundFill(Color.WHITE,
JFXButtonSkin.this.buttonContainer.backgroundProperty().get() != null && !JFXButtonSkin.this.buttonContainer.getBackground().getFills().isEmpty()
? JFXButtonSkin.this.buttonContainer.getBackground().getFills().get(0).getRadii()
: JFXButtonSkin.this.defaultRadii, JFXButtonSkin.this.buttonContainer.backgroundProperty().get() != null && !JFXButtonSkin.this.buttonContainer.getBackground().getFills().isEmpty() ? JFXButtonSkin.this.buttonContainer.getBackground().getFills().get(0).getInsets() : Insets.EMPTY)),
JFXButtonSkin.this.buttonContainer.backgroundProperty()));
mask.resize(JFXButtonSkin.this.buttonContainer.getWidth() - JFXButtonSkin.this.buttonContainer.snappedRightInset() - JFXButtonSkin.this.buttonContainer.snappedLeftInset(), JFXButtonSkin.this.buttonContainer.getHeight() - JFXButtonSkin.this.buttonContainer.snappedBottomInset() - JFXButtonSkin.this.buttonContainer.snappedTopInset());
return mask;
}
private void initListeners() {
this.ripplerPane.setOnMousePressed((event) -> {
if (JFXButtonSkin.this.releaseManualRippler != null) {
JFXButtonSkin.this.releaseManualRippler.run();
}
JFXButtonSkin.this.releaseManualRippler = null;
this.createRipple(event.getX(), event.getY());
});
}
};
private Transition clickedAnimation;
private final CornerRadii defaultRadii = new CornerRadii(3.0);
private boolean invalid = true;
private Runnable releaseManualRippler = null;
public JFXButtonSkin(JFXButton button) {
super(button);
this.getSkinnable().armedProperty().addListener((o, oldVal, newVal) -> {
if (newVal) {
this.releaseManualRippler = this.buttonRippler.createManualRipple();
if (this.clickedAnimation != null) {
this.clickedAnimation.setRate(1.0);
this.clickedAnimation.play();
}
} else {
if (this.releaseManualRippler != null) {
this.releaseManualRippler.run();
}
if (this.clickedAnimation != null) {
this.clickedAnimation.setRate(-1.0);
this.clickedAnimation.play();
}
}
});
this.buttonContainer.getChildren().add(this.buttonRippler);
button.buttonTypeProperty().addListener((o, oldVal, newVal) -> this.updateButtonType(newVal));
button.setOnMousePressed((e) -> {
if (this.clickedAnimation != null) {
this.clickedAnimation.setRate(1.0F);
this.clickedAnimation.play();
}
});
button.setOnMouseReleased((e) -> {
if (this.clickedAnimation != null) {
this.clickedAnimation.setRate(-1.0F);
this.clickedAnimation.play();
}
});
ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(button);
if (focusVisibleProperty == null) {
focusVisibleProperty = button.focusedProperty();
}
focusVisibleProperty.addListener((o, oldVal, newVal) -> {
if (newVal) {
if (!this.getSkinnable().isPressed()) {
this.buttonRippler.showOverlay();
}
} else {
this.buttonRippler.hideOverlay();
}
});
button.pressedProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.hideOverlay());
button.setPickOnBounds(false);
this.buttonContainer.setPickOnBounds(false);
this.buttonContainer.shapeProperty().bind(this.getSkinnable().shapeProperty());
this.buttonContainer.borderProperty().bind(this.getSkinnable().borderProperty());
this.buttonContainer.backgroundProperty().bind(Bindings.createObjectBinding(() -> {
if (button.getBackground() == null || this.isJavaDefaultBackground(button.getBackground()) || this.isJavaDefaultClickedBackground(button.getBackground())) {
button.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, this.defaultRadii, null)));
}
try {
return new Background(new BackgroundFill(this.getSkinnable().getBackground() != null ? this.getSkinnable().getBackground().getFills().get(0).getFill() : Color.TRANSPARENT, this.getSkinnable().getBackground() != null ? this.getSkinnable().getBackground().getFills().get(0).getRadii() : this.defaultRadii, Insets.EMPTY));
} catch (Exception var3) {
return this.getSkinnable().getBackground();
}
}, this.getSkinnable().backgroundProperty()));
button.ripplerFillProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.setRipplerFill(newVal));
if (button.getBackground() == null || this.isJavaDefaultBackground(button.getBackground())) {
button.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, this.defaultRadii, null)));
}
this.updateButtonType(button.getButtonType());
this.updateChildren();
}
protected void updateChildren() {
super.updateChildren();
if (this.buttonContainer != null) {
this.getChildren().add(0, this.buttonContainer);
}
for (int i = 1; i < this.getChildren().size(); ++i) {
this.getChildren().get(i).setMouseTransparent(true);
}
}
protected void layoutChildren(double x, double y, double w, double h) {
if (this.invalid) {
if (((JFXButton) this.getSkinnable()).getRipplerFill() == null) {
for (int i = this.getChildren().size() - 1; i >= 1; --i) {
if (this.getChildren().get(i) instanceof Shape shape) {
this.buttonRippler.setRipplerFill(shape.getFill());
shape.fillProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.setRipplerFill(newVal));
break;
}
if (this.getChildren().get(i) instanceof Label label) {
this.buttonRippler.setRipplerFill(label.getTextFill());
label.textFillProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.setRipplerFill(newVal));
break;
}
}
} else {
this.buttonRippler.setRipplerFill(((JFXButton) this.getSkinnable()).getRipplerFill());
}
this.invalid = false;
}
double shift = 1.0F;
this.buttonContainer.resizeRelocate(this.getSkinnable().getLayoutBounds().getMinX() - shift, this.getSkinnable().getLayoutBounds().getMinY() - shift, this.getSkinnable().getWidth() + (double) 2.0F * shift, this.getSkinnable().getHeight() + (double) 2.0F * shift);
this.layoutLabelInArea(x, y, w, h);
}
private boolean isJavaDefaultBackground(Background background) {
try {
String firstFill = background.getFills().get(0).getFill().toString();
return "0xffffffba".equals(firstFill) || "0xffffffbf".equals(firstFill) || "0xffffff12".equals(firstFill) || "0xffffffbd".equals(firstFill);
} catch (Exception var3) {
return false;
}
}
private boolean isJavaDefaultClickedBackground(Background background) {
try {
return "0x039ed3ff".equals(background.getFills().get(0).getFill().toString());
} catch (Exception var3) {
return false;
}
}
private void updateButtonType(JFXButton.ButtonType type) {
switch (type) {
case RAISED -> {
JFXDepthManager.setDepth(this.buttonContainer, 2);
this.clickedAnimation = new ButtonClickTransition();
}
case FLAT -> this.buttonContainer.setEffect(null);
}
}
private class ButtonClickTransition extends CachedTransition {
public ButtonClickTransition() {
super(JFXButtonSkin.this.buttonContainer, new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).radiusProperty(), JFXDepthManager.getShadowAt(2).radiusProperty().get(), Interpolator.EASE_BOTH),
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).spreadProperty(), JFXDepthManager.getShadowAt(2).spreadProperty().get(), Interpolator.EASE_BOTH),
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetXProperty(), JFXDepthManager.getShadowAt(2).offsetXProperty().get(), Interpolator.EASE_BOTH),
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetYProperty(), JFXDepthManager.getShadowAt(2).offsetYProperty().get(), Interpolator.EASE_BOTH)),
new KeyFrame(Duration.millis(1000.0F),
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).radiusProperty(), JFXDepthManager.getShadowAt(5).radiusProperty().get(), Interpolator.EASE_BOTH),
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).spreadProperty(), JFXDepthManager.getShadowAt(5).spreadProperty().get(), Interpolator.EASE_BOTH),
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetXProperty(), JFXDepthManager.getShadowAt(5).offsetXProperty().get(), Interpolator.EASE_BOTH),
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetYProperty(), JFXDepthManager.getShadowAt(5).offsetYProperty().get(), Interpolator.EASE_BOTH))));
this.setCycleDuration(Duration.seconds(0.2));
this.setDelay(Duration.seconds(0.0F));
}
}
}
@@ -0,0 +1,302 @@
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package com.jfoenix.skins;
import com.jfoenix.controls.JFXCheckBox;
import com.jfoenix.controls.JFXRippler;
import com.jfoenix.controls.JFXRippler.RipplerMask;
import com.jfoenix.controls.JFXRippler.RipplerPos;
import com.jfoenix.transitions.CachedTransition;
import com.jfoenix.transitions.JFXFillTransition;
import com.jfoenix.utils.JFXNodeUtils;
import javafx.animation.*;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.VPos;
import javafx.scene.control.CheckBox;
import javafx.scene.control.skin.CheckBoxSkin;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.shape.SVGPath;
import javafx.util.Duration;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
public class JFXCheckBoxSkin extends CheckBoxSkin {
private final StackPane box = new StackPane();
private final StackPane mark = new StackPane();
private final SVGPath markShape = new SVGPath();
private final double lineThick = 2.0;
private final double padding = 10.0;
private final JFXRippler rippler;
private final AnchorPane container = new AnchorPane();
private final double labelOffset = -8.0;
private final Transition transition;
private boolean invalid = true;
private boolean selected;
private boolean indeterminate;
/// The checkbox fill animation, initialized with the configured checked color.
private JFXFillTransition fillTransition;
public JFXCheckBoxSkin(JFXCheckBox control) {
super(control);
this.box.setMinSize(18.0, 18.0);
this.box.setPrefSize(18.0, 18.0);
this.box.setMaxSize(18.0, 18.0);
this.box.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, new CornerRadii(2.0), Insets.EMPTY)));
this.box.setBorder(new Border(new BorderStroke(Themes.getColorScheme().getOnSurfaceVariant(), BorderStrokeStyle.SOLID, new CornerRadii(2.0), new BorderWidths(this.lineThick))));
StackPane boxContainer = new StackPane();
boxContainer.getChildren().add(this.box);
boxContainer.setPadding(new Insets(this.padding));
this.rippler = new JFXRippler(boxContainer, RipplerMask.CIRCLE, RipplerPos.BACK);
this.updateRippleColor(control.isSelected(), control.isIndeterminate());
this.mark.setShape(this.markShape);
this.updateMarkShape(true, false);
this.mark.setVisible(false);
this.mark.setScaleX(0.0);
this.mark.setScaleY(0.0);
boxContainer.getChildren().add(this.mark);
this.container.getChildren().add(this.rippler);
AnchorPane.setRightAnchor(this.rippler, this.labelOffset);
this.registerChangeListener(control.selectedProperty(), ignored -> requestSelectionUpdate());
this.registerChangeListener(control.indeterminateProperty(), ignored -> requestSelectionUpdate());
ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(control);
if (focusVisibleProperty == null)
focusVisibleProperty = control.focusedProperty();
focusVisibleProperty.addListener((o, oldVal, newVal) -> {
if (newVal) {
if (!this.getSkinnable().isPressed()) {
this.rippler.showOverlay();
}
} else {
this.rippler.hideOverlay();
}
});
control.pressedProperty().addListener((o, oldVal, newVal) -> this.rippler.hideOverlay());
this.updateChildren();
this.registerChangeListener(control.checkedColorProperty(), ignored -> {
if (fillTransition != null) {
fillTransition.stop();
}
boolean selected = control.isSelected();
boolean indeterminate = control.isIndeterminate();
JFXFillTransition fillTransition = this.createFillTransition();
this.fillTransition = fillTransition;
fillTransition.jumpTo(selected || indeterminate ? fillTransition.getCycleDuration() : Duration.ZERO);
updateColors(selected, indeterminate);
});
this.registerChangeListener(control.unCheckedColorProperty(), ignored -> updateColors(control.isSelected(), control.isIndeterminate()));
this.transition = new CheckBoxTransition();
this.fillTransition = this.createFillTransition();
}
private void requestSelectionUpdate() {
this.invalid = true;
this.getSkinnable().requestLayout();
}
private void updateRippleColor(boolean selected, boolean indeterminate) {
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
this.rippler.setRipplerFill(selected || indeterminate
? control.getCheckedColor()
: control.getUnCheckedColor());
}
private void updateColors(boolean selected, boolean indeterminate) {
var control = (JFXCheckBox) getSkinnable();
boolean marked = selected || indeterminate;
JFXNodeUtils.updateBackground(box.getBackground(), box, marked ? control.getCheckedColor() : Color.TRANSPARENT);
rippler.setRipplerFill(marked ? control.getCheckedColor() : control.getUnCheckedColor());
this.updateBorder(selected, indeterminate);
}
private void updateBorder(boolean selected, boolean indeterminate) {
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
final BorderStroke borderStroke = box.getBorder().getStrokes().get(0);
box.setBorder(new Border(new BorderStroke(
selected || indeterminate ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
borderStroke.getTopStyle(),
borderStroke.getRadii(),
borderStroke.getWidths())));
}
private void updateMarkShape(boolean selected, boolean indeterminate) {
if (indeterminate) {
this.markShape.setContent("M1 0 H9 A1 1 0 0 1 10 1 A1 1 0 0 1 9 2 H1 A1 1 0 0 1 0 1 A1 1 0 0 1 1 0 Z");
this.mark.setMaxSize(10.0, 2.0);
this.mark.setStyle("-fx-background-color:-monet-on-primary;");
} else if (selected) {
this.markShape.setContent("M384 690l452-452 60 60-512 512-238-238 60-60z");
this.mark.setMaxSize(15.0, 12.0);
this.mark.setStyle("-fx-background-color:-monet-on-primary; -fx-border-color:-monet-on-primary; -fx-border-width:2px;");
}
}
protected void updateChildren() {
super.updateChildren();
if (this.container != null) {
this.getChildren().remove(1);
this.getChildren().add(this.container);
}
}
protected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
return super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset) + this.snapSizeX(this.box.minWidth(-1.0)) + this.labelOffset + 2.0 * this.padding;
}
protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
return super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset) + this.snapSizeY(this.box.prefWidth(-1.0)) + this.labelOffset + 2.0 * this.padding;
}
protected void layoutChildren(double x, double y, double w, double h) {
CheckBox checkBox = this.getSkinnable();
double boxWidth = this.snapSizeX(this.container.prefWidth(-1.0));
double boxHeight = this.snapSizeY(this.container.prefHeight(-1.0));
double computeWidth = Math.min(checkBox.prefWidth(-1.0), checkBox.minWidth(-1.0)) + this.labelOffset + 2.0 * this.padding;
double labelWidth = Math.min(computeWidth - boxWidth, w - this.snapSizeX(boxWidth)) + this.labelOffset + 2.0 * this.padding;
double labelHeight = Math.min(checkBox.prefHeight(labelWidth), h);
double maxHeight = Math.max(boxHeight, labelHeight);
double xOffset = computeXOffset(w, labelWidth + boxWidth, checkBox.getAlignment().getHpos()) + x;
double yOffset = computeYOffset(h, maxHeight, checkBox.getAlignment().getVpos()) + x;
if (this.invalid) {
boolean selected = checkBox.isSelected();
boolean indeterminate = checkBox.isIndeterminate();
this.updateRippleColor(selected, indeterminate);
this.playSelectAnimation(selected, indeterminate);
this.invalid = false;
}
this.layoutLabelInArea(xOffset + boxWidth, yOffset, labelWidth, maxHeight, checkBox.getAlignment());
this.container.resize(boxWidth, boxHeight);
this.positionInArea(this.container, xOffset, yOffset, boxWidth, maxHeight, 0.0, checkBox.getAlignment().getHpos(), checkBox.getAlignment().getVpos());
}
static double computeXOffset(double width, double contentWidth, HPos hpos) {
return switch (hpos) {
case LEFT -> 0.0;
case CENTER -> (width - contentWidth) / 2.0;
case RIGHT -> width - contentWidth;
};
}
static double computeYOffset(double height, double contentHeight, VPos vpos) {
return switch (vpos) {
case TOP -> 0.0;
case CENTER -> (height - contentHeight) / 2.0;
case BOTTOM -> height - contentHeight;
default -> 0.0;
};
}
private void playSelectAnimation(boolean selected, boolean indeterminate) {
boolean previousSelected = this.selected;
boolean previousIndeterminate = this.indeterminate;
this.selected = selected;
this.indeterminate = indeterminate;
boolean previousMarked = previousSelected || previousIndeterminate;
boolean marked = selected || indeterminate;
boolean sameVisualState = previousIndeterminate == indeterminate
&& (indeterminate || previousSelected == selected);
this.updateBorder(selected, indeterminate);
if (sameVisualState) {
return;
}
this.invalid = false;
if (!AnimationUtils.isAnimationEnabled()) {
finishSelectionAnimation(selected, indeterminate);
return;
}
if (previousMarked && marked) {
this.transition.stop();
this.transition.setOnFinished(null);
this.transition.jumpTo(this.transition.getCycleDuration());
this.transition.setRate(-1.0);
this.transition.setOnFinished(event -> {
this.transition.setOnFinished(null);
this.updateMarkShape(selected, indeterminate);
this.transition.jumpTo(Duration.ZERO);
this.transition.setRate(1.0);
this.transition.play();
});
this.transition.play();
return;
}
this.transition.setOnFinished(null);
if (marked) {
this.updateMarkShape(selected, indeterminate);
}
this.transition.setRate(marked ? 1.0 : -1.0);
this.fillTransition.setRate(marked ? 1.0 : -1.0);
this.fillTransition.play();
this.transition.play();
}
/// Stops selection animations and immediately displays the checkbox's current state.
///
/// The state indicator, fill, and border are updated even if no animation is running.
/// This also suppresses pending selection updates and the initial selection animation.
/// The skin must not have been disposed.
public void finishSelectionAnimation() {
CheckBox checkBox = this.getSkinnable();
this.finishSelectionAnimation(checkBox.isSelected(), checkBox.isIndeterminate());
}
private void finishSelectionAnimation(boolean selected, boolean indeterminate) {
this.selected = selected;
this.indeterminate = indeterminate;
boolean marked = selected || indeterminate;
transition.stop();
transition.setOnFinished(null);
transition.jumpTo(marked ? transition.getCycleDuration() : Duration.ZERO);
fillTransition.stop();
fillTransition.jumpTo(marked ? fillTransition.getCycleDuration() : Duration.ZERO);
invalid = false;
updateMarkShape(selected, indeterminate);
mark.setVisible(marked);
mark.setScaleX(marked ? 1.0 : 0.0);
mark.setScaleY(marked ? 1.0 : 0.0);
updateColors(selected, indeterminate);
}
private JFXFillTransition createFillTransition() {
JFXFillTransition fillTransition = new JFXFillTransition(Duration.millis(120.0), this.box, Color.TRANSPARENT,
(Color) ((JFXCheckBox) this.getSkinnable()).getCheckedColor());
fillTransition.setInterpolator(Interpolator.EASE_OUT);
return fillTransition;
}
private final class CheckBoxTransition extends CachedTransition {
CheckBoxTransition() {
super(JFXCheckBoxSkin.this.mark,
new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(JFXCheckBoxSkin.this.mark.visibleProperty(), false, Interpolator.EASE_OUT),
new KeyValue(JFXCheckBoxSkin.this.mark.scaleXProperty(), (double) 0.5F, Interpolator.EASE_OUT),
new KeyValue(JFXCheckBoxSkin.this.mark.scaleYProperty(), (double) 0.5F, Interpolator.EASE_OUT)),
new KeyFrame(Duration.millis(400.0),
new KeyValue(JFXCheckBoxSkin.this.mark.visibleProperty(), true, Interpolator.EASE_OUT),
new KeyValue(JFXCheckBoxSkin.this.mark.scaleXProperty(), (double) 0.5F, Interpolator.EASE_OUT),
new KeyValue(JFXCheckBoxSkin.this.mark.scaleYProperty(), (double) 0.5F, Interpolator.EASE_OUT)),
new KeyFrame(Duration.millis(1000.0),
new KeyValue(JFXCheckBoxSkin.this.mark.visibleProperty(), true, Interpolator.EASE_OUT),
new KeyValue(JFXCheckBoxSkin.this.mark.scaleXProperty(), 1, Interpolator.EASE_OUT),
new KeyValue(JFXCheckBoxSkin.this.mark.scaleYProperty(), 1, Interpolator.EASE_OUT))
)
);
this.setCycleDuration(Duration.seconds(0.12));
this.setDelay(Duration.seconds(0.05));
}
}
}
@@ -0,0 +1,623 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.skins;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXColorPicker;
import com.jfoenix.utils.JFXNodeUtils;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener.Change;
import javafx.collections.ObservableList;
import javafx.event.ActionEvent;
import javafx.event.Event;
import javafx.geometry.Bounds;
import javafx.geometry.NodeOrientation;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.PopupControl;
import javafx.scene.control.Tooltip;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.StrokeType;
import java.util.List;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/**
* @author Shadi Shaheen FUTURE WORK: this UI will get re-designed to match material design guidlines
*/
final class JFXColorPalette extends Region {
private static final int SQUARE_SIZE = 15;
// package protected for testing purposes
JFXColorGrid colorPickerGrid;
final JFXButton customColorLink = new JFXButton(i18n("color.custom"));
JFXCustomColorPickerDialog customColorDialog = null;
private final JFXColorPicker colorPicker;
private final GridPane customColorGrid = new GridPane();
private final Label customColorLabel = new Label(i18n("color.recent"));
private PopupControl popupControl;
private ColorSquare focusedSquare;
private Color mouseDragColor = null;
private boolean dragDetected = false;
private final ColorSquare hoverSquare = new ColorSquare();
public JFXColorPalette(final JFXColorPicker colorPicker) {
getStyleClass().add("color-palette-region");
this.colorPicker = colorPicker;
colorPickerGrid = new JFXColorGrid();
colorPickerGrid.getChildren().get(0).requestFocus();
customColorLabel.setAlignment(Pos.CENTER_LEFT);
customColorLink.setPrefWidth(colorPickerGrid.prefWidth(-1));
customColorLink.setAlignment(Pos.CENTER);
customColorLink.setFocusTraversable(true);
customColorLink.setOnAction(ev -> {
if (customColorDialog == null) {
customColorDialog = new JFXCustomColorPickerDialog(popupControl);
customColorDialog.customColorProperty().addListener((ov, t1, t2) -> {
colorPicker.setValue(customColorDialog.customColorProperty().get());
});
customColorDialog.setOnSave(() -> {
Color customColor = customColorDialog.customColorProperty().get();
buildCustomColors();
colorPicker.getCustomColors().add(customColor);
updateSelection(customColor);
Event.fireEvent(colorPicker, new ActionEvent());
colorPicker.hide();
});
}
customColorDialog.setCurrentColor(colorPicker.valueProperty().get());
if (popupControl != null) {
popupControl.setAutoHide(false);
}
customColorDialog.show();
customColorDialog.setOnHidden(event -> {
if (popupControl != null) {
popupControl.setAutoHide(true);
}
});
});
initNavigation();
customColorGrid.getStyleClass().add("color-picker-grid");
customColorGrid.setVisible(false);
buildCustomColors();
colorPicker.getCustomColors().addListener((Change<? extends Color> change) -> buildCustomColors());
VBox paletteBox = new VBox();
paletteBox.getStyleClass().add("color-palette");
paletteBox.getChildren().addAll(colorPickerGrid);
if (colorPicker.getPreDefinedColors() == null) {
paletteBox.getChildren().addAll(customColorLabel, customColorGrid, customColorLink);
}
hoverSquare.setMouseTransparent(true);
hoverSquare.getStyleClass().addAll("hover-square");
setFocusedSquare(null);
getChildren().addAll(paletteBox, hoverSquare);
}
private void setFocusedSquare(ColorSquare square) {
hoverSquare.setVisible(square != null);
if (square == focusedSquare) {
return;
}
focusedSquare = square;
hoverSquare.setVisible(focusedSquare != null);
if (focusedSquare == null) {
return;
}
if (!focusedSquare.isFocused()) {
focusedSquare.requestFocus();
}
hoverSquare.rectangle.setFill(focusedSquare.rectangle.getFill());
Bounds b = square.localToScene(square.getLayoutBounds());
double x = b.getMinX();
double y = b.getMinY();
double xAdjust;
double scaleAdjust = hoverSquare.getScaleX() == 1.0 ? 0 : hoverSquare.getWidth() / 4.0;
if (colorPicker.getEffectiveNodeOrientation() == NodeOrientation.RIGHT_TO_LEFT) {
x = focusedSquare.getLayoutX();
xAdjust = -focusedSquare.getWidth() + scaleAdjust;
} else {
xAdjust = focusedSquare.getWidth() / 2.0 + scaleAdjust;
}
hoverSquare.setLayoutX(snapPositionX(x) - xAdjust);
hoverSquare.setLayoutY(snapPositionY(y) - focusedSquare.getHeight() / 2.0 + (hoverSquare.getScaleY() == 1.0 ? 0 : focusedSquare.getHeight() / 4.0));
}
private void buildCustomColors() {
final ObservableList<Color> customColors = colorPicker.getCustomColors();
customColorGrid.getChildren().clear();
if (customColors.isEmpty()) {
customColorLabel.setVisible(false);
customColorLabel.setManaged(false);
customColorGrid.setVisible(false);
customColorGrid.setManaged(false);
return;
} else {
customColorLabel.setVisible(true);
customColorLabel.setManaged(true);
customColorGrid.setVisible(true);
customColorGrid.setManaged(true);
}
int customColumnIndex = 0;
int customRowIndex = 0;
int remainingSquares = customColors.size() % NUM_OF_COLUMNS;
int numEmpty = (remainingSquares == 0) ? 0 : NUM_OF_COLUMNS - remainingSquares;
for (int i = 0; i < customColors.size(); i++) {
Color c = customColors.get(i);
ColorSquare square = new ColorSquare(c, i, true);
customColorGrid.add(square, customColumnIndex, customRowIndex);
customColumnIndex++;
if (customColumnIndex == NUM_OF_COLUMNS) {
customColumnIndex = 0;
customRowIndex++;
}
}
for (int i = 0; i < numEmpty; i++) {
ColorSquare emptySquare = new ColorSquare();
customColorGrid.add(emptySquare, customColumnIndex, customRowIndex);
customColumnIndex++;
}
requestLayout();
}
private void initNavigation() {
setOnKeyPressed(ke -> {
switch (ke.getCode()) {
case SPACE:
case ENTER:
// select the focused color
if (focusedSquare != null) {
focusedSquare.selectColor(ke);
}
ke.consume();
break;
default: // no-op
}
});
}
public void setPopupControl(PopupControl pc) {
this.popupControl = pc;
}
public JFXColorGrid getColorGrid() {
return colorPickerGrid;
}
public boolean isCustomColorDialogShowing() {
return customColorDialog != null && customColorDialog.isVisible();
}
class ColorSquare extends StackPane {
Rectangle rectangle;
boolean isEmpty;
public ColorSquare() {
this(null, -1, false);
}
public ColorSquare(Color color, int index) {
this(color, index, false);
}
public ColorSquare(Color color, int index, boolean isCustom) {
// Add style class to handle selected color square
getStyleClass().add("color-square");
if (color != null) {
setFocusTraversable(true);
focusedProperty().addListener((s, ov, nv) -> setFocusedSquare(nv ? this : null));
addEventHandler(MouseEvent.MOUSE_ENTERED, event -> setFocusedSquare(ColorSquare.this));
addEventHandler(MouseEvent.MOUSE_EXITED, event -> setFocusedSquare(null));
addEventHandler(MouseEvent.MOUSE_RELEASED, event -> {
if (!dragDetected && event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 1) {
if (!isEmpty) {
Color fill = (Color) rectangle.getFill();
colorPicker.setValue(fill);
colorPicker.fireEvent(new ActionEvent());
updateSelection(fill);
event.consume();
}
colorPicker.hide();
}
});
}
rectangle = new Rectangle(SQUARE_SIZE, SQUARE_SIZE);
if (color == null) {
rectangle.setFill(Color.WHITE);
isEmpty = true;
} else {
rectangle.setFill(color);
}
rectangle.setStrokeType(StrokeType.INSIDE);
String tooltipStr = JFXNodeUtils.colorToHex(color);
Tooltip.install(this, new Tooltip((tooltipStr == null) ? "" : tooltipStr));
rectangle.getStyleClass().add("color-rect");
getChildren().add(rectangle);
}
public void selectColor(KeyEvent event) {
if (rectangle.getFill() != null) {
if (rectangle.getFill() instanceof Color) {
colorPicker.setValue((Color) rectangle.getFill());
colorPicker.fireEvent(new ActionEvent());
}
event.consume();
}
colorPicker.hide();
}
}
// The skin can update selection if colorpicker value changes..
public void updateSelection(Color color) {
setFocusedSquare(null);
for (ColorSquare c : colorPickerGrid.getSquares()) {
if (c.rectangle.getFill().equals(color)) {
setFocusedSquare(c);
return;
}
}
// check custom colors
for (Node n : customColorGrid.getChildren()) {
ColorSquare c = (ColorSquare) n;
if (c.rectangle.getFill().equals(color)) {
setFocusedSquare(c);
return;
}
}
}
class JFXColorGrid extends GridPane {
private final List<ColorSquare> squares;
final int NUM_OF_COLORS;
final int NUM_OF_ROWS;
public JFXColorGrid() {
getStyleClass().add("color-picker-grid");
setId("ColorCustomizerColorGrid");
int columnIndex = 0;
int rowIndex = 0;
squares = FXCollections.observableArrayList();
double[] limitedColors = colorPicker.getPreDefinedColors();
limitedColors = limitedColors == null ? RAW_VALUES : limitedColors;
NUM_OF_COLORS = limitedColors.length / 3;
NUM_OF_ROWS = (int) Math.ceil((double) NUM_OF_COLORS / (double) NUM_OF_COLUMNS);
final int numColors = limitedColors.length / 3;
Color[] colors = new Color[numColors];
for (int i = 0; i < numColors; i++) {
colors[i] = new Color(limitedColors[i * 3] / 255,
limitedColors[(i * 3) + 1] / 255, limitedColors[(i * 3) + 2] / 255,
1.0);
ColorSquare cs = new ColorSquare(colors[i], i);
squares.add(cs);
}
for (ColorSquare square : squares) {
add(square, columnIndex, rowIndex);
columnIndex++;
if (columnIndex == NUM_OF_COLUMNS) {
columnIndex = 0;
rowIndex++;
}
}
setOnMouseDragged(t -> {
if (!dragDetected) {
dragDetected = true;
mouseDragColor = colorPicker.getValue();
}
int xIndex = clamp(0,
(int) t.getX() / (SQUARE_SIZE + 1), NUM_OF_COLUMNS - 1);
int yIndex = clamp(0,
(int) t.getY() / (SQUARE_SIZE + 1), NUM_OF_ROWS - 1);
int index = xIndex + yIndex * NUM_OF_COLUMNS;
colorPicker.setValue((Color) squares.get(index).rectangle.getFill());
updateSelection(colorPicker.getValue());
});
addEventHandler(MouseEvent.MOUSE_RELEASED, t -> {
if (colorPickerGrid.getBoundsInLocal().contains(t.getX(), t.getY())) {
updateSelection(colorPicker.getValue());
colorPicker.fireEvent(new ActionEvent());
colorPicker.hide();
} else {
// restore color as mouse release happened outside the grid.
if (mouseDragColor != null) {
colorPicker.setValue(mouseDragColor);
updateSelection(mouseDragColor);
}
}
dragDetected = false;
});
}
public List<ColorSquare> getSquares() {
return squares;
}
@Override
protected double computePrefWidth(double height) {
return (SQUARE_SIZE + 1) * NUM_OF_COLUMNS;
}
@Override
protected double computePrefHeight(double width) {
return (SQUARE_SIZE + 1) * NUM_OF_ROWS;
}
}
private static final int NUM_OF_COLUMNS = 10;
private static final double[] RAW_VALUES = {
// WARNING: always make sure the number of colors is a divisable by NUM_OF_COLUMNS
250, 250, 250, // first row
245, 245, 245,
238, 238, 238,
224, 224, 224,
189, 189, 189,
158, 158, 158,
117, 117, 117,
97, 97, 97,
66, 66, 66,
33, 33, 33,
// second row
236, 239, 241,
207, 216, 220,
176, 190, 197,
144, 164, 174,
120, 144, 156,
96, 125, 139,
84, 110, 122,
69, 90, 100,
55, 71, 79,
38, 50, 56,
// third row
255, 235, 238,
255, 205, 210,
239, 154, 154,
229, 115, 115,
239, 83, 80,
244, 67, 54,
229, 57, 53,
211, 47, 47,
198, 40, 40,
183, 28, 28,
// forth row
252, 228, 236,
248, 187, 208,
244, 143, 177,
240, 98, 146,
236, 64, 122,
233, 30, 99,
216, 27, 96,
194, 24, 91,
173, 20, 87,
136, 14, 79,
// fifth row
243, 229, 245,
225, 190, 231,
206, 147, 216,
186, 104, 200,
171, 71, 188,
156, 39, 176,
142, 36, 170,
123, 31, 162,
106, 27, 154,
74, 20, 140,
// sixth row
237, 231, 246,
209, 196, 233,
179, 157, 219,
149, 117, 205,
126, 87, 194,
103, 58, 183,
94, 53, 177,
81, 45, 168,
69, 39, 160,
49, 27, 146,
// seventh row
232, 234, 246,
197, 202, 233,
159, 168, 218,
121, 134, 203,
92, 107, 192,
63, 81, 181,
57, 73, 171,
48, 63, 159,
40, 53, 147,
26, 35, 126,
// eigth row
227, 242, 253,
187, 222, 251,
144, 202, 249,
100, 181, 246,
66, 165, 245,
33, 150, 243,
30, 136, 229,
25, 118, 210,
21, 101, 192,
13, 71, 161,
// ninth row
225, 245, 254,
179, 229, 252,
129, 212, 250,
79, 195, 247,
41, 182, 246,
3, 169, 244,
3, 155, 229,
2, 136, 209,
2, 119, 189,
1, 87, 155,
// tenth row
224, 247, 250,
178, 235, 242,
128, 222, 234,
77, 208, 225,
38, 198, 218,
0, 188, 212,
0, 172, 193,
0, 151, 167,
0, 131, 143,
0, 96, 100,
// eleventh row
224, 242, 241,
178, 223, 219,
128, 203, 196,
77, 182, 172,
38, 166, 154,
0, 150, 136,
0, 137, 123,
0, 121, 107,
0, 105, 92,
0, 77, 64,
// twelfth row
232, 245, 233,
200, 230, 201,
165, 214, 167,
129, 199, 132,
102, 187, 106,
76, 175, 80,
67, 160, 71,
56, 142, 60,
46, 125, 50,
27, 94, 32,
// thirteenth row
241, 248, 233,
220, 237, 200,
197, 225, 165,
174, 213, 129,
156, 204, 101,
139, 195, 74,
124, 179, 66,
104, 159, 56,
85, 139, 47,
51, 105, 30,
// fourteenth row
249, 251, 231,
240, 244, 195,
230, 238, 156,
220, 231, 117,
212, 225, 87,
205, 220, 57,
192, 202, 51,
175, 180, 43,
158, 157, 36,
130, 119, 23,
// fifteenth row
255, 253, 231,
255, 249, 196,
255, 245, 157,
255, 241, 118,
255, 238, 88,
255, 235, 59,
253, 216, 53,
251, 192, 45,
249, 168, 37,
245, 127, 23,
// sixteenth row
255, 248, 225,
255, 236, 179,
255, 224, 130,
255, 213, 79,
255, 202, 40,
255, 193, 7,
255, 179, 0,
255, 160, 0,
255, 143, 0,
255, 111, 0,
// seventeenth row
255, 243, 224,
255, 224, 178,
255, 204, 128,
255, 183, 77,
255, 167, 38,
255, 152, 0,
251, 140, 0,
245, 124, 0,
239, 108, 0,
230, 81, 0,
// eighteenth row
251, 233, 231,
255, 204, 188,
255, 171, 145,
255, 138, 101,
255, 112, 67,
255, 87, 34,
244, 81, 30,
230, 74, 25,
216, 67, 21,
191, 54, 12,
// nineteenth row
239, 235, 233,
215, 204, 200,
188, 170, 164,
161, 136, 127,
141, 110, 99,
121, 85, 72,
109, 76, 65,
93, 64, 55,
78, 52, 46,
62, 39, 35,
};
private static int clamp(int min, int value, int max) {
if (value < min) {
return min;
}
if (value > max) {
return max;
}
return value;
}
}
@@ -0,0 +1,258 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.skins;
import com.jfoenix.controls.JFXClippedPane;
import com.jfoenix.controls.JFXColorPicker;
import com.jfoenix.controls.JFXRippler;
import com.jfoenix.effects.JFXDepthManager;
import com.jfoenix.utils.JFXNodeUtils;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.css.*;
import javafx.css.converter.BooleanConverter;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.ColorPicker;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.control.skin.ComboBoxPopupControl;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.CornerRadii;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.util.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* @author Shadi Shaheen
*/
public final class JFXColorPickerSkin extends JFXGenericPickerSkin<Color> {
private final Label displayNode;
private final JFXClippedPane colorBox;
private JFXColorPalette popupContent;
StyleableBooleanProperty colorLabelVisible = new SimpleStyleableBooleanProperty(StyleableProperties.COLOR_LABEL_VISIBLE,
JFXColorPickerSkin.this,
"colorLabelVisible",
true);
private final ObjectProperty<Color> textFill = new SimpleObjectProperty<>(Color.BLACK);
private final ObjectProperty<Background> colorBoxBackground = new SimpleObjectProperty<>(null);
public JFXColorPickerSkin(final ColorPicker colorPicker) {
super(colorPicker);
// create displayNode
displayNode = new Label("");
displayNode.getStyleClass().add("color-label");
displayNode.setMouseTransparent(true);
displayNode.textFillProperty().bind(textFill);
// label graphic
colorBox = new JFXClippedPane(displayNode);
colorBox.getStyleClass().add("color-box");
colorBox.setManaged(false);
colorBox.backgroundProperty().bind(colorBoxBackground);
initColor();
final JFXRippler rippler = new JFXRippler(colorBox, JFXRippler.RipplerMask.FIT);
rippler.ripplerFillProperty().bind(textFill);
getChildren().setAll(rippler);
JFXDepthManager.setDepth(getSkinnable(), 1);
getSkinnable().setPickOnBounds(false);
colorPicker.focusedProperty().addListener(observable -> {
if (colorPicker.isFocused()) {
if (!getSkinnable().isPressed()) {
rippler.setOverlayVisible(true);
}
} else {
rippler.setOverlayVisible(false);
}
});
// add listeners
registerChangeListener(colorPicker.valueProperty(), obs -> updateColor());
colorLabelVisible.addListener(invalidate -> {
if (colorLabelVisible.get()) {
displayNode.setText(JFXNodeUtils.colorToHex(getSkinnable().getValue()));
} else {
displayNode.setText("");
}
});
}
@Override
protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
double width = 100;
String displayNodeText = displayNode.getText();
displayNode.setText("#DDDDDD");
width = Math.max(width, super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset));
displayNode.setText(displayNodeText);
return width + rightInset + leftInset;
}
@Override
protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
if (colorBox == null) {
reflectUpdateDisplayArea();
}
return topInset + colorBox.prefHeight(width) + bottomInset;
}
@Override
protected void layoutChildren(double x, double y, double w, double h) {
super.layoutChildren(x, y, w, h);
double hInsets = snappedLeftInset() + snappedRightInset();
double vInsets = snappedTopInset() + snappedBottomInset();
double width = w + hInsets;
double height = h + vInsets;
colorBox.resizeRelocate(0, 0, width, height);
}
@Override
protected Node getPopupContent() {
if (popupContent == null) {
popupContent = new JFXColorPalette((JFXColorPicker) getSkinnable());
}
return popupContent;
}
@Override
public void show() {
super.show();
final ColorPicker colorPicker = (ColorPicker) getSkinnable();
popupContent.updateSelection(colorPicker.getValue());
}
@Override
public Node getDisplayNode() {
return displayNode;
}
private void updateColor() {
final ColorPicker colorPicker = (ColorPicker) getSkinnable();
Color color = colorPicker.getValue();
Color circleColor = color == null ? Color.WHITE : color;
// update picker box color
if (((JFXColorPicker) getSkinnable()).isDisableAnimation()) {
JFXNodeUtils.updateBackground(colorBox.getBackground(), colorBox, circleColor);
} else {
Circle colorCircle = new Circle();
colorCircle.setFill(circleColor);
colorCircle.setManaged(false);
colorCircle.setLayoutX(colorBox.getWidth() / 4);
colorCircle.setLayoutY(colorBox.getHeight() / 2);
colorBox.getChildren().add(colorCircle);
Timeline animateColor = new Timeline(new KeyFrame(Duration.millis(240),
new KeyValue(colorCircle.radiusProperty(),
200,
Interpolator.EASE_BOTH)));
animateColor.setOnFinished((finish) -> {
colorBoxBackground.set(new Background(new BackgroundFill(colorCircle.getFill(), new CornerRadii(3), Insets.EMPTY)));
colorBox.getChildren().remove(colorCircle);
});
animateColor.play();
}
// update label color
textFill.set(circleColor.grayscale().getRed() < 0.5 ? Color.valueOf(
"rgba(255, 255, 255, 0.87)") : Color.valueOf("rgba(0, 0, 0, 0.87)"));
if (colorLabelVisible.get()) {
displayNode.setText(JFXNodeUtils.colorToHex(circleColor));
} else {
displayNode.setText("");
}
}
private void initColor() {
final ColorPicker colorPicker = (ColorPicker) getSkinnable();
Color color = colorPicker.getValue();
Color circleColor = color == null ? Color.WHITE : color;
// update picker box color
colorBoxBackground.set(new Background(new BackgroundFill(circleColor, new CornerRadii(3), Insets.EMPTY)));
// update label color
textFill.set(circleColor.grayscale().getRed() < 0.5 ? Color.valueOf(
"rgba(255, 255, 255, 0.87)") : Color.valueOf("rgba(0, 0, 0, 0.87)"));
if (colorLabelVisible.get()) {
displayNode.setText(JFXNodeUtils.colorToHex(circleColor));
} else {
displayNode.setText("");
}
}
/***************************************************************************
* *
* Stylesheet Handling *
* *
**************************************************************************/
private static final class StyleableProperties {
private static final CssMetaData<ColorPicker, Boolean> COLOR_LABEL_VISIBLE =
new CssMetaData<ColorPicker, Boolean>("-fx-color-label-visible",
BooleanConverter.getInstance(), Boolean.TRUE) {
@Override
public boolean isSettable(ColorPicker n) {
final JFXColorPickerSkin skin = (JFXColorPickerSkin) n.getSkin();
return skin.colorLabelVisible == null || !skin.colorLabelVisible.isBound();
}
@Override
public StyleableProperty<Boolean> getStyleableProperty(ColorPicker n) {
final JFXColorPickerSkin skin = (JFXColorPickerSkin) n.getSkin();
return skin.colorLabelVisible;
}
};
private static final List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables =
new ArrayList<>(ComboBoxPopupControl.getClassCssMetaData());
styleables.add(COLOR_LABEL_VISIBLE);
STYLEABLES = Collections.unmodifiableList(styleables);
}
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.STYLEABLES;
}
@Override
public List<CssMetaData<? extends Styleable, ?>> getCssMetaData() {
return getClassCssMetaData();
}
protected TextField getEditor() {
return null;
}
protected javafx.util.StringConverter<Color> getConverter() {
return null;
}
}
@@ -0,0 +1,629 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.skins;
import com.jfoenix.effects.JFXDepthManager;
import com.jfoenix.transitions.CachedTransition;
import javafx.animation.Animation.Status;
import javafx.animation.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.effect.ColorAdjust;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.image.PixelWriter;
import javafx.scene.image.WritableImage;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Path;
import javafx.util.Duration;
/**
* @author Shadi Shaheen & Bassel El Mabsout this UI allows the user to pick a color using HSL color system
*/
final class JFXColorPickerUI extends Pane {
private CachedTransition selectorTransition;
private int pickerSize = 400;
// sl circle selector size
private final int selectorSize = 20;
private final double centerX;
private final double centerY;
private final double huesRadius;
private final double slRadius;
private double currentHue = 0;
private final ImageView huesCircleView;
private final ImageView slCircleView;
private final Pane colorSelector;
private final Pane selector;
private CurveTransition colorsTransition;
public JFXColorPickerUI(int pickerSize) {
JFXDepthManager.setDepth(this, 1);
this.pickerSize = pickerSize;
this.centerX = (double) pickerSize / 2;
this.centerY = (double) pickerSize / 2;
final double pickerRadius = (double) pickerSize / 2;
this.huesRadius = pickerRadius * 0.9;
final double huesSmallR = pickerRadius * 0.8;
final double huesLargeR = pickerRadius;
this.slRadius = pickerRadius * 0.7;
// Create Hues Circle
huesCircleView = new ImageView(getHuesCircle(pickerSize, pickerSize));
// clip to smooth the edges
Circle outterCircle = new Circle(centerX, centerY, huesLargeR - 2);
Circle innterCircle = new Circle(centerX, centerY, huesSmallR + 2);
huesCircleView.setClip(Path.subtract(outterCircle, innterCircle));
this.getChildren().add(huesCircleView);
// create Hues Circle Selector
Circle r1 = new Circle(pickerRadius - huesSmallR);
Circle r2 = new Circle(pickerRadius - huesRadius);
colorSelector = new Pane();
colorSelector.setStyle("-fx-border-color:#424242; -fx-border-width:1px; -fx-background-color:rgba(255, 255, 255, 0.87);");
colorSelector.setPrefSize(pickerRadius - huesSmallR, pickerRadius - huesSmallR);
colorSelector.setShape(Path.subtract(r1, r2));
colorSelector.setCache(true);
colorSelector.setMouseTransparent(true);
colorSelector.setPickOnBounds(false);
this.getChildren().add(colorSelector);
// add Hues Selection Listeners
huesCircleView.addEventHandler(MouseEvent.MOUSE_DRAGGED, (event) -> {
if (colorsTransition != null) {
colorsTransition.stop();
}
double dx = event.getX() - centerX;
double dy = event.getY() - centerY;
double theta = Math.atan2(dy, dx);
double x = centerX + huesRadius * Math.cos(theta);
double y = centerY + huesRadius * Math.sin(theta);
colorSelector.setRotate(90 + Math.toDegrees(Math.atan2(dy, dx)));
colorSelector.setTranslateX(x - colorSelector.getPrefWidth() / 2);
colorSelector.setTranslateY(y - colorSelector.getPrefHeight() / 2);
});
huesCircleView.addEventHandler(MouseEvent.MOUSE_PRESSED, (event) -> {
double dx = event.getX() - centerX;
double dy = event.getY() - centerY;
double theta = Math.atan2(dy, dx);
double x = centerX + huesRadius * Math.cos(theta);
double y = centerY + huesRadius * Math.sin(theta);
colorsTransition = new CurveTransition(new Point2D(colorSelector.getTranslateX() + colorSelector.getPrefWidth() / 2,
colorSelector.getTranslateY() + colorSelector.getPrefHeight() / 2),
new Point2D(x, y));
colorsTransition.play();
});
colorSelector.translateXProperty()
.addListener((o, oldVal, newVal) -> updateHSLCircleColor((int) (newVal.intValue() + colorSelector.getPrefWidth() / 2),
(int) (colorSelector.getTranslateY() + colorSelector
.getPrefHeight() / 2)));
colorSelector.translateYProperty()
.addListener((o, oldVal, newVal) -> updateHSLCircleColor((int) (colorSelector.getTranslateX() + colorSelector
.getPrefWidth() / 2), (int) (newVal.intValue() + colorSelector.getPrefHeight() / 2)));
// Create SL Circle
slCircleView = new ImageView(getSLCricle(pickerSize, pickerSize));
slCircleView.setClip(new Circle(centerX, centerY, slRadius - 2));
slCircleView.setPickOnBounds(false);
this.getChildren().add(slCircleView);
// create SL Circle Selector
selector = new Pane();
Circle c1 = new Circle(selectorSize / 2);
Circle c2 = new Circle((selectorSize / 2) * 0.5);
selector.setShape(Path.subtract(c1, c2));
selector.setStyle(
"-fx-border-color:#424242; -fx-border-width:1px;-fx-background-color:rgba(255, 255, 255, 0.87);");
selector.setPrefSize(selectorSize, selectorSize);
selector.setMinSize(selectorSize, selectorSize);
selector.setMaxSize(selectorSize, selectorSize);
selector.setCache(true);
selector.setMouseTransparent(true);
this.getChildren().add(selector);
// add SL selection Listeners
slCircleView.addEventHandler(MouseEvent.MOUSE_DRAGGED, (event) -> {
if (selectorTransition != null) {
selectorTransition.stop();
}
if (Math.pow(event.getX() - centerX, 2) + Math.pow(event.getY() - centerY, 2) < Math.pow(slRadius - 2, 2)) {
selector.setTranslateX(event.getX() - selector.getPrefWidth() / 2);
selector.setTranslateY(event.getY() - selector.getPrefHeight() / 2);
} else {
double dx = event.getX() - centerX;
double dy = event.getY() - centerY;
double theta = Math.atan2(dy, dx);
double x = centerX + (slRadius - 2) * Math.cos(theta);
double y = centerY + (slRadius - 2) * Math.sin(theta);
selector.setTranslateX(x - selector.getPrefWidth() / 2);
selector.setTranslateY(y - selector.getPrefHeight() / 2);
}
});
slCircleView.addEventHandler(MouseEvent.MOUSE_PRESSED, (event) -> {
selectorTransition = new CachedTransition(selector, new Timeline(new KeyFrame(Duration.millis(1000),
new KeyValue(selector.translateXProperty(),
event.getX() - selector.getPrefWidth() / 2,
Interpolator.EASE_BOTH),
new KeyValue(selector.translateYProperty(),
event.getY() - selector.getPrefHeight() / 2,
Interpolator.EASE_BOTH)))) {
{
setCycleDuration(Duration.millis(160));
setDelay(Duration.seconds(0));
}
};
selectorTransition.play();
});
// add slCircleView listener
selector.translateXProperty()
.addListener((o, oldVal, newVal) -> setColorAtLocation(newVal.intValue() + selectorSize / 2,
(int) selector.getTranslateY() + selectorSize / 2));
selector.translateYProperty()
.addListener((o, oldVal, newVal) -> setColorAtLocation((int) selector.getTranslateX() + selectorSize / 2,
newVal.intValue() + selectorSize / 2));
// initial color selection
double dx = 20 - centerX;
double dy = 20 - centerY;
double theta = Math.atan2(dy, dx);
double x = centerX + huesRadius * Math.cos(theta);
double y = centerY + huesRadius * Math.sin(theta);
colorSelector.setRotate(90 + Math.toDegrees(Math.atan2(dy, dx)));
colorSelector.setTranslateX(x - colorSelector.getPrefWidth() / 2);
colorSelector.setTranslateY(y - colorSelector.getPrefHeight() / 2);
selector.setTranslateX(centerX - selector.getPrefWidth() / 2);
selector.setTranslateY(centerY - selector.getPrefHeight() / 2);
}
/**
* List of Color Nodes that needs to be updated when picking a color
*/
private final ObservableList<Node> colorNodes = FXCollections.observableArrayList();
public void addColorSelectionNode(Node... nodes) {
colorNodes.addAll(nodes);
}
public void removeColorSelectionNode(Node... nodes) {
colorNodes.removeAll(nodes);
}
private void updateHSLCircleColor(int x, int y) {
// transform color to HSL space
Color color = huesCircleView.getImage().getPixelReader().getColor(x, y);
double max = Math.max(color.getRed(), Math.max(color.getGreen(), color.getBlue()));
double min = Math.min(color.getRed(), Math.min(color.getGreen(), color.getBlue()));
double hue = 0;
if (max != min) {
double d = max - min;
if (max == color.getRed()) {
hue = (color.getGreen() - color.getBlue()) / d + (color.getGreen() < color.getBlue() ? 6 : 0);
} else if (max == color.getGreen()) {
hue = (color.getBlue() - color.getRed()) / d + 2;
} else if (max == color.getBlue()) {
hue = (color.getRed() - color.getGreen()) / d + 4;
}
hue /= 6;
}
currentHue = map(hue, 0, 1, 0, 255);
// refresh the HSL circle
refreshHSLCircle();
}
private void refreshHSLCircle() {
ColorAdjust colorAdjust = new ColorAdjust();
colorAdjust.setHue(map(currentHue + (currentHue < 127.5 ? 1 : -1) * 127.5, 0, 255, -1, 1));
slCircleView.setEffect(colorAdjust);
setColorAtLocation((int) selector.getTranslateX() + selectorSize / 2,
(int) selector.getTranslateY() + selectorSize / 2);
}
/**
* this method is used to move selectors to a certain color
*/
private boolean allowColorChange = true;
private ParallelTransition pTrans;
public void moveToColor(Color color) {
allowColorChange = false;
double max = Math.max(color.getRed(),
Math.max(color.getGreen(), color.getBlue())), min = Math.min(color.getRed(),
Math.min(color.getGreen(),
color.getBlue()));
double hue = 0;
double l = (max + min) / 2;
double s = 0;
if (max == min) {
hue = s = 0; // achromatic
} else {
double d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max == color.getRed()) {
hue = (color.getGreen() - color.getBlue()) / d + (color.getGreen() < color.getBlue() ? 6 : 0);
} else if (max == color.getGreen()) {
hue = (color.getBlue() - color.getRed()) / d + 2;
} else if (max == color.getBlue()) {
hue = (color.getRed() - color.getGreen()) / d + 4;
}
hue /= 6;
}
currentHue = map(hue, 0, 1, 0, 255);
// Animate Hue
double theta = map(currentHue, 0, 255, -Math.PI, Math.PI);
double x = centerX + huesRadius * Math.cos(theta);
double y = centerY + huesRadius * Math.sin(theta);
colorsTransition = new CurveTransition(
new Point2D(
colorSelector.getTranslateX() + colorSelector.getPrefWidth() / 2,
colorSelector.getTranslateY() + colorSelector.getPrefHeight() / 2
),
new Point2D(x, y));
// Animate SL
s = map(s, 0, 1, 0, 255);
l = map(l, 0, 1, 0, 255);
Point2D point = getPointFromSL((int) s, (int) l, slRadius);
double pX = centerX - point.getX();
double pY = centerY - point.getY();
double endPointX;
double endPointY;
if (Math.pow(pX - centerX, 2) + Math.pow(pY - centerY, 2) < Math.pow(slRadius - 2, 2)) {
endPointX = pX - selector.getPrefWidth() / 2;
endPointY = pY - selector.getPrefHeight() / 2;
} else {
double dx = pX - centerX;
double dy = pY - centerY;
theta = Math.atan2(dy, dx);
x = centerX + (slRadius - 2) * Math.cos(theta);
y = centerY + (slRadius - 2) * Math.sin(theta);
endPointX = x - selector.getPrefWidth() / 2;
endPointY = y - selector.getPrefHeight() / 2;
}
selectorTransition = new CachedTransition(selector, new Timeline(new KeyFrame(Duration.millis(1000),
new KeyValue(selector.translateXProperty(),
endPointX,
Interpolator.EASE_BOTH),
new KeyValue(selector.translateYProperty(),
endPointY,
Interpolator.EASE_BOTH)))) {
{
setCycleDuration(Duration.millis(160));
setDelay(Duration.seconds(0));
}
};
if (pTrans != null) {
pTrans.stop();
}
pTrans = new ParallelTransition(colorsTransition, selectorTransition);
pTrans.setOnFinished((finish) -> {
if (pTrans.getStatus() == Status.STOPPED) {
allowColorChange = true;
}
});
pTrans.play();
refreshHSLCircle();
}
private void setColorAtLocation(int x, int y) {
if (allowColorChange) {
Color color = getColorAtLocation(x, y);
String colorString = "rgb(" + color.getRed() * 255 + "," + color.getGreen() * 255 + "," + color.getBlue() * 255 + ");";
for (Node node : colorNodes)
node.setStyle("-fx-background-color:" + colorString + "; -fx-fill:" + colorString + ";");
}
}
private Color getColorAtLocation(double x, double y) {
double dy = x - centerX;
double dx = y - centerY;
return getColor(dx, dy);
}
private Image getHuesCircle(int width, int height) {
WritableImage raster = new WritableImage(width, height);
PixelWriter pixelWriter = raster.getPixelWriter();
Point2D center = new Point2D((double) width / 2, (double) height / 2);
double rsmall = 0.8 * width / 2;
double rbig = (double) width / 2;
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
double dx = x - center.getX();
double dy = y - center.getY();
double distance = Math.sqrt((dx * dx) + (dy * dy));
double o = Math.atan2(dy, dx);
if (distance > rsmall && distance < rbig) {
double H = map(o, -Math.PI, Math.PI, 0, 255);
double S = 255;
double L = 152;
pixelWriter.setColor(x, y, HSL2RGB(H, S, L));
}
}
}
return raster;
}
private Image getSLCricle(int width, int height) {
WritableImage raster = new WritableImage(width, height);
PixelWriter pixelWriter = raster.getPixelWriter();
Point2D center = new Point2D((double) width / 2, (double) height / 2);
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
double dy = x - center.getX();
double dx = y - center.getY();
pixelWriter.setColor(x, y, getColor(dx, dy));
}
}
return raster;
}
private double clamp(double from, double small, double big) {
return Math.min(Math.max(from, small), big);
}
private Color getColor(double dx, double dy) {
double distance = Math.sqrt((dx * dx) + (dy * dy));
double rverysmall = 0.65 * ((double) pickerSize / 2);
Color pixelColor = Color.BLUE;
if (distance <= rverysmall * 1.1) {
double angle = -Math.PI / 2.;
double angle1 = angle + 2 * Math.PI / 3.;
double angle2 = angle1 + 2 * Math.PI / 3.;
double x1 = rverysmall * Math.sin(angle1);
double y1 = rverysmall * Math.cos(angle1);
double x2 = rverysmall * Math.sin(angle2);
double y2 = rverysmall * Math.cos(angle2);
dx += 0.01;
double[] circle = circleFrom3Points(new Point2D(x1, y1), new Point2D(x2, y2), new Point2D(dx, dy));
double xArc = circle[0];
double yArc = 0;
double arcR = circle[2];
double Arco = Math.atan2(dx - xArc, dy - yArc);
double Arco1 = Math.atan2(x1 - xArc, y1 - yArc);
double Arco2 = Math.atan2(x2 - xArc, y2 - yArc);
double finalX = xArc > 0 ? xArc - arcR : xArc + arcR;
double saturation = map(finalX, -rverysmall, rverysmall, 255, 0);
double lightness = 255;
double diffAngle = Arco2 - Arco1;
double diffArco = Arco - Arco1;
if (dx < x1) {
diffAngle = diffAngle < 0 ? 2 * Math.PI + diffAngle : diffAngle;
diffAngle = Math.abs(2 * Math.PI - diffAngle);
diffArco = diffArco < 0 ? 2 * Math.PI + diffArco : diffArco;
diffArco = Math.abs(2 * Math.PI - diffArco);
}
lightness = map(diffArco, 0, diffAngle, 0, 255);
if (distance > rverysmall) {
saturation = 255 - saturation;
if (lightness < 0 && dy < 0) {
lightness = 255;
}
}
lightness = clamp(lightness, 0, 255);
if ((saturation < 10 && dx < x1) || (saturation > 240 && dx > x1)) {
saturation = 255 - saturation;
}
saturation = clamp(saturation, 0, 255);
pixelColor = HSL2RGB(currentHue, saturation, lightness);
}
return pixelColor;
}
/***************************************************************************
* *
* Hues Animation *
* *
**************************************************************************/
private final class CurveTransition extends Transition {
Point2D from;
double fromTheta;
double toTheta;
public CurveTransition(Point2D from, Point2D to) {
this.from = from;
double fromDx = from.getX() - centerX;
double fromDy = from.getY() - centerY;
fromTheta = Math.atan2(fromDy, fromDx);
double toDx = to.getX() - centerX;
double toDy = to.getY() - centerY;
toTheta = Math.atan2(toDy, toDx);
setInterpolator(Interpolator.EASE_BOTH);
setDelay(Duration.millis(0));
setCycleDuration(Duration.millis(240));
}
@Override
protected void interpolate(double frac) {
double dif = Math.min(Math.abs(toTheta - fromTheta), 2 * Math.PI - Math.abs(toTheta - fromTheta));
if (dif == 2 * Math.PI - Math.abs(toTheta - fromTheta)) {
int dir = -1;
if (toTheta < fromTheta) {
dir = 1;
}
dif = dir * dif;
} else {
dif = toTheta - fromTheta;
}
Point2D newP = rotate(from, new Point2D(centerX, centerY), frac * dif);
colorSelector.setRotate(90 + Math.toDegrees(Math.atan2(newP.getY() - centerY, newP.getX() - centerX)));
colorSelector.setTranslateX(newP.getX() - colorSelector.getPrefWidth() / 2);
colorSelector.setTranslateY(newP.getY() - colorSelector.getPrefHeight() / 2);
}
}
/***************************************************************************
* *
* Util methods *
* *
**************************************************************************/
private double map(double val, double min1, double max1, double min2, double max2) {
return min2 + (max2 - min2) * ((val - min1) / (max1 - min1));
}
private Color HSL2RGB(double hue, double sat, double lum) {
hue = map(hue, 0, 255, 0, 359);
sat = map(sat, 0, 255, 0, 1);
lum = map(lum, 0, 255, 0, 1);
double v;
double red, green, blue;
double m;
double sv;
int sextant;
double fract, vsf, mid1, mid2;
red = lum; // default to gray
green = lum;
blue = lum;
v = (lum <= 0.5) ? (lum * (1.0 + sat)) : (lum + sat - lum * sat);
m = lum + lum - v;
sv = (v - m) / v;
hue /= 60.0; //get into range 0..6
sextant = (int) Math.floor(hue); // int32 rounds up or down.
fract = hue - sextant;
vsf = v * sv * fract;
mid1 = m + vsf;
mid2 = v - vsf;
if (v > 0) {
switch (sextant) {
case 0:
red = v;
green = mid1;
blue = m;
break;
case 1:
red = mid2;
green = v;
blue = m;
break;
case 2:
red = m;
green = v;
blue = mid1;
break;
case 3:
red = m;
green = mid2;
blue = v;
break;
case 4:
red = mid1;
green = m;
blue = v;
break;
case 5:
red = v;
green = m;
blue = mid2;
break;
}
}
return new Color(red, green, blue, 1);
}
private double[] circleFrom3Points(Point2D a, Point2D b, Point2D c) {
double ax, ay, bx, by, cx, cy, x1, y11, dx1, dy1, x2, y2, dx2, dy2, ox, oy, dx, dy, radius; // Variables Used and to Declared
ax = a.getX();
ay = a.getY(); //first Point X and Y
bx = b.getX();
by = b.getY(); // Second Point X and Y
cx = c.getX();
cy = c.getY(); // Third Point X and Y
////****************Following are Basic Procedure**********************///
x1 = (bx + ax) / 2;
y11 = (by + ay) / 2;
dy1 = bx - ax;
dx1 = -(by - ay);
x2 = (cx + bx) / 2;
y2 = (cy + by) / 2;
dy2 = cx - bx;
dx2 = -(cy - by);
ox = (y11 * dx1 * dx2 + x2 * dx1 * dy2 - x1 * dy1 * dx2 - y2 * dx1 * dx2) / (dx1 * dy2 - dy1 * dx2);
oy = (ox - x1) * dy1 / dx1 + y11;
dx = ox - ax;
dy = oy - ay;
radius = Math.sqrt(dx * dx + dy * dy);
return new double[]{ox, oy, radius};
}
private Point2D getPointFromSL(int saturation, int lightness, double radius) {
double dy = map(saturation, 0, 255, -radius, radius);
double angle = 0.;
double angle1 = angle + 2 * Math.PI / 3.;
double angle2 = angle1 + 2 * Math.PI / 3.;
double x1 = radius * Math.sin(angle1);
double y1 = radius * Math.cos(angle1);
double x2 = radius * Math.sin(angle2);
double y2 = radius * Math.cos(angle2);
double dx = 0;
double[] circle = circleFrom3Points(new Point2D(x1, y1), new Point2D(dx, dy), new Point2D(x2, y2));
double xArc = circle[0];
double yArc = circle[1];
double arcR = circle[2];
double Arco1 = Math.atan2(x1 - xArc, y1 - yArc);
double Arco2 = Math.atan2(x2 - xArc, y2 - yArc);
double ArcoFinal = map(lightness, 0, 255, Arco2, Arco1);
double finalX = xArc + arcR * Math.sin(ArcoFinal);
double finalY = yArc + arcR * Math.cos(ArcoFinal);
if (dy < y1) {
ArcoFinal = map(lightness, 0, 255, Arco1, Arco2 + 2 * Math.PI);
finalX = -xArc - arcR * Math.sin(ArcoFinal);
finalY = yArc + arcR * Math.cos(ArcoFinal);
}
return new Point2D(finalX, finalY);
}
private Point2D rotate(Point2D a, Point2D center, double angle) {
double resultX = center.getX() + (a.getX() - center.getX()) * Math.cos(angle) - (a.getY() - center.getY()) * Math
.sin(angle);
double resultY = center.getY() + (a.getX() - center.getX()) * Math.sin(angle) + (a.getY() - center.getY()) * Math
.cos(angle);
return new Point2D(resultX, resultY);
}
}
@@ -0,0 +1,314 @@
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package com.jfoenix.skins;
import com.jfoenix.adapters.skins.ComboBoxListViewSkin;
import com.jfoenix.concurrency.JFXUtilities;
import com.jfoenix.controls.JFXComboBox;
import com.jfoenix.transitions.CachedTransition;
import javafx.animation.*;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.event.EventTarget;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ComboBoxBase;
import javafx.scene.control.ListView;
import javafx.scene.control.skin.VirtualFlow;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.text.Text;
import javafx.scene.transform.Scale;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.FXUtils;
import java.util.List;
public class JFXComboBoxListViewSkin<T> extends ComboBoxListViewSkin<T> {
protected final ObjectProperty<Paint> promptTextFill;
private boolean invalid = true;
private final StackPane customPane;
private final StackPane line = new StackPane();
private final StackPane focusedLine = new StackPane();
private final Text promptText = new Text();
private final double initScale = 0.05;
private final Scale scale;
private final Timeline linesAnimation;
private ParallelTransition transition;
private CachedTransition promptTextUpTransition;
private CachedTransition promptTextDownTransition;
private CachedTransition promptTextColorTransition;
private final Scale promptTextScale;
private Paint oldPromptTextFill;
private final BooleanBinding usePromptText;
public JFXComboBoxListViewSkin(JFXComboBox<T> comboBox) {
super(comboBox);
this.scale = new Scale(this.initScale, 1.0F);
this.linesAnimation = new Timeline(new KeyFrame(Duration.ZERO, new KeyValue(this.scale.xProperty(), this.initScale, Interpolator.EASE_BOTH), new KeyValue(this.focusedLine.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(1.0F), new KeyValue(this.focusedLine.opacityProperty(), 1, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(160.0F), new KeyValue(this.scale.xProperty(), 1, Interpolator.EASE_BOTH)));
this.promptTextScale = new Scale(1.0F, 1.0F, 0.0F, 0.0F);
this.promptTextFill = new SimpleObjectProperty(Color.valueOf("#B2B2B2"));
this.usePromptText = Bindings.createBooleanBinding(this::usePromptText, ((JFXComboBox) this.getSkinnable()).valueProperty(), this.getSkinnable().promptTextProperty());
this.getArrowButton().setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, null, null)));
this.promptText.textProperty().bind(comboBox.promptTextProperty());
this.promptText.fillProperty().bind(this.promptTextFill);
this.promptText.getStyleClass().addAll("text", "prompt-text");
this.promptText.getTransforms().add(this.promptTextScale);
if (!comboBox.isLabelFloat()) {
this.promptText.visibleProperty().bind(this.usePromptText);
}
this.customPane = new StackPane();
this.customPane.setMouseTransparent(true);
this.customPane.getStyleClass().add("combo-box-button-container");
this.customPane.backgroundProperty().bindBidirectional(this.getSkinnable().backgroundProperty());
this.customPane.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, null, null)));
this.customPane.getChildren().add(this.promptText);
this.getChildren().add(0, this.customPane);
StackPane.setAlignment(this.promptText, Pos.CENTER_LEFT);
this.line.getStyleClass().add("input-line");
this.focusedLine.getStyleClass().add("input-focused-line");
this.getChildren().add(this.line);
this.getChildren().add(this.focusedLine);
this.line.setPrefHeight(1.0F);
this.line.setTranslateY(1.0F);
this.line.setManaged(false);
this.line.setBackground(new Background(new BackgroundFill(((JFXComboBox) this.getSkinnable()).getUnFocusColor(), CornerRadii.EMPTY, Insets.EMPTY)));
if (this.getSkinnable().isDisabled()) {
this.line.setBorder(new Border(new BorderStroke(((JFXComboBox) this.getSkinnable()).getUnFocusColor(), BorderStrokeStyle.DASHED, CornerRadii.EMPTY, new BorderWidths(1.0F))));
this.line.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY)));
}
this.focusedLine.setPrefHeight(2.0F);
this.focusedLine.setTranslateY(0.0F);
this.focusedLine.setBackground(new Background(new BackgroundFill(((JFXComboBox) this.getSkinnable()).getFocusColor(), CornerRadii.EMPTY, Insets.EMPTY)));
this.focusedLine.setOpacity(0.0F);
this.focusedLine.getTransforms().add(this.scale);
this.focusedLine.setManaged(false);
if (comboBox.isEditable()) {
comboBox.getEditor().setStyle("-fx-background-color:TRANSPARENT;-fx-padding: 4 8 4 8");
comboBox.getEditor().promptTextProperty().unbind();
comboBox.getEditor().setPromptText(null);
comboBox.getEditor().textProperty().addListener((o, oldVal, newVal) -> {
this.usePromptText.invalidate();
comboBox.setValue(this.getConverter().fromString(newVal));
});
}
comboBox.labelFloatProperty().addListener((o, oldVal, newVal) -> {
if (newVal) {
this.promptText.visibleProperty().unbind();
JFXUtilities.runInFX(() -> this.createFloatingAnimation());
} else {
this.promptText.visibleProperty().bind(this.usePromptText);
}
this.createFocusTransition();
});
comboBox.focusColorProperty().addListener((o, oldVal, newVal) -> {
if (newVal != null) {
this.focusedLine.setBackground(new Background(new BackgroundFill(newVal, CornerRadii.EMPTY, Insets.EMPTY)));
if (((JFXComboBox) this.getSkinnable()).isLabelFloat()) {
this.promptTextColorTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptTextFill, newVal, Interpolator.EASE_BOTH)))) {
{
this.setDelay(Duration.millis(0.0F));
this.setCycleDuration(Duration.millis(160.0F));
}
protected void starting() {
super.starting();
JFXComboBoxListViewSkin.this.oldPromptTextFill = JFXComboBoxListViewSkin.this.promptTextFill.get();
}
};
this.transition = null;
}
}
});
comboBox.unFocusColorProperty().addListener((o, oldVal, newVal) -> {
if (newVal != null) {
this.line.setBackground(new Background(new BackgroundFill(newVal, CornerRadii.EMPTY, Insets.EMPTY)));
}
});
comboBox.disabledProperty().addListener((o, oldVal, newVal) -> {
this.line.setBorder(newVal ? new Border(new BorderStroke(((JFXComboBox) this.getSkinnable()).getUnFocusColor(), BorderStrokeStyle.DASHED, CornerRadii.EMPTY, new BorderWidths(this.line.getHeight()))) : Border.EMPTY);
this.line.setBackground(new Background(new BackgroundFill(newVal ? Color.TRANSPARENT : ((JFXComboBox) this.getSkinnable()).getUnFocusColor(), CornerRadii.EMPTY, Insets.EMPTY)));
});
comboBox.focusedProperty().addListener((o, oldVal, newVal) -> {
if (newVal) {
this.focus();
} else {
this.unFocus();
}
});
comboBox.valueProperty().addListener((o, oldVal, newVal) -> {
if (((JFXComboBox) this.getSkinnable()).isLabelFloat()) {
this.animateFloatingLabel(newVal != null && !newVal.toString().isEmpty());
}
});
for (Node child : getChildren()) {
if (child instanceof ListView<?> listView && "list-view".equals(listView.getId())) {
// The default hideOnClick logic of ComboBoxListViewSkin doesn't work well with FloatScrollBarSkin.
// We implement hideOnClick ourselves to prevent the popup from
// hiding when releasing the mouse on the scrollbar.
this.setHideOnClick(false);
listView.addEventFilter(MouseEvent.MOUSE_RELEASED, t -> {
EventTarget target = t.getTarget();
if (target instanceof Node targetNode) {
List<String> s = targetNode.getStyleClass();
if (s.contains("thumb")
|| s.contains("track")
|| s.contains("decrement-arrow")
|| s.contains("increment-arrow")) {
return;
}
}
comboBox.hide();
});
Platform.runLater(() -> {
if (listView.lookup(".virtual-flow") instanceof VirtualFlow<?> virtualFlow) {
FXUtils.smoothScrolling(virtualFlow);
}
});
}
}
}
protected void layoutChildren(double x, double y, double w, double h) {
super.layoutChildren(x, y, w, h);
this.customPane.resizeRelocate(x, y, w, h);
if (this.invalid) {
this.invalid = false;
if (!this.getSkinnable().isEditable()) {
Text javaPromptText = (Text) super.getDisplayNode().lookup(".text");
if (javaPromptText != null) {
this.promptTextFill.set(javaPromptText.getFill());
}
}
this.createFloatingAnimation();
if (((ComboBoxBase) this.getSkinnable()).getValue() != null) {
this.animateFloatingLabel(true);
}
}
this.focusedLine.resizeRelocate(x, this.getSkinnable().getHeight(), w, this.focusedLine.prefHeight(-1.0F));
this.line.resizeRelocate(x, this.getSkinnable().getHeight(), w, this.line.prefHeight(-1.0F));
this.scale.setPivotX(w / (double) 2.0F);
}
private void createFloatingAnimation() {
this.promptTextUpTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptText.translateYProperty(), -this.customPane.getHeight() + 6.05, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.xProperty(), 0.85, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.yProperty(), 0.85, Interpolator.EASE_BOTH)))) {
{
this.setDelay(Duration.millis(0.0F));
this.setCycleDuration(Duration.millis(240.0F));
}
};
this.promptTextColorTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptTextFill, ((JFXComboBox) this.getSkinnable()).getFocusColor(), Interpolator.EASE_BOTH)))) {
{
this.setDelay(Duration.millis(0.0F));
this.setCycleDuration(Duration.millis(160.0F));
}
protected void starting() {
super.starting();
JFXComboBoxListViewSkin.this.oldPromptTextFill = JFXComboBoxListViewSkin.this.promptTextFill.get();
}
};
this.promptTextDownTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptText.translateYProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.xProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.yProperty(), 1, Interpolator.EASE_BOTH)))) {
{
this.setDelay(Duration.millis(0.0F));
this.setCycleDuration(Duration.millis(240.0F));
}
};
this.promptTextDownTransition.setOnFinished((finish) -> {
this.promptText.setTranslateY(0.0F);
this.promptTextScale.setX(1.0F);
this.promptTextScale.setY(1.0F);
});
}
private void focus() {
if (this.transition == null) {
this.createFocusTransition();
}
this.transition.play();
}
private void animateFloatingLabel(boolean up) {
if (this.promptText == null) {
Platform.runLater(() -> this.animateFloatingLabel(up));
} else {
if (this.transition != null) {
this.transition.stop();
this.transition.getChildren().remove(this.promptTextUpTransition);
this.transition.getChildren().remove(this.promptTextColorTransition);
this.transition = null;
}
if (up && this.promptText.getTranslateY() == (double) 0.0F) {
this.promptTextDownTransition.stop();
this.promptTextUpTransition.play();
if (this.getSkinnable().isFocused()) {
this.promptTextColorTransition.play();
}
} else if (!up) {
this.promptTextUpTransition.stop();
if (this.getSkinnable().isFocused()) {
this.promptTextFill.set(this.oldPromptTextFill);
}
this.promptTextDownTransition.play();
}
}
}
private void createFocusTransition() {
this.transition = new ParallelTransition();
if (((JFXComboBox) this.getSkinnable()).isLabelFloat()) {
this.transition.getChildren().add(this.promptTextUpTransition);
this.transition.getChildren().add(this.promptTextColorTransition);
}
this.transition.getChildren().add(this.linesAnimation);
}
private void unFocus() {
if (this.transition != null) {
this.transition.stop();
}
this.scale.setX(this.initScale);
this.focusedLine.setOpacity(0.0F);
if (((JFXComboBox) this.getSkinnable()).isLabelFloat() && this.oldPromptTextFill != null) {
this.promptTextFill.set(this.oldPromptTextFill);
if (this.usePromptText()) {
this.promptTextDownTransition.play();
}
}
}
private boolean usePromptText() {
Object txt = ((JFXComboBox) this.getSkinnable()).getValue();
String promptTxt = this.getSkinnable().getPromptText();
return (txt == null || txt.toString().isEmpty()) && promptTxt != null && !promptTxt.isEmpty() && !this.promptTextFill.get().equals(Color.TRANSPARENT);
}
}
@@ -0,0 +1,520 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.skins;
import com.jfoenix.effects.JFXDepthManager;
import com.jfoenix.transitions.CachedTransition;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.binding.Bindings;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.effect.DropShadow;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.shape.*;
import javafx.scene.transform.Rotate;
import javafx.util.Duration;
import java.util.ArrayList;
import static javafx.animation.Interpolator.EASE_BOTH;
/// @author Shadi Shaheen
final class JFXCustomColorPicker extends Pane {
ObjectProperty<RecentColorPath> selectedPath = new SimpleObjectProperty<>();
private MoveTo startPoint;
private CubicCurveTo curve0To;
private CubicCurveTo outerCircleCurveTo;
private CubicCurveTo curve1To;
private CubicCurveTo innerCircleCurveTo;
private final ArrayList<CubicCurve> curves = new ArrayList<>();
private final double distance = 200;
private final double centerX = distance;
private final double centerY = distance;
private final double radius = 110;
private static final int shapesNumber = 13;
private final ArrayList<RecentColorPath> shapes = new ArrayList<>();
private CachedTransition showAnimation;
private final JFXColorPickerUI hslColorPicker;
public JFXCustomColorPicker() {
this.setPickOnBounds(false);
this.setMinSize(distance * 2, distance * 2);
final DoubleProperty rotationAngle = new SimpleDoubleProperty(2.1);
// draw recent colors shape using cubic curves
init(rotationAngle, centerX + 53, centerY + 162);
hslColorPicker = new JFXColorPickerUI((int) distance);
hslColorPicker.setLayoutX(centerX - distance / 2);
hslColorPicker.setLayoutY(centerY - distance / 2);
this.getChildren().add(hslColorPicker);
// add recent colors shapes
final int shapesStartIndex = this.getChildren().size();
final int shapesEndIndex = shapesStartIndex + shapesNumber;
for (int i = 0; i < shapesNumber; i++) {
final double angle = 2 * i * Math.PI / shapesNumber;
final RecentColorPath path = new RecentColorPath(startPoint,
curve0To,
outerCircleCurveTo,
curve1To,
innerCircleCurveTo);
shapes.add(path);
path.setPickOnBounds(false);
final Rotate rotate = new Rotate(Math.toDegrees(angle), centerX, centerY);
path.getTransforms().add(rotate);
this.getChildren().add(shapesStartIndex, path);
path.setFill(Color.valueOf(getDefaultColor(i)));
path.setFocusTraversable(true);
path.addEventHandler(MouseEvent.MOUSE_CLICKED, (event) -> {
path.requestFocus();
selectedPath.set(path);
});
}
// add selection listeners
selectedPath.addListener((o, oldVal, newVal) -> {
if (oldVal != null) {
hslColorPicker.removeColorSelectionNode(oldVal);
oldVal.playTransition(-1);
}
// re-arrange children
while (this.getChildren().indexOf(newVal) != shapesEndIndex - 1) {
final Node temp = this.getChildren().get(shapesEndIndex - 1);
this.getChildren().remove(shapesEndIndex - 1);
this.getChildren().add(shapesStartIndex, temp);
}
// update path fill according to the color picker
newVal.setStroke(Color.rgb(255, 255, 255, 0.87));
newVal.playTransition(1);
hslColorPicker.moveToColor((Color) newVal.getFill());
hslColorPicker.addColorSelectionNode(newVal);
});
// init selection
selectedPath.set((RecentColorPath) this.getChildren().get(shapesStartIndex));
}
public int getShapesNumber() {
return shapesNumber;
}
public int getSelectedIndex() {
if (selectedPath.get() != null) {
return shapes.indexOf(selectedPath.get());
}
return -1;
}
public void setColor(final Color color) {
shapes.get(getSelectedIndex()).setFill(color);
hslColorPicker.moveToColor(color);
}
public Color getColor(final int index) {
if (index >= 0 && index < shapes.size()) {
return (Color) shapes.get(index).getFill();
} else {
return Color.WHITE;
}
}
public void preAnimate() {
final CubicCurve firstCurve = curves.get(0);
final double x = firstCurve.getStartX();
final double y = firstCurve.getStartY();
firstCurve.setStartX(centerX);
firstCurve.setStartY(centerY);
final CubicCurve secondCurve = curves.get(1);
final double x1 = secondCurve.getStartX();
final double y1 = secondCurve.getStartY();
secondCurve.setStartX(centerX);
secondCurve.setStartY(centerY);
final double cx1 = firstCurve.getControlX1();
final double cy1 = firstCurve.getControlY1();
firstCurve.setControlX1(centerX + radius);
firstCurve.setControlY1(centerY + radius / 2);
final KeyFrame keyFrame = new KeyFrame(Duration.millis(1000),
new KeyValue(firstCurve.startXProperty(), x, EASE_BOTH),
new KeyValue(firstCurve.startYProperty(), y, EASE_BOTH),
new KeyValue(secondCurve.startXProperty(), x1, EASE_BOTH),
new KeyValue(secondCurve.startYProperty(), y1, EASE_BOTH),
new KeyValue(firstCurve.controlX1Property(), cx1, EASE_BOTH),
new KeyValue(firstCurve.controlY1Property(), cy1, EASE_BOTH)
);
final Timeline timeline = new Timeline(keyFrame);
showAnimation = new CachedTransition(this, timeline) {
{
setCycleDuration(Duration.millis(240));
setDelay(Duration.millis(0));
}
};
}
public void animate() {
showAnimation.play();
}
private void init(final DoubleProperty rotationAngle, final double initControlX1, final double initControlY1) {
final Circle innerCircle = new Circle(centerX, centerY, radius, Color.TRANSPARENT);
final Circle outerCircle = new Circle(centerX, centerY, radius * 2, Color.web("blue", 0.5));
// Create a composite shape of 4 cubic curves
// create 2 cubic curves of the shape
createQuadraticCurve(rotationAngle, initControlX1, initControlY1);
// inner circle curve
final CubicCurve innerCircleCurve = new CubicCurve();
innerCircleCurve.startXProperty().bind(curves.get(0).startXProperty());
innerCircleCurve.startYProperty().bind(curves.get(0).startYProperty());
innerCircleCurve.endXProperty().bind(curves.get(1).startXProperty());
innerCircleCurve.endYProperty().bind(curves.get(1).startYProperty());
curves.get(0).startXProperty().addListener((o, oldVal, newVal) -> {
final Point2D controlPoint = makeControlPoint(newVal.doubleValue(),
curves.get(0).getStartY(),
innerCircle,
shapesNumber,
-1);
innerCircleCurve.setControlX1(controlPoint.getX());
innerCircleCurve.setControlY1(controlPoint.getY());
});
curves.get(0).startYProperty().addListener((o, oldVal, newVal) -> {
final Point2D controlPoint = makeControlPoint(curves.get(0).getStartX(),
newVal.doubleValue(),
innerCircle,
shapesNumber,
-1);
innerCircleCurve.setControlX1(controlPoint.getX());
innerCircleCurve.setControlY1(controlPoint.getY());
});
curves.get(1).startXProperty().addListener((o, oldVal, newVal) -> {
final Point2D controlPoint = makeControlPoint(newVal.doubleValue(),
curves.get(1).getStartY(),
innerCircle,
shapesNumber,
1);
innerCircleCurve.setControlX2(controlPoint.getX());
innerCircleCurve.setControlY2(controlPoint.getY());
});
curves.get(1).startYProperty().addListener((o, oldVal, newVal) -> {
final Point2D controlPoint = makeControlPoint(curves.get(1).getStartX(),
newVal.doubleValue(),
innerCircle,
shapesNumber,
1);
innerCircleCurve.setControlX2(controlPoint.getX());
innerCircleCurve.setControlY2(controlPoint.getY());
});
Point2D controlPoint = makeControlPoint(curves.get(0).getStartX(),
curves.get(0).getStartY(),
innerCircle,
shapesNumber,
-1);
innerCircleCurve.setControlX1(controlPoint.getX());
innerCircleCurve.setControlY1(controlPoint.getY());
controlPoint = makeControlPoint(curves.get(1).getStartX(),
curves.get(1).getStartY(),
innerCircle,
shapesNumber,
1);
innerCircleCurve.setControlX2(controlPoint.getX());
innerCircleCurve.setControlY2(controlPoint.getY());
// outer circle curve
final CubicCurve outerCircleCurve = new CubicCurve();
outerCircleCurve.startXProperty().bind(curves.get(0).endXProperty());
outerCircleCurve.startYProperty().bind(curves.get(0).endYProperty());
outerCircleCurve.endXProperty().bind(curves.get(1).endXProperty());
outerCircleCurve.endYProperty().bind(curves.get(1).endYProperty());
controlPoint = makeControlPoint(curves.get(0).getEndX(),
curves.get(0).getEndY(),
outerCircle,
shapesNumber,
-1);
outerCircleCurve.setControlX1(controlPoint.getX());
outerCircleCurve.setControlY1(controlPoint.getY());
controlPoint = makeControlPoint(curves.get(1).getEndX(), curves.get(1).getEndY(), outerCircle, shapesNumber, 1);
outerCircleCurve.setControlX2(controlPoint.getX());
outerCircleCurve.setControlY2(controlPoint.getY());
startPoint = new MoveTo();
startPoint.xProperty().bind(curves.get(0).startXProperty());
startPoint.yProperty().bind(curves.get(0).startYProperty());
curve0To = new CubicCurveTo();
curve0To.controlX1Property().bind(curves.get(0).controlX1Property());
curve0To.controlY1Property().bind(curves.get(0).controlY1Property());
curve0To.controlX2Property().bind(curves.get(0).controlX2Property());
curve0To.controlY2Property().bind(curves.get(0).controlY2Property());
curve0To.xProperty().bind(curves.get(0).endXProperty());
curve0To.yProperty().bind(curves.get(0).endYProperty());
outerCircleCurveTo = new CubicCurveTo();
outerCircleCurveTo.controlX1Property().bind(outerCircleCurve.controlX1Property());
outerCircleCurveTo.controlY1Property().bind(outerCircleCurve.controlY1Property());
outerCircleCurveTo.controlX2Property().bind(outerCircleCurve.controlX2Property());
outerCircleCurveTo.controlY2Property().bind(outerCircleCurve.controlY2Property());
outerCircleCurveTo.xProperty().bind(outerCircleCurve.endXProperty());
outerCircleCurveTo.yProperty().bind(outerCircleCurve.endYProperty());
curve1To = new CubicCurveTo();
curve1To.controlX1Property().bind(curves.get(1).controlX2Property());
curve1To.controlY1Property().bind(curves.get(1).controlY2Property());
curve1To.controlX2Property().bind(curves.get(1).controlX1Property());
curve1To.controlY2Property().bind(curves.get(1).controlY1Property());
curve1To.xProperty().bind(curves.get(1).startXProperty());
curve1To.yProperty().bind(curves.get(1).startYProperty());
innerCircleCurveTo = new CubicCurveTo();
innerCircleCurveTo.controlX1Property().bind(innerCircleCurve.controlX2Property());
innerCircleCurveTo.controlY1Property().bind(innerCircleCurve.controlY2Property());
innerCircleCurveTo.controlX2Property().bind(innerCircleCurve.controlX1Property());
innerCircleCurveTo.controlY2Property().bind(innerCircleCurve.controlY1Property());
innerCircleCurveTo.xProperty().bind(innerCircleCurve.startXProperty());
innerCircleCurveTo.yProperty().bind(innerCircleCurve.startYProperty());
}
private void createQuadraticCurve(final DoubleProperty rotationAngle, final double initControlX1, final double initControlY1) {
for (int i = 0; i < 2; i++) {
double angle = 2 * i * Math.PI / shapesNumber;
double xOffset = radius * Math.cos(angle);
double yOffset = radius * Math.sin(angle);
final double startx = centerX + xOffset;
final double starty = centerY + yOffset;
final double diffStartCenterX = startx - centerX;
final double diffStartCenterY = starty - centerY;
final double sinRotAngle = Math.sin(rotationAngle.get());
final double cosRotAngle = Math.cos(rotationAngle.get());
final double startXR = cosRotAngle * diffStartCenterX - sinRotAngle * diffStartCenterY + centerX;
final double startYR = sinRotAngle * diffStartCenterX + cosRotAngle * diffStartCenterY + centerY;
angle = 2 * i * Math.PI / shapesNumber;
xOffset = distance * Math.cos(angle);
yOffset = distance * Math.sin(angle);
final double endx = centerX + xOffset;
final double endy = centerY + yOffset;
final CubicCurve curvedLine = new CubicCurve();
curvedLine.setStartX(startXR);
curvedLine.setStartY(startYR);
curvedLine.setControlX1(startXR);
curvedLine.setControlY1(startYR);
curvedLine.setControlX2(endx);
curvedLine.setControlY2(endy);
curvedLine.setEndX(endx);
curvedLine.setEndY(endy);
curvedLine.setStroke(Color.FORESTGREEN);
curvedLine.setStrokeWidth(1);
curvedLine.setStrokeLineCap(StrokeLineCap.ROUND);
curvedLine.setFill(Color.TRANSPARENT);
curvedLine.setMouseTransparent(true);
rotationAngle.addListener((o, oldVal, newVal) -> {
final double newstartXR = ((cosRotAngle * diffStartCenterX) - (sinRotAngle * diffStartCenterY)) + centerX;
final double newstartYR = (sinRotAngle * diffStartCenterX) + (cosRotAngle * diffStartCenterY) + centerY;
curvedLine.setStartX(newstartXR);
curvedLine.setStartY(newstartYR);
});
curves.add(curvedLine);
if (i == 0) {
curvedLine.setControlX1(initControlX1);
curvedLine.setControlY1(initControlY1);
} else {
final CubicCurve firstCurve = curves.get(0);
final double curveTheta = 2 * curves.indexOf(curvedLine) * Math.PI / shapesNumber;
curvedLine.controlX1Property().bind(Bindings.createDoubleBinding(() -> {
final double a = firstCurve.getControlX1() - centerX;
final double b = Math.sin(curveTheta) * (firstCurve.getControlY1() - centerY);
return ((Math.cos(curveTheta) * a) - b) + centerX;
}, firstCurve.controlX1Property(), firstCurve.controlY1Property()));
curvedLine.controlY1Property().bind(Bindings.createDoubleBinding(() -> {
final double a = Math.sin(curveTheta) * (firstCurve.getControlX1() - centerX);
final double b = Math.cos(curveTheta) * (firstCurve.getControlY1() - centerY);
return a + b + centerY;
}, firstCurve.controlX1Property(), firstCurve.controlY1Property()));
curvedLine.controlX2Property().bind(Bindings.createDoubleBinding(() -> {
final double a = firstCurve.getControlX2() - centerX;
final double b = firstCurve.getControlY2() - centerY;
return ((Math.cos(curveTheta) * a) - (Math.sin(curveTheta) * b)) + centerX;
}, firstCurve.controlX2Property(), firstCurve.controlY2Property()));
curvedLine.controlY2Property().bind(Bindings.createDoubleBinding(() -> {
final double a = Math.sin(curveTheta) * (firstCurve.getControlX2() - centerX);
final double b = Math.cos(curveTheta) * (firstCurve.getControlY2() - centerY);
return a + b + centerY;
}, firstCurve.controlX2Property(), firstCurve.controlY2Property()));
}
}
}
private String getDefaultColor(final int i) {
String color = "#FFFFFF";
switch (i) {
case 0:
color = "#8F3F7E";
break;
case 1:
color = "#B5305F";
break;
case 2:
color = "#CE584A";
break;
case 3:
color = "#DB8D5C";
break;
case 4:
color = "#DA854E";
break;
case 5:
color = "#E9AB44";
break;
case 6:
color = "#FEE435";
break;
case 7:
color = "#99C286";
break;
case 8:
color = "#01A05E";
break;
case 9:
color = "#4A8895";
break;
case 10:
color = "#16669B";
break;
case 11:
color = "#2F65A5";
break;
case 12:
color = "#4E6A9C";
break;
default:
break;
}
return color;
}
private Point2D rotate(final Point2D a, final Point2D center, final double angle) {
final double resultX = center.getX() + (a.getX() - center.getX()) * Math.cos(angle) - (a.getY() - center.getY()) * Math
.sin(angle);
final double resultY = center.getY() + (a.getX() - center.getX()) * Math.sin(angle) + (a.getY() - center.getY()) * Math
.cos(angle);
return new Point2D(resultX, resultY);
}
private Point2D makeControlPoint(final double endX, final double endY, final Circle circle, final int numSegments, int direction) {
final double controlPointDistance = (4.0 / 3.0) * Math.tan(Math.PI / (2 * numSegments)) * circle.getRadius();
final Point2D center = new Point2D(circle.getCenterX(), circle.getCenterY());
final Point2D end = new Point2D(endX, endY);
Point2D perp = rotate(center, end, direction * Math.PI / 2.);
Point2D diff = perp.subtract(end);
diff = diff.normalize();
diff = scale(diff, controlPointDistance);
return end.add(diff);
}
private Point2D scale(final Point2D a, final double scale) {
return new Point2D(a.getX() * scale, a.getY() * scale);
}
final class RecentColorPath extends Path {
PathClickTransition transition;
RecentColorPath(final PathElement... elements) {
super(elements);
this.setStrokeLineCap(StrokeLineCap.ROUND);
this.setStrokeWidth(0);
this.setStrokeType(StrokeType.CENTERED);
this.setCache(true);
JFXDepthManager.setDepth(this, 2);
this.transition = new PathClickTransition(this);
}
void playTransition(final double rate) {
transition.setRate(rate);
transition.play();
}
}
private final class PathClickTransition extends CachedTransition {
PathClickTransition(final Path path) {
super(JFXCustomColorPicker.this, new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(((DropShadow) path.getEffect()).radiusProperty(),
JFXDepthManager.getShadowAt(2).radiusProperty().get(),
EASE_BOTH),
new KeyValue(((DropShadow) path.getEffect()).spreadProperty(),
JFXDepthManager.getShadowAt(2).spreadProperty().get(),
EASE_BOTH),
new KeyValue(((DropShadow) path.getEffect()).offsetXProperty(),
JFXDepthManager.getShadowAt(2).offsetXProperty().get(),
EASE_BOTH),
new KeyValue(((DropShadow) path.getEffect()).offsetYProperty(),
JFXDepthManager.getShadowAt(2).offsetYProperty().get(),
EASE_BOTH),
new KeyValue(path.strokeWidthProperty(), 0, EASE_BOTH)
),
new KeyFrame(Duration.millis(1000),
new KeyValue(((DropShadow) path.getEffect()).radiusProperty(),
JFXDepthManager.getShadowAt(5).radiusProperty().get(),
EASE_BOTH),
new KeyValue(((DropShadow) path.getEffect()).spreadProperty(),
JFXDepthManager.getShadowAt(5).spreadProperty().get(),
EASE_BOTH),
new KeyValue(((DropShadow) path.getEffect()).offsetXProperty(),
JFXDepthManager.getShadowAt(5).offsetXProperty().get(),
EASE_BOTH),
new KeyValue(((DropShadow) path.getEffect()).offsetYProperty(),
JFXDepthManager.getShadowAt(5).offsetYProperty().get(),
EASE_BOTH),
new KeyValue(path.strokeWidthProperty(), 2, EASE_BOTH)
)
)
);
// reduce the number to increase the shifting , increase number to reduce shifting
setCycleDuration(Duration.millis(120));
setDelay(Duration.seconds(0));
setAutoReverse(false);
}
}
}
@@ -0,0 +1,459 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.skins;
import com.jfoenix.controls.*;
import com.jfoenix.svg.SVGGlyph;
import com.jfoenix.transitions.JFXFillTransition;
import javafx.animation.*;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Rectangle2D;
import javafx.scene.Scene;
import javafx.scene.control.Tab;
import javafx.scene.control.TextFormatter;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.stage.*;
import javafx.util.Duration;
import org.glavo.monetfx.ColorScheme;
import org.jackhuang.hmcl.setting.StyleSheets;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.util.StringUtils;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/**
* @author Shadi Shaheen
*/
public class JFXCustomColorPickerDialog extends StackPane {
private final Stage dialog = new Stage();
// used for concurrency control and preventing FX-thread over use
private final AtomicInteger concurrencyController = new AtomicInteger(-1);
private final ObjectProperty<Color> currentColorProperty = new SimpleObjectProperty<>(Color.WHITE);
private final ObjectProperty<Color> customColorProperty = new SimpleObjectProperty<>(Color.TRANSPARENT);
private Runnable onSave;
private final Scene customScene;
private final JFXCustomColorPicker curvedColorPicker;
private ParallelTransition paraTransition;
private final JFXDecorator pickerDecorator;
private boolean systemChange = false;
private boolean userChange = false;
private boolean initOnce = true;
private final Runnable initRun;
public JFXCustomColorPickerDialog(Window owner) {
getStyleClass().add("custom-color-dialog");
if (owner != null) {
dialog.initOwner(owner);
}
dialog.initModality(Modality.APPLICATION_MODAL);
dialog.initStyle(StageStyle.TRANSPARENT);
dialog.setResizable(false);
// create JFX Decorator
pickerDecorator = new JFXDecorator(dialog, this, false, false, false);
pickerDecorator.setOnCloseButtonAction(this::close);
pickerDecorator.setPickOnBounds(false);
customScene = new Scene(pickerDecorator, Color.TRANSPARENT);
StyleSheets.init(customScene);
curvedColorPicker = new JFXCustomColorPicker();
StackPane pane = new StackPane(curvedColorPicker);
pane.setPadding(new Insets(18));
VBox container = new VBox();
container.getChildren().add(pane);
JFXTabPane tabs = new JFXTabPane();
JFXTextField rgbField = new JFXTextField();
JFXTextField hsbField = new JFXTextField();
JFXTextField hexField = new JFXTextField();
rgbField.getStyleClass().add("custom-color-field");
rgbField.setPromptText("RGB Color");
rgbField.setTextFormatter(colorCharFormatter());
rgbField.textProperty().addListener((o, oldVal, newVal) -> updateColorFromUserInput(newVal));
hsbField.getStyleClass().add("custom-color-field");
hsbField.setPromptText("HSB Color");
hsbField.setTextFormatter(colorCharFormatter());
hsbField.textProperty().addListener((o, oldVal, newVal) -> updateColorFromUserInput(newVal));
hexField.getStyleClass().add("custom-color-field");
hexField.setPromptText("#HEX Color");
hexField.setTextFormatter(colorCharFormatter());
hexField.textProperty().addListener((o, oldVal, newVal) -> updateColorFromUserInput(newVal));
StackPane tabContent = new StackPane();
tabContent.getChildren().add(rgbField);
tabContent.setMinHeight(100);
Tab rgbTab = new Tab("RGB");
rgbTab.setContent(tabContent);
Tab hsbTab = new Tab("HSB");
hsbTab.setContent(hsbField);
Tab hexTab = new Tab("HEX");
hexTab.setContent(hexField);
tabs.getTabs().add(hexTab);
tabs.getTabs().add(rgbTab);
tabs.getTabs().add(hsbTab);
curvedColorPicker.selectedPath.addListener((o, oldVal, newVal) -> {
if (paraTransition != null) {
paraTransition.stop();
}
Region tabsHeader = (Region) tabs.lookup(".tab-header-background");
pane.backgroundProperty().unbind();
tabsHeader.backgroundProperty().unbind();
JFXFillTransition fillTransition = new JFXFillTransition(Duration.millis(240),
pane,
(Color) oldVal.getFill(),
(Color) newVal.getFill());
JFXFillTransition tabsFillTransition = new JFXFillTransition(Duration.millis(240),
tabsHeader,
(Color) oldVal.getFill(),
(Color) newVal.getFill());
paraTransition = new ParallelTransition(fillTransition, tabsFillTransition);
paraTransition.setOnFinished((finish) -> {
tabsHeader.backgroundProperty().bind(Bindings.createObjectBinding(() -> {
return new Background(new BackgroundFill(newVal.getFill(), CornerRadii.EMPTY, Insets.EMPTY));
}, newVal.fillProperty()));
pane.backgroundProperty().bind(Bindings.createObjectBinding(() -> {
return new Background(new BackgroundFill(newVal.getFill(), CornerRadii.EMPTY, Insets.EMPTY));
}, newVal.fillProperty()));
});
paraTransition.play();
});
container.getChildren().add(tabs);
HBox actionsHBox = new HBox();
actionsHBox.getStyleClass().add("jfx-color-dialog-actions");
JFXButton acceptButton = new JFXButton(i18n("button.ok"));
acceptButton.setOnAction(event -> updateColor());
acceptButton.getStyleClass().add("jfx-color-dialog-accept");
JFXButton cancelButton = new JFXButton(i18n("button.cancel"));
cancelButton.setOnAction(event -> close());
cancelButton.getStyleClass().add("jfx-color-dialog-cancel");
actionsHBox.getChildren().addAll(acceptButton, cancelButton);
container.getChildren().add(actionsHBox);
initRun = () -> {
// change tabs labels font color according to the selected color
pane.backgroundProperty().addListener((o, oldVal, newVal) -> {
if (concurrencyController.getAndSet(1) == -1) {
Color fontColor = ((Color) newVal.getFills().get(0).getFill()).grayscale()
.getRed() > 0.5 ? Color.valueOf(
"rgba(40, 40, 40, 0.87)") : Color.valueOf("rgba(255, 255, 255, 0.87)");
// for (Node tabNode : tabs.lookupAll(".tab")) {
// for (Node node : tabNode.lookupAll(".tab-label")) {
// ((Label) node).setTextFill(fontColor);
// }
// for (Node node : tabNode.lookupAll(".jfx-rippler")) {
// ((JFXRippler) node).setRipplerFill(fontColor);
// }
// }
// ((Pane) tabs.lookup(".tab-selected-line")).setBackground(new Background(new BackgroundFill(fontColor, CornerRadii.EMPTY, Insets.EMPTY)));
pickerDecorator.lookupAll(".jfx-decorator-button").forEach(button -> {
((JFXButton) button).setRipplerFill(fontColor);
((SVGGlyph) ((JFXButton) button).getGraphic()).setFill(fontColor);
});
Color newColor = (Color) newVal.getFills().get(0).getFill();
String hex = String.format("#%02X%02X%02X",
(int) (newColor.getRed() * 255),
(int) (newColor.getGreen() * 255),
(int) (newColor.getBlue() * 255));
String rgb = String.format("rgb(%d, %d, %d)",
(int) (newColor.getRed() * 255),
(int) (newColor.getGreen() * 255),
(int) (newColor.getBlue() * 255));
String hsb = String.format("hsl(%d, %d%%, %d%%)",
(int) (newColor.getHue()),
(int) (newColor.getSaturation() * 100),
(int) (newColor.getBrightness() * 100));
if (!userChange) {
systemChange = true;
rgbField.setText(rgb);
hsbField.setText(hsb);
hexField.setText(hex);
systemChange = false;
}
concurrencyController.getAndSet(-1);
}
});
// initial selected colors
Platform.runLater(() -> {
pane.setBackground(new Background(new BackgroundFill(curvedColorPicker.getColor(curvedColorPicker.getSelectedIndex()),
CornerRadii.EMPTY,
Insets.EMPTY)));
((Region) tabs.lookup(".tab-header-background")).setBackground(new Background(new BackgroundFill(
curvedColorPicker.getColor(curvedColorPicker.getSelectedIndex()),
CornerRadii.EMPTY,
Insets.EMPTY)));
Region tabsHeader = (Region) tabs.lookup(".tab-header-background");
pane.backgroundProperty().unbind();
tabsHeader.backgroundProperty().unbind();
tabsHeader.backgroundProperty().bind(Bindings.createObjectBinding(() -> {
return new Background(new BackgroundFill(curvedColorPicker.selectedPath.get().getFill(),
CornerRadii.EMPTY,
Insets.EMPTY));
}, curvedColorPicker.selectedPath.get().fillProperty()));
pane.backgroundProperty().bind(Bindings.createObjectBinding(() -> {
return new Background(new BackgroundFill(curvedColorPicker.selectedPath.get().getFill(),
CornerRadii.EMPTY,
Insets.EMPTY));
}, curvedColorPicker.selectedPath.get().fillProperty()));
// bind text field line color
rgbField.focusColorProperty().bind(Bindings.createObjectBinding(() -> {
return pane.getBackground().getFills().get(0).getFill();
}, pane.backgroundProperty()));
hsbField.focusColorProperty().bind(Bindings.createObjectBinding(() -> {
return pane.getBackground().getFills().get(0).getFill();
}, pane.backgroundProperty()));
hexField.focusColorProperty().bind(Bindings.createObjectBinding(() -> {
return pane.getBackground().getFills().get(0).getFill();
}, pane.backgroundProperty()));
acceptButton.textFillProperty().bind(Bindings.createObjectBinding(() -> {
Color fill = (Color) pane.getBackground().getFills().get(0).getFill();
return ColorScheme.newBuilder(Themes.getColorScheme())
.setPrimaryColorSeed(fill)
.build()
.getPrimary();
}, pane.backgroundProperty()));
((Pane) pickerDecorator.lookup(".jfx-decorator-buttons-container")).backgroundProperty()
.bind(Bindings.createObjectBinding(() -> {
return new Background(new BackgroundFill(
pane.getBackground()
.getFills()
.get(0)
.getFill(),
CornerRadii.EMPTY,
Insets.EMPTY));
}, pane.backgroundProperty()));
((Pane) pickerDecorator.lookup(".jfx-decorator-content-container")).borderProperty()
.bind(Bindings.createObjectBinding(() -> {
return new Border(new BorderStroke(
pane.getBackground()
.getFills()
.get(0)
.getFill(),
BorderStrokeStyle.SOLID,
CornerRadii.EMPTY,
new BorderWidths(0,
4,
4,
4)));
}, pane.backgroundProperty()));
});
};
this.getChildren().add(container);
this.setPadding(new Insets(0));
dialog.setScene(customScene);
final EventHandler<KeyEvent> keyEventListener = key -> {
switch (key.getCode()) {
case ESCAPE:
close();
break;
case ENTER:
updateColor();
break;
default:
break;
}
};
dialog.addEventHandler(KeyEvent.ANY, keyEventListener);
}
private void updateColor() {
close();
this.customColorProperty.set(curvedColorPicker.getColor(curvedColorPicker.getSelectedIndex()));
this.onSave.run();
}
private void updateColorFromUserInput(String colorWebString) {
if (!systemChange) {
userChange = true;
try {
Color color = Color.valueOf(colorWebString);
if (color.getOpacity() != 1.0) {
color = Color.color(color.getRed(), color.getGreen(), color.getBlue());
}
curvedColorPicker.setColor(color);
} catch (IllegalArgumentException ignored) {
// if color is not valid then do nothing
}
userChange = false;
}
}
private void close() {
dialog.setScene(null);
dialog.close();
}
public void setCurrentColor(Color currentColor) {
this.currentColorProperty.set(currentColor);
if (curvedColorPicker != null && currentColor != null) {
curvedColorPicker.setColor(currentColor);
}
}
Color getCurrentColor() {
return currentColorProperty.get();
}
ObjectProperty<Color> customColorProperty() {
return customColorProperty;
}
void setCustomColor(Color color) {
customColorProperty.set(color);
}
Color getCustomColor() {
return customColorProperty.get();
}
public Runnable getOnSave() {
return onSave;
}
public void setOnSave(Runnable onSave) {
this.onSave = onSave;
}
public void setOnHidden(EventHandler<WindowEvent> onHidden) {
dialog.setOnHidden(onHidden);
}
public void show() {
dialog.setOpacity(0);
// pickerDecorator.setOpacity(0);
if (dialog.getOwner() != null) {
dialog.widthProperty().addListener(positionAdjuster);
dialog.heightProperty().addListener(positionAdjuster);
positionAdjuster.invalidated(null);
}
if (dialog.getScene() == null) {
dialog.setScene(customScene);
}
curvedColorPicker.preAnimate();
dialog.show();
if (initOnce) {
initRun.run();
initOnce = false;
}
Timeline timeline = new Timeline(new KeyFrame(Duration.millis(120),
new KeyValue(dialog.opacityProperty(),
1,
Interpolator.EASE_BOTH)));
timeline.setOnFinished((finish) -> curvedColorPicker.animate());
timeline.play();
}
// add option to show color picker using JFX Dialog
private InvalidationListener positionAdjuster = new InvalidationListener() {
@Override
public void invalidated(Observable ignored) {
if (Double.isNaN(dialog.getWidth()) || Double.isNaN(dialog.getHeight())) {
return;
}
dialog.widthProperty().removeListener(positionAdjuster);
dialog.heightProperty().removeListener(positionAdjuster);
fixPosition();
}
};
private void fixPosition() {
Window w = dialog.getOwner();
Screen s = com.sun.javafx.util.Utils.getScreen(w);
Rectangle2D sb = s.getBounds();
double xR = w.getX() + w.getWidth();
double xL = w.getX() - dialog.getWidth();
double x;
double y;
if (sb.getMaxX() >= xR + dialog.getWidth()) {
x = xR;
} else if (sb.getMinX() <= xL) {
x = xL;
} else {
x = Math.max(sb.getMinX(), sb.getMaxX() - dialog.getWidth());
}
y = Math.max(sb.getMinY(), Math.min(sb.getMaxY() - dialog.getHeight(), w.getY()));
dialog.setX(x);
dialog.setY(y);
}
@Override
public void layoutChildren() {
super.layoutChildren();
if (dialog.getMinWidth() > 0 && dialog.getMinHeight() > 0) {
return;
}
double minWidth = Math.max(0, computeMinWidth(getHeight()) + (dialog.getWidth() - customScene.getWidth()));
double minHeight = Math.max(0, computeMinHeight(getWidth()) + (dialog.getHeight() - customScene.getHeight()));
dialog.setMinWidth(minWidth);
dialog.setMinHeight(minHeight);
}
private static final Pattern COLOR_CHAR_PATTERN = Pattern.compile("[0-9a-zA-Z#(),%.\\s]*");
private static TextFormatter<String> colorCharFormatter() {
return new TextFormatter<>(change -> {
if (!change.isContentChange()) return change;
String ins = StringUtils.toHalfWidth(change.getText());
if (!COLOR_CHAR_PATTERN.matcher(ins).matches()) return null;
String full = StringUtils.toHalfWidth(change.getControlNewText());
long h = full.chars().filter(c -> c == '#').count();
if (h > 1 || (h == 1 && full.indexOf('#') != 0)) return null;
change.setText(ins);
return change;
});
}
}
@@ -0,0 +1,731 @@
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package com.jfoenix.skins;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDatePicker;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.svg.SVGGlyph;
import com.jfoenix.transitions.CachedTransition;
import javafx.animation.*;
import javafx.animation.Animation.Status;
import javafx.beans.InvalidationListener;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.SnapshotParameters;
import javafx.scene.control.*;
import javafx.scene.image.ImageView;
import javafx.scene.image.WritableImage;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.FontWeight;
import javafx.util.Duration;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.SVGContainer;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import java.time.DateTimeException;
import java.time.LocalDate;
import java.time.Year;
import java.time.YearMonth;
import java.time.chrono.ChronoLocalDate;
import java.time.chrono.Chronology;
import java.time.format.DateTimeFormatter;
import java.time.format.DecimalStyle;
import java.time.temporal.ChronoUnit;
import java.time.temporal.WeekFields;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.stream.IntStream;
public class JFXDatePickerContent extends VBox {
private static final String SPINNER_LABEL = "spinner-label";
private static final String ROBOTO = "Roboto";
protected JFXDatePicker datePicker;
private JFXButton backMonthButton;
private JFXButton forwardMonthButton;
private Label selectedDateLabel;
private Label selectedYearLabel;
private Label monthYearLabel;
protected GridPane contentGrid;
private final StackPane calendarPlaceHolder = new StackPane();
private final CachedTransition showTransition;
private final CachedTransition hideTransition;
private ParallelTransition tempImageTransition;
private final int daysPerWeek = 7;
private final List<DateCell> weekDaysCells = new ArrayList<>();
private final List<DateCell> weekNumberCells = new ArrayList<>();
protected List<DateCell> dayCells = new ArrayList<>();
private LocalDate[] dayCellDates;
private DateCell currentFocusedDayCell = null;
private final ListView<String> yearsListView = new JFXListView<>() {
{
this.getStyleClass().addAll("date-picker-list-view", "no-padding");
this.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
this.setCellFactory((listView) -> new ListCell<>() {
static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
final Label label = new Label();
final StackPane root;
final RipplerContainer ripplerContainer;
final WeakListenerHolder holder = new WeakListenerHolder();
{
label.getStyleClass().add(SPINNER_LABEL);
label.setMaxWidth(Double.MAX_VALUE);
label.textProperty().bind(itemProperty());
StackPane pane = new StackPane(label);
pane.setPadding(new Insets(7));
ripplerContainer = new RipplerContainer(pane);
root = new StackPane(ripplerContainer);
root.getStyleClass().add("data-picker-list-cell");
this.setGraphic(root);
var selectedListenerWeak = holder.weak((InvalidationListener) observable -> {
String newVal = JFXDatePickerContent.this.selectedYearLabel.getText();
if (newVal != null && getItem() != null && getItem().equals(newVal))
updateSelected(true);
});
JFXDatePickerContent.this.selectedYearLabel.textProperty().addListener(selectedListenerWeak);
itemProperty().addListener(selectedListenerWeak);
FXUtils.onChangeAndOperate(selectedProperty(), selected -> {
if (getListView() != null) {
if (selected) {
getListView().getSelectionModel().select(getIndex());
} else {
getListView().getSelectionModel().clearSelection(getIndex());
}
}
root.pseudoClassStateChanged(SELECTED, selected);
if (selected) {
int offset = Integer.parseInt(getItem()) - Integer.parseInt(JFXDatePickerContent.this.selectedYearLabel.getText());
JFXDatePickerContent.this.forward(offset, ChronoUnit.YEARS, false, false);
}
});
}
public void updateItem(String item, boolean empty) {
String oldItem = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(item, empty);
setText(null);
if (Objects.equals(oldItem, item) && oldEmpty == empty) return;
ripplerContainer.resetFeedback();
if (!empty && item != null) {
setGraphic(root);
} else {
setGraphic(null);
}
}
});
}
};
final DateTimeFormatter fullFormatter = DateTimeFormatter.ofPattern("EEE, MMM yyyy");
final DateTimeFormatter monthFormatter = DateTimeFormatter.ofPattern("MMMM");
final DateTimeFormatter yearFormatter = DateTimeFormatter.ofPattern("y");
final DateTimeFormatter weekNumberFormatter = DateTimeFormatter.ofPattern("w");
final DateTimeFormatter weekDayNameFormatter = DateTimeFormatter.ofPattern("ccc");
final DateTimeFormatter dayCellFormatter = DateTimeFormatter.ofPattern("d");
private final ObjectProperty<YearMonth> selectedYearMonth = new SimpleObjectProperty<>(this, "selectedYearMonth");
final ObjectProperty<LocalDate> valueProperty = new SimpleObjectProperty<>(this, "value");
JFXDatePickerContent(final DatePicker datePicker) {
this.datePicker = (JFXDatePicker) datePicker;
this.getStyleClass().add("date-picker-popup");
LocalDate date = datePicker.getValue();
this.valueProperty.set(date);
this.valueProperty.addListener((o, old, n) -> {
this.displayedYearMonthProperty().set(n != null ? YearMonth.from(n) : YearMonth.now());
this.updateValues();
});
this.selectedYearMonth.set(date != null ? YearMonth.from(date) : YearMonth.now());
this.selectedYearMonth.addListener((observable, oldValue, newValue) -> this.updateValues());
this.getChildren().add(this.createHeaderPane());
this.getChildren().add(new Separator());
this.contentGrid = new GridPane() {
protected double computePrefWidth(double height) {
int nCols = JFXDatePickerContent.this.daysPerWeek + (datePicker.isShowWeekNumbers() ? 1 : 0);
double leftSpace = this.snapSpaceX(this.getInsets().getLeft());
double rightSpace = this.snapSpaceX(this.getInsets().getRight());
double hgaps = this.snapSpaceX(this.getHgap()) * (double) (nCols - 1);
double contentWidth = super.computePrefWidth(height) - leftSpace - rightSpace - hgaps;
return this.snapSizeX(contentWidth / (double) nCols) * (double) nCols + leftSpace + rightSpace + hgaps;
}
protected void layoutChildren() {
if (this.getWidth() > (double) 0.0F && this.getHeight() > (double) 0.0F) {
super.layoutChildren();
}
}
};
this.contentGrid.setFocusTraversable(true);
this.contentGrid.getStyleClass().add("calendar-grid");
this.contentGrid.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY)));
this.contentGrid.setPadding(new Insets(0.0F, 12.0F, 12.0F, 12.0F));
this.contentGrid.setVgap(4);
this.contentGrid.setHgap(4);
this.createWeekDaysCells();
this.createDayCells();
this.yearsListView.getItems().addAll(IntStream.rangeClosed(1900, Year.now().getValue() + 100).mapToObj(Integer::toString).toList());
StackPane main = new StackPane();
this.yearsListView.setVisible(false);
this.yearsListView.setOpacity(0.0F);
this.yearsListView.maxWidthProperty().bind(main.widthProperty());
this.yearsListView.maxHeightProperty().bind(main.heightProperty());
main.getChildren().setAll(this.contentGrid, this.yearsListView);
VBox contentHolder = new VBox();
contentHolder.getChildren().setAll(this.createCalendarMonthLabelPane(), main);
this.calendarPlaceHolder.getStyleClass().add("content");
this.calendarPlaceHolder.getChildren().setAll(contentHolder, this.createCalendarArrowsPane());
FXUtils.setOverflowHidden(this.calendarPlaceHolder);
this.getChildren().add(this.calendarPlaceHolder);
this.getChildren().add(this.createActions());
this.refresh();
this.addEventHandler(KeyEvent.ANY, (event) -> {
Node node = this.getScene().getFocusOwner();
if (node instanceof DateCell) {
this.currentFocusedDayCell = (DateCell) node;
}
switch (event.getCode()) {
case HOME -> {
this.init();
this.goToDate(LocalDate.now(), true);
event.consume();
}
case PAGE_UP -> {
if (!this.backMonthButton.isDisabled()) {
this.forward(-1, ChronoUnit.MONTHS, true, true);
}
event.consume();
}
case PAGE_DOWN -> {
if (!this.forwardMonthButton.isDisabled()) {
this.forward(1, ChronoUnit.MONTHS, true, true);
}
event.consume();
}
case ESCAPE -> {
fireEvent(new DialogCloseEvent());
event.consume();
}
default -> event.consume();
}
});
this.showTransition = new CachedTransition(this.yearsListView, new Timeline(new KeyFrame(Duration.millis(0.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 1, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(500.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(1000.0F), new KeyValue(this.yearsListView.opacityProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)))) {
{
this.setCycleDuration(Duration.millis(320.0F));
this.setDelay(Duration.seconds(0.0F));
}
protected void starting() {
super.starting();
JFXDatePickerContent.this.yearsListView.setVisible(true);
}
};
this.hideTransition = new CachedTransition(this.yearsListView, new Timeline(new KeyFrame(Duration.millis(0.0F), new KeyValue(this.yearsListView.opacityProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(500.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(1000.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 1, Interpolator.EASE_BOTH)))) {
{
this.setCycleDuration(Duration.millis(320.0F));
this.setDelay(Duration.seconds(0.0F));
}
protected void stopping() {
super.stopping();
JFXDatePickerContent.this.yearsListView.setVisible(false);
}
};
}
ObjectProperty<YearMonth> displayedYearMonthProperty() {
return this.selectedYearMonth;
}
private void createWeekDaysCells() {
for (int i = 0; i < this.daysPerWeek; ++i) {
DateCell cell = new DateCell();
cell.getStyleClass().add("day-name-cell");
cell.setFont(Font.font(ROBOTO, FontWeight.BOLD, 12.0F));
cell.setAlignment(Pos.BASELINE_CENTER);
this.weekDaysCells.add(cell);
}
for (int i = 0; i < 6; ++i) {
DateCell cell = new DateCell();
cell.getStyleClass().add("week-number-cell");
cell.setFont(Font.font(ROBOTO, FontWeight.BOLD, 12.0F));
this.weekNumberCells.add(cell);
}
}
protected VBox createHeaderPane() {
Label title = new Label(I18n.i18n("button.select_date"));
title.getStyleClass().add("title");
title.setFont(Font.font(ROBOTO, FontWeight.NORMAL, 12.0F));
HBox titleContainer = new HBox(title);
titleContainer.setAlignment(Pos.CENTER_LEFT);
titleContainer.setPadding(new Insets(0, 0, 5, 0));
this.selectedYearLabel = new Label();
this.selectedYearLabel.getStyleClass().add(SPINNER_LABEL);
this.selectedYearLabel.setFont(Font.font(ROBOTO, FontWeight.BOLD, 14.0F));
HBox yearLabelContainer = new HBox();
yearLabelContainer.getStyleClass().add("spinner");
yearLabelContainer.getChildren().addAll(this.selectedYearLabel);
yearLabelContainer.setAlignment(Pos.CENTER_LEFT);
yearLabelContainer.setFillHeight(false);
yearLabelContainer.setOnMouseClicked((click) -> {
if (!this.yearsListView.isVisible()) {
int yearIndex = Integer.parseInt(this.selectedYearLabel.getText()) - 1900 - 2;
this.yearsListView.scrollTo(yearIndex >= 0 ? yearIndex : yearIndex + 2);
this.hideTransition.stop();
this.showTransition.play();
}
});
this.selectedDateLabel = new Label();
this.selectedDateLabel.getStyleClass().add(SPINNER_LABEL);
this.selectedDateLabel.setFont(Font.font(ROBOTO, FontWeight.BOLD, 32.0F));
HBox selectedDateContainer = new HBox();
selectedDateContainer.getStyleClass().add("spinner");
selectedDateContainer.getChildren().addAll(this.selectedDateLabel);
selectedDateContainer.setAlignment(Pos.CENTER_LEFT);
selectedDateContainer.setOnMouseClicked((click) -> {
if (this.yearsListView.isVisible()) {
this.showTransition.stop();
this.hideTransition.play();
}
});
VBox headerPanel = new VBox();
headerPanel.getStyleClass().add("month-year-pane");
headerPanel.setPadding(new Insets(12.0F, 24.0F, 12.0F, 24.0F));
headerPanel.getChildren().addAll(titleContainer, yearLabelContainer, selectedDateContainer);
return headerPanel;
}
protected BorderPane createCalendarArrowsPane() {
SVGGlyph leftChevron = new SVGGlyph(0, "CHEVRON_LEFT", "M 742,-37 90,614 Q 53,651 53,704.5 53,758 90,795 l 652,651 q 37,37 90.5,37 53.5,0 90.5,-37 l 75,-75 q 37,-37 37,-90.5 0,-53.5 -37,-90.5 L 512,704 998,219 q 37,-38 37,-91 0,-53 -37,-90 L 923,-37 Q 886,-74 832.5,-74 779,-74 742,-37 z", Color.GRAY);
SVGGlyph rightChevron = new SVGGlyph(0, "CHEVRON_RIGHT", "m 1099,704 q 0,-52 -37,-91 L 410,-38 q -37,-37 -90,-37 -53,0 -90,37 l -76,75 q -37,39 -37,91 0,53 37,90 l 486,486 -486,485 q -37,39 -37,91 0,53 37,90 l 76,75 q 36,38 90,38 54,0 90,-38 l 652,-651 q 37,-37 37,-90 z", Color.GRAY);
leftChevron.setSize(6.0F, 11.0F);
rightChevron.setSize(6.0F, 11.0F);
this.backMonthButton = new JFXButton();
this.backMonthButton.setMinSize(40.0F, 40.0F);
this.backMonthButton.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, new CornerRadii(40.0F), Insets.EMPTY)));
this.backMonthButton.getStyleClass().add("left-button");
this.backMonthButton.setGraphic(leftChevron);
this.backMonthButton.setRipplerFill(this.datePicker.getDefaultColor());
this.backMonthButton.setOnAction((t) -> this.forward(-1, ChronoUnit.MONTHS, false, true));
this.forwardMonthButton = new JFXButton();
this.forwardMonthButton.setMinSize(40.0F, 40.0F);
this.forwardMonthButton.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, new CornerRadii(40.0F), Insets.EMPTY)));
this.forwardMonthButton.getStyleClass().add("right-button");
this.forwardMonthButton.setGraphic(rightChevron);
this.forwardMonthButton.setRipplerFill(this.datePicker.getDefaultColor());
this.forwardMonthButton.setOnAction((t) -> this.forward(1, ChronoUnit.MONTHS, false, true));
BorderPane arrowsContainer = new BorderPane();
arrowsContainer.setLeft(this.backMonthButton);
arrowsContainer.setRight(this.forwardMonthButton);
arrowsContainer.setPadding(new Insets(4.0F, 12.0F, 2.0F, 12.0F));
arrowsContainer.setPickOnBounds(false);
return arrowsContainer;
}
protected BorderPane createCalendarMonthLabelPane() {
this.monthYearLabel = new Label();
this.monthYearLabel.getStyleClass().add(SPINNER_LABEL);
this.monthYearLabel.setFont(Font.font(ROBOTO, FontWeight.BOLD, 13.0F));
SVGContainer svgContainer = new SVGContainer(13);
HBox box = new HBox(monthYearLabel, svgContainer);
box.setSpacing(3);
box.setAlignment(Pos.CENTER);
FXUtils.onChangeAndOperate(this.yearsListView.visibleProperty(), b -> svgContainer.setIcon(b ? SVG.ARROW_DROP_UP : SVG.ARROW_DROP_DOWN));
FXUtils.onClicked(box, () -> {
if (!this.yearsListView.isVisible()) {
int yearIndex = Integer.parseInt(this.selectedYearLabel.getText()) - 1900 - 2;
this.yearsListView.scrollTo(yearIndex >= 0 ? yearIndex : yearIndex + 2);
this.hideTransition.stop();
this.showTransition.play();
} else {
this.showTransition.stop();
this.hideTransition.play();
}
});
BorderPane monthContainer = new BorderPane();
monthContainer.setMinHeight(50.0F);
monthContainer.setCenter(box);
monthContainer.setPadding(new Insets(2.0F, 12.0F, 2.0F, 12.0F));
return monthContainer;
}
protected HBox createActions() {
JFXButton cancelButton = new JFXButton(I18n.i18n("button.cancel"));
cancelButton.getStyleClass().add("dialog-cancel");
FXUtils.onClicked(cancelButton, () -> fireEvent(new DialogCloseEvent()));
JFXButton acceptButton = new JFXButton(I18n.i18n("button.ok"));
acceptButton.getStyleClass().add("dialog-accept");
FXUtils.onClicked(acceptButton, () -> {
datePicker.setValue(valueProperty.get());
fireEvent(new DialogCloseEvent());
});
HBox hBox = new HBox(cancelButton, acceptButton);
hBox.setPadding(new Insets(0, 5, 3, 0));
hBox.setSpacing(5);
hBox.setAlignment(Pos.CENTER_RIGHT);
return hBox;
}
void updateContentGrid() {
this.contentGrid.getColumnConstraints().clear();
this.contentGrid.getChildren().clear();
int colsNumber = this.daysPerWeek + (this.datePicker.isShowWeekNumbers() ? 1 : 0);
ColumnConstraints columnConstraints = new ColumnConstraints();
columnConstraints.setPercentWidth(100.0F);
for (int i = 0; i < colsNumber; ++i) {
this.contentGrid.getColumnConstraints().add(columnConstraints);
}
for (int i = 0; i < this.daysPerWeek; ++i) {
this.contentGrid.add(this.weekDaysCells.get(i), i + colsNumber - this.daysPerWeek, 1);
}
if (this.datePicker.isShowWeekNumbers()) {
for (int i = 0; i < 6; ++i) {
this.contentGrid.add(this.weekNumberCells.get(i), 0, i + 2);
}
}
for (int row = 0; row < 6; ++row) {
for (int col = 0; col < this.daysPerWeek; ++col) {
this.contentGrid.add(this.dayCells.get(row * this.daysPerWeek + col), col + colsNumber - this.daysPerWeek, row + 2);
}
}
}
private void refresh() {
this.updateDayNameCells();
this.updateValues();
}
private void updateDayNameCells() {
int weekFirstDay = WeekFields.of(this.getLocale()).getFirstDayOfWeek().getValue();
LocalDate date = LocalDate.of(2009, 7, 12 + weekFirstDay);
for (int i = 0; i < this.daysPerWeek; ++i) {
String name = this.weekDayNameFormatter.withLocale(this.getLocale()).format(date.plusDays(i));
if (!LocaleUtils.isChinese(this.weekNumberFormatter.getLocale())) {
name = name.substring(0, 1).toUpperCase(Locale.ROOT);
}
this.weekDaysCells.get(i).setText(name);
}
}
void updateValues() {
this.updateWeekNumberDateCells();
this.updateDayCells();
this.updateMonthYearPane();
}
void updateWeekNumberDateCells() {
if (this.datePicker.isShowWeekNumbers()) {
Locale locale = this.getLocale();
LocalDate firstDayOfMonth = this.selectedYearMonth.get().atDay(1);
for (int i = 0; i < 6; ++i) {
LocalDate date = firstDayOfMonth.plusWeeks(i);
String weekNumber = this.weekNumberFormatter.withLocale(locale).withDecimalStyle(DecimalStyle.of(locale)).format(date);
this.weekNumberCells.get(i).setText(weekNumber);
}
}
}
private void updateDayCells() {
Locale locale = this.getLocale();
Chronology chrono = this.getPrimaryChronology();
int firstDayOfWeek = WeekFields.of(this.getLocale()).getFirstDayOfWeek().getValue();
int firstOfMonthIndex = this.selectedYearMonth.get().atDay(1).getDayOfWeek().getValue() - firstDayOfWeek;
firstOfMonthIndex += firstOfMonthIndex < 0 ? this.daysPerWeek : 0;
YearMonth currentYearMonth = this.selectedYearMonth.get();
int daysInCurMonth = -1;
for (int i = 0; i < 6 * this.daysPerWeek; ++i) {
DateCell dayCell = this.dayCells.get(i);
try {
if (daysInCurMonth == -1) {
daysInCurMonth = currentYearMonth.lengthOfMonth();
}
int dayIndex = i - firstOfMonthIndex + 1;
LocalDate date;
if (dayIndex <= 0) {
YearMonth previous = currentYearMonth.minusMonths(1);
date = previous.atDay(previous.lengthOfMonth() + dayIndex);
} else if (dayIndex > daysInCurMonth) {
YearMonth next = currentYearMonth.plusMonths(1);
date = next.atDay(dayIndex - daysInCurMonth);
} else {
date = currentYearMonth.atDay(dayIndex);
}
this.dayCellDates[i] = date;
if (date.equals(LocalDate.now())) {
dayCell.getStyleClass().add("today");
} else {
dayCell.getStyleClass().removeAll("today");
}
if (date.equals(valueProperty.get())) {
dayCell.getStyleClass().add("selected");
this.currentFocusedDayCell = dayCell;
} else {
dayCell.getStyleClass().removeAll("selected");
}
ChronoLocalDate cDate = chrono.date(date);
String cellText = this.dayCellFormatter.withLocale(locale).withChronology(chrono).withDecimalStyle(DecimalStyle.of(locale)).format(cDate);
dayCell.setText(cellText);
if (i < firstOfMonthIndex) {
dayCell.getStyleClass().add("previous-month");
dayCell.getStyleClass().removeAll("next-month");
dayCell.setDisable(true);
} else if (i >= firstOfMonthIndex + daysInCurMonth) {
dayCell.getStyleClass().add("next-month");
dayCell.getStyleClass().removeAll("previous-month");
dayCell.setDisable(true);
} else {
dayCell.getStyleClass().removeAll("previous-month", "next-month");
dayCell.setDisable(false);
}
dayCell.updateItem(date, false);
} catch (DateTimeException var13) {
dayCell.setText("");
dayCell.setDisable(true);
}
}
}
protected void updateMonthYearPane() {
YearMonth yearMonth = this.selectedYearMonth.get();
this.selectedDateLabel.setText(fullFormatter.withLocale(this.getLocale()).format(Objects.requireNonNullElseGet(valueProperty.get(), LocalDate::now)));
this.selectedYearLabel.setText(this.formatYear(yearMonth));
this.monthYearLabel.setText(this.formatMonth(yearMonth) + " " + this.formatYear(yearMonth));
Chronology chrono = this.datePicker.getChronology();
LocalDate firstDayOfMonth = yearMonth.atDay(1);
this.backMonthButton.setDisable(!this.isValidDate(chrono, firstDayOfMonth, -1, ChronoUnit.DAYS));
this.forwardMonthButton.setDisable(!this.isValidDate(chrono, firstDayOfMonth, 1, ChronoUnit.MONTHS));
}
private String formatMonth(YearMonth yearMonth) {
try {
Chronology chrono = this.getPrimaryChronology();
ChronoLocalDate cDate = chrono.date(yearMonth.atDay(1));
return this.monthFormatter.withLocale(this.getLocale()).withChronology(chrono).format(cDate);
} catch (DateTimeException var4) {
return "";
}
}
private String formatYear(YearMonth yearMonth) {
try {
Chronology chrono = this.getPrimaryChronology();
ChronoLocalDate cDate = chrono.date(yearMonth.atDay(1));
return this.yearFormatter.withLocale(this.getLocale()).withChronology(chrono).withDecimalStyle(DecimalStyle.of(this.getLocale())).format(cDate);
} catch (DateTimeException var4) {
return "";
}
}
protected LocalDate dayCellDate(DateCell dateCell) {
assert this.dayCellDates != null;
return this.dayCellDates[this.dayCells.indexOf(dateCell)];
}
protected void forward(int offset, ChronoUnit unit, boolean focusDayCell, boolean withAnimation) {
if (withAnimation && (this.tempImageTransition == null || this.tempImageTransition.getStatus() == Status.STOPPED)) {
Pane monthContent = (Pane) this.calendarPlaceHolder.getChildren().get(0);
this.getParent().setManaged(false);
SnapshotParameters snapShotparams = new SnapshotParameters();
snapShotparams.setFill(Color.TRANSPARENT);
WritableImage temp = monthContent.snapshot(snapShotparams, new WritableImage((int) monthContent.getWidth(), (int) monthContent.getHeight()));
ImageView tempImage = new ImageView(temp);
this.calendarPlaceHolder.getChildren().add(this.calendarPlaceHolder.getChildren().size() - 2, tempImage);
TranslateTransition imageTransition = new TranslateTransition(Duration.millis(160.0F), tempImage);
imageTransition.setToX((double) (-offset) * this.calendarPlaceHolder.getWidth());
imageTransition.setOnFinished((finish) -> this.calendarPlaceHolder.getChildren().remove(tempImage));
monthContent.setTranslateX((double) offset * this.calendarPlaceHolder.getWidth());
TranslateTransition contentTransition = new TranslateTransition(Duration.millis(160.0F), monthContent);
contentTransition.setToX(0.0F);
this.tempImageTransition = new ParallelTransition(imageTransition, contentTransition);
this.tempImageTransition.setOnFinished((finish) -> {
this.calendarPlaceHolder.getChildren().remove(tempImage);
this.getParent().setManaged(true);
});
this.tempImageTransition.play();
}
YearMonth yearMonth = this.selectedYearMonth.get();
DateCell dateCell = this.currentFocusedDayCell;
if (dateCell == null || this.dayCellDate(dateCell).getMonth() != yearMonth.getMonth()) {
dateCell = this.findDayCellOfDate(yearMonth.atDay(1));
}
this.goToDayCell(dateCell, offset, unit, focusDayCell);
}
private void goToDayCell(DateCell dateCell, int offset, ChronoUnit unit, boolean focusDayCell) {
this.goToDate(this.dayCellDate(dateCell).plus(offset, unit), focusDayCell);
}
private void goToDate(LocalDate date, boolean focusDayCell) {
if (this.isValidDate(this.datePicker.getChronology(), date)) {
this.valueProperty.set(date);
this.selectedYearMonth.set(YearMonth.from(date));
if (focusDayCell) {
this.findDayCellOfDate(date).requestFocus();
}
}
}
private void selectDayCell(DateCell dateCell) {
this.valueProperty.set(this.dayCellDate(dateCell));
}
private DateCell findDayCellOfDate(LocalDate date) {
for (int i = 0; i < this.dayCellDates.length; ++i) {
if (date.equals(this.dayCellDates[i])) {
return this.dayCells.get(i);
}
}
return this.dayCells.get(this.dayCells.size() / 2 + 1);
}
void init() {
this.contentGrid.setOpacity(1.0F);
this.yearsListView.setOpacity(0.0F);
this.yearsListView.setVisible(false);
}
void clearFocus() {
LocalDate focusDate = this.valueProperty.get();
if (focusDate == null) {
focusDate = LocalDate.now();
}
if (YearMonth.from(focusDate).equals(this.selectedYearMonth.get())) {
this.goToDate(focusDate, true);
}
}
protected void createDayCells() {
for (int row = 0; row < 6; ++row) {
for (int col = 0; col < this.daysPerWeek; ++col) {
DateCell dayCell = this.createDayCell();
dayCell.addEventHandler(MouseEvent.MOUSE_CLICKED, (click) -> {
if (click.getButton() == MouseButton.PRIMARY) {
DateCell selectedDayCell = (DateCell) click.getSource();
this.selectDayCell(selectedDayCell);
}
});
dayCell.setOnMouseEntered((event) -> dayCell.getStyleClass().add("hovered"));
dayCell.setOnMouseExited((event) -> dayCell.getStyleClass().removeAll("hovered"));
dayCell.setAlignment(Pos.BASELINE_CENTER);
dayCell.setBorder(new Border(new BorderStroke(Color.TRANSPARENT, BorderStrokeStyle.SOLID, CornerRadii.EMPTY, new BorderWidths(5.0F))));
dayCell.setFont(Font.font(ROBOTO, FontWeight.BOLD, 12.0F));
this.dayCells.add(dayCell);
}
}
this.dayCellDates = new LocalDate[6 * this.daysPerWeek];
this.updateContentGrid();
}
private DateCell createDayCell() {
DateCell dayCell = null;
if (this.datePicker.getDayCellFactory() != null) {
dayCell = this.datePicker.getDayCellFactory().call(this.datePicker);
}
if (dayCell == null) {
dayCell = new DateCell();
}
dayCell.getStyleClass().add("day-cell");
dayCell.setPrefSize(36.0F, 36.0F);
dayCell.setDisable(false);
dayCell.setStyle(null);
dayCell.setGraphic(null);
dayCell.setTooltip(null);
return dayCell;
}
protected Chronology getPrimaryChronology() {
return this.datePicker.getChronology();
}
protected Locale getLocale() {
return SettingsManager.settings().languageProperty().get().getLocale();
}
protected boolean isValidDate(Chronology chrono, LocalDate date, int offset, ChronoUnit unit) {
return date != null && this.isValidDate(chrono, date.plus(offset, unit));
}
protected boolean isValidDate(Chronology chrono, LocalDate date) {
try {
if (date != null) {
chrono.date(date);
}
return true;
} catch (DateTimeException var4) {
return false;
}
}
}
@@ -0,0 +1,181 @@
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package com.jfoenix.skins;
import com.jfoenix.adapters.ReflectionHelper;
import com.jfoenix.adapters.skins.DatePickerSkin;
import com.jfoenix.controls.JFXDatePicker;
import com.jfoenix.controls.JFXDialog;
import com.jfoenix.controls.JFXDialog.DialogTransition;
import javafx.beans.value.ChangeListener;
import javafx.event.ActionEvent;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.TextField;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.Stack;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class JFXDatePickerSkin extends DatePickerSkin {
public static final String PROPERTY_DATE_PICKER_CONTENTS = JFXDatePicker.class.getName() + ".dialog.contents";
private final JFXDatePicker jfxDatePicker;
private TextField displayNode;
private JFXDatePickerContent content;
private JFXDialog dialog;
public JFXDatePickerSkin(JFXDatePicker datePicker) {
super(datePicker);
this.jfxDatePicker = datePicker;
try {
Object expressionHelper = ReflectionHelper.getFieldContent(datePicker.focusedProperty().getClass().getSuperclass(), datePicker.focusedProperty(), "helper");
ChangeListener<Boolean>[] changeListeners = ReflectionHelper.getFieldContent(expressionHelper, "changeListeners");
int i = changeListeners.length - 1;
if (i >= 0) {
while (i >= 0 && changeListeners[i] == null) {
--i;
}
if (i >= 0) datePicker.focusedProperty().removeListener(changeListeners[i]);
}
} catch (NullPointerException e) {
LOG.warning("Cannot remove focusedProperty listener", e);
}
datePicker.focusedProperty().addListener((obj, oldVal, newVal) -> {
if (this.getEditor() != null && !newVal) {
this.setTextFromTextFieldIntoComboBoxValue2();
}
});
var arrowBox = new HBox(new RipplerContainer(SVG.EDIT_CALENDAR.createIcon(30)));
arrowBox.setAlignment(Pos.CENTER);
this.setArrow(arrowBox);
this.getArrowButton().getChildren().setAll(arrowBox);
this.getArrowButton().setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY)));
this.getArrowButton().setPadding(new Insets(1.0F, 8.0F, 1.0F, 8.0F));
this.registerChangeListener2(datePicker.converterProperty(), "CONVERTER", this::updateDisplayNode2);
this.registerChangeListener2(datePicker.dayCellFactoryProperty(), "DAY_CELL_FACTORY", () -> {
this.updateDisplayNode2();
this.content = null;
this.setPopup2(null);
});
this.registerChangeListener2(datePicker.editorProperty(), "EDITOR", this::getEditableInputNode2);
this.registerChangeListener2(datePicker.showingProperty(), "SHOWING", () -> {
if (this.jfxDatePicker.isShowing()) {
if (this.content != null) {
LocalDate date = this.jfxDatePicker.getValue();
this.content.displayedYearMonthProperty().set(date != null ? YearMonth.from(date) : YearMonth.now());
this.content.updateValues();
}
this.show();
} else {
this.hide();
}
});
this.registerChangeListener2(datePicker.showWeekNumbersProperty(), "SHOW_WEEK_NUMBERS", () -> {
if (this.content != null) {
this.content.updateContentGrid();
this.content.updateWeekNumberDateCells();
}
});
this.registerChangeListener2(datePicker.valueProperty(), "VALUE", () -> {
this.updateDisplayNode2();
this.jfxDatePicker.fireEvent(new ActionEvent());
});
}
@Override
public JFXDatePickerContent getPopupContent() {
if (this.content == null) {
this.content = new JFXDatePickerContent(this.jfxDatePicker);
}
return this.content;
}
@Override
public void show() {
if (!((JFXDatePicker) this.getSkinnable()).isOverLay()) {
super.show();
}
if (this.content != null) {
this.content.init();
this.content.clearFocus();
}
if (((JFXDatePicker) this.getSkinnable()).isOverLay() && this.dialog == null) {
StackPane dialogParent = this.jfxDatePicker.getDialogParent();
if (dialogParent == null) {
dialogParent = (StackPane) this.getSkinnable().getScene().getRoot();
}
this.dialog = new JFXDialog(dialogParent, this.getPopupContent(), DialogTransition.CENTER, true) {
@Override
public void close() {
super.close();
Object o = getDialogContainer().getProperties().get(PROPERTY_DATE_PICKER_CONTENTS);
if (o instanceof Stack<?> stack) {
stack.pop();
}
jfxDatePicker.hide();
}
};
this.getPopupContent().addEventHandler(DialogCloseEvent.CLOSE, e -> this.dialog.close());
getArrowButton().setOnMouseClicked((click) -> {
if (((JFXDatePicker) this.getSkinnable()).isOverLay()) {
StackPane parent = this.jfxDatePicker.getDialogParent();
if (parent == null) {
parent = (StackPane) this.getSkinnable().getScene().getRoot();
}
Stack<JFXDatePickerContent> contentStack = (Stack<JFXDatePickerContent>) parent.getProperties().computeIfAbsent(PROPERTY_DATE_PICKER_CONTENTS, k -> new Stack<>());
contentStack.push(this.getPopupContent());
this.getPopupContent().valueProperty.set(this.getSkinnable().getValue());
this.dialog.show(parent);
}
});
}
}
JFXDialog getDialog() {
return this.dialog;
}
@Override
public Node getDisplayNode() {
if (this.displayNode == null) {
this.displayNode = this.getEditableInputNode2();
this.displayNode.getStyleClass().add("date-picker-display-node");
this.updateDisplayNode2();
}
this.displayNode.setEditable(this.jfxDatePicker.isEditable());
return this.displayNode;
}
public void syncWithAutoUpdate() {
if (!this.getPopup2().isShowing() && this.jfxDatePicker.isShowing()) {
this.jfxDatePicker.hide();
}
}
}
@@ -0,0 +1,242 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.skins;
import com.jfoenix.adapters.ReflectionHelper;
import com.jfoenix.controls.behavior.JFXGenericPickerBehavior;
import com.sun.javafx.binding.ExpressionHelper;
import com.sun.javafx.event.EventHandlerManager;
import com.sun.javafx.stage.WindowEventDispatcher;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.ReadOnlyBooleanPropertyBase;
import javafx.beans.value.ChangeListener;
import javafx.event.EventHandler;
import javafx.event.EventType;
import javafx.scene.control.ComboBoxBase;
import javafx.scene.control.PopupControl;
import javafx.scene.control.TextField;
import javafx.scene.control.skin.ComboBoxBaseSkin;
import javafx.scene.control.skin.ComboBoxPopupControl;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Pane;
import javafx.stage.Window;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.function.Function;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public abstract class JFXGenericPickerSkin<T> extends ComboBoxPopupControl<T> {
private final EventHandler<MouseEvent> mouseEnteredEventHandler;
private final EventHandler<MouseEvent> mousePressedEventHandler;
private final EventHandler<MouseEvent> mouseReleasedEventHandler;
private final EventHandler<MouseEvent> mouseExitedEventHandler;
protected JFXGenericPickerBehavior<T> behavior;
// reference of the arrow button node in getChildren (not the actual field)
protected Pane arrowButton;
protected PopupControl popup;
public JFXGenericPickerSkin(ComboBoxBase<T> comboBoxBase) {
super(comboBoxBase);
behavior = new JFXGenericPickerBehavior<T>(comboBoxBase);
removeParentFakeFocusListener(comboBoxBase);
this.mouseEnteredEventHandler = event -> behavior.mouseEntered(event);
this.mousePressedEventHandler = event -> {
behavior.mousePressed(event);
event.consume();
};
this.mouseReleasedEventHandler = event -> {
behavior.mouseReleased(event);
event.consume();
};
this.mouseExitedEventHandler = event -> behavior.mouseExited(event);
arrowButton = (Pane) getChildren().get(0);
parentArrowEventHandlerTerminator.accept("mouseEnteredEventHandler", MouseEvent.MOUSE_ENTERED);
parentArrowEventHandlerTerminator.accept("mousePressedEventHandler", MouseEvent.MOUSE_PRESSED);
parentArrowEventHandlerTerminator.accept("mouseReleasedEventHandler", MouseEvent.MOUSE_RELEASED);
parentArrowEventHandlerTerminator.accept("mouseExitedEventHandler", MouseEvent.MOUSE_EXITED);
this.unregisterChangeListeners(comboBoxBase.editableProperty());
updateArrowButtonListeners();
registerChangeListener(comboBoxBase.editableProperty(), obs -> {
updateArrowButtonListeners();
reflectUpdateDisplayArea();
});
removeParentPopupHandlers();
popup = ReflectionHelper.getFieldContent(ComboBoxPopupControl.class, this, "popup");
}
@Override
public void dispose() {
super.dispose();
if (this.behavior != null) {
this.behavior.dispose();
}
}
/***************************************************************************
* *
* Reflections internal API *
* *
**************************************************************************/
private final BiConsumer<String, EventType<?>> parentArrowEventHandlerTerminator = (handlerName, eventType) -> {
try {
EventHandler handler = ReflectionHelper.getFieldContent(ComboBoxBaseSkin.class, this, handlerName);
arrowButton.removeEventHandler(eventType, handler);
} catch (Exception e) {
e.printStackTrace();
}
};
private static final VarHandle READ_ONLY_BOOLEAN_PROPERTY_BASE_HELPER =
findVarHandle(ReadOnlyBooleanPropertyBase.class, "helper", ExpressionHelper.class);
/// @author Glavo
private static VarHandle findVarHandle(Class<?> targetClass, String fieldName, Class<?> type) {
try {
return MethodHandles.privateLookupIn(targetClass, MethodHandles.lookup()).findVarHandle(targetClass, fieldName, type);
} catch (NoSuchFieldException | IllegalAccessException e) {
LOG.warning("Failed to get var handle", e);
return null;
}
}
private void removeParentFakeFocusListener(ComboBoxBase<T> comboBoxBase) {
// handle FakeFocusField cast exception
try {
final ReadOnlyBooleanProperty focusedProperty = comboBoxBase.focusedProperty();
//noinspection unchecked
ExpressionHelper<Boolean> value = (ExpressionHelper<Boolean>) READ_ONLY_BOOLEAN_PROPERTY_BASE_HELPER.get(focusedProperty);
ChangeListener<? super Boolean>[] changeListeners = ReflectionHelper.getFieldContent(value.getClass(), value, "changeListeners");
// remove parent focus listener to prevent editor class cast exception
for (int i = changeListeners.length - 1; i > 0; i--) {
if (changeListeners[i] != null && changeListeners[i].getClass().getName().contains("ComboBoxPopupControl")) {
focusedProperty.removeListener(changeListeners[i]);
break;
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
private void removeParentPopupHandlers() {
try {
PopupControl popup = ReflectionHelper.invoke(ComboBoxPopupControl.class, this, "getPopup");
popup.setOnAutoHide(event -> behavior.onAutoHide(popup));
WindowEventDispatcher dispatcher = ReflectionHelper.invoke(Window.class, popup, "getInternalEventDispatcher");
Map compositeEventHandlersMap = ReflectionHelper.getFieldContent(EventHandlerManager.class, dispatcher.getEventHandlerManager(), "eventHandlerMap");
compositeEventHandlersMap.remove(MouseEvent.MOUSE_CLICKED);
// CompositeEventHandler compositeEventHandler = (CompositeEventHandler) compositeEventHandlersMap.get(MouseEvent.MOUSE_CLICKED);
// Object obj = fieldConsumer.apply(()->CompositeEventHandler.class.getDeclaredField("firstRecord"),compositeEventHandler);
// EventHandler handler = (EventHandler) fieldConsumer.apply(() -> obj.getClass().getDeclaredField("eventHandler"), obj);
// popup.removeEventHandler(MouseEvent.MOUSE_CLICKED, handler);
popup.addEventHandler(MouseEvent.MOUSE_CLICKED, click -> behavior.onAutoHide(popup));
} catch (Exception e) {
e.printStackTrace();
}
}
private void updateArrowButtonListeners() {
if (getSkinnable().isEditable()) {
arrowButton.addEventHandler(MouseEvent.MOUSE_ENTERED, mouseEnteredEventHandler);
arrowButton.addEventHandler(MouseEvent.MOUSE_PRESSED, mousePressedEventHandler);
arrowButton.addEventHandler(MouseEvent.MOUSE_RELEASED, mouseReleasedEventHandler);
arrowButton.addEventHandler(MouseEvent.MOUSE_EXITED, mouseExitedEventHandler);
} else {
arrowButton.removeEventHandler(MouseEvent.MOUSE_ENTERED, mouseEnteredEventHandler);
arrowButton.removeEventHandler(MouseEvent.MOUSE_PRESSED, mousePressedEventHandler);
arrowButton.removeEventHandler(MouseEvent.MOUSE_RELEASED, mouseReleasedEventHandler);
arrowButton.removeEventHandler(MouseEvent.MOUSE_EXITED, mouseExitedEventHandler);
}
}
/***************************************************************************
* *
* Reflections internal API for ComboBoxPopupControl *
* *
**************************************************************************/
private final HashMap<String, Method> parentCachedMethods = new HashMap<>();
Function<String, Method> methodSupplier = name -> {
if (!parentCachedMethods.containsKey(name)) {
try {
Method method = ComboBoxPopupControl.class.getDeclaredMethod(name);
method.setAccessible(true);
parentCachedMethods.put(name, method);
} catch (Exception e) {
e.printStackTrace();
}
}
return parentCachedMethods.get(name);
};
final Consumer<Method> methodInvoker = method -> {
try {
method.invoke(this);
} catch (Exception e) {
e.printStackTrace();
}
};
final Function<Method, Object> methodReturnInvoker = method -> {
try {
return method.invoke(this);
} catch (Exception e) {
e.printStackTrace();
}
return null;
};
protected void reflectUpdateDisplayArea() {
methodInvoker.accept(methodSupplier.apply("updateDisplayArea"));
}
protected void reflectSetTextFromTextFieldIntoComboBoxValue() {
methodInvoker.accept(methodSupplier.apply("setTextFromTextFieldIntoComboBoxValue"));
}
protected TextField reflectGetEditableInputNode() {
return (TextField) methodReturnInvoker.apply(methodSupplier.apply("getEditableInputNode"));
}
protected void reflectUpdateDisplayNode() {
methodInvoker.accept(methodSupplier.apply("updateDisplayNode"));
}
}
@@ -0,0 +1,47 @@
// Copy from https://github.com/sshahine/JFoenix/blob/d427fd801a338f934307ba41ce604eb5c79f0b20/jfoenix/src/main/java/com/jfoenix/skins/JFXListViewSkin.java
/*
* 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.JFXListView;
import com.jfoenix.effects.JFXDepthManager;
import javafx.scene.control.ListCell;
import javafx.scene.control.skin.ListViewSkin;
import javafx.scene.control.skin.VirtualFlow;
import org.jackhuang.hmcl.ui.FXUtils;
public class JFXListViewSkin<T> extends ListViewSkin<T> {
public JFXListViewSkin(final JFXListView<T> listView) {
super(listView);
VirtualFlow<ListCell<T>> flow = getVirtualFlow();
FXUtils.onChangeAndOperate(listView.depthProperty(), depth -> JFXDepthManager.setDepth(flow, depth.intValue()));
if (!Boolean.TRUE.equals(listView.getProperties().get("no-smooth-scrolling"))) {
FXUtils.smoothScrolling(flow);
}
}
@Override
protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
return 200;
}
}
@@ -0,0 +1,139 @@
/*
* Copyright (c) 2016 JFoenix
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.jfoenix.skins;
import com.jfoenix.controls.JFXPopup;
import com.jfoenix.controls.JFXPopup.PopupHPosition;
import com.jfoenix.controls.JFXPopup.PopupVPosition;
import com.jfoenix.effects.JFXDepthManager;
import javafx.animation.*;
import javafx.animation.Animation.Status;
import javafx.scene.Node;
import javafx.scene.control.Skin;
import javafx.scene.layout.*;
import javafx.scene.transform.Scale;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.Motion;
/// # Material Design Popup Skin
/// TODO: REWORK
///
/// @author Shadi Shaheen
/// @version 2.0
/// @since 2017-03-01
public class JFXPopupSkin implements Skin<JFXPopup> {
protected JFXPopup control;
protected StackPane container = new StackPane();
protected Region popupContent;
protected Node root;
private Animation animation;
protected Scale scale;
public JFXPopupSkin(JFXPopup control) {
this.control = control;
// set scale y to 0.01 instead of 0 to allow layout of the content,
// otherwise it will cause exception in traverse engine, when focusing the 1st node
scale = new Scale(1.0, 0.01, 0, 0);
popupContent = control.getPopupContent();
container.getStyleClass().add("jfx-popup-container");
container.getChildren().add(popupContent);
container.getTransforms().add(scale);
container.setOpacity(0);
root = JFXDepthManager.createMaterialNode(container, 4);
animation = AnimationUtils.isAnimationEnabled() ? getAnimation() : null;
}
public void reset(PopupVPosition vAlign, PopupHPosition hAlign, double offsetX, double offsetY) {
// postion the popup according to its animation
scale.setPivotX(hAlign == PopupHPosition.RIGHT ? container.getWidth() : 0);
scale.setPivotY(vAlign == PopupVPosition.BOTTOM ? container.getHeight() : 0);
control.setX(control.getX() + (hAlign == PopupHPosition.RIGHT ? -container.getWidth() + offsetX : offsetX));
control.setY(control.getY() + (vAlign == PopupVPosition.BOTTOM ? -container.getHeight() + offsetY : offsetY));
}
public final void animate() {
if (animation != null) {
if (animation.getStatus() == Status.STOPPED) {
container.setOpacity(1);
animation.playFromStart();
}
} else {
container.setOpacity(1);
popupContent.setOpacity(1);
scale.setX(1.0);
scale.setY(1.0);
}
}
@Override
public JFXPopup getSkinnable() {
return control;
}
@Override
public Node getNode() {
return root;
}
@Override
public void dispose() {
if (animation != null) {
animation.stop();
animation = null;
}
container = null;
control = null;
popupContent = null;
root = null;
}
protected Animation getAnimation() {
Interpolator interpolator = Motion.EASE;
return new Timeline(
new KeyFrame(
Duration.ZERO,
new KeyValue(popupContent.opacityProperty(), 0, interpolator),
new KeyValue(scale.xProperty(), 0, interpolator),
new KeyValue(scale.yProperty(), 0, interpolator)
),
new KeyFrame(Motion.SHORT4,
new KeyValue(popupContent.opacityProperty(), 0, interpolator),
new KeyValue(scale.xProperty(), 1, interpolator)
),
new KeyFrame(Motion.MEDIUM2,
new KeyValue(popupContent.opacityProperty(), 1, interpolator),
new KeyValue(scale.yProperty(), 1, interpolator)
)
);
}
public void init() {
if (animation != null)
animation.stop();
container.setOpacity(0);
scale.setX(1.0);
scale.setY(0.01);
}
}
@@ -0,0 +1,225 @@
/*
* 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.JFXProgressBar;
import com.jfoenix.utils.TreeShowingProperty;
import javafx.animation.*;
import javafx.scene.Node;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.*;
import javafx.scene.shape.Rectangle;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
/// # Material Design ProgressBar Skin
///
/// @author Shadi Shaheen
/// @version 2.0
/// @since 2017-10-06
public class JFXProgressBarSkin extends SkinBase<JFXProgressBar> {
private static final double DEFAULT_HEIGHT = 4;
private final StackPane track;
private final StackPane bar;
private final Rectangle clip;
private Animation transition;
private final TreeShowingProperty treeShowingProperty;
private double fullWidth;
public JFXProgressBarSkin(JFXProgressBar control) {
super(control);
this.treeShowingProperty = new TreeShowingProperty(control);
registerChangeListener(treeShowingProperty, obs -> updateProgress(false));
registerChangeListener(control.progressProperty(), obs -> updateProgress(true));
track = new StackPane();
track.getStyleClass().setAll("track");
bar = new StackPane();
bar.getStyleClass().setAll("bar");
clip = new Rectangle();
clip.setArcWidth(DEFAULT_HEIGHT);
clip.setArcHeight(DEFAULT_HEIGHT);
bar.setClip(clip);
getChildren().setAll(track, bar);
getSkinnable().requestLayout();
}
@Override
public double computeBaselineOffset(double topInset, double rightInset, double bottomInset, double leftInset) {
return Node.BASELINE_OFFSET_SAME_AS_HEIGHT;
}
@Override
protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
return Math.max(100, leftInset + bar.prefWidth(getSkinnable().getWidth()) + rightInset);
}
@Override
protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
return topInset + DEFAULT_HEIGHT + bottomInset;
}
@Override
protected double computeMaxWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
return getSkinnable().prefWidth(height);
}
@Override
protected double computeMaxHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
return getSkinnable().prefHeight(width);
}
@Override
protected void layoutChildren(double x, double y, double w, double h) {
track.resizeRelocate(x, y, w, h);
bar.resizeRelocate(x, y, w, h);
clip.relocate(0, 0);
clip.setWidth(0);
clip.setHeight(h);
clip.setTranslateX(0);
fullWidth = w;
clearAnimation();
updateProgress(false);
}
private boolean wasIndeterminate = false;
private void updateProgress(boolean playProgressAnimation) {
double progress = Math.min(getSkinnable().getProgress(), 1.0);
boolean isIndeterminate = progress < 0.0;
boolean isTreeShowing = treeShowingProperty.get();
if (isIndeterminate != wasIndeterminate) {
wasIndeterminate = isIndeterminate;
clearAnimation();
clip.setTranslateX(0);
}
if (isIndeterminate) { // indeterminate
if (isTreeShowing) {
if (transition == null) {
transition = createIndeterminateTransition();
transition.playFromStart();
} else {
transition.play();
}
} else if (transition != null) {
transition.pause();
}
} else { // determinate
clearAnimation();
if (isTreeShowing && playProgressAnimation
&& AnimationUtils.isAnimationEnabled()
&& getSkinnable().isSmoothProgress()) {
transition = createDeterminateTransition(progress);
transition.playFromStart();
} else {
clip.setWidth(computeBarWidth(progress));
}
}
}
private static final Duration INDETERMINATE_DURATION = Duration.seconds(1);
private Transition createIndeterminateTransition() {
double minWidth = 0;
double maxWidth = fullWidth * 0.4;
Transition transition = new Transition() {
{
setInterpolator(Interpolator.LINEAR);
setCycleDuration(INDETERMINATE_DURATION);
}
@Override
protected void interpolate(double frac) {
double currentWidth;
if (frac <= 0.5) {
currentWidth = Interpolator.EASE_IN.interpolate(minWidth, maxWidth, frac / 0.5);
} else {
currentWidth = Interpolator.EASE_OUT.interpolate(maxWidth, minWidth, (frac - 0.5) / 0.5);
}
double targetCenter;
if (frac <= 0.1) {
targetCenter = 0.0;
} else if (frac >= 0.9) {
targetCenter = fullWidth;
} else {
targetCenter = ((frac - 0.1) / 0.8) * fullWidth;
}
clip.setWidth(currentWidth);
clip.setTranslateX(targetCenter - currentWidth / 2.0);
}
};
transition.setCycleCount(Timeline.INDEFINITE);
return transition;
}
private static final Duration DETERMINATE_DURATION = Duration.seconds(0.2);
private Timeline createDeterminateTransition(double targetProgress) {
Timeline timeline = new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(clip.widthProperty(), clip.getWidth(), Interpolator.EASE_OUT)),
new KeyFrame(DETERMINATE_DURATION,
new KeyValue(clip.widthProperty(), computeBarWidth(targetProgress), Interpolator.EASE_OUT))
);
timeline.setOnFinished(e -> {
if (transition == timeline) {
transition = null;
}
});
return timeline;
}
private void clearAnimation() {
if (transition != null) {
transition.stop();
transition = null;
}
}
@Override
public void dispose() {
super.dispose();
treeShowingProperty.dispose();
clearAnimation();
}
private double computeBarWidth(double progress) {
assert progress >= 0 && progress <= 1;
double barWidth = ((int) fullWidth * 2 * progress) / 2.0;
return progress > 0 ? Math.max(barWidth, DEFAULT_HEIGHT) : barWidth;
}
}
@@ -0,0 +1,179 @@
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package com.jfoenix.skins;
import com.jfoenix.controls.JFXRadioButton;
import com.jfoenix.controls.JFXRippler;
import com.jfoenix.controls.JFXRippler.RipplerMask;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.VPos;
import javafx.scene.control.RadioButton;
import javafx.scene.control.skin.RadioButtonSkin;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.scene.text.Text;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
public class JFXRadioButtonSkin extends RadioButtonSkin {
private static final double PADDING = 15.0;
private boolean invalid = true;
private final JFXRippler rippler;
private final Circle radio;
private final Circle dot;
private Timeline timeline;
private final AnchorPane container = new AnchorPane();
private final double labelOffset = -10.0;
public JFXRadioButtonSkin(JFXRadioButton control) {
super(control);
double radioRadius = 7.0;
this.radio = new Circle(radioRadius);
this.radio.getStyleClass().setAll("radio");
this.radio.setStrokeWidth(2.0);
this.radio.setFill(Color.TRANSPARENT);
this.dot = new Circle(4);
this.dot.getStyleClass().setAll("dot");
this.dot.fillProperty().bind(control.selectedColorProperty());
this.dot.setScaleX(0.0);
this.dot.setScaleY(0.0);
StackPane boxContainer = new StackPane();
boxContainer.getChildren().addAll(this.radio, this.dot);
boxContainer.setPadding(new Insets(PADDING));
this.rippler = new JFXRippler(boxContainer, RipplerMask.CIRCLE);
this.container.getChildren().add(this.rippler);
AnchorPane.setRightAnchor(this.rippler, this.labelOffset);
this.updateChildren();
ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(control);
if (focusVisibleProperty == null) {
focusVisibleProperty = control.focusedProperty();
}
focusVisibleProperty.addListener((o, oldVal, newVal) -> {
if (newVal) {
if (!this.getSkinnable().isPressed()) {
this.rippler.showOverlay();
}
} else {
this.rippler.hideOverlay();
}
});
control.pressedProperty().addListener((o, oldVal, newVal) -> this.rippler.hideOverlay());
this.registerChangeListener(control.selectedColorProperty(), ignored -> updateColors());
this.registerChangeListener(control.unSelectedColorProperty(), ignored -> updateColors());
this.registerChangeListener(control.selectedProperty(), ignored -> {
updateColors();
this.playAnimation();
});
}
protected void updateChildren() {
super.updateChildren();
if (this.radio != null) {
this.removeRadio();
this.getChildren().add(this.container);
}
}
protected void layoutChildren(double x, double y, double w, double h) {
RadioButton radioButton = this.getSkinnable();
double contWidth = this.snapSizeX(this.container.prefWidth(-1.0)) + (double) (this.invalid ? 2 : 0);
double contHeight = this.snapSizeY(this.container.prefHeight(-1.0)) + (double) (this.invalid ? 2 : 0);
double computeWidth = Math.min(radioButton.prefWidth(-1.0), radioButton.minWidth(-1.0)) + this.labelOffset + 2.0 * this.PADDING;
double labelWidth = Math.min(computeWidth - contWidth, w - this.snapSizeX(contWidth)) + this.labelOffset + 2.0 * PADDING;
double labelHeight = Math.min(radioButton.prefHeight(labelWidth), h);
double maxHeight = Math.max(contHeight, labelHeight);
double xOffset = computeXOffset(w, labelWidth + contWidth, radioButton.getAlignment().getHpos()) + x;
double yOffset = computeYOffset(h, maxHeight, radioButton.getAlignment().getVpos()) + x;
if (this.invalid) {
this.initializeComponents();
this.invalid = false;
}
this.layoutLabelInArea(xOffset + contWidth, yOffset, labelWidth, maxHeight, radioButton.getAlignment());
((Text) this.getChildren().get(this.getChildren().get(0) instanceof Text ? 0 : 1)).textProperty().set(this.getSkinnable().textProperty().get());
this.container.resize(this.snapSizeX(contWidth), this.snapSizeY(contHeight));
this.positionInArea(this.container, xOffset, yOffset, contWidth, maxHeight, 0.0, radioButton.getAlignment().getHpos(), radioButton.getAlignment().getVpos());
}
private void initializeComponents() {
this.updateColors();
this.playAnimation();
}
private void playAnimation() {
if (AnimationUtils.isAnimationEnabled()) {
if (this.timeline == null) {
this.timeline = new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(this.dot.scaleXProperty(), 0, Interpolator.EASE_BOTH),
new KeyValue(this.dot.scaleYProperty(), 0, Interpolator.EASE_BOTH)),
new KeyFrame(Duration.millis(200.0),
new KeyValue(this.dot.scaleXProperty(), 1, Interpolator.EASE_BOTH),
new KeyValue(this.dot.scaleYProperty(), 1, Interpolator.EASE_BOTH))
);
} else {
this.timeline.stop();
}
this.timeline.setRate(this.getSkinnable().isSelected() ? 1.0 : -1.0);
this.timeline.play();
} else {
double endScale = this.getSkinnable().isSelected() ? 1.0 : 0.0;
this.dot.setScaleX(endScale);
this.dot.setScaleY(endScale);
}
}
private void removeRadio() {
this.getChildren().removeIf(node -> "radio".equals(node.getStyleClass().get(0)));
}
private void updateColors() {
var control = (JFXRadioButton) getSkinnable();
boolean isSelected = control.isSelected();
Color unSelectedColor = control.getUnSelectedColor();
Color selectedColor = control.getSelectedColor();
rippler.setRipplerFill(isSelected ? selectedColor : unSelectedColor);
radio.setStroke(isSelected ? selectedColor : unSelectedColor);
}
protected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
return super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset) + this.snapSizeX(this.radio.minWidth(-1.0)) + this.labelOffset + 2.0 * PADDING;
}
protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
return super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset) + this.snapSizeX(this.radio.prefWidth(-1.0)) + this.labelOffset + 2.0 * PADDING;
}
static double computeXOffset(double width, double contentWidth, HPos hpos) {
return switch (hpos) {
case LEFT -> 0.0;
case CENTER -> (width - contentWidth) / 2.0;
case RIGHT -> width - contentWidth;
};
}
static double computeYOffset(double height, double contentHeight, VPos vpos) {
return switch (vpos) {
case TOP, BASELINE -> 0.0;
case CENTER -> (height - contentHeight) / 2.0;
case BOTTOM -> height - contentHeight;
};
}
}
@@ -0,0 +1,291 @@
/*
* 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.JFXSlider;
import com.jfoenix.controls.JFXSlider.IndicatorPosition;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.binding.Bindings;
import javafx.beans.property.DoubleProperty;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.css.PseudoClass;
import javafx.geometry.Orientation;
import javafx.scene.Node;
import javafx.scene.chart.NumberAxis;
import javafx.scene.control.Slider;
import javafx.scene.control.skin.SliderSkin;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.scene.text.Text;
import javafx.util.Duration;
/// # Material Design Slider Skin
///
/// rework of JFXSliderSkin by extending Java SliderSkin
/// this solves padding and resizing issues
///
/// @author Shadi Shaheen
/// @version 1.0
/// @since 2016-03-09
public class JFXSliderSkin extends SliderSkin {
private static final PseudoClass MIN_VALUE = PseudoClass.getPseudoClass("min");
private static final PseudoClass MAX_VALUE = PseudoClass.getPseudoClass("max");
private final Pane mouseHandlerPane = new Pane();
private final Text sliderValue;
private final StackPane coloredTrack;
private final StackPane thumb;
private final StackPane track;
private final StackPane animatedThumb;
private NumberAxis tickLine;
private Timeline timeline;
private double indicatorRotation;
private double horizontalRotation;
private double shifting;
public JFXSliderSkin(JFXSlider slider) {
super(slider);
track = (StackPane) getSkinnable().lookup(".track");
thumb = (StackPane) getSkinnable().lookup(".thumb");
tickLine = (NumberAxis) getSkinnable().lookup(".axis");
if (tickLine != null) tickLine.setAnimated(false);
coloredTrack = new StackPane();
coloredTrack.getStyleClass().add("colored-track");
coloredTrack.setMouseTransparent(true);
sliderValue = new Text();
sliderValue.getStyleClass().setAll("slider-value");
animatedThumb = new StackPane();
animatedThumb.getStyleClass().add("animated-thumb");
animatedThumb.getChildren().add(sliderValue);
animatedThumb.setMouseTransparent(true);
animatedThumb.setScaleX(0);
animatedThumb.setScaleY(0);
thumb.layoutXProperty().addListener(x -> {
if (slider.getOrientation() == Orientation.VERTICAL) initAnimation(Orientation.VERTICAL);
});
thumb.layoutYProperty().addListener(y -> {
if (slider.getOrientation() == Orientation.HORIZONTAL) initAnimation(Orientation.HORIZONTAL);
});
addJFXChildren();
getChildren().addListener((ListChangeListener<Node>) c -> {
while (c.next()) {
if (c.wasAdded()) {
c.getAddedSubList().forEach(added -> {
if (added instanceof NumberAxis) {
tickLine = (NumberAxis) added;
tickLine.setAnimated(false);
}
});
}
}
});
registerChangeListener(slider.showTickMarksProperty(), e -> addJFXChildren());
registerChangeListener(slider.showTickLabelsProperty(), e -> addJFXChildren());
registerChangeListener(slider.valueFactoryProperty(), obs -> refreshSliderValueBinding());
initListeners();
}
private void addJFXChildren() {
ObservableList<Node> children = getChildren();
Slider slider = getSkinnable();
if ((slider.isShowTickMarks() || slider.isShowTickLabels()) && tickLine != null && !children.contains(tickLine)) {
children.add(0, tickLine);
}
if (children.contains(coloredTrack)) return;
children.add(children.indexOf(thumb), coloredTrack);
children.add(children.indexOf(thumb), animatedThumb);
children.add(0, mouseHandlerPane);
}
private void refreshSliderValueBinding() {
sliderValue.textProperty().unbind();
if (((JFXSlider) getSkinnable()).getValueFactory() != null) {
sliderValue.textProperty()
.bind(((JFXSlider) getSkinnable()).getValueFactory().call((JFXSlider) getSkinnable()));
} else {
sliderValue.textProperty().bind(Bindings.createStringBinding(() -> {
if (getSkinnable().getLabelFormatter() != null) {
return getSkinnable().getLabelFormatter().toString(getSkinnable().getValue());
} else {
return String.valueOf(Math.round(getSkinnable().getValue()));
}
}, getSkinnable().valueProperty()));
}
}
@Override
protected void layoutChildren(double x, double y, double w, double h) {
super.layoutChildren(x, y, w, h);
if (timeline == null) {
initAnimation(getSkinnable().getOrientation());
}
double prefWidth = animatedThumb.prefWidth(-1);
animatedThumb.resize(prefWidth, animatedThumb.prefHeight(prefWidth));
boolean horizontal = getSkinnable().getOrientation() == Orientation.HORIZONTAL;
double width, height, layoutX, layoutY;
if (horizontal) {
width = thumb.getLayoutX() - snappedLeftInset();
height = track.getHeight();
layoutX = track.getLayoutX();
layoutY = track.getLayoutY();
animatedThumb.setLayoutX(thumb.getLayoutX() + thumb.getWidth() / 2 - animatedThumb.getWidth() / 2);
} else {
height = track.getLayoutBounds().getMaxY() + track.getLayoutY() - thumb.getLayoutY() - snappedBottomInset();
width = track.getWidth();
layoutX = track.getLayoutX();
layoutY = thumb.getLayoutY();
animatedThumb.setLayoutY(thumb.getLayoutY() + thumb.getHeight() / 2 - animatedThumb.getHeight() / 2);
}
coloredTrack.resizeRelocate(layoutX, layoutY, width, height);
mouseHandlerPane.resizeRelocate(x, y, w, h);
}
private void initializeVariables() {
shifting = 30 + thumb.getWidth();
if (getSkinnable().getOrientation() != Orientation.HORIZONTAL) {
horizontalRotation = -90;
}
if (((JFXSlider) getSkinnable()).getIndicatorPosition() != IndicatorPosition.LEFT) {
indicatorRotation = 180;
shifting = -shifting;
}
final double rotationAngle = 45;
sliderValue.setRotate(rotationAngle + indicatorRotation + 3 * horizontalRotation);
animatedThumb.setRotate(-rotationAngle + indicatorRotation + horizontalRotation);
}
private void initListeners() {
// delegate slider mouse events to track node
mouseHandlerPane.setOnMousePressed(this::delegateToTrack);
mouseHandlerPane.setOnMouseReleased(this::delegateToTrack);
mouseHandlerPane.setOnMouseDragged(this::delegateToTrack);
// animate value node
track.addEventHandler(MouseEvent.MOUSE_PRESSED, (event) -> {
timeline.setRate(1);
timeline.play();
});
track.addEventHandler(MouseEvent.MOUSE_RELEASED, (event) -> {
timeline.setRate(-1);
timeline.play();
});
thumb.addEventHandler(MouseEvent.MOUSE_PRESSED, (event) -> {
timeline.setRate(1);
timeline.play();
});
thumb.addEventHandler(MouseEvent.MOUSE_RELEASED, (event) -> {
timeline.setRate(-1);
timeline.play();
});
refreshSliderValueBinding();
updateValueStyleClass();
getSkinnable().valueProperty().addListener(observable -> updateValueStyleClass());
}
private void delegateToTrack(MouseEvent event) {
if (!event.isConsumed()) {
event.consume();
track.fireEvent(event);
}
}
private void updateValueStyleClass() {
getSkinnable().pseudoClassStateChanged(MIN_VALUE, getSkinnable().getMin() == getSkinnable().getValue());
getSkinnable().pseudoClassStateChanged(MAX_VALUE, getSkinnable().getMax() == getSkinnable().getValue());
}
private void initAnimation(Orientation orientation) {
initializeVariables();
double thumbPos, thumbNewPos;
DoubleProperty layoutProperty;
if (orientation == Orientation.HORIZONTAL) {
if (((JFXSlider) getSkinnable()).getIndicatorPosition() == IndicatorPosition.RIGHT) {
thumbPos = thumb.getLayoutY() - thumb.getHeight();
thumbNewPos = thumbPos - shifting;
} else {
double height = animatedThumb.prefHeight(animatedThumb.prefWidth(-1));
thumbPos = thumb.getLayoutY() - height / 2;
thumbNewPos = thumb.getLayoutY() - height - thumb.getHeight();
}
layoutProperty = animatedThumb.translateYProperty();
} else {
if (((JFXSlider) getSkinnable()).getIndicatorPosition() == IndicatorPosition.RIGHT) {
thumbPos = thumb.getLayoutX() - thumb.getWidth();
thumbNewPos = thumbPos - shifting;
} else {
double width = animatedThumb.prefWidth(-1);
thumbPos = thumb.getLayoutX() - width / 2;
thumbNewPos = thumb.getLayoutX() - width - thumb.getWidth();
}
layoutProperty = animatedThumb.translateXProperty();
}
clearAnimation();
timeline = new Timeline(
new KeyFrame(
Duration.ZERO,
new KeyValue(animatedThumb.scaleXProperty(), 0, Interpolator.EASE_BOTH),
new KeyValue(animatedThumb.scaleYProperty(), 0, Interpolator.EASE_BOTH),
new KeyValue(layoutProperty, thumbPos, Interpolator.EASE_BOTH)),
new KeyFrame(
Duration.seconds(0.2),
new KeyValue(animatedThumb.scaleXProperty(), 1, Interpolator.EASE_BOTH),
new KeyValue(animatedThumb.scaleYProperty(), 1, Interpolator.EASE_BOTH),
new KeyValue(layoutProperty, thumbNewPos, Interpolator.EASE_BOTH)));
}
@Override
public void dispose() {
super.dispose();
clearAnimation();
}
private void clearAnimation() {
if (timeline != null) {
timeline.stop();
timeline.getKeyFrames().clear();
timeline = null;
}
}
}
@@ -0,0 +1,264 @@
/*
* 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.JFXSpinner;
import com.jfoenix.utils.TreeShowingProperty;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.scene.Group;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Arc;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.StrokeLineCap;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import java.util.ArrayList;
import java.util.List;
/// JFXSpinner material design skin
///
/// @author Shadi Shaheen & Gerard Moubarak
/// @version 1.0
/// @since 2017-09-25
public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
private static final double DEFAULT_STROKE_WIDTH = 4;
private JFXSpinner control;
private final TreeShowingProperty treeShowingProperty;
private boolean isValid = false;
private Timeline timeline;
private Arc arc;
private Arc track;
private final StackPane arcPane;
private final Rectangle fillRect;
private final double startingAngle;
public JFXSpinnerSkin(JFXSpinner control) {
super(control);
this.control = control;
this.treeShowingProperty = new TreeShowingProperty(control);
this.startingAngle = control.getStartingAngle();
arc = new Arc();
arc.setManaged(false);
arc.setLength(-180);
arc.getStyleClass().setAll("arc");
arc.setFill(Color.TRANSPARENT);
arc.setStrokeWidth(DEFAULT_STROKE_WIDTH);
arc.setStrokeLineCap(StrokeLineCap.ROUND);
track = new Arc();
track.setManaged(false);
track.setLength(360);
track.setStrokeWidth(DEFAULT_STROKE_WIDTH);
track.getStyleClass().setAll("track");
track.setFill(Color.TRANSPARENT);
fillRect = new Rectangle();
fillRect.setFill(Color.TRANSPARENT);
final Group group = new Group(fillRect, track, arc);
group.setManaged(false);
arcPane = new StackPane(group);
arcPane.setPrefSize(50, 50);
getChildren().setAll(arcPane);
// register listeners
registerChangeListener(control.progressProperty(), obs -> updateProgress());
registerChangeListener(treeShowingProperty, obs -> updateProgress());
}
private void updateProgress() {
double progress = Double.min(getSkinnable().getProgress(), 1.0);
if (progress < 0) { // indeterminate
boolean treeShowing = treeShowingProperty.get();
if (treeShowing) {
if (timeline == null) {
timeline = createTransition();
timeline.playFromStart();
} else {
timeline.play();
}
} else if (timeline != null) {
timeline.pause();
}
} else { // determinate
clearAnimation();
arc.setStartAngle(90);
arc.setLength(-360 * progress);
}
}
private double computeSize() {
return control.getRadius() * 2 + arc.getStrokeWidth() * 2;
}
@Override
protected double computeMaxHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
if (Region.USE_COMPUTED_SIZE == control.getRadius()) {
return super.computeMaxHeight(width, topInset, rightInset, bottomInset, leftInset);
} else {
return computeSize();
}
}
@Override
protected double computeMaxWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
if (Region.USE_COMPUTED_SIZE == control.getRadius()) {
return super.computeMaxWidth(height, topInset, rightInset, bottomInset, leftInset);
} else {
return computeSize();
}
}
@Override
protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
if (Region.USE_COMPUTED_SIZE == control.getRadius()) {
return arcPane.prefWidth(-1);
} else {
return computeSize();
}
}
@Override
protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
if (Region.USE_COMPUTED_SIZE == control.getRadius()) {
return arcPane.prefHeight(-1);
} else {
return computeSize();
}
}
/**
* {@inheritDoc}
*/
@Override
protected void layoutChildren(double contentX, double contentY, double contentWidth, double contentHeight) {
final double strokeWidth = arc.getStrokeWidth();
double radius = control.getRadius();
final double arcSize = snapSizeX(radius * 2 + strokeWidth);
arcPane.resizeRelocate((contentWidth - arcSize) / 2 + 1, (contentHeight - arcSize) / 2 + 1, arcSize, arcSize);
updateArcLayout(radius, arcSize);
fillRect.setWidth(arcSize);
fillRect.setHeight(arcSize);
if (!isValid) {
updateProgress();
isValid = true;
}
}
private void updateArcLayout(double radius, double arcSize) {
arc.setRadiusX(radius);
arc.setRadiusY(radius);
arc.setCenterX(arcSize / 2);
arc.setCenterY(arcSize / 2);
track.setRadiusX(radius);
track.setRadiusY(radius);
track.setCenterX(arcSize / 2);
track.setCenterY(arcSize / 2);
track.setStrokeWidth(arc.getStrokeWidth());
}
private void addKeyFrames(List<KeyFrame> frames, double angle, double duration) {
frames.add(new KeyFrame(Duration.seconds(duration),
new KeyValue(arc.lengthProperty(), -5, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
-(angle + 45 + startingAngle),
Interpolator.LINEAR)));
frames.add(new KeyFrame(Duration.seconds(duration + 0.4),
new KeyValue(arc.lengthProperty(), -250, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
-(angle + 90 + startingAngle),
Interpolator.LINEAR)));
frames.add(new KeyFrame(Duration.seconds(duration + 0.7),
new KeyValue(arc.lengthProperty(), -250, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
-(angle + 135 + startingAngle),
Interpolator.LINEAR)));
frames.add(new KeyFrame(Duration.seconds(duration + 1.1),
new KeyValue(arc.lengthProperty(), -5, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
-(angle + 435 + startingAngle),
Interpolator.LINEAR)));
}
private Timeline createTransition() {
Timeline timeline;
if (AnimationUtils.isAnimationEnabled()) {
var keyFrames = new ArrayList<KeyFrame>(17);
addKeyFrames(keyFrames, 0, 0);
addKeyFrames(keyFrames, 450, 1.4);
addKeyFrames(keyFrames, 900, 2.8);
addKeyFrames(keyFrames, 1350, 4.2);
keyFrames.add(new KeyFrame(Duration.seconds(5.6),
new KeyValue(arc.lengthProperty(), -5, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
-(1845 + startingAngle),
Interpolator.LINEAR)));
timeline = new Timeline();
timeline.getKeyFrames().setAll(keyFrames);
} else {
final double arcLength = -250;
timeline = new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(arc.startAngleProperty(), -(45 + startingAngle), Interpolator.LINEAR),
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE)),
new KeyFrame(Duration.seconds(1.2),
new KeyValue(arc.startAngleProperty(), -(45 + 360 + startingAngle), Interpolator.LINEAR),
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE))
);
}
timeline.setCycleCount(Timeline.INDEFINITE);
return timeline;
}
private void clearAnimation() {
if (timeline != null) {
timeline.stop();
timeline = null;
}
}
@Override
public void dispose() {
super.dispose();
treeShowingProperty.dispose();
clearAnimation();
arc = null;
track = null;
control = null;
}
}
@@ -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);
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,68 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.transitions;
import javafx.scene.CacheHint;
import javafx.scene.Node;
import javafx.scene.layout.Region;
import java.util.concurrent.atomic.AtomicBoolean;
public final class CacheMemento {
private boolean cache;
private boolean cacheShape;
private boolean snapToPixel;
private CacheHint cacheHint = CacheHint.DEFAULT;
private final Node node;
private final AtomicBoolean isCached = new AtomicBoolean(false);
public CacheMemento(Node node) {
this.node = node;
}
/**
* this method will cache the node only if it wasn't cached before
*/
public void cache() {
if (!isCached.getAndSet(true)) {
this.cache = node.isCache();
this.cacheHint = node.getCacheHint();
node.setCache(true);
node.setCacheHint(CacheHint.SPEED);
if (node instanceof Region region) {
this.cacheShape = region.isCacheShape();
this.snapToPixel = region.isSnapToPixel();
region.setCacheShape(true);
region.setSnapToPixel(true);
}
}
}
public void restore() {
if (isCached.getAndSet(false)) {
node.setCache(cache);
node.setCacheHint(cacheHint);
if (node instanceof Region region) {
region.setCacheShape(cacheShape);
region.setSnapToPixel(snapToPixel);
}
}
}
}
@@ -0,0 +1,286 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.transitions;
import javafx.animation.AnimationTimer;
import javafx.beans.value.WritableValue;
import javafx.scene.Node;
import javafx.util.Duration;
import java.util.*;
import java.util.function.Supplier;
/**
* Custom AnimationTimer that can be created the same way as a timeline,
* however it doesn't behave the same yet. it only animates in one direction,
* it doesn't support animation 0 -> 1 -> 0.5
*
* @author Shadi Shaheen
* @version 1.0
* @since 2017-09-21
*/
public class JFXAnimationTimer extends AnimationTimer {
private Set<AnimationHandler> animationHandlers = new HashSet<>();
private long startTime = -1;
private boolean running = false;
private List<CacheMemento> caches = new ArrayList<>();
private double totalElapsedMilliseconds;
public JFXAnimationTimer(JFXKeyFrame... keyFrames) {
for (JFXKeyFrame keyFrame : keyFrames) {
Duration duration = keyFrame.getDuration();
final Set<JFXKeyValue<?>> keyValuesSet = keyFrame.getValues();
if (!keyValuesSet.isEmpty()) {
animationHandlers.add(new AnimationHandler(duration, keyFrame.getAnimateCondition(), keyFrame.getValues()));
}
}
}
private final HashMap<JFXKeyFrame, AnimationHandler> mutableFrames = new HashMap<>();
public void addKeyFrame(JFXKeyFrame keyFrame) throws Exception {
if (isRunning()) {
throw new Exception("Can't update animation timer while running");
}
Duration duration = keyFrame.getDuration();
final Set<JFXKeyValue<?>> keyValuesSet = keyFrame.getValues();
if (!keyValuesSet.isEmpty()) {
final AnimationHandler handler = new AnimationHandler(duration, keyFrame.getAnimateCondition(), keyFrame.getValues());
animationHandlers.add(handler);
mutableFrames.put(keyFrame, handler);
}
}
public void removeKeyFrame(JFXKeyFrame keyFrame) throws Exception {
if (isRunning()) {
throw new Exception("Can't update animation timer while running");
}
AnimationHandler handler = mutableFrames.get(keyFrame);
animationHandlers.remove(handler);
}
@Override
public void start() {
super.start();
running = true;
startTime = -1;
for (AnimationHandler animationHandler : animationHandlers) {
animationHandler.init();
}
for (CacheMemento cache : caches) {
cache.cache();
}
}
@Override
public void handle(long now) {
startTime = startTime == -1 ? now : startTime;
totalElapsedMilliseconds = (now - startTime) / 1000000.0;
boolean stop = true;
for (AnimationHandler handler : animationHandlers) {
handler.animate(totalElapsedMilliseconds);
if (!handler.finished) {
stop = false;
}
}
if (stop) {
this.stop();
}
}
/**
* this method will pause the timer and reverse the animation if the timer already
* started otherwise it will start the animation.
*/
public void reverseAndContinue() {
if (isRunning()) {
super.stop();
for (AnimationHandler handler : animationHandlers) {
handler.reverse(totalElapsedMilliseconds);
}
startTime = -1;
super.start();
} else {
start();
}
}
@Override
public void stop() {
super.stop();
running = false;
for (AnimationHandler handler : animationHandlers) {
handler.clear();
}
for (CacheMemento cache : caches) {
cache.restore();
}
if (onFinished != null) {
onFinished.run();
}
}
public void applyEndValues() {
if (isRunning()) {
super.stop();
}
for (AnimationHandler handler : animationHandlers) {
handler.applyEndValues();
}
startTime = -1;
}
public boolean isRunning() {
return running;
}
private Runnable onFinished = null;
public void setOnFinished(Runnable onFinished) {
this.onFinished = onFinished;
}
public void setCacheNodes(Node... nodesToCache) {
caches.clear();
if (nodesToCache != null) {
for (Node node : nodesToCache) {
caches.add(new CacheMemento(node));
}
}
}
public void dispose() {
caches.clear();
for (AnimationHandler handler : animationHandlers) {
handler.dispose();
}
animationHandlers.clear();
}
static class AnimationHandler {
private final double duration;
private double currentDuration;
private final Set<JFXKeyValue<?>> keyValues;
private Supplier<Boolean> animationCondition = null;
private boolean finished = false;
private final HashMap<WritableValue<?>, Object> initialValuesMap = new HashMap<>();
private final HashMap<WritableValue<?>, Object> endValuesMap = new HashMap<>();
AnimationHandler(Duration duration, Supplier<Boolean> animationCondition, Set<JFXKeyValue<?>> keyValues) {
this.duration = duration.toMillis();
currentDuration = this.duration;
this.keyValues = keyValues;
this.animationCondition = animationCondition;
}
public void init() {
finished = animationCondition != null && !animationCondition.get();
for (JFXKeyValue<?> keyValue : keyValues) {
if (keyValue.getTarget() != null) {
// replaced putIfAbsent for mobile compatibility
if (!initialValuesMap.containsKey(keyValue.getTarget())) {
initialValuesMap.put(keyValue.getTarget(), keyValue.getTarget().getValue());
}
if (!endValuesMap.containsKey(keyValue.getTarget())) {
endValuesMap.put(keyValue.getTarget(), keyValue.getEndValue());
}
}
}
}
void reverse(double now) {
finished = animationCondition != null && !animationCondition.get();
currentDuration = duration - (currentDuration - now);
// update initial values
for (JFXKeyValue<?> keyValue : keyValues) {
final WritableValue<?> target = keyValue.getTarget();
if (target != null) {
initialValuesMap.put(target, target.getValue());
endValuesMap.put(target, keyValue.getEndValue());
}
}
}
// now in milliseconds
@SuppressWarnings({"unchecked"})
public void animate(double now) {
// if animate condition for the key frame is not met then do nothing
if (finished) {
return;
}
if (now <= currentDuration) {
for (JFXKeyValue<?> keyValue : keyValues) {
if (keyValue.isValid()) {
@SuppressWarnings("rawtypes") final WritableValue target = keyValue.getTarget();
final Object endValue = endValuesMap.get(target);
if (endValue != null && target != null && !target.getValue().equals(endValue)) {
target.setValue(keyValue.getInterpolator().interpolate(initialValuesMap.get(target), endValue, now / currentDuration));
}
}
}
} else {
if (!finished) {
finished = true;
for (JFXKeyValue<?> keyValue : keyValues) {
if (keyValue.isValid()) {
@SuppressWarnings("rawtypes") final WritableValue target = keyValue.getTarget();
if (target != null) {
// set updated end value instead of cached
final Object endValue = keyValue.getEndValue();
if (endValue != null) {
target.setValue(endValue);
}
}
}
}
currentDuration = duration;
}
}
}
@SuppressWarnings("unchecked")
public void applyEndValues() {
for (JFXKeyValue<?> keyValue : keyValues) {
if (keyValue.isValid()) {
@SuppressWarnings("rawtypes") final WritableValue target = keyValue.getTarget();
if (target != null) {
final Object endValue = keyValue.getEndValue();
if (endValue != null && !target.getValue().equals(endValue)) {
target.setValue(endValue);
}
}
}
}
}
public void clear() {
initialValuesMap.clear();
endValuesMap.clear();
}
void dispose() {
clear();
keyValues.clear();
}
}
}
@@ -0,0 +1,104 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.transitions;
import javafx.util.Duration;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.function.Supplier;
/**
* @author Shadi Shaheen
* @version 1.0
* @since 2017-09-21
*/
public class JFXKeyFrame {
private Duration duration;
private Set<JFXKeyValue<?>> keyValues = new CopyOnWriteArraySet<>();
private Supplier<Boolean> animateCondition = null;
public JFXKeyFrame(Duration duration, JFXKeyValue<?>... keyValues) {
this.duration = duration;
for (final JFXKeyValue<?> keyValue : keyValues) {
if (keyValue != null) {
this.keyValues.add(keyValue);
}
}
}
private JFXKeyFrame() {
}
public final Duration getDuration() {
return duration;
}
public final Set<JFXKeyValue<?>> getValues() {
return keyValues;
}
public Supplier<Boolean> getAnimateCondition() {
return animateCondition;
}
public static Builder builder() {
return new Builder();
}
public static final class Builder {
private Duration duration;
private final Set<JFXKeyValue<?>> keyValues = new CopyOnWriteArraySet<>();
private Supplier<Boolean> animateCondition = null;
private Builder() {
}
public Builder setDuration(Duration duration) {
this.duration = duration;
return this;
}
public Builder setKeyValues(JFXKeyValue<?>... keyValues) {
for (final JFXKeyValue<?> keyValue : keyValues) {
if (keyValue != null) {
this.keyValues.add(keyValue);
}
}
return this;
}
public Builder setAnimateCondition(Supplier<Boolean> animateCondition) {
this.animateCondition = animateCondition;
return this;
}
public JFXKeyFrame build() {
JFXKeyFrame jFXKeyFrame = new JFXKeyFrame();
jFXKeyFrame.duration = this.duration;
jFXKeyFrame.keyValues = this.keyValues;
jFXKeyFrame.animateCondition = this.animateCondition;
return jFXKeyFrame;
}
}
}
@@ -0,0 +1,157 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.transitions;
import javafx.animation.Interpolator;
import javafx.beans.value.WritableValue;
import java.util.function.Supplier;
/**
* @author Shadi Shaheen
* @version 1.0
* @since 2017-09-21
*/
public final class JFXKeyValue<T> {
private WritableValue<T> target;
private Supplier<WritableValue<T>> targetSupplier;
private Supplier<T> endValueSupplier;
private T endValue;
private Supplier<Boolean> animateCondition = () -> true;
private Interpolator interpolator;
private JFXKeyValue() {
}
// this builder is created to ensure type inference from method arguments
public static Builder builder() {
return new Builder();
}
public T getEndValue() {
return endValue == null ? endValueSupplier.get() : endValue;
}
public WritableValue<T> getTarget() {
return target == null ? targetSupplier.get() : target;
}
public Interpolator getInterpolator() {
return interpolator;
}
public boolean isValid() {
return animateCondition == null || animateCondition.get();
}
public static final class Builder {
public <T> JFXKeyValueBuilder<T> setTarget(WritableValue<T> target) {
JFXKeyValueBuilder<T> builder = new JFXKeyValueBuilder<>();
builder.setTarget(target);
return builder;
}
public <T> JFXKeyValueBuilder<T> setTargetSupplier(Supplier<WritableValue<T>> targetSupplier) {
JFXKeyValueBuilder<T> builder = new JFXKeyValueBuilder<>();
builder.setTargetSupplier(targetSupplier);
return builder;
}
public <T> JFXKeyValueBuilder<T> setEndValueSupplier(Supplier<T> endValueSupplier) {
JFXKeyValueBuilder<T> builder = new JFXKeyValueBuilder<>();
builder.setEndValueSupplier(endValueSupplier);
return builder;
}
public <T> JFXKeyValueBuilder<T> setEndValue(T endValue) {
JFXKeyValueBuilder<T> builder = new JFXKeyValueBuilder<>();
builder.setEndValue(endValue);
return builder;
}
public <T> JFXKeyValueBuilder<T> setAnimateCondition(Supplier<Boolean> animateCondition) {
JFXKeyValueBuilder<T> builder = new JFXKeyValueBuilder<>();
builder.setAnimateCondition(animateCondition);
return builder;
}
public <T> JFXKeyValueBuilder<T> setInterpolator(Interpolator interpolator) {
JFXKeyValueBuilder<T> builder = new JFXKeyValueBuilder<>();
builder.setInterpolator(interpolator);
return builder;
}
}
public static final class JFXKeyValueBuilder<T> {
private WritableValue<T> target;
private Supplier<WritableValue<T>> targetSupplier;
private Supplier<T> endValueSupplier;
private T endValue;
private Supplier<Boolean> animateCondition = () -> true;
private Interpolator interpolator = Interpolator.EASE_BOTH;
private JFXKeyValueBuilder() {
}
public JFXKeyValueBuilder<T> setTarget(WritableValue<T> target) {
this.target = target;
return this;
}
public JFXKeyValueBuilder<T> setTargetSupplier(Supplier<WritableValue<T>> targetSupplier) {
this.targetSupplier = targetSupplier;
return this;
}
public JFXKeyValueBuilder<T> setEndValueSupplier(Supplier<T> endValueSupplier) {
this.endValueSupplier = endValueSupplier;
return this;
}
public JFXKeyValueBuilder<T> setEndValue(T endValue) {
this.endValue = endValue;
return this;
}
public JFXKeyValueBuilder<T> setAnimateCondition(Supplier<Boolean> animateCondition) {
this.animateCondition = animateCondition;
return this;
}
public JFXKeyValueBuilder<T> setInterpolator(Interpolator interpolator) {
this.interpolator = interpolator;
return this;
}
public JFXKeyValue<T> build() {
JFXKeyValue<T> jFXKeyValue = new JFXKeyValue<>();
jFXKeyValue.target = this.target;
jFXKeyValue.interpolator = this.interpolator;
jFXKeyValue.targetSupplier = this.targetSupplier;
jFXKeyValue.endValue = this.endValue;
jFXKeyValue.endValueSupplier = this.endValueSupplier;
jFXKeyValue.animateCondition = this.animateCondition;
return jFXKeyValue;
}
}
}
@@ -0,0 +1,140 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.utils;
import javafx.beans.value.ObservableBooleanValue;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.stage.Window;
import org.jetbrains.annotations.NotNull;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Method;
import java.util.Locale;
import java.util.function.Function;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Shadi Shaheen
/// @version 1.0
/// @since 2017-02-11
public final class JFXNodeUtils {
public static void updateBackground(Background newBackground, Region nodeToUpdate) {
updateBackground(newBackground, nodeToUpdate, Color.BLACK);
}
public static void updateBackground(Background newBackground, Region nodeToUpdate, Paint fill) {
if (newBackground != null && !newBackground.getFills().isEmpty()) {
final BackgroundFill[] fills = new BackgroundFill[newBackground.getFills().size()];
for (int i = 0; i < newBackground.getFills().size(); i++) {
BackgroundFill bf = newBackground.getFills().get(i);
fills[i] = new BackgroundFill(fill, bf.getRadii(), bf.getInsets());
}
nodeToUpdate.setBackground(new Background(fills));
}
}
public static String colorToHex(Color c) {
if (c != null) {
return String.format((Locale) null, "#%02X%02X%02X",
Math.round(c.getRed() * 255),
Math.round(c.getGreen() * 255),
Math.round(c.getBlue() * 255));
} else {
return null;
}
}
private static final @NotNull Function<Node, ObservableBooleanValue> treeVisiblePropertyGetter = initTreeVisiblePropertyGetter();
private static @NotNull Function<Node, ObservableBooleanValue> initTreeVisiblePropertyGetter() {
MethodHandles.Lookup lookup;
try {
lookup = MethodHandles.privateLookupIn(Node.class, MethodHandles.lookup());
} catch (IllegalAccessException e) {
LOG.warning("Failed to get private lookup for Node", e);
return JFXNodeUtils::defaultTreeVisibleProperty;
}
try {
Method treeVisiblePropertyMethod = Node.class.getDeclaredMethod("treeVisibleProperty");
if (!ObservableBooleanValue.class.isAssignableFrom(treeVisiblePropertyMethod.getReturnType())) {
LOG.warning("Node.treeVisibleProperty() does not return ObservableBooleanValue: " + treeVisiblePropertyMethod.getReturnType());
return JFXNodeUtils::defaultTreeVisibleProperty;
}
MethodHandle handle = lookup.unreflect(treeVisiblePropertyMethod)
.asType(MethodType.methodType(ObservableBooleanValue.class, Node.class));
return item -> {
try {
return (ObservableBooleanValue) handle.invokeExact((Node) item);
} catch (RuntimeException | Error e) {
throw e;
} catch (Throwable e) {
throw new AssertionError("Unreachable", e);
}
};
} catch (Exception e) {
LOG.warning("Failed to get method handle for Node.treeVisibleProperty()", e);
return JFXNodeUtils::defaultTreeVisibleProperty;
}
}
/// If `Node.treeVisibleProperty()` does not exist, use `Node.visibleProperty()` as a fallback
private static @NotNull ObservableBooleanValue defaultTreeVisibleProperty(Node item) {
return item.visibleProperty();
}
public static @NotNull ObservableBooleanValue treeVisibleProperty(Node item) {
return treeVisiblePropertyGetter.apply(item);
}
public static boolean isTreeVisible(Node item) {
return treeVisibleProperty(item).getValue();
}
public static boolean isTreeShowing(Node node) {
if (node == null)
return false;
Scene scene = node.getScene();
if (scene == null)
return false;
Window window = scene.getWindow();
if (window == null || !window.isShowing())
return false;
return isTreeVisible(node);
}
private JFXNodeUtils() {
}
}
@@ -0,0 +1,143 @@
/*
* Copyright (c) 2021, 2023, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package com.jfoenix.utils;
import javafx.beans.property.ReadOnlyBooleanPropertyBase;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableBooleanValue;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.stage.Window;
/**
* Used to observe changes in tree showing status for a {@link Node}. For a Node's tree to be showing
* it must be visible, its ancestors must be visible, the node must be part of a {@link Scene} and
* the scene must have a {@link Window} which is currently showing.<p>
*
* This class provides the exact same functionality as {@link JFXNodeUtils#isTreeShowing(Node)} in
* an observable form.
*/
public class TreeShowingProperty extends ReadOnlyBooleanPropertyBase {
private final ChangeListener<Boolean> windowShowingChangedListener = (obs, old, current) -> updateTreeShowing();
private final ChangeListener<Window> sceneWindowChangedListener = (obs, old, current) -> windowChanged(old, current);
private final ChangeListener<Scene> nodeSceneChangedListener = (obs, old, current) -> sceneChanged(old, current);
private final Node node;
private final ObservableBooleanValue treeVisibleProperty;
private boolean valid;
private boolean treeShowing;
/**
* Constructs a new instance.
*
* @param node a {@link Node} for which the tree showing status should be observed, cannot be null
*/
public TreeShowingProperty(Node node) {
this.node = node;
this.treeVisibleProperty = JFXNodeUtils.treeVisibleProperty(node);
this.node.sceneProperty().addListener(nodeSceneChangedListener);
this.treeVisibleProperty.addListener(windowShowingChangedListener);
sceneChanged(null, node.getScene());
}
@Override
public Object getBean() {
return node;
}
@Override
public String getName() {
return "treeShowing";
}
/**
* Cleans up any listeners that this class may have registered on the {@link Node}
* that was supplied at construction.
*/
public void dispose() {
node.sceneProperty().removeListener(nodeSceneChangedListener);
if (treeVisibleProperty != null)
treeVisibleProperty.removeListener(windowShowingChangedListener);
valid = false; // prevents unregistration from triggering an invalidation notification
sceneChanged(node.getScene(), null);
}
protected void invalidate() {
if (valid) {
valid = false;
fireValueChangedEvent();
}
}
@Override
public boolean get() {
if (!valid) {
updateTreeShowing();
valid = true;
}
return treeShowing;
}
private void sceneChanged(Scene oldScene, Scene newScene) {
if (oldScene != null) {
oldScene.windowProperty().removeListener(sceneWindowChangedListener);
}
if (newScene != null) {
newScene.windowProperty().addListener(sceneWindowChangedListener);
}
windowChanged(
oldScene == null ? null : oldScene.getWindow(),
newScene == null ? null : newScene.getWindow()
);
}
private void windowChanged(Window oldWindow, Window newWindow) {
if (oldWindow != null) {
oldWindow.showingProperty().removeListener(windowShowingChangedListener);
}
if (newWindow != null) {
newWindow.showingProperty().addListener(windowShowingChangedListener);
}
updateTreeShowing();
}
private void updateTreeShowing() {
boolean newValue = JFXNodeUtils.isTreeShowing(node);
if (newValue != treeShowing) {
treeShowing = newValue;
invalidate();
}
}
}
@@ -1,33 +1,23 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.util.SelfDependencyPatcher;
import org.jackhuang.hmcl.util.SwingUtils;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.io.TLSSecurity;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import javax.swing.JOptionPane;
import java.io.IOException;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.CancellationException;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -38,19 +28,23 @@ public final class EntryPoint {
private EntryPoint() {
}
/// Initializes TLS protection before downloading dependencies and starting the launcher.
public static void main(String[] args) {
System.getProperties().putIfAbsent("java.net.useSystemProxies", "true");
System.getProperties().putIfAbsent("javafx.autoproxy.disable", "true");
System.getProperties().putIfAbsent("http.agent", "HMCL/" + Metadata.VERSION);
createHMCLDirectories();
LOG.start(Metadata.HMCL_CURRENT_DIRECTORY.resolve("logs"));
LOG.start(Metadata.HMCL_LOCAL_HOME.resolve("logs"));
TLSSecurity.install();
setupJavaFXVMOptions();
checkDirectoryPath();
checkWine();
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
initIcon();
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
System.getProperties().putIfAbsent("apple.awt.application.appearance", "system");
if (!isInsideMacAppBundle())
initIcon();
}
checkJavaFX();
verifyJavaFX();
@@ -66,99 +60,53 @@ public final class EntryPoint {
System.exit(exitCode);
}
private static void setupJavaFXVMOptions() {
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
LOG.info("HMCL_FORCE_GPU: true");
System.getProperties().putIfAbsent("prism.forceGPU", "true");
}
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
if (animationFrameRate != null) {
LOG.info("HMCL_ANIMATION_FRAME_RATE: " + animationFrameRate);
try {
if (Integer.parseInt(animationFrameRate) <= 0)
throw new NumberFormatException(animationFrameRate);
System.getProperties().putIfAbsent("javafx.animation.pulse", animationFrameRate);
} catch (NumberFormatException e) {
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
}
}
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
if (uiScale != null) {
uiScale = uiScale.trim();
LOG.info("HMCL_UI_SCALE: " + uiScale);
try {
float scaleValue;
if (uiScale.endsWith("%")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 1)) / 100.0f;
} else if (uiScale.endsWith("dpi") || uiScale.endsWith("DPI")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 3)) / 96.0f;
} else {
scaleValue = Float.parseFloat(uiScale);
}
float lowerBound;
float upperBound;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
// JavaFX behavior may be abnormal when the DPI scaling factor is too high
lowerBound = 0.25f;
upperBound = 4f;
} else {
lowerBound = 0.01f;
upperBound = 10f;
}
if (scaleValue >= lowerBound && scaleValue <= upperBound) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
System.getProperties().putIfAbsent("glass.win.uiScale", uiScale);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
LOG.warning("macOS does not support setting UI scale, so it will be ignored");
} else {
System.getProperties().putIfAbsent("glass.gtk.uiScale", uiScale);
}
} else {
LOG.warning("UI scale out of range: " + uiScale);
}
} catch (Throwable e) {
LOG.warning("Invalid UI scale: " + uiScale);
}
}
}
private static void createHMCLDirectories() {
if (!Files.isDirectory(Metadata.HMCL_CURRENT_DIRECTORY)) {
if (!Files.isDirectory(Metadata.HMCL_LOCAL_HOME)) {
try {
Files.createDirectories(Metadata.HMCL_CURRENT_DIRECTORY);
Files.createDirectories(Metadata.HMCL_LOCAL_HOME);
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
try {
Files.setAttribute(Metadata.HMCL_CURRENT_DIRECTORY, "dos:hidden", true);
Files.setAttribute(Metadata.HMCL_LOCAL_HOME, "dos:hidden", true);
} catch (IOException e) {
LOG.warning("Failed to set hidden attribute of " + Metadata.HMCL_CURRENT_DIRECTORY, e);
LOG.warning("Failed to set hidden attribute of " + Metadata.HMCL_LOCAL_HOME, e);
}
}
} catch (IOException e) {
// Logger has not been started yet, so print directly to System.err
System.err.println("Failed to create HMCL directory: " + Metadata.HMCL_CURRENT_DIRECTORY);
System.err.println("Failed to create HMCL directory: " + Metadata.HMCL_LOCAL_HOME);
e.printStackTrace(System.err);
showErrorAndExit(i18n("fatal.create_hmcl_current_directory_failure", Metadata.HMCL_CURRENT_DIRECTORY));
showErrorAndExit(i18n("fatal.create_hmcl_current_directory_failure", Metadata.HMCL_LOCAL_HOME));
}
}
if (!Files.isDirectory(Metadata.HMCL_GLOBAL_DIRECTORY)) {
if (!Files.isDirectory(Metadata.HMCL_USER_HOME)) {
try {
Files.createDirectories(Metadata.HMCL_GLOBAL_DIRECTORY);
Files.createDirectories(Metadata.HMCL_USER_HOME);
} catch (IOException e) {
LOG.warning("Failed to create HMCL global directory " + Metadata.HMCL_GLOBAL_DIRECTORY, e);
LOG.warning("Failed to create HMCL user home " + Metadata.HMCL_USER_HOME, e);
}
}
}
private static boolean isInsideMacAppBundle() {
Path thisJar = JarUtils.thisJarPath();
if (thisJar == null)
return false;
for (Path current = thisJar.getParent();
current != null && current.getParent() != null;
current = current.getParent()
) {
if ("Contents".equals(FileUtils.getName(current))
&& FileUtils.getName(current.getParent()).endsWith(".app")
&& Files.exists(current.resolve("Info.plist"))
) {
return true;
}
}
return false;
}
private static void initIcon() {
try {
if (java.awt.Taskbar.isTaskbarSupported()) {
@@ -170,25 +118,12 @@ public final class EntryPoint {
}
}
private static void checkDirectoryPath() {
String currentDir = System.getProperty("user.dir", "");
if (currentDir.contains("!")) {
LOG.error("The current working path contains an exclamation mark: " + currentDir);
// No Chinese translation because both Swing and JavaFX cannot render Chinese character properly when exclamation mark exists in the path.
showErrorAndExit("Exclamation mark(!) is not allowed in the path where HMCL is in.\n"
+ "The path is " + currentDir);
}
}
private static void checkJavaFX() {
try {
SelfDependencyPatcher.patch();
} catch (SelfDependencyPatcher.PatchException e) {
LOG.error("Unable to patch JVM", e);
showErrorAndExit(i18n("fatal.javafx.missing"));
} catch (SelfDependencyPatcher.IncompatibleVersionException e) {
LOG.error("Unable to patch JVM", e);
showErrorAndExit(i18n("fatal.javafx.incompatible"));
} catch (CancellationException e) {
LOG.error("User cancels downloading JavaFX", e);
exit(0);
@@ -209,6 +144,13 @@ public final class EntryPoint {
}
}
private static void checkWine() {
if (OperatingSystem.isRunningUnderWine()) {
LOG.warning("HMCL is running under Wine or its distributions!");
showWarning(i18n("fatal.wine_warning"));
}
}
private static void addEnableNativeAccess() {
if (JavaRuntime.CURRENT_VERSION > 21) {
try {
@@ -245,10 +187,21 @@ public final class EntryPoint {
}
}
static void showWarning(String message) {
SwingUtils.initLookAndFeel();
int result = JOptionPane.showOptionDialog(null, message, i18n("message.warning"), JOptionPane.OK_CANCEL_OPTION,
JOptionPane.WARNING_MESSAGE, null, null, null);
if (result == JOptionPane.CANCEL_OPTION || result == JOptionPane.CLOSED_OPTION) {
exit(1);
}
}
/**
* Indicates that a fatal error has occurred, and that the application cannot start.
*/
private static void showErrorAndExit(String message) {
static void showErrorAndExit(String message) {
SwingUtils.showErrorDialog(message);
exit(1);
}
@@ -1,52 +1,49 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl;
import com.sun.jna.Pointer;
import com.sun.jna.WString;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.value.ObservableBooleanValue;
import javafx.geometry.Rectangle2D;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.input.Clipboard;
import javafx.scene.input.DataFormat;
import javafx.stage.Screen;
import javafx.stage.Stage;
import org.jackhuang.hmcl.setting.ConfigHolder;
import org.jackhuang.hmcl.setting.SambaException;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.FileSaver;
import javafx.util.Duration;
import org.jackhuang.hmcl.game.HMCLCacheRepository;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.AsyncTaskExecutor;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.CrashReporter;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.CommandBuilder;
import org.jackhuang.hmcl.util.platform.NativeUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemInfo;
import org.jackhuang.hmcl.util.platform.*;
import org.jackhuang.hmcl.util.platform.macos.LibSystem;
import org.jackhuang.hmcl.util.platform.windows.Gdi32;
import org.jackhuang.hmcl.util.platform.windows.Shell32;
import org.jackhuang.hmcl.util.platform.windows.User32;
import org.jackhuang.hmcl.util.platform.windows.WinReg;
import org.jetbrains.annotations.Nullable;
import javax.swing.*;
import java.awt.*;
import java.awt.datatransfer.StringSelection;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
import java.net.CookieHandler;
@@ -54,17 +51,21 @@ import java.net.CookieManager;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// The main JavaFX application entry point.
public final class Launcher extends Application {
public static final CookieManager COOKIE_MANAGER = new CookieManager();
@@ -79,50 +80,51 @@ public final class Launcher extends Application {
LOG.info("Dark Mode: " + Optional.ofNullable(FXUtils.DARK_MODE).map(ObservableBooleanValue::get).orElse(false));
LOG.info("Reduced Motion: " + Objects.requireNonNullElse(FXUtils.REDUCED_MOTION, false));
try {
try {
ConfigHolder.init();
} catch (SambaException e) {
showAlert(AlertType.WARNING, i18n("fatal.samba"));
} catch (IOException e) {
LOG.error("Failed to load config", e);
checkConfigInTempDir();
checkConfigOwner();
showAlert(AlertType.ERROR, i18n("fatal.config_loading_failure", ConfigHolder.configLocation().getParent()));
EntryPoint.exit(1);
if (Screen.getScreens().isEmpty()) {
LOG.info("No screen");
} else {
StringBuilder builder = new StringBuilder("Screens:");
int count = 0;
for (Screen screen : Screen.getScreens()) {
builder.append("\n - Screen ").append(++count).append(": ");
appendScreen(builder, screen);
}
LOG.info(builder.toString());
}
try {
initializeSettingsRuntime();
// https://lapcatsoftware.com/articles/app-translocation.html
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
&& ConfigHolder.isNewlyCreated()
&& SettingsManager.isNewlyCreated()
&& System.getProperty("user.dir").startsWith("/private/var/folders/")) {
if (showAlert(AlertType.WARNING, i18n("fatal.mac_app_translocation"), ButtonType.YES, ButtonType.NO) == ButtonType.NO)
if (!confirmWithCountdown(AlertType.WARNING, i18n("fatal.mac_app_translocation"), 5))
return;
} else {
checkConfigInTempDir();
}
if (ConfigHolder.isOwnerChanged()) {
if (SettingsManager.isOwnerChanged()) {
if (showAlert(AlertType.WARNING, i18n("fatal.config_change_owner_root"), ButtonType.YES, ButtonType.NO) == ButtonType.NO)
return;
}
if (ConfigHolder.isUnsupportedVersion()) {
if (SettingsManager.hasReadOnlyCoreSettings()) {
showAlert(AlertType.WARNING, i18n("fatal.config_unsupported_version"));
}
if (Metadata.HMCL_CURRENT_DIRECTORY.toString().indexOf('=') >= 0) {
if (Metadata.HMCL_LOCAL_HOME.toString().indexOf('=') >= 0) {
showAlert(AlertType.WARNING, i18n("fatal.illegal_char"));
}
// runLater to ensure ConfigHolder.init() finished initialization
// runLater to ensure SettingsManager.init() finished initialization
Platform.runLater(() -> {
// When launcher visibility is set to "hide and reopen" without Platform.implicitExit = false,
// Stage.show() cannot work again because JavaFX Toolkit have already shut down.
Platform.setImplicitExit(false);
Controllers.initialize(primaryStage);
UpdateChecker.init();
primaryStage.show();
});
@@ -131,12 +133,85 @@ public final class Launcher extends Application {
}
}
/// Initializes modules and runtime services that depend on loaded settings.
private static void initializeSettingsRuntime() {
DownloadProviders.init();
ProxyManager.init();
Accounts.init();
GameDirectoryManager.init();
AuthlibInjectorServers.init();
AnimationUtils.init();
CacheRepository.setInstance(HMCLCacheRepository.REPOSITORY);
HMCLCacheRepository.REPOSITORY.directoryProperty().bind(Bindings.createStringBinding(() -> {
String commonDirectory = settings().getResolvedCommonDirectory();
if (commonDirectory != null && FileUtils.canCreateDirectory(commonDirectory)) {
return commonDirectory;
} else {
return LauncherSettings.getDefaultCommonDirectory();
}
}, settings().commonDirectoryProperty(), settings().commonDirectoryTypeProperty()));
}
private static void appendScreen(StringBuilder builder, Screen screen) {
Rectangle2D bounds = screen.getBounds();
double scale = screen.getOutputScaleX();
builder.append(Math.round(bounds.getWidth() * scale));
builder.append('x');
builder.append(Math.round(bounds.getHeight() * scale));
DecimalFormat decimalFormat = new DecimalFormat("#.##");
if (scale != 1.0) {
builder.append(" @ ");
builder.append(decimalFormat.format(scale));
builder.append('x');
}
double dpi = screen.getDpi();
builder.append(' ');
builder.append(decimalFormat.format(dpi));
builder.append("dpi");
builder.append(" in ")
.append(Math.round(Math.sqrt(bounds.getWidth() * bounds.getWidth() + bounds.getHeight() * bounds.getHeight()) / dpi))
.append('"');
builder.append(" (").append(decimalFormat.format(bounds.getMinX()))
.append(", ").append(decimalFormat.format(bounds.getMinY()))
.append(", ").append(decimalFormat.format(bounds.getMaxX()))
.append(", ").append(decimalFormat.format(bounds.getMaxY()))
.append(")");
}
private static ButtonType showAlert(AlertType alertType, String contentText, ButtonType... buttons) {
return new Alert(alertType, contentText, buttons).showAndWait().orElse(null);
}
private static boolean confirmWithCountdown(Alert.AlertType alertType, String contentText, int seconds) {
Alert alert = new Alert(alertType, contentText, ButtonType.YES, ButtonType.NO);
Button okButton = (Button) alert.getDialogPane().lookupButton(ButtonType.YES);
okButton.setDisable(true);
KeyFrame[] keyFrames = new KeyFrame[seconds + 1];
for (int i = 0; i < seconds; i++) {
keyFrames[i] = new KeyFrame(Duration.seconds(i),
new KeyValue(okButton.textProperty(), i18n("button.ok.countdown", seconds - i)));
}
keyFrames[seconds] = new KeyFrame(Duration.seconds(seconds),
new KeyValue(okButton.textProperty(), i18n("button.ok")),
new KeyValue(okButton.disableProperty(), false));
Timeline timeline = new Timeline(keyFrames);
alert.setOnShown(e -> timeline.play());
alert.setOnCloseRequest(e -> timeline.stop());
return alert.showAndWait().orElse(null) == ButtonType.YES;
}
private static boolean isConfigInTempDir() {
String configPath = ConfigHolder.configLocation().toString();
String configPath = SettingsManager.localConfigDirectory().toString();
String tmpdir = System.getProperty("java.io.tmpdir");
if (StringUtils.isNotBlank(tmpdir) && configPath.startsWith(tmpdir))
@@ -173,64 +248,20 @@ public final class Launcher extends Application {
}
private static void checkConfigInTempDir() {
if (ConfigHolder.isNewlyCreated() && isConfigInTempDir()
&& showAlert(AlertType.WARNING, i18n("fatal.config_in_temp_dir"), ButtonType.YES, ButtonType.NO) == ButtonType.NO) {
if (SettingsManager.isNewlyCreated() && isConfigInTempDir()
&& !confirmWithCountdown(AlertType.WARNING, i18n("fatal.config_in_temp_dir"), 5)) {
EntryPoint.exit(0);
}
}
private static void checkConfigOwner() {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
return;
String userName = System.getProperty("user.name");
String owner;
try {
owner = Files.getOwner(ConfigHolder.configLocation()).getName();
} catch (IOException ioe) {
LOG.warning("Failed to get file owner", ioe);
return;
}
if (Files.isWritable(ConfigHolder.configLocation()) || userName.equals("root") || userName.equals(owner))
return;
ArrayList<String> files = new ArrayList<>();
files.add(ConfigHolder.configLocation().toString());
if (Files.exists(Metadata.HMCL_GLOBAL_DIRECTORY))
files.add(Metadata.HMCL_GLOBAL_DIRECTORY.toString());
if (Files.exists(Metadata.HMCL_CURRENT_DIRECTORY))
files.add(Metadata.HMCL_CURRENT_DIRECTORY.toString());
Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize();
if (Files.exists(mcDir))
files.add(mcDir.toString());
String command = new CommandBuilder().add("sudo", "chown", "-R", userName).addAll(files).toString();
ButtonType copyAndExit = new ButtonType(i18n("button.copy_and_exit"));
if (showAlert(AlertType.ERROR,
i18n("fatal.config_loading_failure.unix", owner, command),
copyAndExit, ButtonType.CLOSE) == copyAndExit) {
Clipboard.getSystemClipboard()
.setContent(Collections.singletonMap(DataFormat.PLAIN_TEXT, command));
}
EntryPoint.exit(1);
}
@Override
public void stop() throws Exception {
Controllers.onApplicationStop();
FileSaver.shutdown();
SettingsManager.shutdown();
LOG.shutdown();
}
public static void main(String[] args) {
if (UpdateHandler.processArguments(args)) {
LOG.shutdown();
return;
}
Thread.setDefaultUncaughtExceptionHandler(CRASH_REPORTER);
AsyncTaskExecutor.setUncaughtExceptionHandler(new CrashReporter(false));
@@ -243,6 +274,10 @@ public final class Launcher extends Application {
LOG.info("Processor Identifier: " + System.getenv("PROCESSOR_IDENTIFIER"));
}
LOG.info("System Architecture: " + Architecture.SYSTEM_ARCH.getDisplayName());
try {
LOG.info("Process PID: " + ProcessHandle.current().pid());
} catch (UnsupportedOperationException ignored) {
}
LOG.info("Native Encoding: " + OperatingSystem.NATIVE_CHARSET);
LOG.info("JNU Encoding: " + System.getProperty("sun.jnu.encoding"));
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
@@ -253,10 +288,10 @@ public final class Launcher extends Application {
LOG.info("Java VM Version: " + System.getProperty("java.vm.name") + " (" + System.getProperty("java.vm.info") + "), " + System.getProperty("java.vm.vendor"));
LOG.info("Java Home: " + System.getProperty("java.home"));
LOG.info("Current Directory: " + Metadata.CURRENT_DIRECTORY);
LOG.info("HMCL Global Directory: " + Metadata.HMCL_GLOBAL_DIRECTORY);
LOG.info("HMCL Current Directory: " + Metadata.HMCL_CURRENT_DIRECTORY);
LOG.info("HMCL Jar Path: " + Lang.requireNonNullElse(JarUtils.thisJarPath(), "Not Found"));
LOG.info("HMCL Log File: " + Lang.requireNonNullElse(LOG.getLogFile(), "In Memory"));
LOG.info("HMCL User Home: " + Metadata.HMCL_USER_HOME);
LOG.info("HMCL Local Home: " + Metadata.HMCL_LOCAL_HOME);
LOG.info("HMCL Jar Path: " + Objects.requireNonNullElse(JarUtils.thisJarPath(), "Not Found"));
LOG.info("HMCL Log File: " + Objects.requireNonNullElse(LOG.getLogFile(), "In Memory"));
LOG.info("JVM Max Memory: " + MEGABYTES.formatBytes(Runtime.getRuntime().maxMemory()));
try {
for (MemoryPoolMXBean bean : ManagementFactory.getMemoryPoolMXBeans()) {
@@ -274,8 +309,27 @@ public final class Launcher extends Application {
LOG.info("XDG Current Desktop: " + System.getenv("XDG_CURRENT_DESKTOP"));
}
@Nullable Boolean isRoot = isRootUser();
LOG.info("Administrator User: " + (isRoot == null ? "Unknown" : isRoot.toString()));
LOG.info("Zlib Compatible: " + ZlibUtils.IS_ZLIB_COMPATIBLE);
Lang.thread(SystemInfo::initialize, "Detection System Information", true);
try {
SettingsManager.init();
} catch (SambaException e) {
EntryPoint.showWarning(i18n("fatal.samba"));
} catch (IOException e) {
LOG.error("Failed to load config", e);
checkConfigOwner();
SwingUtils.showErrorDialog(i18n("fatal.config_loading_failure", SettingsManager.localConfigDirectory()));
EntryPoint.exit(1);
}
setupJavaFXVMOptions();
setupWindowsAppUserModelID();
launch(Launcher.class, args);
} catch (Throwable e) { // Fucking JavaFX will suppress the exception and will break our crash reporter.
CRASH_REPORTER.uncaughtException(Thread.currentThread(), e);
@@ -308,5 +362,283 @@ public final class Launcher extends Application {
});
}
/// Sets the process-level AppUserModelID on Windows so the launcher groups correctly on the taskbar.
private static void setupWindowsAppUserModelID() {
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS)
return;
if (!NativeUtils.USE_JNA || Shell32.INSTANCE == null)
return;
@Nullable Path thisJar = JarUtils.thisJarPath();
if (thisJar == null || !Files.isRegularFile(thisJar) || !"exe".equalsIgnoreCase(FileUtils.getExtension(thisJar)))
return;
try {
int hr = Shell32.INSTANCE.SetCurrentProcessExplicitAppUserModelID(new WString(Metadata.WINDOWS_APP_USER_MODEL_ID));
if (hr < 0) {
LOG.warning("Failed to set AppUserModelID, HRESULT=0x" + Integer.toHexString(hr));
} else {
LOG.info("Set AppUserModelID: " + Metadata.WINDOWS_APP_USER_MODEL_ID);
}
} catch (Throwable e) {
LOG.warning("Failed to set AppUserModelID", e);
}
}
/// Sets JavaFX VM options before the toolkit starts.
/// On Wayland, the UI scale is auto-detected when the user did not configure one explicitly.
private static void setupJavaFXVMOptions() {
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
LOG.info("HMCL_FORCE_GPU: true");
System.getProperties().putIfAbsent("prism.forceGPU", "true");
}
setUpAnimationFrameRate:
{
if (System.getProperty("javafx.animation.pulse") != null) {
break setUpAnimationFrameRate;
}
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
if (animationFrameRate != null) {
LOG.info("HMCL_ANIMATION_FRAME_RATE: " + animationFrameRate);
try {
int value = Integer.parseInt(animationFrameRate);
if (value <= 0)
throw new NumberFormatException(animationFrameRate);
if (value != 60)
System.setProperty("javafx.animation.pulse", animationFrameRate);
} catch (NumberFormatException e) {
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
}
break setUpAnimationFrameRate;
}
// To avoid prematurely loading FXUtils, we only check if animationDisabled has been explicitly set to true
if (Boolean.TRUE.equals(settings().animationDisabledProperty().get()))
break setUpAnimationFrameRate;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
if (NativeUtils.USE_JNA && Gdi32.INSTANCE != null && User32.INSTANCE != null) {
@Nullable Pointer pointer = User32.INSTANCE.GetDC(Pointer.NULL);
if (pointer != null) {
try {
int refreshRate = Gdi32.INSTANCE.GetDeviceCaps(pointer, Gdi32.VREFRESH);
if (refreshRate > 0) {
LOG.info("Detected refresh rate: " + refreshRate + "Hz");
if (refreshRate >= 90) {
System.getProperties().putIfAbsent("javafx.animation.pulse", String.valueOf(refreshRate));
}
}
} finally {
User32.INSTANCE.ReleaseDC(Pointer.NULL, pointer);
}
}
}
}
}
float uiScaleLowerBound;
float uiScaleUpperBound;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
// JavaFX behavior may be abnormal when the DPI scaling factor is too high
uiScaleLowerBound = 0.25f;
uiScaleUpperBound = 4f;
} else {
uiScaleLowerBound = 0.01f;
uiScaleUpperBound = 10f;
}
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
float scaleValue = Float.NaN;
if (uiScale != null && !(uiScale = uiScale.trim()).isEmpty()) {
LOG.info("HMCL_UI_SCALE: " + uiScale);
try {
if (uiScale.endsWith("%")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 1)) / 100.0f;
} else if (uiScale.endsWith("dpi") || uiScale.endsWith("DPI")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 3)) / 96.0f;
} else {
scaleValue = Float.parseFloat(uiScale);
}
} catch (Throwable e) {
LOG.warning("Invalid UI scale: " + uiScale);
}
} else if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
String xdgSessionType = Objects.requireNonNullElse(System.getenv("XDG_SESSION_TYPE"), "");
String xdgCurrentDesktop = Objects.requireNonNullElse(System.getenv("XDG_CURRENT_DESKTOP"), "");
if ("wayland".equals(xdgSessionType)
&& StringUtils.startsWithIgnoreCase(xdgCurrentDesktop, "KDE")) {
try {
@Nullable Path xrdb = SystemUtils.which("xrdb");
@Nullable String xftDpi = xrdb != null ? SystemUtils.run(List.of(FileUtils.getAbsolutePath(xrdb), "-query"), inputStream -> {
@Nullable String dpiLine;
try (var reader = new InputStreamReader(inputStream, OperatingSystem.NATIVE_CHARSET);
BufferedReader bufferedReader = new BufferedReader(reader);
Stream<String> lines = bufferedReader.lines()) {
dpiLine = lines.filter(line -> line.trim().startsWith("Xft.dpi:"))
.findFirst().orElse(null);
}
return dpiLine != null
? dpiLine.substring("Xft.dpi:".length()).trim()
: null;
}, java.time.Duration.ofSeconds(1)) : null;
if (xftDpi != null) {
float dpiValue = Float.parseFloat(xftDpi);
float scale = dpiValue / 96f;
if (scale > 1 && scale <= 10) {
LOG.info("Detected Xft.dpi: %s (%.1fx)".formatted(dpiValue, scale));
scaleValue = scale;
uiScale = Float.toString(scale);
}
}
} catch (Exception e) {
LOG.warning("Failed to read Xft.dpi from xrdb", e);
}
}
}
if (Float.isFinite(scaleValue)) {
if (scaleValue >= uiScaleLowerBound && scaleValue <= uiScaleUpperBound) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
System.getProperties().putIfAbsent("glass.win.uiScale", uiScale);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
LOG.warning("macOS does not support setting UI scale, so it will be ignored");
} else {
System.getProperties().putIfAbsent("glass.gtk.uiScale", uiScale);
}
} else {
LOG.warning("UI scale out of range: " + uiScale);
}
}
}
private static void checkConfigOwner() {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
return;
String userName = System.getProperty("user.name");
Path configDirectory = SettingsManager.localConfigDirectory();
if (!Files.exists(configDirectory)) {
return;
}
String owner;
try {
owner = Files.getOwner(configDirectory).getName();
} catch (IOException ioe) {
LOG.warning("Failed to get file owner", ioe);
return;
}
if (Files.isWritable(configDirectory) || userName.equals("root") || userName.equals(owner))
return;
ArrayList<String> files = new ArrayList<>();
files.add(configDirectory.toString());
if (Files.exists(Metadata.HMCL_USER_HOME))
files.add(Metadata.HMCL_USER_HOME.toString());
Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize();
if (Files.exists(mcDir))
files.add(mcDir.toString());
String command = new CommandBuilder().addAll("sudo", "chown", "-R", userName).addAll(files).toString();
SwingUtils.initLookAndFeel();
Object[] options = {i18n("button.copy_and_exit"), i18n("button.cancel")};
int result = JOptionPane.showOptionDialog(null,
i18n("fatal.config_loading_failure.unix", owner, command),
i18n("message.error"),
JOptionPane.DEFAULT_OPTION,
JOptionPane.ERROR_MESSAGE,
null,
options,
options[0]);
if (result == 0) {
try {
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(command), null);
} catch (Throwable e) {
LOG.warning("Failed to copy command to clipboard", e);
}
}
EntryPoint.exit(1);
}
private static @Nullable Boolean isRootUser() {
switch (OperatingSystem.CURRENT_OS) {
case WINDOWS -> {
WinReg reg = WinReg.INSTANCE;
if (reg == null)
return null;
try {
return reg.exists(WinReg.HKEY.HKEY_USERS, "S-1-5-19");
} catch (Exception e) {
LOG.warning("Failed to check Windows administrator privileges", e);
return null;
}
}
case LINUX -> {
Path selfStatus = Paths.get("/proc/self/status");
if (Files.exists(selfStatus)) {
try (var reader = Files.newBufferedReader(selfStatus)) {
@Nullable String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("Uid:")) {
String[] parts = line.split("\t");
if (parts.length >= 3) {
String uid = parts[2].trim();
return "0".equals(uid);
}
}
}
} catch (Exception e) {
LOG.warning("Failed to read " + selfStatus, e);
}
}
}
case MACOS -> {
if (NativeUtils.USE_JNA) {
LibSystem libSystem = LibSystem.INSTANCE;
if (libSystem != null) {
try {
return libSystem.geteuid() == 0;
} catch (Throwable e) {
LOG.warning("Failed to check macOS root privileges", e);
return null;
}
}
}
}
}
if (OperatingSystem.CURRENT_OS.isUnixLike()) {
@Nullable Path id = SystemUtils.which("id");
if (id != null) {
try {
return "0".equals(SystemUtils.run(id.toString(), "-u").trim());
} catch (Exception e) {
LOG.warning("Failed to run id -u", e);
return null;
}
}
}
return null;
}
public static final CrashReporter CRASH_REPORTER = new CrashReporter(true);
}
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl;
import org.jackhuang.hmcl.util.StringUtils;
@@ -23,8 +8,8 @@ import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.EnumSet;
/**
@@ -38,55 +23,56 @@ public final class Metadata {
public static final String FULL_NAME = "Hello Minecraft! Launcher";
public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.getAttribute("hmcl.version", "@develop@"));
/// Explicit Application User Model ID used for Windows taskbar grouping and pinning.
public static final String WINDOWS_APP_USER_MODEL_ID = "org.jackhuang.hmcl";
public static final String TITLE = NAME + " " + VERSION;
public static final String FULL_TITLE = FULL_NAME + " v" + VERSION;
/// Whether the build metadata identifies this launcher as an official build.
public static final boolean OFFICIAL_BUILD = Boolean.parseBoolean(JarUtils.getAttribute("hmcl.official", "false"));
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 ABOUT_URL = PUBLISH_URL + "/about";
public static final String DOWNLOAD_URL = PUBLISH_URL + "/download";
public static final String HMCL_UPDATE_URL = System.getProperty("hmcl.update_source.override", PUBLISH_URL + "/api/update_link");
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);
public static final Path CURRENT_DIRECTORY = Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize();
public static final Path CURRENT_DIRECTORY = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize();
public static final Path MINECRAFT_DIRECTORY = OperatingSystem.getWorkingDirectory("minecraft");
public static final Path HMCL_GLOBAL_DIRECTORY;
public static final Path HMCL_CURRENT_DIRECTORY;
public static final Path HMCL_USER_HOME;
public static final Path HMCL_LOCAL_HOME;
public static final Path DEPENDENCIES_DIRECTORY;
static {
String hmclHome = System.getProperty("hmcl.home");
if (hmclHome == null) {
String hmclHome = System.getProperty("hmcl.home", System.getenv("HMCL_USER_HOME"));
if (StringUtils.isBlank(hmclHome)) {
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
String xdgData = System.getenv("XDG_DATA_HOME");
if (StringUtils.isNotBlank(xdgData)) {
HMCL_GLOBAL_DIRECTORY = Paths.get(xdgData, "hmcl").toAbsolutePath().normalize();
HMCL_USER_HOME = Path.of(xdgData, "hmcl").toAbsolutePath().normalize();
} else {
HMCL_GLOBAL_DIRECTORY = Paths.get(System.getProperty("user.home"), ".local", "share", "hmcl").toAbsolutePath().normalize();
HMCL_USER_HOME = Path.of(System.getProperty("user.home"), ".local", "share", "hmcl").toAbsolutePath().normalize();
}
} else {
HMCL_GLOBAL_DIRECTORY = OperatingSystem.getWorkingDirectory("hmcl");
HMCL_USER_HOME = OperatingSystem.getWorkingDirectory("hmcl");
}
} else {
HMCL_GLOBAL_DIRECTORY = Paths.get(hmclHome).toAbsolutePath().normalize();
HMCL_USER_HOME = Path.of(hmclHome).toAbsolutePath().normalize();
}
String hmclCurrentDir = System.getProperty("hmcl.dir");
HMCL_CURRENT_DIRECTORY = hmclCurrentDir != null
? Paths.get(hmclCurrentDir).toAbsolutePath().normalize()
String hmclCurrentDir = System.getProperty("hmcl.dir", System.getenv("HMCL_LOCAL_HOME"));
HMCL_LOCAL_HOME = StringUtils.isNotBlank(hmclCurrentDir)
? Path.of(hmclCurrentDir).toAbsolutePath().normalize()
: CURRENT_DIRECTORY.resolve(".hmcl");
DEPENDENCIES_DIRECTORY = HMCL_CURRENT_DIRECTORY.resolve("dependencies");
String hmclDependencies = System.getProperty("hmcl.dependencies.dir", System.getenv("HMCL_DEPENDENCIES_DIR"));
DEPENDENCIES_DIRECTORY = StringUtils.isNotBlank(hmclDependencies)
? Path.of(hmclDependencies).toAbsolutePath().normalize()
: HMCL_LOCAL_HOME.resolve("dependencies");
}
public static boolean isStable() {
@@ -103,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)
@@ -127,4 +113,46 @@ public final class Metadata {
return null;
}
}
/// Directory under [HMCL_LOCAL_HOME] that holds a launcher-bundled modpack for automatic install.
public static final String BUNDLED_MODPACK_DIRECTORY_NAME = "modpack";
/// Returns the directory for a launcher-bundled modpack (`[HMCL_LOCAL_HOME]/modpack`).
public static Path getBundledModpackDirectory() {
return HMCL_LOCAL_HOME.resolve(BUNDLED_MODPACK_DIRECTORY_NAME);
}
/// Returns the bundled modpack package under [getBundledModpackDirectory], if present.
///
/// Prefers `modpack.zip` over `modpack.mrpack` when both exist. Presence of the package is the
/// signal to offer automatic install; the file is removed after a successful install.
///
/// @return the modpack path, or `null` when no package is present
public static @Nullable Path findBundledModpackFile() {
Path directory = getBundledModpackDirectory();
Path zipModpack = directory.resolve("modpack.zip");
if (Files.isRegularFile(zipModpack)) {
return zipModpack;
}
Path mrpackModpack = directory.resolve("modpack.mrpack");
if (Files.isRegularFile(mrpackModpack)) {
return mrpackModpack;
}
return null;
}
/// Server address used when the installed modpack manifest does not provide one.
///
/// Can be set at build or run time with the `hmcl.server.address` system property; empty by default.
public static String getDefaultServerAddress() {
return System.getProperty("hmcl.server.address", "").trim();
}
/// The server name shown in the title bar.
///
/// Can be set with the `hmcl.server.name` system property; empty by default.
public static String getServerName() {
return System.getProperty("hmcl.server.name", "").trim();
}
}
@@ -0,0 +1,473 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.addon;
import org.jackhuang.hmcl.addon.mod.ModProjectInfo;
import org.jackhuang.hmcl.modpack.ModpackProjectInfo;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.ref.SoftReference;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Glavo
@NotNullByDefault
public abstract class AddonProjectInfoRegistry<I extends AddonProjectInfo> {
public static final AddonProjectInfoRegistry<ModProjectInfo> MOD = new AddonProjectInfoRegistry<>(
"/assets/addon/mod.csv",
32768
) {
@Override
protected int getFieldsCount() {
return MOD_IDS_INDEX + 1;
}
@Override
protected ModProjectInfo parseAddonProjectInfo(String[] fields) throws Exception {
return new ModProjectInfo(
Integer.parseInt(fields[MCMOD_ID_INDEX]),
fields[CURSEFORGE_ID_INDEX],
fields[MODRINTH_ID_INDEX],
fields[MAIN_NAME_INDEX],
fields[SUB_NAME_INDEX],
fields[ABBR_NAME_INDEX],
fields[MOD_IDS_INDEX]
);
}
};
/// Provides metadata for modpacks.
public static final AddonProjectInfoRegistry<ModpackProjectInfo> MODPACK = new AddonProjectInfoRegistry<>(
"/assets/addon/modpack.csv", 2048
) {
@Override
protected int getFieldsCount() {
return ABBR_NAME_INDEX + 1;
}
@Override
protected ModpackProjectInfo parseAddonProjectInfo(String[] fields) {
return new ModpackProjectInfo(Integer.parseInt(fields[MCMOD_ID_INDEX]),
fields[CURSEFORGE_ID_INDEX], fields[MODRINTH_ID_INDEX],
fields[MAIN_NAME_INDEX], fields[SUB_NAME_INDEX], fields[ABBR_NAME_INDEX]);
}
};
/// Returns the registry for the addon type, or `null` for null or unsupported types.
public static @Nullable AddonProjectInfoRegistry<?> getByAddonType(@Nullable RemoteAddon.Type type) {
if (type == null) return null;
return switch (type) {
case MOD -> MOD;
case MODPACK -> MODPACK;
default -> null;
};
}
protected static final int MCMOD_ID_INDEX = 0;
protected static final int CURSEFORGE_ID_INDEX = 1;
protected static final int MODRINTH_ID_INDEX = 2;
protected static final int MAIN_NAME_INDEX = 3;
protected static final int SUB_NAME_INDEX = 4;
protected static final int ABBR_NAME_INDEX = 5;
protected static final int MOD_IDS_INDEX = 6;
private final ReentrantLock lock = new ReentrantLock();
private final String assetsPath;
private final int initialCapacity;
private volatile @Nullable SoftReference<@Unmodifiable List<I>> infosRef;
/// Softly caches projects indexed by mod ID.
private volatile @Nullable SoftReference<@Unmodifiable Map<String, I>> modIdMapRef;
/// Softly caches projects indexed by cleaned secondary name.
private volatile @Nullable SoftReference<@Unmodifiable Map<String, I>> subnameMapRef;
/// Softly caches projects indexed by CurseForge identifier.
private volatile @Nullable SoftReference<@Unmodifiable Map<String, I>> curseForgeMapRef;
/// Softly caches searchable names in resource and field order.
private volatile @Nullable SoftReference<@Unmodifiable List<Pair<String, I>>> keywordsRef;
/// Creates a registry reading records from the given classpath resource.
AddonProjectInfoRegistry(String assetsPath, int initialCapacity) {
this.assetsPath = assetsPath;
this.initialCapacity = initialCapacity;
}
protected abstract int getFieldsCount();
protected abstract I parseAddonProjectInfo(String[] fields) throws Exception;
private @Unmodifiable List<I> loadInfos() throws IOException {
@Nullable InputStream inputStream = AddonProjectInfoRegistry.class.getResourceAsStream(assetsPath);
if (inputStream == null) {
return List.of();
}
try (var reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
ArrayList<I> infos = new ArrayList<>(initialCapacity);
@Unmodifiable List<I> result = Collections.unmodifiableList(infos);
int fieldsCount = getFieldsCount();
String[] fields = new String[fieldsCount];
@Nullable StringBuilder fieldBuilder = null;
@Nullable String line;
loop:
while ((line = reader.readLine()) != null) {
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
int offset = 0;
for (int fieldIndex = 0; fieldIndex < fieldsCount; fieldIndex++) {
if (offset > line.length()) {
LOG.warning("Invalid line in " + assetsPath + ": " + line);
continue loop;
}
int end = line.indexOf(',', offset);
if (end < 0)
end = line.length();
String field;
if (offset == end) {
field = "";
} else {
int cursor = offset;
while (cursor < end) {
if (line.charAt(cursor) == '\\') {
break;
} else {
cursor++;
}
}
if (cursor == end) {
field = line.substring(offset, end);
} else {
if (fieldBuilder == null) {
fieldBuilder = new StringBuilder(32);
} else {
fieldBuilder.setLength(0);
}
fieldBuilder.append(line, offset, cursor);
while (cursor < end) {
char c = line.charAt(cursor);
if (c == '\\') {
if (cursor < end - 1) {
char next = line.charAt(cursor + 1);
switch (next) {
case '0' -> {
fieldBuilder.append('\0');
cursor += 2;
}
case 'n' -> {
fieldBuilder.append('\n');
cursor += 2;
}
case 'r' -> {
fieldBuilder.append('\r');
cursor += 2;
}
case 't' -> {
fieldBuilder.append('\t');
cursor += 2;
}
case '\\' -> {
fieldBuilder.append('\\');
cursor += 2;
}
case 'c' -> {
fieldBuilder.append(',');
cursor += 2;
}
case 'u' -> {
if (cursor < end - 5) {
try {
int codePoint = Integer.parseInt(line, cursor + 2, cursor + 6, 16);
fieldBuilder.append((char) codePoint);
cursor += 6;
} catch (NumberFormatException e) {
LOG.warning("Invalid line in " + assetsPath + ": " + line);
continue loop;
}
} else {
fieldBuilder.append(c);
cursor++;
}
}
default -> {
LOG.warning("Invalid line in " + assetsPath + ": " + line);
continue loop;
}
}
} else {
fieldBuilder.append(c);
cursor++;
}
} else {
fieldBuilder.append(c);
cursor++;
}
}
field = fieldBuilder.toString();
}
}
fields[fieldIndex] = field;
offset = end + 1;
}
try {
I info = parseAddonProjectInfo(fields);
info.setKeepAlive(result);
infos.add(info);
} catch (Exception e) {
LOG.warning("Failed to parse line in " + assetsPath + ": " + line, e);
}
}
infos.trimToSize();
return result;
}
}
/// Returns an unmodifiable index, rebuilding it after its soft reference is cleared.
/// Duplicate keys retain the first project in resource order.
private @Unmodifiable Map<String, I> getModIdMap() {
@Nullable SoftReference<@Unmodifiable Map<String, I>> reference = this.modIdMapRef;
@Unmodifiable Map<String, I> cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
lock.lock();
try {
reference = this.modIdMapRef;
cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
Map<String, I> map = new HashMap<>();
for (I info : getInfos()) {
if (info instanceof ModProjectInfo mod) {
for (String id : mod.getModIds()) {
if (!id.isEmpty())
map.putIfAbsent(id, info);
}
}
}
cached = Collections.unmodifiableMap(map);
this.modIdMapRef = new SoftReference<>(cached);
return cached;
} finally {
lock.unlock();
}
}
/// Returns an unmodifiable index, rebuilding it after its soft reference is cleared.
/// Duplicate keys retain the first project in resource order.
private @Unmodifiable Map<String, I> getSubnameMap() {
@Nullable SoftReference<@Unmodifiable Map<String, I>> reference = this.subnameMapRef;
@Unmodifiable Map<String, I> cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
lock.lock();
try {
reference = this.subnameMapRef;
cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
Map<String, I> map = new HashMap<>();
for (I info : getInfos()) {
String name = cleanSubname(info.subName());
if (!name.isEmpty())
map.putIfAbsent(name, info);
}
cached = Collections.unmodifiableMap(map);
this.subnameMapRef = new SoftReference<>(cached);
return cached;
} finally {
lock.unlock();
}
}
/// Returns an unmodifiable index, rebuilding it after its soft reference is cleared.
/// Duplicate keys retain the first project in resource order.
private @Unmodifiable Map<String, I> getCurseForgeMap() {
@Nullable SoftReference<@Unmodifiable Map<String, I>> reference = this.curseForgeMapRef;
@Unmodifiable Map<String, I> cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
lock.lock();
try {
reference = this.curseForgeMapRef;
cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
Map<String, I> map = new HashMap<>();
for (I info : getInfos()) {
String id = info.curseforgeId();
if (StringUtils.isNotBlank(id))
map.putIfAbsent(id, info);
}
cached = Collections.unmodifiableMap(map);
this.curseForgeMapRef = new SoftReference<>(cached);
return cached;
} finally {
lock.unlock();
}
}
/// Returns the first project with the exact CurseForge identifier, or `null` if the
/// identifier is null, blank or unknown.
public @Nullable I getInfoByCurseForgeId(@Nullable String id) {
return StringUtils.isBlank(id) ? null : getCurseForgeMap().get(id);
}
/// Finds a project by its cleaned secondary name, falling back to its exact mod ID.
/// A name match is accepted only when the ID is blank or belongs to that mod project.
/// Null and blank inputs are absent; returns `null` if neither lookup matches.
/// Duplicate keys resolve to the first project in resource order.
public @Nullable I getInfo(@Nullable String id, @Nullable String subname) {
String name = cleanSubname(subname);
if (StringUtils.isNotBlank(name)) {
@Nullable I info = getSubnameMap().get(name);
if (info != null && (StringUtils.isBlank(id)
|| info instanceof ModProjectInfo mod && mod.getModIds().contains(id)))
return info;
}
return StringUtils.isBlank(id) ? null : getModIdMap().get(id);
}
private static boolean isSupportedSubnameChar(int ch) {
return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')
|| ".+\\".indexOf(ch) >= 0;
}
/// 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))
return "";
int offset = 0;
while (offset < subname.length()) {
int ch = subname.charAt(offset);
if (!isSupportedSubnameChar(ch))
break;
offset++;
}
if (offset == subname.length())
return subname;
StringBuilder builder = new StringBuilder(subname.length());
builder.append(subname, 0, offset);
while (offset < subname.length()) {
int ch = subname.codePointAt(offset);
if (isSupportedSubnameChar(ch)) {
builder.appendCodePoint(ch);
} else if (!(Character.isWhitespace(ch)
|| "':_-/&()[]{}|,!?~•".indexOf(ch) >= 0)) {
return "";
}
offset += Character.charCount(ch);
}
return builder.toString();
}
/// Returns searchable names, rebuilding the index after its soft reference is cleared.
private @Unmodifiable List<Pair<String, I>> getKeywords() {
@Nullable SoftReference<@Unmodifiable List<Pair<String, I>>> reference = keywordsRef;
@Unmodifiable List<Pair<String, I>> cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
lock.lock();
try {
reference = keywordsRef;
cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
List<Pair<String, I>> keywords = new ArrayList<>();
for (I info : getInfos()) {
if (!info.mainName().isEmpty())
keywords.add(Pair.pair(info.mainName(), info));
if (!info.subName().isEmpty())
keywords.add(Pair.pair(info.subName(), info));
if (!info.abbrName().isEmpty())
keywords.add(Pair.pair(info.abbrName(), info));
}
cached = List.copyOf(keywords);
keywordsRef = new SoftReference<>(cached);
return cached;
} finally {
lock.unlock();
}
}
/// Returns projects ranked by descending longest common subsequence length after removing
/// space characters from the query. Comparison is case-sensitive and uses UTF-16 units.
/// Matches require at least one unit and at least query length minus three units.
/// Each matching name contributes a result, so projects may occur more than once.
/// Ties preserve resource order, then primary name, secondary name and abbreviation order.
public @Unmodifiable List<I> search(String query) {
query = query.chars().filter(ch -> !Character.isSpaceChar(ch))
.collect(StringBuilder::new, (sb, ch) -> sb.append((char) ch), StringBuilder::append).toString();
@Unmodifiable List<Pair<String, I>> keywords = getKeywords();
int maxLength = keywords.stream().mapToInt(keyword -> keyword.getKey().length()).max().orElse(0);
StringUtils.LongestCommonSubsequence lcs = new StringUtils.LongestCommonSubsequence(query.length(), maxLength);
List<Pair<Integer, I>> matches = new ArrayList<>();
for (Pair<String, I> keyword : keywords) {
int score = lcs.calc(query, keyword.getKey());
if (score >= Math.max(1, query.length() - 3))
matches.add(Pair.pair(score, keyword.getValue()));
}
return matches.stream().sorted((a, b) -> Integer.compare(b.getKey(), a.getKey()))
.map(Pair::getValue).toList();
}
/// Returns cached project metadata, loading it on demand. Missing resources and I/O failures
/// produce an empty list; malformed records are skipped. The cache is softly referenced.
public @Unmodifiable List<I> getInfos() {
@Nullable SoftReference<@Unmodifiable List<I>> infosRef = this.infosRef;
@Unmodifiable List<I> infos = infosRef != null ? infosRef.get() : null;
if (infos != null)
return infos;
lock.lock();
try {
infosRef = this.infosRef;
infos = infosRef != null ? infosRef.get() : null;
if (infos != null)
return infos;
try {
infos = loadInfos();
} catch (IOException e) {
LOG.warning("Failed to load addon project info from " + assetsPath, e);
infos = List.of();
}
this.infosRef = new SoftReference<>(infos);
return infos;
} finally {
lock.unlock();
}
}
}
@@ -0,0 +1,89 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.addon.update;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.addon.LocalAddonFile;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.Pair;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class AddonCheckUpdatesTask extends Task<AddonUpdates> {
private final List<Task<Pair<AddonUpdate, @Nullable AddonUpdate>>> dependents;
public AddonCheckUpdatesTask(DownloadProvider downloadProvider, String gameVersion, Collection<? extends LocalAddonFile<?>> addons) {
dependents = addons.stream().map(addon ->
Task.supplyAsync(Schedulers.io(), () -> {
AddonUpdate candidate = null;
AddonUpdate candidateRelease = null;
for (RemoteAddon.Source source : RemoteAddon.Source.values()) {
AddonUpdate update = null;
AddonUpdate updateRelease = null;
try {
var u = addon.checkUpdates(downloadProvider, gameVersion, source);
if (u != null) {
update = u.key();
updateRelease = u.value();
}
} catch (IOException e) {
LOG.warning(String.format("Cannot check update for addon %s.", addon.getFileName()), e);
}
if (update == null) continue;
if (candidate == null || candidate.targetVersion().datePublished().isBefore(update.targetVersion().datePublished()))
candidate = update;
if (updateRelease == null) continue;
if (candidateRelease == null || candidateRelease.targetVersion().datePublished().isBefore(updateRelease.targetVersion().datePublished()))
candidateRelease = updateRelease;
}
if (candidate == null) return null; // If there's no candidate for all channels, then no candidate for release channel
return Pair.pair(candidate, candidateRelease);
}).setName(addon.getFileName()).setSignificance(TaskSignificance.MAJOR).withCounter("update.checking")
).toList();
setStage("update.checking");
getProperties().put("total", dependents.size());
}
@Override
public boolean doPreExecute() {
return true;
}
@Override
public void preExecute() {
notifyPropertiesChanged();
}
@Override
public Collection<? extends Task<?>> getDependents() {
return dependents;
}
@Override
public boolean isRelyingOnDependents() {
return false;
}
@Override
public void execute() throws Exception {
List<AddonUpdate> commonUpdates = new ArrayList<>(), releaseUpdates = new ArrayList<>();
dependents.stream().map(Task::getResult).filter(Objects::nonNull)
.forEachOrdered(pair -> {
commonUpdates.add(pair.key());
if (pair.value() != null) releaseUpdates.add(pair.value());
});
setResult(new AddonUpdates(commonUpdates, releaseUpdates));
}
}
@@ -0,0 +1,108 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.addon.update;
import org.jackhuang.hmcl.addon.LocalAddonFile;
import org.jackhuang.hmcl.addon.LocalAddonManager;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.StringUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class AddonUpdateTask extends Task<Void> {
private final Collection<Task<?>> dependents = new ArrayList<>();
private final List<LocalAddonFile<?>> failedAddons = new ArrayList<>();
public AddonUpdateTask(DownloadProvider downloadProvider, Path addonDirectory, List<AddonUpdate> addons) {
setStage("addon.check_update.confirm");
getProperties().put("total", addons.size());
for (AddonUpdate addon : addons) {
LocalAddonFile<?> local = addon.localAddonFile();
RemoteAddon.Version remote = addon.targetVersion();
boolean isDisabled = local.isDisabled();
String fileName = remote.file().filename();
if (isDisabled)
fileName = StringUtils.addSuffix(fileName, LocalAddonManager.DISABLED_EXTENSION);
String newFileName = fileName;
Path targetFile = addonDirectory.resolve(newFileName);
dependents.add(Task
.supplyAsync(Schedulers.io(), () -> Files.createTempFile("hmcl-addon-update-", ".tmp"))
.thenComposeAsync(tempFile ->
new FileDownloadTask(downloadProvider.getDownloadCandidates(remote.file().url()), tempFile)
.setName(remote.name())
.thenRunAsync(Schedulers.javafx(), () -> local.setOld(true))
.thenRunAsync(Schedulers.io(), () -> Files.move(tempFile, targetFile))
.whenComplete(Schedulers.javafx(), exception -> {
try {
Files.deleteIfExists(tempFile);
} catch (Exception e) {
LOG.warning("Failed to delete temp file " + tempFile, e);
}
if (exception != null) {
// restore state if failed
local.setOld(false);
if (isDisabled)
local.markDisabled();
} else {
local.onUpdated(newFileName);
if (!local.keepOldFiles()) {
try {
local.delete();
} catch (IOException e) {
LOG.warning("Failed to delete outdated addon: " + local.getFile(), e);
}
}
}
})
).whenComplete(Schedulers.javafx(), exception -> {
if (exception != null) {
LOG.warning("Failed to update addon", exception);
failedAddons.add(local);
}
}).withCounter("addon.check_update.confirm"));
}
}
public List<LocalAddonFile<?>> getFailedAddons() {
return failedAddons;
}
@Override
public Collection<Task<?>> getDependents() {
return dependents;
}
@Override
public boolean doPreExecute() {
return true;
}
@Override
public void preExecute() {
notifyPropertiesChanged();
}
@Override
public boolean isRelyingOnDependents() {
return false;
}
@Override
public void execute() throws Exception {
if (!isDependentsSucceeded())
throw getException();
}
}
@@ -0,0 +1,413 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.download;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.download.cleanroom.CleanroomRemoteVersion;
import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion;
import org.jackhuang.hmcl.download.forge.ForgeRemoteVersion;
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
import org.jackhuang.hmcl.download.neoforge.NeoForgeRemoteVersion;
import org.jackhuang.hmcl.download.optifine.OptiFineRemoteVersion;
import org.jackhuang.hmcl.game.AssetObject;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.HMCLCacheRepository;
import org.jackhuang.hmcl.setting.DownloadSource;
import org.jackhuang.hmcl.task.Task;
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.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 officialUrl,
List<WebURL> mirrorUrls
) {
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 {
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");
}
// true means prefer mirror, false means prefer official, null means do not use mirror
@Nullable Boolean mirrorFirst;
if ("CN".equals(RegionUtils.MACHINE_REGION)) {
mirrorFirst = switch (source) {
case DEFAULT, MIRROR -> true;
case OFFICIAL -> false;
};
} else {
mirrorFirst = switch (source) {
case DEFAULT, OFFICIAL -> null;
case MIRROR -> true;
};
}
if (mirrorFirst == null) {
return DownloadCandidates.of(urls.stream().map(DownloadCandidate::of).toArray(DownloadCandidate[]::new));
}
var candidates = new ArrayList<DownloadCandidate>(urls.size() * 3);
for (String url : urls) {
DownloadCandidate candidate = DownloadCandidate.of(url);
if (candidate.url() == null) {
// Invalid URL, just add it to the list and let the download task handle it.
candidates.add(candidate);
continue;
}
boolean added = false;
for (MirrorRule rule : rules) {
@Nullable List<DownloadCandidate> list = rule.getCandidates(candidate, mirrorFirst);
if (list != null) {
candidates.addAll(list);
added = true;
break;
}
}
if (!added)
candidates.add(candidate);
}
return DownloadCandidates.of(List.copyOf(candidates));
}
private final Supplier<@Unmodifiable List<String>> mirrorRoots;
private final List<MirrorRule> rules;
public HMCLDownloadProvider(Supplier<@Unmodifiable List<String>> mirrorRoots) {
this.mirrorRoots = Objects.requireNonNull(mirrorRoots);
this.rules = List.of(
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/";
if (source.url() == null || !source.url().href().startsWith(neoforgedMaven))
return null;
var candidates = new ArrayList<DownloadCandidate>();
List<String> roots = mirrorRoots.get();
if (source.url().href().startsWith(neoforgedMavenRelease)) {
String suffix = source.url().href().substring(neoforgedMavenRelease.length());
List<DownloadCandidate> mirrors = roots.stream()
.map(root -> DownloadCandidate.of(root + "/maven/" + suffix))
.toList();
if (mirrorFirst) {
candidates.addAll(spreadPrimary(mirrors, source.url().href()));
candidates.add(source);
} else {
candidates.add(source);
candidates.addAll(mirrors);
}
} else {
candidates.add(source);
}
// https://neoforged.forgecdn.net
candidates.add(DownloadCandidate.of("https://neoforged.forgecdn.net/" + source.url().href().substring(neoforgedMaven.length())));
return candidates;
},
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"),
new SimpleRule("https://hmcl.glavo.site/metadata/fmllibs", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/fmllibs"),
new SimpleRule("https://zkitefly.github.io/unlisted-versions-of-minecraft", "https://alist.8mi.tech/d/mirror/unlisted-versions-of-minecraft/Auto"),
// https://github.com/mcmod-info-mirror/mcim-rust-api
new SimpleRule("https://api.modrinth.com", null, "https://mod.mcimirror.top/modrinth"),
new SimpleRule("https://cdn.modrinth.com", null, "https://mod.mcimirror.top"),
new SimpleRule("https://api.curseforge.com", null, "https://mod.mcimirror.top/curseforge"),
new SimpleRule("https://edge.forgecdn.net", null, "https://mod.mcimirror.top")
);
}
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.OFFICIAL) {
return fetchOfficial;
}
Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchBMCL = ForgeRemoteVersion.fetchBMCLAsync(this, bmclRoot, gameVersion);
if (preferMirror(source)) {
return new FallbackTask<>(fetchBMCL, fetchOfficial);
} else {
return new FallbackTask<>(fetchOfficial, fetchBMCL);
}
}
case NEO_FORGE -> {
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchOfficial = NeoForgeRemoteVersion.fetchAsync(this, gameVersion);
DownloadSource source = versionListSource;
if (source == DownloadSource.OFFICIAL) {
return fetchOfficial;
}
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchBMCL = NeoForgeRemoteVersion.fetchBMCLAsync(bmclRoot, gameVersion);
if (preferMirror(source)) {
return new FallbackTask<>(fetchBMCL, fetchOfficial);
} else {
return new FallbackTask<>(fetchOfficial, fetchBMCL);
}
}
case OPTIFINE -> {
return OptiFineRemoteVersion.fetchBMCLAsync(this, bmclRoot, gameVersion);
}
}
return super.fetchVersionsAsync(type, gameVersion);
}
public void setFileSource(DownloadSource fileSource) {
this.fileSource = Objects.requireNonNull(fileSource);
}
public void setVersionListSource(DownloadSource versionListSource) {
this.versionListSource = Objects.requireNonNull(versionListSource);
}
@Override
public HMCLCacheRepository getCacheRepository() {
return HMCLCacheRepository.REPOSITORY;
}
@Override
public DownloadCandidates getGameVersionListCandidates() {
return getCandidates(
versionListSource,
GameRemoteVersion.VERSION_MANIFEST_URL,
mirrorUrls("/mc/game/version_manifest.json")
);
}
@Override
public FabricLikeVersionListCandidates getFabricVersionListCandidates() {
DownloadSource source = versionListSource;
return new FabricLikeVersionListCandidates(
getCandidates(source, FabricRemoteVersion.LOADER_META_URL, mirrorUrls("/fabric-meta/v2/versions/loader")),
getCandidates(source, FabricRemoteVersion.GAME_META_URL, mirrorUrls("/fabric-meta/v2/versions/game"))
);
}
@Override
public DownloadCandidates getCleanroomVersionListCandidates() {
return "CN".equals(RegionUtils.MACHINE_REGION)
? DownloadCandidates.of(CleanroomRemoteVersion.LOADER_LIST_URL, WebURL.parse("https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom/index.json"))
: DownloadCandidates.of(CleanroomRemoteVersion.LOADER_LIST_URL);
}
@Override
public DownloadCandidates getDownloadCandidates(List<String> urls) {
return getCandidates(fileSource, rules, urls);
}
@Override
public DownloadCandidates getVersionListCandidates(List<String> urls) {
return getCandidates(versionListSource, rules, urls);
}
@Override
public DownloadCandidates getAssetObjectCandidates(AssetObject assetObject) {
String location = assetObject.getLocation();
return getCandidates(
fileSource,
WebURL.parse("https://resources.download.minecraft.net/" + location),
mirrorUrls("/assets/" + location)
);
}
@NotNullByDefault
private static final class FallbackTask<T> extends Task<T> {
private final Task<? extends T> task;
private final Task<? extends T> fallback;
private List<Task<?>> dependencies = List.of();
public FallbackTask(Task<? extends T> task, Task<? extends T> fallback) {
this.task = task;
this.fallback = fallback;
}
@Override
public Collection<? extends Task<?>> getDependents() {
return List.of(task);
}
@Override
public boolean isRelyingOnDependents() {
return false;
}
@Override
public void execute() throws Exception {
if (isDependentsSucceeded()) {
setResult(task.getResult());
} else {
dependencies = List.of(fallback);
fallback.storeTo(this::setResult);
}
}
@Override
public List<Task<?>> getDependencies() {
return dependencies;
}
}
@NotNullByDefault
@FunctionalInterface
private interface MirrorRule {
@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) {
this(source, target, null);
}
@Override
public @Nullable List<DownloadCandidate> getCandidates(DownloadCandidate source, boolean mirrorFirst) {
if (source.url() == null || !source.url().href().startsWith(this.source))
return null;
boolean addSourceFirst = !mirrorFirst || first == null;
String urlWithoutPrefix = source.url().href().substring(this.source.length());
var candidates = new ArrayList<DownloadCandidate>();
if (addSourceFirst)
candidates.add(source);
if (first != null)
candidates.add(DownloadCandidate.of(first + urlWithoutPrefix));
if (!addSourceFirst)
candidates.add(source);
if (fallback != null)
candidates.add(DownloadCandidate.of(fallback + urlWithoutPrefix));
return candidates;
}
}
}
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import javafx.beans.property.SimpleStringProperty;
@@ -0,0 +1,50 @@
// 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.download.DefaultCacheRepository;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jetbrains.annotations.NotNullByDefault;
/// Provides HMCL-specific game builders for an HMCL game repository.
@NotNullByDefault
public class HMCLDependencyManager extends DefaultDependencyManager {
/// Creates a dependency manager for a repository and download context.
///
/// @param repository the associated game repository
/// @param downloadProvider the remote download provider
/// @param cacheRepository the artifact cache
public HMCLDependencyManager(
HMCLGameRepository repository,
DownloadProvider downloadProvider,
DefaultCacheRepository cacheRepository) {
super(repository, downloadProvider, cacheRepository);
}
/// {@inheritDoc}
@Override
public HMCLGameRepository getGameRepository() {
return (HMCLGameRepository) super.getGameRepository();
}
/// {@inheritDoc}
@Override
public HMCLGameBuilder newGameBuilder(GameInstanceID instanceId) {
GameInstanceManifest initialManifest = new GameInstanceManifest(instanceId);
DefaultGameRepositoryDraft draft = openGameBuilderDraft(initialManifest, null);
return new HMCLGameBuilder(this, instanceId, null, draft, initialManifest);
}
/// {@inheritDoc}
@Override
public HMCLGameBuilder newGameBuilder(GameInstance instance) {
validateGameInstance(instance);
HMCLGameInstance updateTarget = (HMCLGameInstance) instance;
GameInstanceManifest initialManifest = new GameInstanceManifest(updateTarget.getId());
DefaultGameRepositoryDraft draft = openGameBuilderDraft(initialManifest, updateTarget);
return new HMCLGameBuilder(
this, updateTarget.getId(), updateTarget, draft, initialManifest);
}
}
@@ -0,0 +1,38 @@
// 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.download.DefaultGameBuilder;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
/// Builds game instances and applies HMCL-specific post-installation settings.
@NotNullByDefault
public class HMCLGameBuilder extends DefaultGameBuilder {
/// Creates a builder around an HMCL target already reserved by its dependency manager.
///
/// @param dependencyManager the dependency manager for the target repository
/// @param instanceId the id of the reserved instance
/// @param updateTarget the exact update target retained by the draft, or `null` for install
/// @param draft the open draft containing `initialManifest`
/// @param initialManifest the empty target manifest retained by `draft`
HMCLGameBuilder(
HMCLDependencyManager dependencyManager,
GameInstanceID instanceId,
@Nullable HMCLGameInstance updateTarget,
DefaultGameRepositoryDraft draft,
GameInstanceManifest initialManifest) {
super(dependencyManager, instanceId, updateTarget, draft, initialManifest);
}
/// {@inheritDoc}
///
/// When isolation was enabled, persists the corresponding HMCL instance setting so subsequent
/// launches use the instance root. A read-only instance retains its existing setting.
@Override
protected void onInstanceCommitted(DefaultGameInstance instance) {
if (isolationEnabled) {
((HMCLGameInstance) instance).enableIsolation();
}
}
}
@@ -0,0 +1,848 @@
// 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 com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.reflect.TypeToken;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectPropertyBase;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
import org.jackhuang.hmcl.modpack.ModpackProvider;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonSchema;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.SystemInfo;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// HMCL-specific game instance that owns instance-local settings and run-directory policy.
@NotNullByDefault
public class HMCLGameInstance extends DefaultGameInstance {
/// Whether the instance-local game settings file has already been inspected.
private boolean gameSettingsLoaded;
/// Whether the instance-local game settings file cannot be overwritten safely.
private boolean gameSettingsReadOnly;
/// Cached instance-local game settings, or `null` when none exist after loading.
private GameSettings.@Nullable Instance gameSettings;
/// Creates a registered instance bound to the given repository snapshot.
///
/// @param snapshot the repository snapshot that owns this instance
/// @param id the instance id
/// @param manifest the stored instance manifest
protected HMCLGameInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) {
this(snapshot, id, manifest, (Path) null);
}
/// Creates a registered instance with an optional non-conventional manifest path.
///
/// @param snapshot the repository snapshot that owns this instance
/// @param id the instance id
/// @param manifest the stored instance manifest
/// @param manifestFile the actual manifest JSON path, or `null` for the layout default
protected HMCLGameInstance(
DefaultGameRepositorySnapshot snapshot,
GameInstanceID id,
GameInstanceManifest manifest,
@Nullable Path manifestFile) {
super(snapshot, id, manifest, manifestFile);
}
/// Creates an instance that shares mutable instance-local state with another instance.
///
/// Used when the repository clones a snapshot so that settings and the icon property remain
/// available on the new wrapper.
private HMCLGameInstance(
DefaultGameRepositorySnapshot snapshot,
GameInstanceID id,
GameInstanceManifest manifest,
HMCLGameInstance shareState) {
super(snapshot, id, manifest, shareState);
this.gameSettingsLoaded = shareState.gameSettingsLoaded;
this.gameSettingsReadOnly = shareState.gameSettingsReadOnly;
this.gameSettings = shareState.gameSettings;
}
@Override
protected HMCLGameInstance withNewSnapshot(DefaultGameRepositorySnapshot newSnapshot) {
return new HMCLGameInstance(newSnapshot, id, manifest, this);
}
@Override
protected HMCLGameInstance withManifest(DefaultGameRepositorySnapshot newSnapshot, GameInstanceManifest manifest) {
return new HMCLGameInstance(newSnapshot, id, manifest, this);
}
@Override
public HMCLGameRepository getRepository() {
return (HMCLGameRepository) super.getRepository();
}
@Override
public HMCLGameRepositoryLayout getLayout() {
return (HMCLGameRepositoryLayout) super.getLayout();
}
/// Returns the HMCL modpack configuration file for this instance.
///
/// @return the `modpack.cfg` path in the instance root
@Override
public Path getModpackConfigurationFile() {
return getLayout().getModpackConfigurationFile(getId());
}
/// Returns whether this instance has an HMCL modpack configuration file.
///
/// @return whether [#getModpackConfigurationFile()] exists
public boolean isModpack() {
return Files.exists(getModpackConfigurationFile());
}
/// Reads this instance's HMCL modpack configuration.
///
/// @return the parsed configuration, or `null` when the file does not exist
/// @throws IOException if the configuration cannot be read
public @Nullable ModpackConfiguration<?> readModpackConfiguration() throws IOException {
Path file = getModpackConfigurationFile();
if (Files.notExists(file)) {
return null;
}
try {
return JsonUtils.fromJsonFile(file, ModpackConfiguration.class);
} catch (JsonParseException e) {
throw new IOException("Malformed modpack configuration: " + file, e);
}
}
@Override
public Path getRunDirectory() {
return getRepository().computeRunDirectory(getId(), isModpack(), getSettings());
}
/// Returns the loaded instance-local game settings, loading them while this instance is current.
///
/// @return the settings, or `null` when no local settings exist after loading
public @Nullable GameSettings.Instance getSettings() {
ensureGameSettingsLoaded();
return gameSettings;
}
/// Returns the instance-local game settings, creating an empty settings object when absent.
///
/// @return the settings, or `null` when the settings file is read-only and no settings are loaded
public @Nullable GameSettings.Instance getSettingsOrCreate() {
GameSettings.Instance setting = getSettings();
if (setting == null) {
setting = createSettings();
}
return setting;
}
/// Resolves this instance's effective settings against its selected parent preset.
///
/// @return the effective settings
public GameSettings.Effective getEffectiveSettings() {
@Nullable GameSettings.Instance setting = getSettings();
return GameSettings.resolve(getRepository().getParentGameSettings(setting), setting);
}
/// Creates empty instance-local game settings when none are loaded.
///
/// @return the settings, or `null` when settings are read-only or already present in a non-creatable state
public @Nullable GameSettings.Instance createSettings() {
ensureGameSettingsLoaded();
if (gameSettingsReadOnly) {
return null;
}
if (gameSettings != null) {
return gameSettings;
}
return initSettings(new GameSettings.Instance(), true);
}
/// Returns whether the instance-local game settings file cannot be overwritten safely.
///
/// @return whether the settings are loaded in read-only mode
public boolean isSettingsReadOnly() {
ensureGameSettingsLoaded();
return gameSettingsReadOnly;
}
/// Enables instance-local running-directory selection for this instance.
///
/// A blank local running directory resolves to the instance root. This operation is idempotent
/// and schedules a settings save only when it adds the override. It leaves the instance
/// unchanged when its local settings cannot be written safely.
public void enableIsolation() {
if (isSettingsReadOnly()) {
return;
}
@Nullable GameSettings.Instance setting = getSettingsOrCreate();
if (setting != null
&& setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) {
saveSettings();
}
}
/// Backs up and overwrites the settings file when this instance still owns its settings.
public void forceOverwriteSettings() {
ensureGameSettingsLoaded();
if (!ownsGameSettings()) {
return;
}
GameSettings.Instance setting = gameSettings;
if (setting == null) {
setting = new GameSettings.Instance();
gameSettings = setting;
gameSettingsLoaded = true;
}
boolean installAutoSave = !setting.isSavable();
Path file = getGameSettingsFile().toAbsolutePath().normalize();
SettingFileUtils.backupInvalidConfig(file);
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
setting.setSavable(true);
setting.setBackupOnNextSave(false);
gameSettingsReadOnly = false;
saveSettings();
if (installAutoSave) {
setting.addListener(a -> saveSettings());
}
}
/// Saves the settings asynchronously when writable and still owned by the current instance.
public void saveSettings() {
if (gameSettings == null || gameSettingsReadOnly || !ownsGameSettings()) {
return;
}
GameSettings.Instance setting = gameSettings;
Path file = getGameSettingsFile().toAbsolutePath().normalize();
try {
Files.createDirectories(file.getParent());
} catch (IOException e) {
LOG.warning("Failed to create directory: " + file.getParent(), e);
}
if (setting.isBackupOnNextSave()) {
setting.setBackupOnNextSave(false);
SettingFileUtils.backupInvalidConfig(file);
}
FileSaver.save(file, LauncherSettings.SETTINGS_GSON.toJson(setting));
}
/// Saves the settings synchronously when writable and still owned by the current instance.
///
/// @throws IOException if saving the file fails
public void saveSettingsSync() throws IOException {
if (gameSettings == null || gameSettingsReadOnly || !ownsGameSettings()) {
return;
}
GameSettings.Instance setting = gameSettings;
Path file = getGameSettingsFile().toAbsolutePath().normalize();
Files.createDirectories(file.getParent());
if (setting.isBackupOnNextSave()) {
setting.setBackupOnNextSave(false);
SettingFileUtils.backupInvalidConfig(file);
}
FileUtils.saveSafely(file, LauncherSettings.SETTINGS_GSON.toJson(setting));
}
/// Initializes this instance with the given settings object.
///
/// @param setting the settings to install
/// @param allowSave whether the settings may be written back to disk
/// @return the installed settings
public GameSettings.Instance initSettings(GameSettings.Instance setting, boolean allowSave) {
normalizeRunningDirectoryOverride(setting);
setting.setSavable(allowSave);
gameSettingsLoaded = true;
gameSettings = setting;
setting.iconProperty().addListener(observable -> invalidateIconImage());
if (allowSave) {
gameSettingsReadOnly = false;
setting.addListener(a -> saveSettings());
} else {
gameSettingsReadOnly = true;
}
return setting;
}
/// Returns a deep copy of the currently loaded settings, or a new settings object that inherits
/// the effective parent preset when no local settings exist.
///
/// @return a detached copy suitable for installing into another instance
public GameSettings.Instance copySettings() {
@Nullable GameSettings.Instance setting = getSettings();
if (setting != null) {
return JsonUtils.clone(LauncherSettings.SETTINGS_GSON, setting, TypeToken.get(GameSettings.Instance.class));
}
GameSettings.Instance copied = new GameSettings.Instance();
copied.parentProperty().setValue(
getEffectiveSettings().getPreset().idProperty().getValue());
return copied;
}
/// Returns the first custom icon file found in this instance's root directory.
///
/// @return the icon file, or empty when no supported icon file exists
public @Nullable Path getIconFile() {
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
Path file = getInstanceRoot().resolve("icon." + extension);
if (Files.exists(file)) {
return file;
}
}
return null;
}
/// Replaces this instance's custom icon file.
///
/// Existing supported icon files are removed before `iconFile` is copied.
///
/// @param iconFile the source icon file
/// @throws IOException if the icon cannot be copied
/// @throws IllegalArgumentException if the file extension is unsupported
public void setIconFile(Path iconFile) throws IOException {
String extension = FileUtils.getExtension(iconFile).toLowerCase(Locale.ROOT);
if (!FXUtils.IMAGE_EXTENSIONS.contains(extension)) {
throw new IllegalArgumentException("Unsupported icon file: " + extension);
}
clearIconFiles();
FileUtils.copyFile(iconFile, getInstanceRoot().resolve("icon." + extension));
invalidateIconImage();
}
/// Deletes all supported custom icon files for this instance.
///
/// Individual deletion failures are logged and do not stop later files from being attempted.
public void deleteIconFile() {
clearIconFiles();
invalidateIconImage();
}
private void clearIconFiles() {
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
Path file = getInstanceRoot().resolve("icon." + extension);
try {
Files.deleteIfExists(file);
} catch (IOException e) {
LOG.warning("Failed to delete instance icon file: " + file, e);
}
}
}
/// Soft-cached icon image for this instance id.
///
/// Shared across COW snapshot wrappers. The computed [Image] is retained only via a
/// [WeakReference], so it can be reclaimed under memory pressure when nothing else holds it.
private @Nullable WeakCachedIconImageProperty iconImage;
/// Returns the observable icon image for this instance.
///
/// The image is stored in a [WeakReference] cache: when nothing else strongly references it
/// (for example no UI node is displaying it), the JVM may reclaim the [Image] under memory
/// pressure. The next [#getIconImage] reloads it.
///
/// @return the icon image property
public ReadOnlyObjectProperty<Image> iconImageProperty() {
if (iconImage == null) {
iconImage = new WeakCachedIconImageProperty();
}
return iconImage;
}
/// Returns the icon image selected for this instance.
///
/// Equivalent to [ReadOnlyObjectProperty#get()] on [#iconImageProperty].
///
/// @return the selected or derived icon image
public Image getIconImage() {
return iconImageProperty().get();
}
/// Drops the soft-cached icon image and notifies observers.
public void invalidateIconImage() {
((WeakCachedIconImageProperty) iconImageProperty()).invalidate();
}
/// Soft-cached read-only icon property compatible with JavaFX versions before 19.
private final class WeakCachedIconImageProperty extends ReadOnlyObjectPropertyBase<Image> {
private @Nullable WeakReference<Image> cache;
@Override
public Object getBean() {
return HMCLGameInstance.this;
}
@Override
public String getName() {
return "iconImage";
}
@Override
public Image get() {
WeakReference<Image> current = cache;
Image image = current != null ? current.get() : null;
if (image != null) {
return image;
}
image = computeIconImage();
cache = new WeakReference<>(image);
return image;
}
/// Computes the icon image from settings, custom files, and the launch manifest.
///
/// @return the selected or derived icon image
private Image computeIconImage() {
@Nullable GameSettings.Instance setting = getSettings();
GameInstanceIconType iconType = setting != null
? Lang.requireNonNullElse(setting.iconProperty().getValue(), GameInstanceIconType.DEFAULT)
: GameInstanceIconType.DEFAULT;
if (iconType != GameInstanceIconType.DEFAULT) {
return iconType.getIcon();
}
@Nullable Path iconFile = getIconFile();
if (iconFile != null) {
try {
return FXUtils.loadImage(iconFile, 64, 64, true, true);
} catch (Exception e) {
LOG.warning("Failed to load instance icon for " + getId(), e);
}
}
for (ModLoaderType modLoader : getModLoaders()) {
return GameInstanceIconType.getIconType(modLoader).getIcon();
}
if (hasComponent(GameComponentType.OPTIFINE))
return GameInstanceIconType.OPTIFINE.getIcon();
GameVersionNumber version = getVersion();
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();
else
return GameInstanceIconType.GRASS.getIcon();
}
/// Clears the weak cache and notifies listeners.
void invalidate() {
cache = null;
fireValueChangedEvent();
}
}
/// Creates the marker indicating that the most recent launch ended abnormally.
public void markLaunchedAbnormally() {
try {
Files.createFile(getInstanceRoot().resolve(".abnormal"));
} catch (IOException ignored) {
}
}
/// Deletes the abnormal-launch marker when present.
///
/// @return whether a regular marker file was present
public boolean unmarkLaunchedAbnormally() {
Path file = getInstanceRoot().resolve(".abnormal");
if (!Files.isRegularFile(file)) {
return false;
}
try {
Files.delete(file);
} catch (IOException e) {
LOG.warning("Failed to delete abnormal launch marker: " + file, e);
}
return true;
}
/// Loads settings once while this object is the repository's current instance.
private void ensureGameSettingsLoaded() {
if (!gameSettingsLoaded && getRepository().findInstance(id) == this) {
loadGameSettings();
}
}
/// Returns whether this instance still owns the settings object used by the current snapshot.
private boolean ownsGameSettings() {
@Nullable HMCLGameInstance current = getRepository().findInstance(id);
boolean owns = current == this
|| current != null && gameSettings != null && current.gameSettings == gameSettings;
if (!owns && gameSettings != null) {
gameSettings.setSavable(false);
}
return owns;
}
private void loadGameSettings() {
gameSettingsLoaded = true;
LoadResult result = loadGameSettingsFile(getGameSettingsFile());
if (result.setting() != null) {
initSettings(result.setting(), result.allowSave());
return;
}
if (!result.allowSave()) {
gameSettingsReadOnly = true;
return;
}
@Nullable GameSettingsPresetID legacyParent = getRepository().getGameDirectory().getLegacyGameSettings();
if (SettingsManager.getGameSettings(legacyParent) == null) {
legacyParent = null;
}
LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult =
LegacyGameSettingsMigrator.migrateInstanceGameSettings(
getRepository(), id, legacyParent);
if (migrationResult != null) {
initSettings(migrationResult.setting(), true);
try {
saveSettingsSync();
migrationResult.saveReceipt();
} catch (IOException e) {
LOG.warning("Failed to save migrated instance game settings for " + id, e);
}
}
}
private Path getGameSettingsFile() {
return getLayout().getInstanceGameSettingsFile(id);
}
public LaunchOptions.Builder getLaunchOptions(
JavaRuntime javaRuntime,
Path javaExecutable,
Path gameDir,
List<String> javaAgents,
List<String> javaArguments,
boolean makeLaunchScript) {
GameSettings.Effective vs = getEffectiveSettings();
boolean noJVMOptions = vs.getInheritable(GameSettings::noJVMOptionsProperty);
boolean autoMemory = vs.getInheritable(GameSettings::autoMemoryProperty);
GameVersionNumber gameVersionNumber = getVersion();
@Nullable Integer maxMemory;
if (autoMemory) {
maxMemory = noJVMOptions
? null
: Math.toIntExact(HMCLGameRepository.getAutoAllocatedMemory(
SystemInfo.getPhysicalMemoryStatus(),
javaRuntime.getPlatform()) / 1024L / 1024L);
} else {
maxMemory = vs.getMaxMemory();
}
LaunchOptions.Builder builder = new LaunchOptions.Builder()
.setInstanceId(getId())
.setGameDir(gameDir)
.setJava(javaRuntime)
.setJavaExecutable(javaExecutable)
.setVersionType(Metadata.TITLE)
.setVersionName(getId().id())
.setProfileName(Metadata.TITLE)
.setGameArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::gameArgumentsProperty)))
.setOverrideJavaArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::jvmOptionsProperty)))
.setMaxMemory(maxMemory)
.setMinMemory(vs.getInheritable(GameSettings::minMemoryProperty))
.setMetaspace(Lang.toIntOrNull(vs.getInheritable(GameSettings::permSizeProperty)))
.setEnvironmentVariables(
Lang.mapOf(StringUtils.tokenize(vs.getInheritable(GameSettings::environmentVariablesProperty))
.stream()
.map(it -> {
int idx = it.indexOf('=');
return idx >= 0 ? pair(it.substring(0, idx), it.substring(idx + 1)) : pair(it, "");
})
.collect(Collectors.toList())
)
)
.setWidth(vs.getWidth())
.setHeight(vs.getHeight())
.setFullscreen(vs.getInheritable(GameSettings::windowTypeProperty) == GameWindowType.FULLSCREEN)
.setWrapper(vs.getInheritable(GameSettings::commandWrapperProperty))
.setProxyOption(getProxyOption())
.setPreLaunchCommand(vs.getInheritable(GameSettings::preLaunchCommandProperty))
.setPostExitCommand(vs.getInheritable(GameSettings::postExitCommandProperty))
.setNoGeneratedJVMArgs(noJVMOptions)
.setNoGeneratedOptimizingJVMArgs(vs.getInheritable(GameSettings::noOptimizingJVMOptionsProperty))
.setUseCustomNatives(vs.getInheritable(GameSettings::useCustomNativesProperty))
.setNativesDir(vs.getInheritable(GameSettings::nativesDirectoryProperty))
.setProcessPriority(vs.getInheritable(GameSettings::processPriorityProperty))
.setGraphicsBackend(vs.getInheritable(GameSettings::graphicsBackendProperty))
.setRenderer(vs.getRenderer(gameVersionNumber))
.setEnableDebugLogOutput(vs.getInheritable(GameSettings::enableDebugLogOutputProperty))
.setAllowAutoAgent(vs.getInheritable(GameSettings::allowAutoAgentProperty))
.setDisableAutoGameOptions(vs.getInheritable(GameSettings::disableAutoGameOptionsProperty))
.setUseNativeGLFWorSDL(vs.getInheritable(GameSettings::useNativeGLFWorSDLProperty))
.setUseNativeOpenAL(vs.getInheritable(GameSettings::useNativeOpenALProperty))
.setUseHighPerformanceGPU(vs.getInheritable(GameSettings::highPerformanceProperty))
.setDaemon(!makeLaunchScript && vs.getInheritable(GameSettings::launcherVisibilityProperty).isDaemon())
.setJavaAgents(javaAgents)
.setJavaArguments(javaArguments);
QuickPlayOption quickPlayOption = vs.getQuickPlayOption();
if (quickPlayOption != null) {
builder.setQuickPlayOption(quickPlayOption);
}
Path json = getModpackConfigurationFile();
if (Files.exists(json)) {
try {
String jsonText = Files.readString(json);
ModpackConfiguration<?> modpackConfiguration = JsonUtils.GSON.fromJson(jsonText, ModpackConfiguration.class);
ModpackProvider provider = ModpackHelper.getProviderByType(modpackConfiguration.getType());
if (provider != null) provider.injectLaunchOptions(jsonText, builder);
} catch (IOException | JsonParseException e) {
LOG.warning("Failed to parse modpack configuration file " + json, e);
}
}
if (autoMemory && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx")))
builder.setMaxMemory(null);
return builder;
}
private static ProxyOption getProxyOption() {
return switch (settings().proxyTypeProperty().get()) {
case SYSTEM -> ProxyOption.Default.INSTANCE;
case DIRECT -> ProxyOption.Direct.INSTANCE;
case HTTP, SOCKS -> {
String proxyHost = settings().proxyHostProperty().get();
int proxyPort = settings().proxyPortProperty().get();
if (StringUtils.isBlank(proxyHost) || proxyPort < 0 || proxyPort > 0xFFFF) {
yield ProxyOption.Default.INSTANCE;
}
String proxyUser = settings().proxyUserProperty().get();
String proxyPass = settings().proxyPasswordProperty().get();
if (StringUtils.isBlank(proxyUser)) {
proxyUser = null;
proxyPass = null;
} else if (proxyPass == null) {
proxyPass = "";
}
if (settings().proxyTypeProperty().get() == ProxyType.HTTP) {
yield new ProxyOption.Http(proxyHost, proxyPort, proxyUser, proxyPass);
} else {
yield new ProxyOption.Socks(proxyHost, proxyPort, proxyUser, proxyPass);
}
}
};
}
/// Loads a new-format instance game settings file.
private static LoadResult loadGameSettingsFile(Path file) {
if (!Files.exists(file)) {
return new LoadResult(null, true);
}
try {
JsonObject jsonObject = JsonUtils.fromJsonFile(LauncherSettings.SETTINGS_GSON, file, JsonObject.class);
if (jsonObject == null) {
LOG.warning("Instance game settings are empty: " + file);
GameSettings.Instance fallback = new GameSettings.Instance();
return new LoadResult(fallback, true);
}
JsonSchema.CompatibilityResult schemaResult =
JsonSchema.check(jsonObject, GameSettings.Instance.CURRENT_SCHEMA);
switch (schemaResult.status()) {
case MISSING -> LOG.warning("Missing schema in instance game settings: " + file);
case INVALID -> LOG.warning("Invalid schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.invalidValue()));
case UNPARSEABLE -> LOG.warning("Unparseable schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.actual()));
case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual()));
case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> LOG.warning("Unsupported instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual()));
case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> {
}
}
if (!schemaResult.readable()) {
GameSettings.Instance fallback = new GameSettings.Instance();
fallback.setSavable(false);
return new LoadResult(fallback, false);
}
GameSettings.@Nullable Instance setting =
LauncherSettings.SETTINGS_GSON.fromJson(jsonObject, GameSettings.Instance.class);
if (setting == null) {
LOG.warning("Instance game settings deserialized to null: " + file);
GameSettings.Instance fallback = new GameSettings.Instance();
fallback.setBackupOnNextSave(true);
return new LoadResult(fallback, true);
}
if (!schemaResult.preserveSchema() && !GameSettings.Instance.CURRENT_SCHEMA.equals(setting.getSchema())) {
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
}
return new LoadResult(setting, schemaResult.allowSave());
} catch (JsonParseException ex) {
LOG.warning("Failed to parse game setting " + file, ex);
GameSettings.Instance fallback = new GameSettings.Instance();
fallback.setBackupOnNextSave(true);
return new LoadResult(fallback, true);
} catch (Exception ex) {
LOG.warning("Failed to load game setting " + file, ex);
return new LoadResult(null, false);
}
}
/// Keeps old local custom running directories effective under the new source-selection model.
private static void normalizeRunningDirectoryOverride(GameSettings.Instance setting) {
if (StringUtils.isNotBlank(setting.runningDirectoryProperty().getValue())) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
}
}
/// Result of loading an instance-specific game settings file.
///
/// @param setting the loaded instance settings, or `null` when unavailable
/// @param allowSave whether the file may be overwritten
private record LoadResult(@Nullable GameSettings.Instance setting, boolean allowSave) {
}
/// Optional reference to an HMCL game instance bound to a repository.
///
/// Replaces the former `(repository, instanceId)` pair for UI and service context that may or
/// may not have a selected instance. When present, [#instance()] is a snapshot member and may
/// become stale after the repository publishes a new snapshot; call [#refreshed()] to re-resolve
/// from the current snapshot while preserving repository context.
@NotNullByDefault
public static final class Optional {
private final HMCLGameRepository repository;
private final @Nullable HMCLGameInstance instance;
/// Creates an empty optional bound only to a repository.
///
/// @param repository the repository
public Optional(HMCLGameRepository repository) {
this.repository = Objects.requireNonNull(repository);
this.instance = null;
}
/// Creates an optional that holds the given instance.
///
/// @param instance the instance
public Optional(HMCLGameInstance instance) {
this.repository = instance.getRepository();
this.instance = instance;
}
/// Creates an optional by resolving `instanceId` from the repository's current snapshot.
///
/// @param repository the repository
/// @param instanceId the instance id, or `null` for an empty optional
/// @return an optional that is empty when `instanceId` is null or not registered
public static Optional of(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
if (instanceId == null) {
return new Optional(repository);
}
HMCLGameInstance instance = repository.findInstance(instanceId);
return instance != null ? new Optional(instance) : new Optional(repository);
}
/// Creates an optional that holds the given instance.
///
/// @param instance the instance
/// @return the optional
public static Optional of(HMCLGameInstance instance) {
return new Optional(instance);
}
/// Creates an empty optional bound only to a repository.
///
/// @param repository the repository
/// @return the empty optional
public static Optional empty(HMCLGameRepository repository) {
return new Optional(repository);
}
/// Returns the repository associated with this optional.
///
/// @return the repository
public HMCLGameRepository repository() {
return repository;
}
/// Returns the held instance, if any.
///
/// @return the instance, or `null` when empty
@Contract(pure = true)
public @Nullable HMCLGameInstance instance() {
return instance;
}
/// Returns the held instance id, if any.
///
/// @return the instance id, or `null` when empty
@Contract(pure = true)
public @Nullable GameInstanceID instanceId() {
return instance != null ? instance.getId() : null;
}
/// Returns whether an instance is present.
///
/// @return whether [#instance()] is non-null
public boolean isPresent() {
return instance != null;
}
/// Returns whether no instance is present.
///
/// @return whether [#instance()] is null
public boolean isEmpty() {
return instance == null;
}
/// Re-resolves the held instance id from the repository's current snapshot.
///
/// @return this optional when empty; otherwise a fresh optional for the same id
public Optional refreshed() {
if (instance == null) {
return this;
}
return of(repository, instance.getId());
}
}
}
@@ -1,37 +1,25 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2020 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.auth.AuthInfo;
import org.jackhuang.hmcl.launch.DefaultLauncher;
import org.jackhuang.hmcl.launch.ProcessListener;
import org.jackhuang.hmcl.util.NativePatcher;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.CommandBuilder;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
@@ -39,16 +27,27 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
*/
public final class HMCLGameLauncher extends DefaultLauncher {
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options) {
this(repository, version, authInfo, options, null);
/// Creates a launcher with daemon process monitors.
///
/// @param instance the instance being launched
/// @param manifest the effective launch-time manifest
/// @param authInfo authentication information for the game process
/// @param options launch options
/// @param listener process listener, or `null` to inherit IO
public HMCLGameLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) {
this(instance, manifest, authInfo, options, listener, true);
}
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) {
this(repository, version, authInfo, options, listener, true);
}
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) {
super(repository, version, authInfo, options, listener, daemon);
/// Creates a launcher for the given instance and launch plan.
///
/// @param instance the instance being launched
/// @param manifest the effective launch-time manifest
/// @param authInfo authentication information for the game process
/// @param options launch options
/// @param listener process listener, or `null` to inherit IO
/// @param daemon whether monitors should be daemon threads
public HMCLGameLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) {
super(instance, manifest, authInfo, options, listener, daemon);
}
@Override
@@ -60,10 +59,10 @@ public final class HMCLGameLauncher extends DefaultLauncher {
}
private void generateOptionsTxt() {
if (config().isDisableAutoGameOptions())
if (options.isDisableAutoGameOptions())
return;
Path runDir = repository.getRunDirectory(version.getId());
Path runDir = instance.getRunDirectory();
Path optionsFile = runDir.resolve("options.txt");
Path configFolder = runDir.resolve("config");
@@ -88,8 +87,14 @@ public final class HMCLGameLauncher extends DefaultLauncher {
* 1.11 ~ 1.12 : zh_cn works fine, zh_CN will display Chinese but the language setting will incorrectly show English as selected
* 1.13+ : zh_cn works fine, zh_CN will automatically switch to English
*/
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(repository.getGameVersion(version));
if (gameVersion.compareTo("1.1") < 0)
GameVersionNumber gameVersion = instance.getVersion();
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);
@@ -111,6 +116,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
String region = locale.getCountry();
return switch (LocaleUtils.getRootLanguage(locale)) {
case "ar" -> "ar_SA";
case "es" -> "es_ES";
case "ja" -> "ja_JP";
case "ru" -> "ru_RU";
@@ -149,4 +155,60 @@ public final class HMCLGameLauncher extends DefaultLauncher {
generateOptionsTxt();
super.makeLaunchScript(scriptFile);
}
@Override
protected void appendJvmArgs(CommandBuilder result) {
super.appendJvmArgs(result);
if (options.isAllowAutoAgent()
&& !options.isNoGeneratedJVMArgs()
&& !options.isNoGeneratedOptimizingJVMArgs()
&& NativePatcher.needPatchMemoryUtil(manifest, options.getJava().getParsedVersion())) {
LOG.info("Attempting to patch game with lwjgl-unsafe-agent");
try {
result.add("-javaagent:" + extractLwjglUnsafeAgent());
} catch (Exception e) {
LOG.warning("Failed to extract lwjgl-unsafe-agent", e);
}
}
}
private Path extractLwjglUnsafeAgent() throws IOException {
String agentVersion = JarUtils.getAttribute("hmcl.lwjgl-unsafe-agent.version", null);
if (agentVersion == null) {
throw new IOException("Missing hmcl.lwjgl-unsafe-agent.version attribute");
}
Library library = new Library(new Artifact("org.glavo", "lwjgl-unsafe-agent", agentVersion));
String fileName = library.artifact().getFileName();
Path agentPath = instance.getLayout().getLibraryFile(instance.getId(), library).toAbsolutePath().normalize();
if (agentPath.toString().contains("=")) {
throw new IOException("Invalid library path: " + agentPath);
}
byte[] bytes;
try (InputStream input = DefaultLauncher.class.getResourceAsStream("/assets/" + fileName)) {
if (input == null) {
throw new IOException("/assets/" + fileName + " not found");
}
bytes = input.readAllBytes();
}
if (Files.isRegularFile(agentPath)) {
try {
if (Files.size(agentPath) == bytes.length) {
return agentPath;
}
} catch (IOException e) {
LOG.warning("Failed to check size of " + agentPath, e);
}
}
Files.createDirectories(agentPath.getParent());
FileUtils.saveSafely(agentPath, output -> output.write(bytes));
return agentPath;
}
}
@@ -1,547 +1,540 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonParseException;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.mod.ModAdviser;
import org.jackhuang.hmcl.mod.Modpack;
import org.jackhuang.hmcl.mod.ModpackConfiguration;
import org.jackhuang.hmcl.mod.ModpackProvider;
import org.jackhuang.hmcl.setting.Profile;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.modpack.ModAdviser;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.setting.DefaultIsolationType;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jackhuang.hmcl.setting.GameDirectory;
import org.jackhuang.hmcl.setting.LauncherSettings;
import org.jackhuang.hmcl.setting.LegacyGameSettingsMigrator;
import org.jackhuang.hmcl.setting.GameSettingsPresetID;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.setting.VersionSetting;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.MathUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.util.platform.Bits;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemInfo;
import org.jackhuang.hmcl.util.platform.Platform;
import org.jackhuang.hmcl.util.platform.hardware.PhysicalMemoryStatus;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.Pair.pair;
/// HMCL game repository implementation backed by a GameDirectory and per-instance game settings.
@NotNullByDefault
public final class HMCLGameRepository extends DefaultGameRepository {
private final Profile profile;
/// The persistent game directory for this repository.
private final GameDirectory gameDirectory;
// local version settings
private final Map<String, VersionSetting> localVersionSettings = new HashMap<>();
private final Set<String> beingModpackVersions = new HashSet<>();
/// The selected instance ID persisted for this repository's game directory.
private final ObjectBinding<@Nullable GameInstanceID> selectedInstanceId;
public final EventManager<Event> onVersionIconChanged = new EventManager<>();
/// The selected instance resolved from the current repository snapshot.
private final ReadOnlyObjectWrapper<@Nullable HMCLGameInstance> selectedInstance;
public HMCLGameRepository(Profile profile, Path baseDirectory) {
super(baseDirectory);
this.profile = profile;
}
public Profile getProfile() {
return profile;
/// Creates a repository backed by the given game directory.
///
/// @param gameDirectory the persistent game directory represented by this repository
public HMCLGameRepository(GameDirectory gameDirectory) {
super(gameDirectory.getPath().toPath());
this.gameDirectory = gameDirectory;
this.selectedInstanceId = Bindings.valueAt(settings().getSelectedInstance(), gameDirectory.getId());
this.selectedInstance = new ReadOnlyObjectWrapper<>(this, "selectedInstance");
this.selectedInstance.bind(Bindings.createObjectBinding(
this::resolveSelectedInstance,
selectedInstanceId,
snapshotProperty()));
gameDirectory.pathProperty().addListener((a, b, newValue) -> changeDirectory(newValue.toPath()));
}
@Override
public GameDirectoryType getGameDirectoryType(String id) {
if (beingModpackVersions.contains(id) || isModpack(id)) {
return GameDirectoryType.VERSION_FOLDER;
} else {
return getVersionSetting(id).getGameDirType();
protected HMCLGameRepositoryLayout createLayout(Path baseDirectory) {
return new HMCLGameRepositoryLayout(baseDirectory);
}
@Override
protected HMCLGameRepositorySnapshot createSnapshot(DefaultGameRepositoryLayout layout) {
return new HMCLGameRepositorySnapshot(this, (HMCLGameRepositoryLayout) layout);
}
@Override
protected HMCLGameInstance createInstance(
DefaultGameRepositorySnapshot snapshot,
GameInstanceID id,
GameInstanceManifest manifest,
@Nullable Path manifestFile) {
return new HMCLGameInstance(snapshot, id, manifest, manifestFile);
}
@Override
public HMCLGameRepositorySnapshot getSnapshot() {
return (HMCLGameRepositorySnapshot) super.getSnapshot();
}
@Override
@SuppressWarnings("unchecked")
public ReadOnlyObjectProperty<HMCLGameRepositorySnapshot> snapshotProperty() {
return (ReadOnlyObjectProperty<HMCLGameRepositorySnapshot>) super.snapshotProperty();
}
@Override
public HMCLGameRepositoryLayout getLayout() {
return (HMCLGameRepositoryLayout) super.getLayout();
}
@Override
public HMCLGameInstance getInstance(GameInstanceID id) throws NoSuchGameInstanceException {
return (HMCLGameInstance) super.getInstance(id);
}
/// Returns the indexed instance for the given id, or `null` when it is not loaded.
///
/// @param id the instance id
/// @return the instance, or `null` when absent
public @Nullable HMCLGameInstance findInstance(GameInstanceID id) {
return (HMCLGameInstance) getSnapshot().findInstance(id);
}
/// {@inheritDoc}
@Override
protected void flushPendingInstanceWrites() throws IOException {
try {
FileSaver.waitForAllSaves();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted while waiting for pending instance writes", e);
}
}
@Override
public Path getRunDirectory(String id) {
switch (getGameDirectoryType(id)) {
case VERSION_FOLDER:
return getVersionRoot(id);
case ROOT_FOLDER:
return super.getRunDirectory(id);
case CUSTOM:
try {
return Path.of(getVersionSetting(id).getGameDir());
} catch (InvalidPathException ignored) {
return getVersionRoot(id);
}
default:
throw new AssertionError("Unreachable");
/// Returns the persistent game directory for this repository.
public GameDirectory getGameDirectory() {
return gameDirectory;
}
/// Returns the selected instance resolved from the current repository snapshot.
///
/// The property is `null` when the persisted selection is absent or is not registered in the
/// current snapshot. Publishing a new snapshot replaces the value with that snapshot's member,
/// even when the selected ID is unchanged.
///
/// @return the read-only selected-instance property
public ReadOnlyObjectProperty<@Nullable HMCLGameInstance> selectedInstanceProperty() {
return selectedInstance.getReadOnlyProperty();
}
/// Returns the selected instance from the current repository snapshot.
///
/// @return the selected instance, or `null` when no registered instance is selected
public @Nullable HMCLGameInstance getSelectedInstance() {
return selectedInstance.get();
}
/// Persists an instance as this repository's current selection.
///
/// A stale snapshot member from this repository is accepted; the observable property resolves
/// its ID against the current snapshot.
///
/// @param instance the instance to select, or `null` to clear the selection
/// @throws IllegalArgumentException if `instance` belongs to another repository
public void setSelectedInstance(@Nullable HMCLGameInstance instance) {
if (instance != null && instance.getRepository() != this) {
throw new IllegalArgumentException("Selected instance belongs to another repository");
}
settings().setSelectedInstance(gameDirectory.getId(), instance != null ? instance.getId() : null);
}
/// Restores a valid selected instance after repository instances are loaded.
///
/// If the persisted ID is not registered, the first indexed instance is selected. If the
/// repository is empty, the persisted selection is cleared.
public void refreshSelectedInstance() {
@Nullable GameInstanceID persistedId = selectedInstanceId.get();
@Nullable HMCLGameInstance refreshedInstance = persistedId != null ? findInstance(persistedId) : null;
if (refreshedInstance == null) {
refreshedInstance = getSnapshot().getInstances().stream().findFirst().orElse(null);
}
@Nullable GameInstanceID refreshedId = refreshedInstance != null ? refreshedInstance.getId() : null;
if (!Objects.equals(persistedId, refreshedId)) {
setSelectedInstance(refreshedInstance);
}
}
public Stream<Version> getDisplayVersions() {
return getVersions().stream()
.filter(v -> !v.isHidden())
.sorted(Comparator.comparing((Version v) -> Lang.requireNonNullElse(v.getReleaseTime(), Instant.EPOCH))
.thenComparing(v -> VersionNumber.asVersion(v.getId())));
/// Resolves the persisted selected ID from the current repository snapshot.
///
/// @return the current snapshot member, or `null` when the selected ID is absent or unregistered
private @Nullable HMCLGameInstance resolveSelectedInstance() {
@Nullable GameInstanceID instanceId = selectedInstanceId.get();
return instanceId != null ? findInstance(instanceId) : null;
}
@Override
protected void refreshVersionsImpl() {
localVersionSettings.clear();
super.refreshVersionsImpl();
versions.keySet().forEach(this::loadLocalVersionSetting);
versions.keySet().forEach(version -> {
if (isModpack(version)) {
specializeVersionSetting(version);
}
});
/// Returns a dependency manager using the currently selected download provider.
///
/// @return a new dependency manager for this repository
public HMCLDependencyManager getDependency() {
return getDependency(DownloadProviders.getDownloadProvider());
}
/// Returns a dependency manager using the given download provider.
///
/// @param downloadProvider the remote download provider
/// @return a new dependency manager for this repository
public HMCLDependencyManager getDependency(DownloadProvider downloadProvider) {
return new HMCLDependencyManager(this, downloadProvider, HMCLCacheRepository.REPOSITORY);
}
/// Resolves the run directory from modpack state and local settings.
///
/// @param instanceId the instance id
/// @param modpack whether the instance is an HMCL modpack (`modpack.cfg` present)
/// @param localSetting the instance-local settings, or `null` when absent
/// @return the run directory
Path computeRunDirectory(
GameInstanceID instanceId,
boolean modpack,
GameSettings.@Nullable Instance localSetting) {
Path instanceRoot = getLayout().getInstanceRoot(instanceId);
if (modpack) {
return instanceRoot;
}
boolean useInstanceRunningDirectory =
localSetting != null
&& localSetting.getOverrideProperties().contains(GameSettings.PROPERTY_RUNNING_DIRECTORY);
String runningDirectory = selectedRunningDirectory(localSetting, useInstanceRunningDirectory);
if (StringUtils.isBlank(runningDirectory)) {
return useInstanceRunningDirectory ? instanceRoot : getBaseDirectory();
}
try {
Path file = getBaseDirectory().resolve("launcher_profiles.json");
if (!Files.exists(file) && !versions.isEmpty()) {
Files.createDirectories(file.getParent());
Files.writeString(file, PROFILE);
}
} catch (IOException ex) {
LOG.warning("Unable to create launcher_profiles.json, Forge/LiteLoader installer will not work.", ex);
return Path.of(runningDirectory);
} catch (Exception ignored) {
return instanceRoot;
}
}
private String selectedRunningDirectory(
GameSettings.@Nullable Instance localSetting,
boolean useInstanceRunningDirectory) {
if (useInstanceRunningDirectory) {
if (localSetting == null) {
return "";
}
return Objects.requireNonNullElse(localSetting.runningDirectoryProperty().getValue(), "");
}
GameSettings.Preset parent = getParentGameSettings(localSetting);
return Objects.requireNonNullElse(parent.runningDirectoryProperty().getValue(), "");
}
/// Reads instance-local settings from disk without requiring a registered snapshot member.
///
/// Used while loading or migrating settings before the instance is indexed. Does not publish a
/// snapshot entry.
///
/// @param instanceId the instance id
/// @return the loaded settings, or `null` when none can be loaded
private GameSettings.@Nullable Instance peekInstanceGameSettings(GameInstanceID instanceId) {
Path file = getLayout().getInstanceGameSettingsFile(instanceId);
if (!Files.isRegularFile(file)) {
return null;
}
try (var reader = Files.newBufferedReader(file)) {
return LauncherSettings.SETTINGS_GSON.fromJson(reader, GameSettings.Instance.class);
} catch (Exception e) {
LOG.warning("Failed to peek instance game settings: " + file, e);
return null;
}
}
/// Writes instance-local settings to disk for an id that may not yet be registered.
///
/// @param instanceId the instance id
/// @param setting the settings to write
/// @throws IOException if the file cannot be written
private void writeInstanceGameSettings(GameInstanceID instanceId, GameSettings.Instance setting)
throws IOException {
Path file = getLayout().getInstanceGameSettingsFile(instanceId).toAbsolutePath().normalize();
Files.createDirectories(file.getParent());
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
FileUtils.saveSafely(file, LauncherSettings.SETTINGS_GSON.toJson(setting));
}
public Stream<HMCLGameInstance> getDisplayInstances() {
return getSnapshot().getInstances().stream()
.filter(it -> !it.getManifest().isHidden())
.sorted(Comparator.comparing(DefaultGameInstance::getVersion)
.thenComparing(instance -> Lang.requireNonNullElse(instance.getResolvedManifest().releaseTime(), Instant.EPOCH))
.thenComparing(instance -> VersionNumber.asVersion(instance.getId().id())));
}
public void changeDirectory(Path newDirectory) {
setBaseDirectory(newDirectory);
refreshVersionsAsync().start();
refreshAsync().start();
}
private void clean(Path directory) throws IOException {
FileUtils.deleteDirectory(directory.resolve("crash-reports"));
FileUtils.deleteDirectory(directory.resolve("logs"));
for (Path logFile : FileUtils.listFilesByExtension(directory, "log"))
Files.deleteIfExists(logFile);
}
public void clean(String id) throws IOException {
public void clean(GameInstanceID instanceId) throws IOException {
clean(getBaseDirectory());
clean(getRunDirectory(id));
clean(getInstance(instanceId).getRunDirectory());
}
public void duplicateVersion(String srcId, String dstId, boolean copySaves) throws IOException {
Path srcDir = getVersionRoot(srcId);
Path dstDir = getVersionRoot(dstId);
/// Duplicates an instance and publishes the copy through one exclusive repository draft.
///
/// The destination remains unpublished until all selected instance and run-directory files have
/// been copied. Failure aborts the draft and removes the destination instance root.
///
/// @param srcId the source instance id
/// @param dstId the destination instance id
/// @param copySaves whether saved worlds should be copied
/// @throws IOException if the destination exists or any file cannot be copied or committed
public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolean copySaves) throws IOException {
Path srcDir = getLayout().getInstanceRoot(srcId);
Path dstDir = getLayout().getInstanceRoot(dstId);
Version fromVersion = getVersion(srcId);
GameInstanceManifest fromManifest = getInstanceManifest(srcId);
GameInstanceManifest destinationManifest = fromManifest.withId(dstId).withJar(dstId);
List<String> blackList = new ArrayList<>(ModAdviser.MODPACK_BLACK_LIST);
blackList.add(srcId + ".jar");
blackList.add(srcId + ".json");
blackList.add(srcId.id() + ".jar");
blackList.add(srcId.id() + ".json");
if (!copySaves)
blackList.add("saves");
if (Files.exists(dstDir)) throw new IOException("Version exists");
try (DefaultGameRepositoryDraft draft = openDraft()) {
draft.put(destinationManifest);
Files.createDirectories(dstDir);
FileUtils.copyDirectory(srcDir, dstDir, path -> Modpack.acceptFile(path, blackList, null));
Files.createDirectories(dstDir);
FileUtils.copyDirectory(srcDir, dstDir, path -> Modpack.acceptFile(path, blackList, null));
Path fromJson = srcDir.resolve(srcId + ".json");
Path fromJar = srcDir.resolve(srcId + ".jar");
Path toJson = dstDir.resolve(dstId + ".json");
Path toJar = dstDir.resolve(dstId + ".jar");
if (Files.exists(fromJar)) {
Files.copy(fromJar, toJar);
}
Files.copy(fromJson, toJson);
JsonUtils.writeToJsonFile(toJson, fromVersion.setId(dstId));
VersionSetting oldVersionSetting = getVersionSetting(srcId).clone();
GameDirectoryType originalGameDirType = oldVersionSetting.getGameDirType();
oldVersionSetting.setUsesGlobal(false);
oldVersionSetting.setGameDirType(GameDirectoryType.VERSION_FOLDER);
VersionSetting newVersionSetting = initLocalVersionSetting(dstId, oldVersionSetting);
saveVersionSetting(dstId);
Path srcGameDir = getRunDirectory(srcId);
Path dstGameDir = getRunDirectory(dstId);
if (originalGameDirType != GameDirectoryType.VERSION_FOLDER)
FileUtils.copyDirectory(srcGameDir, dstGameDir, path -> Modpack.acceptFile(path, blackList, null));
}
private Path getLocalVersionSettingFile(String id) {
return getVersionRoot(id).resolve("hmclversion.cfg");
}
private void loadLocalVersionSetting(String id) {
Path file = getLocalVersionSettingFile(id);
if (Files.exists(file))
try {
VersionSetting versionSetting = JsonUtils.fromJsonFile(file, VersionSetting.class);
initLocalVersionSetting(id, versionSetting);
} catch (Exception ex) {
// If [JsonParseException], [IOException] or [NullPointerException] happens, the json file is malformed and needed to be recreated.
initLocalVersionSetting(id, new VersionSetting());
Path fromJar = srcDir.resolve(srcId.id() + ".jar");
Path toJar = dstDir.resolve(dstId.id() + ".jar");
if (Files.exists(fromJar)) {
Files.copy(fromJar, toJar);
}
}
/**
* Create new version setting if version id has no version setting.
*
* @param id the version id.
* @return new version setting, null if given version does not exist.
*/
public VersionSetting createLocalVersionSetting(String id) {
if (!hasVersion(id))
return null;
if (localVersionSettings.containsKey(id))
return getLocalVersionSetting(id);
else
return initLocalVersionSetting(id, new VersionSetting());
}
private VersionSetting initLocalVersionSetting(String id, VersionSetting vs) {
localVersionSettings.put(id, vs);
vs.addListener(a -> saveVersionSetting(id));
return vs;
}
/**
* Get the version setting for version id.
*
* @param id version id
* @return corresponding version setting, null if the version has no its own version setting.
*/
@Nullable
public VersionSetting getLocalVersionSetting(String id) {
if (!localVersionSettings.containsKey(id))
loadLocalVersionSetting(id);
VersionSetting setting = localVersionSettings.get(id);
if (setting != null && isModpack(id))
setting.setGameDirType(GameDirectoryType.VERSION_FOLDER);
return setting;
}
@Nullable
public VersionSetting getLocalVersionSettingOrCreate(String id) {
VersionSetting vs = getLocalVersionSetting(id);
if (vs == null) {
vs = createLocalVersionSetting(id);
}
return vs;
}
public VersionSetting getVersionSetting(String id) {
VersionSetting vs = getLocalVersionSetting(id);
if (vs == null || vs.isUsesGlobal()) {
profile.getGlobal().setUsesGlobal(true);
return profile.getGlobal();
} else
return vs;
}
public Optional<Path> getVersionIconFile(String id) {
Path root = getVersionRoot(id);
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
Path file = root.resolve("icon." + extension);
if (Files.exists(file)) {
return Optional.of(file);
}
}
return Optional.empty();
}
public void setVersionIconFile(String id, Path iconFile) throws IOException {
String ext = FileUtils.getExtension(iconFile).toLowerCase(Locale.ROOT);
if (!FXUtils.IMAGE_EXTENSIONS.contains(ext)) {
throw new IllegalArgumentException("Unsupported icon file: " + ext);
}
deleteIconFile(id);
FileUtils.copyFile(iconFile, getVersionRoot(id).resolve("icon." + ext));
}
public void deleteIconFile(String id) {
Path root = getVersionRoot(id);
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
Path file = root.resolve("icon." + extension);
Path srcGameDir = getInstance(srcId).getRunDirectory();
boolean copyOriginalGameDir;
try {
Files.deleteIfExists(file);
copyOriginalGameDir = !Files.isSameFile(srcGameDir, srcDir);
} catch (IOException e) {
LOG.warning("Failed to delete icon file: " + file, e);
copyOriginalGameDir = true;
}
GameSettings.Instance newGameSettings = getInstance(srcId).copySettings();
newGameSettings.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
newGameSettings.runningDirectoryProperty().setValue("");
writeInstanceGameSettings(dstId, newGameSettings);
Path dstGameDir = computeRunDirectory(dstId, false, newGameSettings);
if (copyOriginalGameDir) {
FileUtils.copyDirectory(
srcGameDir,
dstGameDir,
path -> Modpack.acceptFile(path, blackList, null));
}
draft.commit();
}
}
public Image getVersionIconImage(String id) {
if (id == null || !isLoaded())
return VersionIconType.DEFAULT.getIcon();
VersionSetting vs = getLocalVersionSettingOrCreate(id);
VersionIconType iconType = vs != null ? Lang.requireNonNullElse(vs.getVersionIcon(), VersionIconType.DEFAULT) : VersionIconType.DEFAULT;
if (iconType == VersionIconType.DEFAULT) {
Version version = getVersion(id).resolve(this);
Optional<Path> iconFile = getVersionIconFile(id);
if (iconFile.isPresent()) {
try {
return FXUtils.loadImage(iconFile.get());
} catch (Exception e) {
LOG.warning("Failed to load version icon of " + id, e);
}
}
if (LibraryAnalyzer.isModded(this, version)) {
LibraryAnalyzer libraryAnalyzer = LibraryAnalyzer.analyze(version, null);
if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.FABRIC))
return VersionIconType.FABRIC.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.FORGE))
return VersionIconType.FORGE.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM))
return VersionIconType.CLEANROOM.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE))
return VersionIconType.NEO_FORGE.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.QUILT))
return VersionIconType.QUILT.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.OPTIFINE))
return VersionIconType.OPTIFINE.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.LITELOADER))
return VersionIconType.CHICKEN.getIcon();
else
return VersionIconType.FURNACE.getIcon();
}
return VersionIconType.DEFAULT.getIcon();
} else {
return iconType.getIcon();
}
}
public void saveVersionSetting(String id) {
if (!localVersionSettings.containsKey(id))
return;
Path file = getLocalVersionSettingFile(id).toAbsolutePath().normalize();
try {
Files.createDirectories(file.getParent());
} catch (IOException e) {
LOG.warning("Failed to create directory: " + file.getParent(), e);
}
FileSaver.save(file, GSON.toJson(localVersionSettings.get(id)));
}
/**
* Make version use self version settings instead of the global one.
*
* @param id the version id.
* @return specialized version setting, null if given version does not exist.
*/
public VersionSetting specializeVersionSetting(String id) {
VersionSetting vs = getLocalVersionSetting(id);
if (vs == null)
vs = createLocalVersionSetting(id);
if (vs == null)
/// Returns instance-local settings for a registered instance ID, creating empty settings when
/// the settings file is absent and writable.
///
/// Code that already has an [HMCLGameInstance] should use
/// [HMCLGameInstance#getSettingsOrCreate()] instead.
///
/// @param instanceId the registered instance ID
/// @return the settings, or `null` when the instance is not registered or settings are unavailable
public @Nullable GameSettings.Instance getInstanceGameSettingsOrCreate(GameInstanceID instanceId) {
HMCLGameInstance instance = findInstance(instanceId);
if (instance == null) {
return null;
if (vs.isUsesGlobal()) {
vs.setUsesGlobal(false);
}
return vs;
return instance.getSettingsOrCreate();
}
public void globalizeVersionSetting(String id) {
VersionSetting vs = getLocalVersionSetting(id);
if (vs != null)
vs.setUsesGlobal(true);
}
public LaunchOptions getLaunchOptions(String version, JavaRuntime javaVersion, Path gameDir, List<String> javaAgents, List<String> javaArguments, boolean makeLaunchScript) {
VersionSetting vs = getVersionSetting(version);
LaunchOptions.Builder builder = new LaunchOptions.Builder()
.setGameDir(gameDir)
.setJava(javaVersion)
.setVersionType(Metadata.TITLE)
.setVersionName(version)
.setProfileName(Metadata.TITLE)
.setGameArguments(StringUtils.tokenize(vs.getMinecraftArgs()))
.setOverrideJavaArguments(StringUtils.tokenize(vs.getJavaArgs()))
.setMaxMemory(vs.isNoJVMArgs() && vs.isAutoMemory() ? null : (int) (getAllocatedMemory(
vs.getMaxMemory() * 1024L * 1024L,
SystemInfo.getPhysicalMemoryStatus().getAvailable(),
vs.isAutoMemory()
) / 1024 / 1024))
.setMinMemory(vs.getMinMemory())
.setMetaspace(Lang.toIntOrNull(vs.getPermSize()))
.setEnvironmentVariables(
Lang.mapOf(StringUtils.tokenize(vs.getEnvironmentVariables())
.stream()
.map(it -> {
int idx = it.indexOf('=');
return idx >= 0 ? pair(it.substring(0, idx), it.substring(idx + 1)) : pair(it, "");
})
.collect(Collectors.toList())
)
)
.setWidth(vs.getWidth())
.setHeight(vs.getHeight())
.setFullscreen(vs.isFullscreen())
.setServerIp(vs.getServerIp())
.setWrapper(vs.getWrapper())
.setPreLaunchCommand(vs.getPreLaunchCommand())
.setPostExitCommand(vs.getPostExitCommand())
.setNoGeneratedJVMArgs(vs.isNoJVMArgs())
.setNoGeneratedOptimizingJVMArgs(vs.isNoOptimizingJVMArgs())
.setNativesDirType(vs.getNativesDirType())
.setNativesDir(vs.getNativesDir())
.setProcessPriority(vs.getProcessPriority())
.setRenderer(vs.getRenderer())
.setUseNativeGLFW(vs.isUseNativeGLFW())
.setUseNativeOpenAL(vs.isUseNativeOpenAL())
.setDaemon(!makeLaunchScript && vs.getLauncherVisibility().isDaemon())
.setJavaAgents(javaAgents)
.setJavaArguments(javaArguments);
if (config().hasProxy()) {
builder.setProxyType(config().getProxyType());
builder.setProxyHost(config().getProxyHost());
builder.setProxyPort(config().getProxyPort());
if (config().hasProxyAuth()) {
builder.setProxyUser(config().getProxyUser());
builder.setProxyPass(config().getProxyPass());
}
/// Returns instance-local settings for a registered instance ID.
///
/// When the instance is not yet indexed, settings are loaded from disk (including lazy legacy
/// migration) without publishing a snapshot entry. Callers that already have an
/// [HMCLGameInstance] should use [HMCLGameInstance#getSettings()] instead.
///
/// @param instanceId the instance ID
/// @return the settings, or `null` when no local settings exist
public @Nullable GameSettings.Instance getInstanceGameSettings(GameInstanceID instanceId) {
HMCLGameInstance instance = findInstance(instanceId);
if (instance != null) {
return instance.getSettings();
}
Path json = getModpackConfiguration(version);
if (Files.exists(json)) {
try {
String jsonText = Files.readString(json);
ModpackConfiguration<?> modpackConfiguration = JsonUtils.GSON.fromJson(jsonText, ModpackConfiguration.class);
ModpackProvider provider = ModpackHelper.getProviderByType(modpackConfiguration.getType());
if (provider != null) provider.injectLaunchOptions(jsonText, builder);
} catch (IOException | JsonParseException e) {
LOG.warning("Failed to parse modpack configuration file " + json, e);
}
}
if (vs.isAutoMemory() && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx")))
builder.setMaxMemory(null);
return builder.create();
return loadOrMigrateInstanceGameSettings(instanceId);
}
@Override
public Path getModpackConfiguration(String version) {
return getVersionRoot(version).resolve("modpack.cfg");
/// Returns the explicit parent preset of the instance, falling back to the default preset.
public GameSettings.Preset getParentGameSettings(@Nullable GameSettings.Instance instance) {
@Nullable GameSettingsPresetID parent = instance != null ? instance.parentProperty().getValue() : null;
GameSettings.Preset parentSetting = SettingsManager.getGameSettings(parent);
return parentSetting != null ? parentSetting : SettingsManager.getDefaultGameSettingsPresetOrCreate();
}
public void markVersionAsModpack(String id) {
beingModpackVersions.add(id);
/// Resolves effective settings for a registered instance ID.
///
/// Instance-oriented callers should use [HMCLGameInstance#getEffectiveSettings()] instead.
///
/// @param instanceId the registered instance ID
/// @return the effective settings
/// @throws NoSuchGameInstanceException if the instance is not registered
public GameSettings.Effective getEffectiveGameSettings(GameInstanceID instanceId) {
return getInstance(instanceId).getEffectiveSettings();
}
public void undoMark(String id) {
beingModpackVersions.remove(id);
/// Returns whether a new instance should use an isolated running directory under the default isolation settings.
///
/// @param modded whether the new instance contains a mod loader
/// @return whether installation-time run-directory content should be placed under the instance root
public boolean shouldIsolateNewInstance(boolean modded) {
GameSettings.Preset preset = getParentGameSettings(null);
DefaultIsolationType type = Lang.requireNonNullElse(preset.defaultIsolationTypeProperty().getValue(), DefaultIsolationType.MODDED);
return switch (type) {
case NEVER -> false;
case ALWAYS -> true;
case MODDED -> modded;
};
}
public void markVersionLaunchedAbnormally(String id) {
try {
Files.createFile(getVersionRoot(id).resolve(".abnormal"));
} catch (IOException ignored) {
}
}
public boolean unmarkVersionLaunchedAbnormally(String id) {
Path file = getVersionRoot(id).resolve(".abnormal");
/// Loads settings from disk for an unregistered id, running legacy migration when needed.
private GameSettings.@Nullable Instance loadOrMigrateInstanceGameSettings(GameInstanceID instanceId) {
Path file = getLayout().getInstanceGameSettingsFile(instanceId);
if (Files.isRegularFile(file)) {
try {
Files.delete(file);
} catch (IOException e) {
LOG.warning("Failed to delete abnormal mark file: " + file, e);
}
return true;
} else {
return false;
return peekInstanceGameSettings(instanceId);
}
@Nullable GameSettingsPresetID legacyParent = getGameDirectory().getLegacyGameSettings();
if (SettingsManager.getGameSettings(legacyParent) == null) {
legacyParent = null;
}
LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult =
LegacyGameSettingsMigrator.migrateInstanceGameSettings(this, instanceId, legacyParent);
if (migrationResult == null) {
return null;
}
try {
writeInstanceGameSettings(instanceId, migrationResult.setting());
migrationResult.saveReceipt();
} catch (IOException e) {
LOG.warning("Failed to save migrated instance game settings for " + instanceId, e);
}
return migrationResult.setting();
}
private static final Gson GSON = new GsonBuilder()
.setPrettyPrinting()
.create();
// These instance ids are forbidden because they may conflict with modpack configuration filenames
private static final Set<String> FORBIDDEN_INSTANCE_IDS = Set.of("modpack", "minecraftinstance", "manifest");
private static final String PROFILE = "{\"selectedProfile\": \"(Default)\",\"profiles\": {\"(Default)\": {\"name\": \"(Default)\"}},\"clientToken\": \"88888888-8888-8888-8888-888888888888\"}";
public static boolean isValidInstanceId(String id) {
if (!GameInstanceID.isValid(id))
return false;
// These version ids are forbidden because they may conflict with modpack configuration filenames
private static final Set<String> FORBIDDEN_VERSION_IDS = new HashSet<>(Arrays.asList(
"modpack", "minecraftinstance", "manifest"));
public static boolean isValidVersionId(String id) {
if (FORBIDDEN_VERSION_IDS.contains(id))
if (FORBIDDEN_INSTANCE_IDS.contains(id))
return false;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS &&
FORBIDDEN_VERSION_IDS.contains(id.toLowerCase(Locale.ROOT)))
FORBIDDEN_INSTANCE_IDS.contains(id.toLowerCase(Locale.ROOT)))
return false;
return FileUtils.isNameValid(id);
return FileUtils.isNameValidForJar(id);
}
/**
* Returns true if the given version id conflicts with an existing version.
* Returns true if the given instance id conflicts with an existing instance.
*/
public boolean versionIdConflicts(String id) {
public boolean instanceIdConflicts(String instanceId) {
try {
return instanceIdConflicts(new GameInstanceID(instanceId));
} catch (IllegalArgumentException e) {
return false;
}
}
public boolean instanceIdConflicts(GameInstanceID id) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
// on Windows, filenames are case-insensitive
for (String existingId : versions.keySet()) {
if (existingId.equalsIgnoreCase(id)) {
for (HMCLGameInstance instance : getSnapshot().getInstances()) {
if (instance.getId().toString().equalsIgnoreCase(id.toString())) {
return true;
}
}
return false;
} else {
return versions.containsKey(id);
return hasInstance(id);
}
}
public static long getAllocatedMemory(long minimum, long available, boolean auto) {
if (auto) {
available -= 512 * 1024 * 1024; // Reserve 512 MiB memory for off-heap memory and HMCL itself
if (available <= 0) {
return minimum;
}
public static long getAutoAllocatedMemory(PhysicalMemoryStatus memoryStatus, Platform platform) {
final long minHeapSize;
final long maxHeapSize;
final long threshold = 8L * 1024 * 1024 * 1024; // 8 GiB
final long suggested = Math.min(available <= threshold
? (long) (available * 0.8)
: (long) (threshold * 0.8 + (available - threshold) * 0.2),
16L * 1024 * 1024 * 1024);
return Math.max(minimum, suggested);
if (platform.getBits() == Bits.BIT_32) {
// https://github.com/HMCL-dev/HMCL/issues/6638
maxHeapSize = 768 * 1024 * 1024; // 768 MiB
minHeapSize = 256 * 1024 * 1024; // 128 MiB
} else {
return minimum;
maxHeapSize = 24L * 1024 * 1024 * 1024; // 24 GiB
minHeapSize = 512 * 1024 * 1024; // 512 MiB
}
// Reserve memory for off-heap memory and HMCL itself
final long reserved = 256 * 1024 * 1024; // 256 MiB
final long threshold1 = 8L * 1024 * 1024 * 1024; // 8 GiB
final long threshold2 = 16L * 1024 * 1024 * 1024; // 16 GiB
if (memoryStatus.total() <= minHeapSize || memoryStatus.available() < 0) {
return -1L;
}
final long usable = Math.max(
Math.max(memoryStatus.available(), memoryStatus.total() / 16) - reserved,
0
);
long suggested;
if (usable <= threshold1)
suggested = (long) (usable * 0.8);
else if (usable <= threshold2)
suggested = (long) (threshold1 * 0.8 + (usable - threshold1) * 0.4);
else
suggested = (long) (threshold1 * 0.8 + (threshold2 - threshold1) * 0.4 + (usable - threshold2) * 0.2);
return MathUtils.clamp(suggested & ~0xFFFFFL, minHeapSize, maxHeapSize);
}
}
@@ -0,0 +1,48 @@
// 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.jetbrains.annotations.NotNullByDefault;
import java.nio.file.Path;
@NotNullByDefault
public final class HMCLGameRepositoryLayout extends DefaultGameRepositoryLayout {
/// Directory under the instance root that stores HMCL-managed instance metadata.
private static final String INSTANCE_METADATA_DIRECTORY = ".hmcl";
/// Directory under the instance metadata directory that stores instance configuration files.
private static final String INSTANCE_CONFIG_DIRECTORY = "config";
/// Directory under the instance metadata directory that stores instance state files.
private static final String INSTANCE_STATE_DIRECTORY = "state";
/// Current file name for instance-specific game settings.
private static final String INSTANCE_GAME_SETTINGS_FILENAME = "instance-game-settings.json";
public HMCLGameRepositoryLayout(Path baseDirectory) {
super(baseDirectory);
}
/// Returns the HMCL-managed metadata directory under the instance root.
///
/// This directory stores instance-scoped files owned by HMCL.
public Path getInstanceMetadataDirectory(GameInstanceID instanceId) {
return getInstanceRoot(instanceId).resolve(INSTANCE_METADATA_DIRECTORY);
}
/// Returns the HMCL-managed configuration directory under the instance metadata directory.
public Path getInstanceConfigDirectory(GameInstanceID instanceId) {
return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_CONFIG_DIRECTORY);
}
/// Returns the HMCL-managed state directory under the instance metadata directory.
public Path getInstanceStateDirectory(GameInstanceID instanceId) {
return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_STATE_DIRECTORY);
}
/// Returns the current local game settings path under the instance configuration directory.
public Path getInstanceGameSettingsFile(GameInstanceID instanceId) {
return getInstanceConfigDirectory(instanceId).resolve(INSTANCE_GAME_SETTINGS_FILENAME);
}
}
@@ -0,0 +1,40 @@
// 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.jetbrains.annotations.NotNullByDefault;
import java.util.Collection;
/// HMCL repository snapshot, parallel to [HMCLGameInstance] in the instance hierarchy.
@NotNullByDefault
public class HMCLGameRepositorySnapshot extends DefaultGameRepositorySnapshot {
/// Creates an empty unsealed HMCL snapshot.
///
/// @param repository the owning repository
/// @param layout the HMCL layout for this snapshot
public HMCLGameRepositorySnapshot(HMCLGameRepository repository, HMCLGameRepositoryLayout layout) {
super(repository, layout);
}
@Override
public HMCLGameRepository getRepository() {
return (HMCLGameRepository) super.getRepository();
}
@Override
public HMCLGameRepositoryLayout getLayout() {
return (HMCLGameRepositoryLayout) super.getLayout();
}
@Override
protected HMCLGameRepositorySnapshot newEmpty() {
return new HMCLGameRepositorySnapshot(getRepository(), getLayout());
}
@SuppressWarnings("unchecked")
@Override
public Collection<HMCLGameInstance> getInstances() {
return (Collection<HMCLGameInstance>) super.getInstances();
}
}
@@ -1,80 +1,121 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.mod.MinecraftInstanceTask;
import org.jackhuang.hmcl.mod.Modpack;
import org.jackhuang.hmcl.mod.ModpackConfiguration;
import org.jackhuang.hmcl.mod.ModpackInstallTask;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.download.GameBuilder;
import org.jackhuang.hmcl.modpack.MinecraftInstanceTask;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
import org.jackhuang.hmcl.modpack.ModpackInstallTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.regex.Matcher;
/// Installs or updates an HMCL modpack by rebuilding its recognized game components.
@NotNullByDefault
public final class HMCLModpackInstallTask extends Task<Void> {
private final Path zipFile;
private final String name;
private final GameInstanceID instanceId;
/// Existing instance selecting update mode, or `null` for a new installation.
private final @Nullable DefaultGameInstance updateTarget;
private final HMCLGameRepository repository;
private final DefaultDependencyManager dependency;
private final Modpack modpack;
private final List<Task<?>> dependencies = new ArrayList<>(1);
private final List<Task<?>> dependents = new ArrayList<>(4);
public HMCLModpackInstallTask(Profile profile, Path zipFile, Modpack modpack, String name) {
dependency = profile.getDependency();
repository = profile.getRepository();
/// Creates a task that installs a new HMCL modpack instance.
///
/// @param repository the target HMCL game repository
/// @param zipFile the HMCL modpack archive
/// @param modpack the parsed modpack metadata
/// @param instanceId the id of the new instance
public HMCLModpackInstallTask(
HMCLGameRepository repository,
Path zipFile,
Modpack modpack,
GameInstanceID instanceId) {
this(repository, zipFile, modpack, instanceId, null);
}
/// Creates a task that updates an existing HMCL modpack instance.
///
/// @param repository the target HMCL game repository
/// @param zipFile the HMCL modpack archive
/// @param modpack the parsed modpack metadata
/// @param instance the existing instance to update
/// @throws IllegalArgumentException if `instance` belongs to another repository, has no
/// modpack configuration, or records another provider type
public HMCLModpackInstallTask(
HMCLGameRepository repository,
Path zipFile,
Modpack modpack,
DefaultGameInstance instance) {
this(repository, zipFile, modpack, instance.getId(), instance);
}
/// Creates an HMCL modpack task in the mode selected by `updateTarget`.
///
/// @param repository the target HMCL game repository
/// @param zipFile the HMCL modpack archive
/// @param modpack the parsed modpack metadata
/// @param instanceId the target instance id
/// @param updateTarget the existing instance selecting update mode, or `null` for install
/// @throws IllegalArgumentException if an update target belongs to another repository or has
/// no compatible configuration
private HMCLModpackInstallTask(
HMCLGameRepository repository,
Path zipFile,
Modpack modpack,
GameInstanceID instanceId,
@Nullable DefaultGameInstance updateTarget) {
this.repository = repository;
this.dependency = repository.getDependency();
this.zipFile = zipFile;
this.name = name;
this.instanceId = instanceId;
this.updateTarget = updateTarget;
this.modpack = modpack;
if (this.updateTarget != null) {
dependency.validateGameInstance(this.updateTarget);
}
Path run = repository.getRunDirectory(name);
Path json = repository.getModpackConfiguration(name);
if (repository.hasVersion(name) && Files.notExists(json))
throw new IllegalArgumentException("Version " + name + " already exists");
Path run = repository.getLayout().getInstanceRoot(this.instanceId);
Path json = repository.getLayout().getModpackConfigurationFile(this.instanceId);
if (this.updateTarget != null && Files.notExists(json))
throw new IllegalArgumentException("Instance " + instanceId + " is not a HMCL modpack. Cannot update this instance.");
dependents.add(dependency.gameBuilder().name(name).gameVersion(modpack.getGameVersion()).buildAsync());
onDone().register(event -> {
if (event.isFailed()) repository.removeVersionFromDisk(name);
});
ModpackConfiguration<Modpack> config = null;
@Nullable ModpackConfiguration<Modpack> config = null;
try {
if (Files.exists(json)) {
if (this.updateTarget != null && Files.exists(json)) {
config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(Modpack.class));
if (!HMCLModpackProvider.INSTANCE.getName().equals(config.getType()))
throw new IllegalArgumentException("Version " + name + " is not a HMCL modpack. Cannot update this version.");
if (config == null || !HMCLModpackProvider.INSTANCE.getName().equals(config.getType()))
throw new IllegalArgumentException("Instance " + instanceId + " is not a HMCL modpack. Cannot update this instance.");
}
} catch (JsonParseException | IOException ignore) {
}
dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), Collections.singletonList("/minecraft"), it -> !"pack.json".equals(it), config));
dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getModpackConfiguration(name)).withStage("hmcl.modpack"));
onDone().register(event -> {
if (this.updateTarget == null && event.isFailed()) {
repository.removeInstanceFromDisk(this.instanceId);
}
});
dependents.add(new ModpackInstallTask<>(zipFile, run, List.of("/minecraft"), it -> !"pack.json".equals(it), config));
dependents.add(new MinecraftInstanceTask<>(zipFile, List.of("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getLayout().getModpackConfigurationFile(this.instanceId)).withStage("hmcl.modpack"));
}
@Override
@@ -87,20 +128,43 @@ public final class HMCLModpackInstallTask extends Task<Void> {
return dependents;
}
/// Reads the legacy pack manifest and schedules one builder for all recognized components.
///
/// The pack manifest is used only for component detection. The builder creates a new
/// patch-structured manifest instead of attempting to mutate the legacy flattened manifest.
@Override
public void execute() throws Exception {
String json = CompressingUtils.readTextZipEntry(zipFile, "minecraft/pack.json");
Version originalVersion = JsonUtils.GSON.fromJson(json, Version.class).setId(name).setJar(null);
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(originalVersion, null);
Task<Version> libraryTask = Task.supplyAsync(() -> originalVersion);
// reinstall libraries
// libraries of Forge and OptiFine should be obtained by installation.
for (LibraryAnalyzer.LibraryMark mark : analyzer) {
if (LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId().equals(mark.getLibraryId()))
continue;
libraryTask = libraryTask.thenComposeAsync(version -> dependency.installLibraryAsync(modpack.getGameVersion(), version, mark.getLibraryId(), mark.getLibraryVersion()));
}
GameInstanceManifest originalManifest = JsonUtils.fromNonNullJson(json, GameInstanceManifest.class)
.withId(instanceId)
.withJar(null);
GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(originalManifest, null);
dependencies.add(libraryTask.thenComposeAsync(repository::saveAsync));
try (GameBuilder builder = updateTarget == null
? dependency.newGameBuilder(instanceId)
: dependency.newGameBuilder(updateTarget)) {
builder.enableIsolation();
String gameVersion = modpack.getGameVersion();
builder.component(GameComponentType.GAME, gameVersion);
for (GameComponentAnalyzer.Mark mark : analyzer) {
if (mark.componentType() == GameComponentType.GAME) {
continue;
}
@Nullable String componentVersion = mark.version();
if (componentVersion != null) {
if (mark.componentType() == GameComponentType.OPTIFINE) {
Matcher matcher = GameComponentAnalyzer.OPTIFINE_VERSION_PATTERN.matcher(componentVersion);
if (matcher.matches()) {
componentVersion = matcher.group("optifine");
}
}
builder.component(mark.componentType(), componentVersion);
}
}
dependencies.add(builder.buildAsync());
}
}
}
@@ -1,24 +1,9 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.mod.ModpackManifest;
import org.jackhuang.hmcl.mod.ModpackProvider;
import org.jackhuang.hmcl.modpack.ModpackManifest;
import org.jackhuang.hmcl.modpack.ModpackProvider;
public final class HMCLModpackManifest implements ModpackManifest {
public static final HMCLModpackManifest INSTANCE = new HMCLModpackManifest();
@@ -1,38 +1,23 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2022 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2022 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import com.google.gson.JsonParseException;
import kala.compress.archivers.zip.ZipArchiveReader;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.mod.MismatchedModpackTypeException;
import org.jackhuang.hmcl.mod.Modpack;
import org.jackhuang.hmcl.mod.ModpackProvider;
import org.jackhuang.hmcl.mod.ModpackUpdateTask;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.modpack.MismatchedModpackTypeException;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jackhuang.hmcl.modpack.ModpackProvider;
import org.jackhuang.hmcl.modpack.ModpackUpdateTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Path;
import java.util.Set;
public final class HMCLModpackProvider implements ModpackProvider {
public static final HMCLModpackProvider INSTANCE = new HMCLModpackProvider();
@@ -43,12 +28,17 @@ public final class HMCLModpackProvider implements ModpackProvider {
}
@Override
public Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, String version) {
public @Nullable Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) {
return null;
}
@Override
public Task<?> createUpdateTask(DefaultDependencyManager dependencyManager, String name, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException {
public Task<?> createUpdateTask(
DefaultDependencyManager dependencyManager,
DefaultGameInstance instance,
Path zipFile,
Modpack modpack,
@Nullable Set<String> excludedFiles) throws MismatchedModpackTypeException {
if (!(modpack.getManifest() instanceof HMCLModpackManifest))
throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName());
@@ -56,31 +46,34 @@ public final class HMCLModpackProvider implements ModpackProvider {
throw new IllegalArgumentException("HMCLModpackProvider requires HMCLGameRepository");
}
Profile profile = repository.getProfile();
return new ModpackUpdateTask(dependencyManager.getGameRepository(), name, new HMCLModpackInstallTask(profile, zipFile, modpack, name));
return new ModpackUpdateTask(instance, new HMCLModpackInstallTask(repository, zipFile, modpack, instance));
}
@Override
public Modpack readManifest(ZipArchiveReader file, Path path, Charset encoding) throws IOException, JsonParseException {
public Modpack readManifest(ZipArchiveReader file, Path path) throws IOException, JsonParseException {
String manifestJson = CompressingUtils.readTextZipEntry(file, "modpack.json");
Modpack manifest = JsonUtils.fromNonNullJson(manifestJson, HMCLModpack.class).setEncoding(encoding);
Modpack manifest = JsonUtils.fromNonNullJson(manifestJson, HMCLModpack.class);
String gameJson = CompressingUtils.readTextZipEntry(file, "minecraft/pack.json");
Version game = JsonUtils.fromNonNullJson(gameJson, Version.class);
if (game.getJar() == null)
GameInstanceManifest game = JsonUtils.fromNonNullJson(gameJson, GameInstanceManifest.class);
if (game.jar() == null)
if (StringUtils.isBlank(manifest.getVersion()))
throw new JsonParseException("Cannot recognize the game version of modpack " + file + ".");
else
manifest.setManifest(HMCLModpackManifest.INSTANCE);
else
manifest.setManifest(HMCLModpackManifest.INSTANCE).setGameVersion(game.getJar());
manifest.setManifest(HMCLModpackManifest.INSTANCE).setGameVersion(game.jar().id());
return manifest;
}
private final static class HMCLModpack extends Modpack {
@Override
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name) {
return new HMCLModpackInstallTask(((HMCLGameRepository) dependencyManager.getGameRepository()).getProfile(), zipFile, this, name);
public Task<?> getInstallTask(
DefaultDependencyManager dependencyManager,
Path zipFile,
GameInstanceID instanceId,
String iconUrl,
@Nullable Set<String> excludedFiles) {
return new HMCLModpackInstallTask((HMCLGameRepository) dependencyManager.getGameRepository(), zipFile, this, instanceId);
}
}
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import com.jfoenix.controls.JFXButton;
@@ -22,23 +7,24 @@ import javafx.stage.Stage;
import org.jackhuang.hmcl.Launcher;
import org.jackhuang.hmcl.auth.*;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException;
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.download.MaintainTask;
import org.jackhuang.hmcl.download.game.*;
import org.jackhuang.hmcl.java.JavaManager;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.launch.*;
import org.jackhuang.hmcl.mod.ModpackCompletionException;
import org.jackhuang.hmcl.mod.ModpackConfiguration;
import org.jackhuang.hmcl.mod.ModpackProvider;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.modpack.ModpackCompletionException;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
import org.jackhuang.hmcl.modpack.ModpackProvider;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jackhuang.hmcl.setting.JavaVersionType;
import org.jackhuang.hmcl.setting.LauncherVisibility;
import org.jackhuang.hmcl.task.*;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.PromptDialogPane;
import org.jackhuang.hmcl.ui.construct.TaskExecutorDialogPane;
import org.jackhuang.hmcl.util.*;
@@ -46,50 +32,63 @@ import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.ResponseCodeException;
import org.jackhuang.hmcl.util.platform.*;
import org.jackhuang.hmcl.util.platform.windows.WinReg;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jetbrains.annotations.Nullable;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.nio.file.AccessDeniedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
import java.lang.ref.WeakReference;
import static javafx.application.Platform.runLater;
import static javafx.application.Platform.setImplicitExit;
import static org.jackhuang.hmcl.setting.SettingsManager.state;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
import static org.jackhuang.hmcl.util.Lang.resolveException;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.logging.Logger.TIME_FORMATTER;
import static org.jackhuang.hmcl.util.platform.Platform.SYSTEM_PLATFORM;
import static org.jackhuang.hmcl.util.platform.Platform.isCompatibleWithX86Java;
public final class LauncherHelper {
private final Profile profile;
private static final String LWJGL_3_4_1_TIP = "lwjgl3.4.1-ffm";
private final HMCLGameInstance gameInstance;
private Account account;
private final String selectedVersion;
private Path scriptFile;
private final VersionSetting setting;
private final GameSettings.Effective setting;
private LauncherVisibility launcherVisibility;
private boolean showLogs;
private QuickPlayOption quickPlayOption;
private boolean disableOfflineSkin = false;
public LauncherHelper(Profile profile, Account account, String selectedVersion) {
this.profile = Objects.requireNonNull(profile);
public LauncherHelper(HMCLGameInstance gameInstance, Account account) {
this.gameInstance = Objects.requireNonNull(gameInstance);
this.account = Objects.requireNonNull(account);
this.selectedVersion = Objects.requireNonNull(selectedVersion);
this.setting = profile.getVersionSetting(selectedVersion);
this.launcherVisibility = setting.getLauncherVisibility();
this.showLogs = setting.isShowLogs();
this.launchingStepsPane.setTitle(i18n("version.launch"));
this.setting = gameInstance.getEffectiveSettings();
this.launcherVisibility = setting.getInheritable(GameSettings::launcherVisibilityProperty);
this.showLogs = setting.getInheritable(GameSettings::showLogsProperty);
this.launchingStepsPane.setTitle(i18n("instance.launch"));
}
public HMCLGameInstance getGameInstance() {
return gameInstance;
}
private HMCLGameRepository repository() {
return gameInstance.getRepository();
}
private final TaskExecutorDialogPane launchingStepsPane = new TaskExecutorDialogPane(TaskCancellationAction.NORMAL);
@@ -111,10 +110,18 @@ public final class LauncherHelper {
launcherVisibility = LauncherVisibility.KEEP;
}
public void setQuickPlayOption(QuickPlayOption quickPlayOption) {
this.quickPlayOption = quickPlayOption;
}
public void setDisableOfflineSkin() {
disableOfflineSkin = true;
}
public void launch() {
FXUtils.checkFxUserThread();
LOG.info("Launching game version: " + selectedVersion);
LOG.info("Launching game instance: " + gameInstance.getId());
Controllers.dialog(launchingStepsPane);
launch0();
@@ -125,82 +132,170 @@ public final class LauncherHelper {
launch();
}
/// Builds and executes the launch pipeline for the captured game instance.
@SuppressWarnings("NonAtomicOperationOnVolatileField")
private void launch0() {
// https://github.com/HMCL-dev/HMCL/pull/4121
PROCESSES.removeIf(it -> it.get() == null);
HMCLGameRepository repository = profile.getRepository();
DefaultDependencyManager dependencyManager = profile.getDependency();
AtomicReference<Version> version = new AtomicReference<>(MaintainTask.maintain(repository, repository.getResolvedVersion(selectedVersion)));
Optional<String> gameVersion = repository.getGameVersion(version.get());
boolean integrityCheck = repository.unmarkVersionLaunchedAbnormally(selectedVersion);
final var context = new Object() {
volatile GameInstanceManifest launchManifest;
volatile JavaRuntime javaRuntime;
volatile Path javaExecutable;
};
HMCLGameRepository repository = repository();
DefaultDependencyManager dependencyManager = repository.getDependency();
boolean integrityCheck = gameInstance.unmarkLaunchedAbnormally();
CountDownLatch launchingLatch = new CountDownLatch(1);
List<String> javaAgents = new ArrayList<>(0);
List<String> javaArguments = new ArrayList<>(0);
AtomicReference<JavaRuntime> javaVersionRef = new AtomicReference<>();
// Resolve already deduplicated libraries; apply loader-specific argument repairs for this launch.
context.launchManifest = LaunchManifestNormalizer.repairForLaunch(gameInstance.getResolvedManifest());
TaskExecutor executor = checkGameState(profile, setting, version.get())
TaskExecutor executor = checkGameState(gameInstance, setting, context.launchManifest)
.thenComposeAsync(java -> {
javaVersionRef.set(Objects.requireNonNull(java));
version.set(NativePatcher.patchNative(repository, version.get(), gameVersion.orElse(null), java, setting, javaArguments));
if (setting.isNotCheckGame())
context.javaRuntime = Objects.requireNonNull(java);
context.javaExecutable = java.getBinary();
if (java.getPlatform().os() == OperatingSystem.WINDOWS
&& scriptFile == null
&& !setting.getInheritable(GameSettings::notUseJavawProperty)
&& FileUtils.getName(java.getBinary()).equalsIgnoreCase("java.exe")) {
Path javawBinary = java.getBinary().resolveSibling("javaw.exe");
if (Files.isRegularFile(javawBinary)) {
context.javaExecutable = javawBinary;
}
}
context.launchManifest = NativePatcher.patchNative(gameInstance, context.launchManifest, java, setting, javaArguments);
if (setting.getInheritable(GameSettings::notCheckGameProperty))
return null;
return Task.allOf(
dependencyManager.checkGameCompletionAsync(version.get(), integrityCheck),
dependencyManager.checkGameCompletionAsync(gameInstance, context.launchManifest, integrityCheck),
Task.composeAsync(() -> {
try {
ModpackConfiguration<?> configuration = ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(selectedVersion));
ModpackProvider provider = ModpackHelper.getProviderByType(configuration.getType());
@Nullable ModpackConfiguration<?> configuration =
gameInstance.readModpackConfiguration();
if (configuration == null) return null;
@Nullable ModpackProvider provider =
ModpackHelper.getProviderByType(configuration.getType());
if (provider == null) return null;
else return provider.createCompletionTask(dependencyManager, selectedVersion);
else return provider.createCompletionTask(
dependencyManager,
gameInstance);
} catch (IOException e) {
return null;
}
}),
Task.composeAsync(() -> {
Renderer renderer = setting.getRenderer();
if (renderer != Renderer.DEFAULT && OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.SYSTEM_VERSION);
if (lib == null)
return null;
Path file = dependencyManager.getGameRepository().getLibraryFile(version.get(), lib);
if (file.toAbsolutePath().toString().indexOf('=') >= 0) {
LOG.warning("Invalid character '=' in the libraries directory path, unable to attach software renderer loader");
return null;
}
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS
|| !(setting.getRenderer(gameInstance.getVersion()) instanceof Renderer.Driver renderer)
|| renderer.mesaDriverName() == null)
return null;
String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.name().toLowerCase(Locale.ROOT);
Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.SYSTEM_VERSION);
if (lib == null)
return null;
Path file = gameInstance.getLayout().getLibraryFile(gameInstance.getId(), lib);
if (file.toAbsolutePath().toString().indexOf('=') >= 0) {
LOG.warning("Invalid character '=' in the libraries directory path, unable to attach software renderer loader");
return null;
}
if (GameLibrariesTask.shouldDownloadLibrary(repository, version.get(), lib, integrityCheck)) {
return new LibraryDownloadTask(dependencyManager, file, lib)
.thenRunAsync(() -> javaAgents.add(agent));
} else {
javaAgents.add(agent);
return null;
}
String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.mesaDriverName();
if (GameLibrariesTask.shouldDownloadLibrary(repository, context.launchManifest, lib, integrityCheck)) {
return new LibraryDownloadTask(dependencyManager, file, lib)
.thenRunAsync(() -> javaAgents.add(agent));
} else {
javaAgents.add(agent);
return null;
}
})
);
}).withStage("launch.state.dependencies")
.thenComposeAsync(() -> gameVersion.map(s -> new GameVerificationFixTask(dependencyManager, s, version.get())).orElse(null))
.thenComposeAsync(() -> new GameVerificationFixTask(gameInstance, gameInstance.getVersion(), context.launchManifest))
.thenComposeAsync(() -> {
if (setting.getInheritable(GameSettings::allowAutoAgentProperty)
|| setting.getInheritable(GameSettings::noJVMOptionsProperty)
|| setting.getInheritable(GameSettings::noOptimizingJVMOptionsProperty)
|| Boolean.TRUE.equals(state().getShownTips().get(LWJGL_3_4_1_TIP))
|| !NativePatcher.needPatchMemoryUtil(context.launchManifest, context.javaRuntime.getParsedVersion())) {
return Task.completed(null);
} else {
CompletableFuture<Void> future = new CompletableFuture<>();
runInFX(() -> {
Controllers.confirm(i18n("launch.advice.lwjgl_3_4_1"), i18n("launch.advice.lwjgl_3_4_1.title"), MessageType.QUESTION, () -> {
state().getShownTips().put(LWJGL_3_4_1_TIP, true);
enableAutoAgentForCurrentSetting();
future.complete(null);
}, () -> {
state().getShownTips().put(LWJGL_3_4_1_TIP, true);
future.complete(null);
});
});
return Task.fromCompletableFuture(future);
}
})
.thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in"))
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
LaunchOptions launchOptions = repository.getLaunchOptions(
selectedVersion, javaVersionRef.get(), profile.getGameDir(), javaAgents, javaArguments, scriptFile != null);
LaunchOptions.Builder launchOptionsBuilder = gameInstance.getLaunchOptions(
context.javaRuntime,
context.javaExecutable,
repository.getBaseDirectory(),
javaAgents,
javaArguments,
scriptFile != null);
if (disableOfflineSkin) {
launchOptionsBuilder.setDaemon(false);
}
if (quickPlayOption != null) {
launchOptionsBuilder.setQuickPlayOption(quickPlayOption);
}
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModsDirectory(selectedVersion), 10));
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && setting.getInheritable(GameSettings::highPerformanceProperty)) {
@Nullable String javaPathGpuReg = FileUtils.getAbsolutePath(context.javaExecutable);
try {
WinReg reg = WinReg.INSTANCE;
if (reg != null) {
Object current = reg.queryValue(
WinReg.HKEY.HKEY_CURRENT_USER,
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
javaPathGpuReg
);
if (current == null) {
boolean result = reg.setValue(
WinReg.HKEY.HKEY_CURRENT_USER,
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
javaPathGpuReg,
"GpuPreference=2;"
);
if (result)
LOG.info("Successfully applied high performance GPU preference for java: " + javaPathGpuReg);
else
LOG.warning("Failed to apply high performance GPU preference for java: " + javaPathGpuReg);
} else {
LOG.info("GPU preference for %s already exists: %s".formatted(javaPathGpuReg, current));
}
}
} catch (Exception e) {
LOG.warning("Failed to apply high performance GPU preference", e);
}
}
LaunchOptions launchOptions = launchOptionsBuilder.create();
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(gameInstance.getModsDirectory(), 10));
return new HMCLGameLauncher(
repository,
version.get(),
gameInstance,
context.launchManifest,
authInfo,
launchOptions,
launcherVisibility == LauncherVisibility.CLOSE
? null // Unnecessary to start listening to game process output when close launcher immediately after game launched.
: new HMCLProcessListener(repository, version.get(), authInfo, launchOptions, launchingLatch, gameVersion.isPresent())
: new HMCLProcessListener(authInfo, launchOptions, launchingLatch, gameInstance.getVersion().compareTo(GameVersionNumber.unknown()) != 0)
);
}).thenComposeAsync(launcher -> { // launcher is prev task's result
if (scriptFile == null) {
@@ -217,25 +312,25 @@ public final class LauncherHelper {
if (launcherVisibility == LauncherVisibility.CLOSE)
Launcher.stopApplication();
else
launchingStepsPane.setCancel(new TaskCancellationAction(it -> {
launchingStepsPane.setCancel(it -> {
process.stop();
it.fireEvent(new DialogCloseEvent());
}));
});
} else {
runLater(() -> {
launchingStepsPane.fireEvent(new DialogCloseEvent());
Controllers.dialog(i18n("version.launch_script.success", FileUtils.getAbsolutePath(scriptFile)));
Controllers.dialog(i18n("instance.launch_script.success", FileUtils.getAbsolutePath(scriptFile)));
});
}
}).withFakeProgress(
i18n("message.doing"),
() -> launchingLatch.getCount() == 0, 6.95
).withStage("launch.state.waiting_launching"))
.withStagesHint(Lang.immutableListOf(
"launch.state.java",
"launch.state.dependencies",
"launch.state.logging_in",
"launch.state.waiting_launching"))
.withStagesHints(
new Task.StagesHint("launch.state.java"),
new Task.StagesHint("launch.state.dependencies", List.of("hmcl.install.assets", "hmcl.install.libraries", "hmcl.modpack.download")),
new Task.StagesHint("launch.state.logging_in"),
new Task.StagesHint("launch.state.waiting_launching"))
.executor();
launchingStepsPane.setExecutor(executor, false);
executor.addTaskListener(new TaskListener() {
@@ -263,31 +358,29 @@ public final class LauncherHelper {
} else if (ex instanceof NotDecompressingNativesException) {
message = i18n("launch.failed.decompressing_natives") + "\n" + ex.getLocalizedMessage();
} else if (ex instanceof LibraryDownloadException) {
message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().getName()) + "\n";
if (ex.getCause() instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) ex.getCause();
message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().name()) + "\n";
if (ex.getCause() instanceof ResponseCodeException rce) {
int responseCode = rce.getResponseCode();
String uri = rce.getUri();
String url = rce.getUrl();
if (responseCode == 404)
message += i18n("download.code.404", uri);
message += i18n("download.code.404", url);
else
message += i18n("download.failed", uri, responseCode);
message += i18n("download.failed", url, responseCode);
} else {
message += StringUtils.getStackTrace(ex.getCause());
}
} else if (ex instanceof DownloadException) {
URI uri = ((DownloadException) ex).getUri();
} else if (ex instanceof DownloadException de) {
String url = de.getUrl();
if (ex.getCause() instanceof SocketTimeoutException) {
message = i18n("install.failed.downloading.timeout", uri);
} else if (ex.getCause() instanceof ResponseCodeException) {
ResponseCodeException responseCodeException = (ResponseCodeException) ex.getCause();
message = i18n("install.failed.downloading.timeout", url);
} else if (ex.getCause() instanceof ResponseCodeException responseCodeException) {
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
message = i18n("download.code." + responseCodeException.getResponseCode(), uri);
message = i18n("download.code." + responseCodeException.getResponseCode(), url);
} else {
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
}
} else {
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
}
} else if (ex instanceof GameAssetIndexDownloadTask.GameAssetIndexMalformedException) {
message = i18n("assets.index.malformed");
@@ -295,25 +388,24 @@ public final class LauncherHelper {
message = i18n("account.failed.injector_download_failure");
} else if (ex instanceof CharacterDeletedException) {
message = i18n("account.failed.character_deleted");
} else if (ex instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) ex;
} else if (ex instanceof ResponseCodeException rce) {
int responseCode = rce.getResponseCode();
String uri = rce.getUri();
String url = rce.getUrl();
if (responseCode == 404)
message = i18n("download.code.404", uri);
message = i18n("download.code.404", url);
else
message = i18n("download.failed", uri, responseCode);
message = i18n("download.failed", url, responseCode);
} else if (ex instanceof CommandTooLongException) {
message = i18n("launch.failed.command_too_long");
} else if (ex instanceof ExecutionPolicyLimitException) {
Controllers.prompt(new PromptDialogPane.Builder(i18n("launch.failed.execution_policy"),
(result, resolve, reject) -> {
(result, handler) -> {
if (CommandBuilder.setExecutionPolicy()) {
LOG.info("Set the ExecutionPolicy for the scope 'CurrentUser' to 'RemoteSigned'");
resolve.run();
handler.resolve();
} else {
LOG.warning("Failed to set ExecutionPolicy");
reject.accept(i18n("launch.failed.execution_policy.failed_to_set"));
handler.reject(i18n("launch.failed.execution_policy.failed_to_set"));
}
})
.addQuestion(new PromptDialogPane.Builder.HintQuestion(i18n("launch.failed.execution_policy.hint")))
@@ -326,7 +418,7 @@ public final class LauncherHelper {
message = StringUtils.getStackTrace(ex);
}
Controllers.dialog(message,
scriptFile == null ? i18n("launch.failed") : i18n("version.launch_script.failed"),
scriptFile == null ? i18n("launch.failed") : i18n("instance.launch_script.failed"),
MessageType.ERROR);
}
}
@@ -336,24 +428,26 @@ public final class LauncherHelper {
}
});
executor.start();
launchingStepsPane.startExecutor();
}
private static Task<JavaRuntime> checkGameState(Profile profile, VersionSetting setting, Version version) {
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(version, profile.getRepository().getGameVersion(version).orElse(null));
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(analyzer.getVersion(LibraryAnalyzer.LibraryType.MINECRAFT));
/// 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();
Task<JavaRuntime> getJavaTask = Task.supplyAsync(() -> {
try {
return setting.getJava(gameVersion, version);
return setting.getJava(gameVersion, manifest);
} catch (InterruptedException e) {
throw new CancellationException();
}
});
Task<JavaRuntime> task;
if (setting.isNotCheckJVM()) {
JavaVersionType javaVersionType = setting.getInheritable(GameSettings::javaTypeProperty);
if (setting.getInheritable(GameSettings::notCheckJVMProperty)) {
task = getJavaTask.thenApplyAsync(java -> Lang.requireNonNullElse(java, JavaRuntime.getDefault()));
} else if (setting.getJavaVersionType() == JavaVersionType.AUTO || setting.getJavaVersionType() == JavaVersionType.VERSION) {
} else if (javaVersionType == JavaVersionType.AUTO || javaVersionType == JavaVersionType.VERSION) {
task = getJavaTask.thenComposeAsync(Schedulers.javafx(), java -> {
if (java != null) {
return Task.completed(java);
@@ -366,12 +460,21 @@ public final class LauncherHelper {
List<GameJavaVersion> supportedVersions = GameJavaVersion.getSupportedVersions(SYSTEM_PLATFORM);
GameJavaVersion targetJavaVersion = null;
if (setting.getJavaVersionType() == JavaVersionType.VERSION) {
if (javaVersionType == JavaVersionType.VERSION) {
try {
int targetJavaVersionMajor = Integer.parseInt(setting.getJavaVersion());
GameJavaVersion minimumJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
int targetJavaVersionMajor = Integer.parseInt(setting.getInheritable(GameSettings::customJavaVersionProperty));
GameJavaVersion minimumJavaVersion = null;
if (gameVersion.compareTo("1.12.2") == 0) {
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
if (cleanroomVersion != null) {
minimumJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion);
}
}
if (minimumJavaVersion != null && targetJavaVersionMajor < minimumJavaVersion.getMajorVersion()) {
if (minimumJavaVersion == null)
minimumJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
if (minimumJavaVersion != null && targetJavaVersionMajor < minimumJavaVersion.majorVersion()) {
Controllers.dialog(
i18n("launch.failed.java_version_too_low"),
i18n("message.error"),
@@ -384,11 +487,20 @@ public final class LauncherHelper {
targetJavaVersion = GameJavaVersion.get(targetJavaVersionMajor);
} catch (NumberFormatException ignored) {
}
} else
targetJavaVersion = version.getJavaVersion();
} else {
if (gameVersion.compareTo("1.12.2") == 0) {
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
if (cleanroomVersion != null) {
targetJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion);
}
}
if (targetJavaVersion == null)
targetJavaVersion = manifest.javaVersion();
}
if (targetJavaVersion != null && supportedVersions.contains(targetJavaVersion)) {
downloadJava(targetJavaVersion, profile)
downloadJava(targetJavaVersion, gameInstance.getRepository())
.whenCompleteAsync((downloadedJava, exception) -> {
if (exception == null) {
future.complete(downloadedJava);
@@ -414,8 +526,8 @@ public final class LauncherHelper {
if (java != null) {
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) {
if (!constraint.checkJava(gameVersion, version, java)) {
if (constraint.appliesToVersion(gameVersion, manifest, java, analyzer)) {
if (!constraint.checkJava(gameVersion, manifest, java, analyzer)) {
if (constraint.isMandatory()) {
violatedMandatoryConstraints.add(constraint);
} else {
@@ -431,7 +543,7 @@ public final class LauncherHelper {
Runnable breakAction = () -> future.completeExceptionally(new CancellationException("Launch operation was cancelled by user"));
if (java == null || !violatedMandatoryConstraints.isEmpty()) {
JavaRuntime suggestedJava = JavaManager.findSuitableJava(gameVersion, version);
JavaRuntime suggestedJava = JavaManager.findSuitableJava(gameVersion, manifest);
if (suggestedJava != null) {
FXUtils.runInFX(() -> {
Controllers.confirm(i18n("launch.advice.java.auto"), i18n("message.warning"), () -> {
@@ -450,17 +562,21 @@ public final class LauncherHelper {
return result;
} else {
GameJavaVersion gameJavaVersion;
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.CLEANROOM_JAVA_21))
gameJavaVersion = GameJavaVersion.JAVA_21;
else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON))
gameJavaVersion = version.getJavaVersion();
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.CLEANROOM)) {
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
gameJavaVersion = cleanroomVersion != null
? GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion)
: GameJavaVersion.JAVA_21;
} else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON))
gameJavaVersion = manifest.javaVersion();
else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA))
gameJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
else
gameJavaVersion = null;
if (gameJavaVersion != null) {
FXUtils.runInFX(() -> downloadJava(gameJavaVersion, profile).whenCompleteAsync((downloadedJava, throwable) -> {
FXUtils.runInFX(() -> downloadJava(gameJavaVersion, gameInstance.getRepository()).whenCompleteAsync((downloadedJava, throwable) -> {
if (throwable == null) {
setting.setJavaAutoSelected();
future.complete(downloadedJava);
@@ -473,7 +589,7 @@ public final class LauncherHelper {
}
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA_LINUX_JAVA_8)) {
if (setting.getNativesDirType() == NativesDirectoryType.VERSION_FOLDER) {
if (!setting.getInheritable(GameSettings::useCustomNativesProperty)) {
FXUtils.runInFX(() -> Controllers.dialog(i18n("launch.advice.vanilla_linux_java_8"), i18n("message.error"), MessageType.ERROR, breakAction));
return result;
} else {
@@ -510,7 +626,7 @@ public final class LauncherHelper {
}
// 32-bit JVM cannot make use of too much memory.
if (java.getBits() == Bits.BIT_32 && setting.getMaxMemory() > 1.5 * 1024) {
if (java.getBits() == Bits.BIT_32 && !setting.getInheritable(GameSettings::autoMemoryProperty) && setting.getMaxMemory() > 1.5 * 1024) {
// 1.5 * 1024 is an inaccurate number.
// Actual memory limit depends on operating system and memory.
suggestions.add(i18n("launch.advice.too_large_memory_for_32bit"));
@@ -530,7 +646,7 @@ public final class LauncherHelper {
break;
case MODDED_JAVA_16:
// Minecraft<=1.17.1+Forge[37.0.0,37.0.60) not compatible with Java 17
String forgePatchVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE).orElse(null);
@Nullable String forgePatchVersion = analyzer.getVersion(GameComponentType.FORGE);
if (forgePatchVersion != null && VersionNumber.compare(forgePatchVersion, "37.0.60") < 0)
suggestions.add(i18n("launch.advice.forge37_0_60"));
else
@@ -542,9 +658,14 @@ public final class LauncherHelper {
case MODDED_JAVA_21:
suggestions.add(i18n("launch.advice.modded_java", 21, gameVersion));
break;
case CLEANROOM_JAVA_21:
suggestions.add(i18n("launch.advice.cleanroom"));
case CLEANROOM: {
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
if (cleanroomVersion != null)
suggestions.add(i18n("launch.advice.cleanroom", GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion).majorVersion(), cleanroomVersion));
else
suggestions.add(i18n("launch.advice.cleanroom", 21, ""));
break;
}
case VANILLA_JAVA_8_51:
suggestions.add(i18n("launch.advice.java8_51_1_13"));
break;
@@ -552,8 +673,8 @@ public final class LauncherHelper {
suggestions.add(i18n("launch.advice.modlauncher8"));
break;
case VANILLA_X86:
if (setting.getNativesDirType() == NativesDirectoryType.VERSION_FOLDER
&& isCompatibleWithX86Java()) {
if (!setting.getInheritable(GameSettings::useCustomNativesProperty)
&& Platform.isSupportedTranslationX86_64()) {
suggestions.add(i18n("launch.advice.vanilla_x86.translation"));
}
break;
@@ -564,24 +685,24 @@ public final class LauncherHelper {
// Cannot allocate too much memory exceeding free space.
long totalMemorySizeMB = (long) MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize());
if (totalMemorySizeMB > 0 && totalMemorySizeMB < setting.getMaxMemory()) {
if (totalMemorySizeMB > 0 && !setting.getInheritable(GameSettings::autoMemoryProperty) && totalMemorySizeMB < setting.getMaxMemory()) {
suggestions.add(i18n("launch.advice.not_enough_space", totalMemorySizeMB));
}
VersionNumber forgeVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE)
VersionNumber forgeVersion = Optional.ofNullable(analyzer.getVersion(GameComponentType.FORGE))
.map(VersionNumber::asVersion)
.orElse(null);
// Forge 2760~2773 will crash game with LiteLoader.
boolean hasForge2760 = forgeVersion != null && (forgeVersion.compareTo("1.12.2-14.23.5.2760") >= 0) && (forgeVersion.compareTo("1.12.2-14.23.5.2773") < 0);
boolean hasLiteLoader = version.getLibraries().stream().anyMatch(it -> it.is("com.mumfrey", "liteloader"));
boolean hasLiteLoader = manifest.getLibraries().stream().anyMatch(it -> it.is("com.mumfrey", "liteloader"));
if (hasForge2760 && hasLiteLoader && gameVersion.compareTo("1.12.2") == 0) {
suggestions.add(i18n("launch.advice.forge2760_liteloader"));
}
// OptiFine 1.14.4 is not compatible with Forge 28.2.2 and later versions.
boolean hasForge28_2_2 = forgeVersion != null && (forgeVersion.compareTo("1.14.4-28.2.2") >= 0);
boolean hasOptiFine = version.getLibraries().stream().anyMatch(it -> it.is("optifine", "OptiFine"));
boolean hasOptiFine = manifest.getLibraries().stream().anyMatch(it -> it.is("optifine", "OptiFine"));
if (hasForge28_2_2 && hasOptiFine && gameVersion.compareTo("1.14.4") == 0) {
suggestions.add(i18n("launch.advice.forge28_2_2_optifine"));
}
@@ -613,14 +734,19 @@ public final class LauncherHelper {
return task.withStage("launch.state.java");
}
private static CompletableFuture<JavaRuntime> downloadJava(GameJavaVersion javaVersion, Profile profile) {
/// 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.getMajorVersion()),
i18n("launch.advice.require_newer_java_version", javaVersion.majorVersion()),
i18n("message.warning"),
MessageType.QUESTION)
.yesOrNo(() -> {
DownloadProvider downloadProvider = profile.getDependency().getDownloadProvider();
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) -> {
if (exception == null) {
@@ -629,20 +755,23 @@ public final class LauncherHelper {
Throwable resolvedException = resolveException(exception);
LOG.warning("Failed to download java", exception);
if (!(resolvedException instanceof CancellationException)) {
Controllers.dialog(DownloadProviders.localizeErrorMessage(resolvedException), i18n("install.failed"));
Controllers.dialog(I18n.localizeErrorMessage(resolvedException), i18n("install.failed"));
}
future.completeExceptionally(new CancellationException());
}
}), i18n("download.java"), new TaskCancellationAction(() -> future.completeExceptionally(new CancellationException())));
}), i18n("download.java"), it -> future.completeExceptionally(new CancellationException()), launchingStepsPane);
}, () -> future.completeExceptionally(new CancellationException())).build());
return future;
}
private static Task<AuthInfo> logIn(Account account) {
private Task<AuthInfo> logIn(Account account) {
return Task.composeAsync(() -> {
try {
return Task.completed(account.logIn());
if (disableOfflineSkin && account instanceof OfflineAccount offlineAccount)
return Task.completed(offlineAccount.logInWithoutSkin());
else
return Task.completed(account.logIn());
} catch (CredentialExpiredException e) {
LOG.info("Credential has expired", e);
@@ -681,7 +810,10 @@ public final class LauncherHelper {
case HIDE_AND_REOPEN:
runLater(() -> {
Optional.ofNullable(Controllers.getStage())
.ifPresent(Stage::show);
.ifPresent(stage -> {
stage.setIconified(false);
stage.show();
});
});
break;
case KEEP:
@@ -700,16 +832,22 @@ public final class LauncherHelper {
}
}
/**
* The managed process listener.
* Guarantee that one [JavaProcess], one [HMCLProcessListener].
* Because every time we launched a game, we generates a new [HMCLProcessListener]
*/
private void enableAutoAgentForCurrentSetting() {
GameSettings.Instance instance = setting.getInstance();
if (instance != null
&& instance.getOverrideProperties().contains(GameSettings.PROPERTY_ALLOW_AUTO_AGENT)) {
instance.allowAutoAgentProperty().setValue(true);
} else {
setting.getPreset().allowAutoAgentProperty().setValue(true);
}
}
/// The managed process listener.
/// Guarantee that one Java [Process], one [HMCLProcessListener].
/// Because every time we launched a game, we generates a new [HMCLProcessListener]
private final class HMCLProcessListener implements ProcessListener {
private final ReentrantLock lock = new ReentrantLock();
private final HMCLGameRepository repository;
private final Version version;
private final LaunchOptions launchOptions;
private ManagedProcess process;
private volatile boolean lwjgl;
@@ -721,9 +859,7 @@ public final class LauncherHelper {
private Thread submitLogThread;
private LinkedBlockingQueue<Log> logBuffer;
public HMCLProcessListener(HMCLGameRepository repository, Version version, AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
this.repository = repository;
this.version = version;
public HMCLProcessListener(AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
this.launchOptions = launchOptions;
this.launchingLatch = launchingLatch;
this.detectWindow = detectWindow;
@@ -806,8 +942,10 @@ public final class LauncherHelper {
runLater(() -> {
// If application was stopped and execution services did not finish termination,
// these codes will be executed.
if (Controllers.getStage() != null) {
Controllers.getStage().hide();
@Nullable Stage stage = Controllers.getStage();
if (stage != null) {
stage.setIconified(false);
stage.hide();
launchingLatch.countDown();
}
});
@@ -848,7 +986,7 @@ public final class LauncherHelper {
Log4jLevel level = isErrorStream && !log.startsWith("[authlib-injector]") ? Log4jLevel.ERROR : null;
if (showLogs) {
if (level == null)
level = Lang.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO);
level = Objects.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO);
logBuffer.add(new Log(log, level));
} else {
lock.lock();
@@ -880,7 +1018,7 @@ public final class LauncherHelper {
@Override
public void onExit(int exitCode, ExitType exitType) {
if (showLogs) {
logBuffer.add(new Log(String.format("[HMCL ProcessListener] Minecraft exit with code %d(0x%x), type is %s.", exitCode, exitCode, exitType), Log4jLevel.INFO));
logBuffer.add(new Log(String.format("[%s] [HMCL ProcessListener] Minecraft exit with code %d(0x%x), type is %s.", TIME_FORMATTER.format(Instant.now()), exitCode, exitCode, exitType), Log4jLevel.INFO));
submitLogThread.interrupt();
try {
submitLogThread.join();
@@ -906,8 +1044,8 @@ public final class LauncherHelper {
}
if (exitType != ExitType.NORMAL) {
repository.markVersionLaunchedAbnormally(version.getId());
runLater(() -> new GameCrashWindow(process, exitType, repository, version, launchOptions, logs).show());
gameInstance.markLaunchedAbnormally();
runLater(() -> new GameCrashWindow(process, exitType, gameInstance, launchOptions, logs).show());
}
checkExit();
@@ -0,0 +1,151 @@
// SPDX-FileCopyrightText: Copyright (C) 2022 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.addon.AddonProjectInfo;
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import java.util.*;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepository {
private static final int CONTAIN_CHINESE_WEIGHT = 10;
private static final int INITIAL_CAPACITY = 16;
protected abstract RemoteAddonRepository getBackedRemoteModRepository();
@Override
public String getApiBaseUrl() {
return getBackedRemoteModRepository().getApiBaseUrl();
}
@Override
public String getBaseUrl() {
return getBackedRemoteModRepository().getBaseUrl();
}
@Override
public SearchResult search(DownloadProvider downloadProvider, RemoteAddon.Type type, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(type);
if (registry == null || !StringUtils.containsChinese(searchFilter)) {
return getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
}
Set<String> englishSearchFiltersSet = new LinkedHashSet<>(INITIAL_CAPACITY);
int count = 0;
for (AddonProjectInfo mod : registry.search(searchFilter)) {
String englishSearchFilter = String.join(" ", StringUtils.tokenize(StringUtils.isNotBlank(mod.subName()) ? mod.subName() : mod.mainName()));
if (StringUtils.isNotBlank(englishSearchFilter)) {
englishSearchFiltersSet.add(englishSearchFilter);
}
count++;
if (count >= 3) break;
}
if (englishSearchFiltersSet.isEmpty()) {
return getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
}
RemoteAddon[] searchResultArray = new RemoteAddon[pageSize];
int totalPages, chineseIndex = 0, englishIndex = pageSize - 1;
{
SearchResult searchResult = null;
List<RemoteAddon> remoteAddons = List.of();
for (String englishSearchFilter : englishSearchFiltersSet) {
searchResult = getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, englishSearchFilter, SortType.RELEVANCY, sortOrder);
remoteAddons = searchResult.results().toList();
if (!remoteAddons.isEmpty()) {
break;
}
}
for (RemoteAddon remoteAddon : remoteAddons) {
if (chineseIndex > englishIndex) {
LOG.warning("Too many search results! Are the backed remote mod repository broken? Or are the API broken?");
continue;
}
@Nullable AddonProjectInfo chineseTranslation = registry.getInfoByCurseForgeId(remoteAddon.slug());
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.mainName()) && StringUtils.containsChinese(chineseTranslation.mainName())) {
searchResultArray[chineseIndex++] = remoteAddon;
} else {
searchResultArray[englishIndex--] = remoteAddon;
}
}
totalPages = searchResult.totalPages();
}
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
return new SearchResult(Stream.concat(Arrays.stream(searchResultArray, 0, chineseIndex).map(remoteMod -> {
@Nullable AddonProjectInfo chineseRemoteMod = registry.getInfoByCurseForgeId(remoteMod.slug());
if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.mainName()) || !StringUtils.containsChinese(chineseRemoteMod.mainName())) {
return Pair.pair(remoteMod, Integer.MAX_VALUE);
}
String chineseRemoteModName = chineseRemoteMod.mainName();
if (searchFilter.isEmpty() || chineseRemoteModName.isEmpty()) {
return Pair.pair(remoteMod, Math.max(searchFilter.length(), chineseRemoteModName.length()));
}
int l = levCalculator.calc(searchFilter, chineseRemoteModName);
for (int i = 0; i < searchFilter.length(); i++) {
if (chineseRemoteModName.indexOf(searchFilter.charAt(i)) >= 0) {
l -= CONTAIN_CHINESE_WEIGHT;
}
}
return Pair.pair(remoteMod, l);
}).sorted(Comparator.comparingInt(Pair::getValue)).map(Pair::getKey), Arrays.stream(searchResultArray, englishIndex + 1, searchResultArray.length)), totalPages);
}
@Override
public Stream<Category> getCategories(RemoteAddon.Type type) throws IOException {
return getBackedRemoteModRepository().getCategories(type);
}
@Override
public Optional<RemoteAddon.Version> getRemoteVersionByLocalFile(Path file) throws IOException {
return getBackedRemoteModRepository().getRemoteVersionByLocalFile(file);
}
@Override
public RemoteAddon getAddonById(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().getAddonById(downloadProvider, id);
}
@Override
public RemoteAddon resolveDependency(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().resolveDependency(downloadProvider, id);
}
@Override
public RemoteAddon.File getAddonFile(String projectId, String fileId) throws IOException {
return getBackedRemoteModRepository().getAddonFile(projectId, fileId);
}
@Override
public Stream<RemoteAddon.Version> getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().getRemoteVersionsById(downloadProvider, id);
}
@Override
public String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException {
return getBackedRemoteModRepository().getAddonChangelog(downloadProvider, addonId, versionId);
}
@Override
public @NotNull String getVersionPageUrl(RemoteAddon.Version version) throws IOException {
return getBackedRemoteModRepository().getVersionPageUrl(version);
}
}
@@ -1,131 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2022 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.RemoteMod;
import org.jackhuang.hmcl.mod.RemoteModRepository;
import org.jackhuang.hmcl.ui.versions.ModTranslations;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import java.io.IOException;
import java.nio.file.Path;
import java.util.*;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public abstract class LocalizedRemoteModRepository implements RemoteModRepository {
private static final int CONTAIN_CHINESE_WEIGHT = 10;
private static final int INITIAL_CAPACITY = 16;
protected abstract RemoteModRepository getBackedRemoteModRepository();
protected abstract SortType getBackedRemoteModRepositorySortOrder();
@Override
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
if (!StringUtils.containsChinese(searchFilter)) {
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
}
Set<String> englishSearchFiltersSet = new HashSet<>(INITIAL_CAPACITY);
int count = 0;
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByRepositoryType(getType()).searchMod(searchFilter)) {
for (String englishWord : StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName())) {
if (englishSearchFiltersSet.contains(englishWord)) {
continue;
}
englishSearchFiltersSet.add(englishWord);
}
count++;
if (count >= 3) break;
}
RemoteMod[] searchResultArray = new RemoteMod[pageSize];
int totalPages, chineseIndex = 0, englishIndex = pageSize - 1;
{
SearchResult searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, String.join(" ", englishSearchFiltersSet), getBackedRemoteModRepositorySortOrder(), sortOrder);
for (Iterator<RemoteMod> iterator = searchResult.getUnsortedResults().iterator(); iterator.hasNext(); ) {
if (chineseIndex > englishIndex) {
LOG.warning("Too many search results! Are the backed remote mod repository broken? Or are the API broken?");
continue;
}
RemoteMod remoteMod = iterator.next();
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteMod.getSlug());
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.getName()) && StringUtils.containsChinese(chineseTranslation.getName())) {
searchResultArray[chineseIndex++] = remoteMod;
} else {
searchResultArray[englishIndex--] = remoteMod;
}
}
totalPages = searchResult.getTotalPages();
}
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
return new SearchResult(Stream.concat(Arrays.stream(searchResultArray, 0, chineseIndex).map(remoteMod -> {
ModTranslations.Mod chineseRemoteMod = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteMod.getSlug());
if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.getName()) || !StringUtils.containsChinese(chineseRemoteMod.getName())) {
return Pair.pair(remoteMod, Integer.MAX_VALUE);
}
String chineseRemoteModName = chineseRemoteMod.getName();
if (searchFilter.isEmpty() || chineseRemoteModName.isEmpty()) {
return Pair.pair(remoteMod, Math.max(searchFilter.length(), chineseRemoteModName.length()));
}
int l = levCalculator.calc(searchFilter, chineseRemoteModName);
for (int i = 0; i < searchFilter.length(); i++) {
if (chineseRemoteModName.indexOf(searchFilter.charAt(i)) >= 0) {
l -= CONTAIN_CHINESE_WEIGHT;
}
}
return Pair.pair(remoteMod, l);
}).sorted(Comparator.comparingInt(Pair::getValue)).map(Pair::getKey), Arrays.stream(searchResultArray, englishIndex + 1, searchResultArray.length)), totalPages);
}
@Override
public Stream<Category> getCategories() throws IOException {
return getBackedRemoteModRepository().getCategories();
}
@Override
public Optional<RemoteMod.Version> getRemoteVersionByLocalFile(LocalModFile localModFile, Path file) throws IOException {
return getBackedRemoteModRepository().getRemoteVersionByLocalFile(localModFile, file);
}
@Override
public RemoteMod getModById(String id) throws IOException {
return getBackedRemoteModRepository().getModById(id);
}
@Override
public RemoteMod.File getModFile(String modId, String fileId) throws IOException {
return getBackedRemoteModRepository().getModFile(modId, fileId);
}
@Override
public Stream<RemoteMod.Version> getRemoteVersionsById(String id) throws IOException {
return getBackedRemoteModRepository().getRemoteVersionsById(id);
}
}
@@ -1,37 +1,21 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2024 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2024 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.util.Log4jLevel;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
public final class Log {
public static final int DEFAULT_LOG_LINES = 2000;
public static int getLogLines() {
Integer lines = config().getLogLines();
Integer lines = settings().logLinesProperty().get();
return lines != null && lines > 0 ? lines : DEFAULT_LOG_LINES;
}
private final String log;
private Log4jLevel level;
private boolean selected = false;
public Log(String log) {
this.log = log;
@@ -57,14 +41,6 @@ public final class Log {
return level;
}
public boolean isSelected() {
return selected;
}
public void setSelected(boolean selected) {
this.selected = selected;
}
@Override
public String toString() {
return log;
@@ -1,37 +1,23 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.Zipper;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.UncheckedIOException;
import java.lang.management.ManagementFactory;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.nio.file.PathMatcher;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
@@ -43,41 +29,47 @@ public final class LogExporter {
private LogExporter() {
}
public static CompletableFuture<Void> exportLogs(Path zipFile, DefaultGameRepository gameRepository, String versionId, String logs, String launchScript) {
Path runDirectory = gameRepository.getRunDirectory(versionId);
Path baseDirectory = gameRepository.getBaseDirectory();
List<String> versions = new ArrayList<>();
public static CompletableFuture<Void> exportLogs(
Path zipFile, DefaultGameInstance instance, LaunchOptions options, String logs, String launchScript,
PathMatcher logMatcher) {
DefaultGameRepositorySnapshot repositorySnapshot = instance.getSnapshot();
Path runDirectory = options.getGameDir();
List<GameInstanceID> instances = new ArrayList<>();
String currentVersionId = versionId;
HashSet<String> resolvedSoFar = new HashSet<>();
GameInstanceID currentInstanceId = instance.id;
HashSet<GameInstanceID> resolvedSoFar = new HashSet<>();
while (true) {
if (resolvedSoFar.contains(currentVersionId)) break;
resolvedSoFar.add(currentVersionId);
Version currentVersion = gameRepository.getVersion(currentVersionId);
versions.add(currentVersionId);
if (resolvedSoFar.contains(currentInstanceId)) break;
resolvedSoFar.add(currentInstanceId);
@Nullable DefaultGameInstance currentInstance = repositorySnapshot.get(currentInstanceId);
if (currentInstance == null)
break;
instances.add(currentInstanceId);
if (StringUtils.isNotBlank(currentVersion.getInheritsFrom())) {
currentVersionId = currentVersion.getInheritsFrom();
if (currentInstance.getManifest().inheritsFrom() != null) {
currentInstanceId = currentInstance.getManifest().inheritsFrom();
} else {
break;
}
}
return CompletableFuture.runAsync(() -> {
try (Zipper zipper = new Zipper(zipFile)) {
processLogs(runDirectory.resolve("liteconfig"), "*.log", "liteconfig", zipper);
processLogs(runDirectory.resolve("logs"), "*.log", "logs", zipper);
processLogs(runDirectory, "*.log", "runDirectory", zipper);
processLogs(runDirectory.resolve("crash-reports"), "*.txt", "crash-reports", zipper);
try (Zipper zipper = new Zipper(zipFile, true)) {
processLogs(runDirectory.resolve("liteconfig"), "*.log", "liteconfig", zipper, logMatcher);
processLogs(runDirectory.resolve("logs"), "*.log", "logs", zipper, logMatcher);
processLogs(runDirectory, "*.log", "runDirectory", zipper, logMatcher);
processLogs(runDirectory.resolve("crash-reports"), "*.txt", "crash-reports", zipper, logMatcher);
zipper.putTextFile(LOG.getLogs(), "hmcl.log");
zipper.putTextFile(logs, "minecraft.log");
zipper.putTextFile(Logger.filterForbiddenToken(launchScript), OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "launch.bat" : "launch.sh");
for (String id : versions) {
Path versionJson = baseDirectory.resolve("versions").resolve(id).resolve(id + ".json");
if (Files.exists(versionJson)) {
zipper.putFile(versionJson, id + ".json");
for (GameInstanceID id : instances) {
@Nullable DefaultGameInstance currentInstance = repositorySnapshot.get(id);
if (currentInstance != null) {
try (var writer = new OutputStreamWriter(zipper.putStream(id + ".json"), StandardCharsets.UTF_8)) {
JsonUtils.GSON.toJson(currentInstance.getManifest(), writer);
}
}
}
} catch (IOException e) {
@@ -86,14 +78,11 @@ public final class LogExporter {
});
}
private static void processLogs(Path directory, String fileExtension, String logDirectory, Zipper zipper) {
private static void processLogs(Path directory, String fileExtension, String logDirectory, Zipper zipper, PathMatcher logMatcher) {
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory, fileExtension)) {
long processStartTime = ManagementFactory.getRuntimeMXBean().getStartTime();
for (Path file : stream) {
if (Files.isRegularFile(file)) {
FileTime time = Files.readAttributes(file, BasicFileAttributes.class).lastModifiedTime();
if (time.toMillis() >= processStartTime) {
if (logMatcher == null || logMatcher.matches(file)) {
try (BufferedReader reader = IOUtils.newBufferedReaderMaybeNativeEncoding(file)) {
zipper.putLines(reader.lines().map(Logger::filterForbiddenToken), file.getFileName().toString());
} catch (IOException e) {
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import java.nio.file.Path;
@@ -1,61 +1,38 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.Unzipper;
import java.nio.charset.Charset;
import java.nio.file.FileSystem;
import java.nio.file.Path;
import java.nio.file.Paths;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class ManuallyCreatedModpackInstallTask extends Task<Path> {
private final Profile profile;
private final Path zipFile;
private final Charset charset;
private final String name;
public ManuallyCreatedModpackInstallTask(Profile profile, Path zipFile, Charset charset, String name) {
this.profile = profile;
public ManuallyCreatedModpackInstallTask(Path zipFile, String name) {
this.zipFile = zipFile;
this.charset = charset;
this.name = name;
setName(i18n("modpack.installing"));
}
@Override
public void execute() throws Exception {
Path subdirectory;
try (FileSystem fs = CompressingUtils.readonly(zipFile).setEncoding(charset).build()) {
subdirectory = ModpackHelper.findMinecraftDirectoryInManuallyCreatedModpack(zipFile.toString(), fs);
}
String subdirectory = ModpackHelper.findMinecraftDirectoryInManuallyCreatedModpack(zipFile.toString(), zipFile);
Path dest = Paths.get("externalgames").resolve(name);
setResult(dest);
new Unzipper(zipFile, dest)
.setSubDirectory(subdirectory.toString())
.setSubDirectory(subdirectory)
.setTerminateIfSubDirectoryNotExists()
.setEncoding(charset)
.unzip();
}
}
@@ -1,63 +1,52 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import com.google.gson.JsonParseException;
import kala.compress.archivers.zip.ZipArchiveReader;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.mod.*;
import org.jackhuang.hmcl.mod.curse.CurseModpackProvider;
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackManifest;
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackProvider;
import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackProvider;
import org.jackhuang.hmcl.mod.multimc.MultiMCComponents;
import org.jackhuang.hmcl.mod.multimc.MultiMCInstanceConfiguration;
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackProvider;
import org.jackhuang.hmcl.mod.server.ServerModpackManifest;
import org.jackhuang.hmcl.mod.server.ServerModpackProvider;
import org.jackhuang.hmcl.mod.server.ServerModpackRemoteInstallTask;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.setting.Profiles;
import org.jackhuang.hmcl.setting.VersionSetting;
import org.jackhuang.hmcl.modpack.*;
import org.jackhuang.hmcl.modpack.curse.CurseModpackProvider;
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackManifest;
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackProvider;
import org.jackhuang.hmcl.modpack.modrinth.ModrinthModpackProvider;
import org.jackhuang.hmcl.modpack.multimc.MultiMCComponents;
import org.jackhuang.hmcl.modpack.multimc.MultiMCInstanceConfiguration;
import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackProvider;
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
import org.jackhuang.hmcl.modpack.server.ServerModpackProvider;
import org.jackhuang.hmcl.modpack.server.ServerModpackRemoteInstallTask;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.PortablePath;
import org.jackhuang.hmcl.util.function.ExceptionalConsumer;
import org.jackhuang.hmcl.util.function.ExceptionalRunnable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.tree.ArchiveFileTree;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Stream;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Lang.toIterable;
import static org.jackhuang.hmcl.util.Pair.pair;
/// Utilities for reading, installing, and applying modpack-specific game settings.
@NotNullByDefault
public final class ModpackHelper {
private ModpackHelper() {
}
@@ -72,7 +61,7 @@ public final class ModpackHelper {
);
static {
MultiMCComponents.setImplementation(Metadata.FULL_TITLE);
MultiMCComponents.setImplementation(Metadata.FULL_NAME + " v" + Metadata.VERSION);
}
@Nullable
@@ -85,8 +74,8 @@ public final class ModpackHelper {
return "zip".equals(ext) || "mrpack".equals(ext);
}
public static Modpack readModpackManifest(Path file, Charset charset) throws UnsupportedModpackException, ManuallyCreatedModpackException {
try (ZipArchiveReader zipFile = CompressingUtils.openZipFile(file, charset)) {
public static Modpack readModpackManifest(Path file) throws UnsupportedModpackException, ManuallyCreatedModpackException {
try (ZipArchiveReader zipFile = CompressingUtils.openZipFile(file)) {
// Order for trying detecting manifest is necessary here.
// Do not change to iterating providers.
for (ModpackProvider provider : new ModpackProvider[]{
@@ -97,15 +86,15 @@ public final class ModpackHelper {
MultiMCModpackProvider.INSTANCE,
ServerModpackProvider.INSTANCE}) {
try {
return provider.readManifest(zipFile, file, charset);
return provider.readManifest(zipFile, file);
} catch (Exception ignored) {
}
}
} catch (IOException ignored) {
}
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(file, charset)) {
findMinecraftDirectoryInManuallyCreatedModpack(file.toString(), fs);
try {
findMinecraftDirectoryInManuallyCreatedModpack(file.toString(), file);
throw new ManuallyCreatedModpackException(file);
} catch (IOException e) {
// ignore it
@@ -114,28 +103,35 @@ public final class ModpackHelper {
throw new UnsupportedModpackException(file.toString());
}
public static Path findMinecraftDirectoryInManuallyCreatedModpack(String modpackName, FileSystem fs) throws IOException, UnsupportedModpackException {
Path root = fs.getPath("/");
if (isMinecraftDirectory(root)) return root;
try (Stream<Path> firstLayer = Files.list(root)) {
for (Path dir : toIterable(firstLayer)) {
if (isMinecraftDirectory(dir)) return dir;
public static String findMinecraftDirectoryInManuallyCreatedModpack(String modpackName, Path zipPath)
throws IOException, UnsupportedModpackException {
try (Stream<Path> secondLayer = Files.list(dir)) {
for (Path subdir : toIterable(secondLayer)) {
if (isMinecraftDirectory(subdir)) return subdir;
try (ArchiveFileTree<?, ?> tree = ArchiveFileTree.open(zipPath)) {
ArchiveFileTree.Dir<?> rootDir = tree.getRoot();
if (isMinecraftDirectory(rootDir)) {
return "";
}
for (ArchiveFileTree.Dir<?> firstLayer : rootDir.getSubDirs().values()) {
if (isMinecraftDirectory(firstLayer)) {
return firstLayer.getName();
}
for (ArchiveFileTree.Dir<?> secondLayer : firstLayer.getSubDirs().values()) {
if (isMinecraftDirectory(secondLayer)) {
return firstLayer.getName() + "/" + secondLayer.getName();
}
} catch (IOException ignored) {
}
}
} catch (IOException ignored) {
}
throw new UnsupportedModpackException(modpackName);
}
private static boolean isMinecraftDirectory(Path path) {
return Files.isDirectory(path.resolve("versions")) &&
(path.getFileName() == null || ".minecraft".equals(FileUtils.getName(path)));
private static boolean isMinecraftDirectory(ArchiveFileTree.Dir<?> dir) {
return dir.getSubDirs().containsKey("versions") && (dir.isRoot() || ".minecraft".equals(dir.getName()));
}
public static ModpackConfiguration<?> readModpackConfiguration(Path file) throws IOException {
@@ -146,16 +142,10 @@ public final class ModpackHelper {
}
}
public static Task<?> getInstallTask(Profile profile, ServerModpackManifest manifest, String name, Modpack modpack) {
profile.getRepository().markVersionAsModpack(name);
public static Task<?> getInstallTask(HMCLGameRepository repository, ServerModpackManifest manifest, GameInstanceID instanceId, Modpack modpack) {
ExceptionalRunnable<?> success = () -> {
HMCLGameRepository repository = profile.getRepository();
repository.refreshVersions();
VersionSetting vs = repository.specializeVersionSetting(name);
repository.undoMark(name);
if (vs != null)
vs.setGameDirType(GameDirectoryType.VERSION_FOLDER);
repository.refresh();
repository.getInstance(instanceId).enableIsolation();
};
ExceptionalConsumer<Exception, ?> failure = ex -> {
@@ -165,39 +155,59 @@ public final class ModpackHelper {
}
};
return new ServerModpackRemoteInstallTask(profile.getDependency(), manifest, name)
return new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instanceId)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.withStagesHint(Arrays.asList("hmcl.modpack", "hmcl.modpack.download"));
.withStage("hmcl.modpack")
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
}
public static boolean isExternalGameNameConflicts(String name) {
return Files.exists(Paths.get("externalgames").resolve(name));
}
public static Task<?> getInstallManuallyCreatedModpackTask(Profile profile, Path zipFile, String name, Charset charset) {
/// Extracts a manually created modpack and selects its new external game directory.
public static Task<?> getInstallManuallyCreatedModpackTask(Path zipFile, String name) {
if (isExternalGameNameConflicts(name)) {
throw new IllegalArgumentException("name existing");
}
return new ManuallyCreatedModpackInstallTask(profile, zipFile, charset, name)
return new ManuallyCreatedModpackInstallTask(zipFile, name)
.thenAcceptAsync(Schedulers.javafx(), location -> {
Profile newProfile = new Profile(name, location);
newProfile.setUseRelativePath(true);
Profiles.getProfiles().add(newProfile);
Profiles.setSelectedProfile(newProfile);
});
GameDirectory newGameDirectory = new GameDirectory(
GameDirectoryManager.newGameDirectoryId(),
LocalizedText.plain(name),
PortablePath.fromPath(location));
GameDirectoryManager.addLocalGameDirectory(newGameDirectory);
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
})
.withStage("hmcl.modpack")
.withStagesHints("hmcl.modpack");
}
public static Task<?> getInstallTask(Profile profile, Path zipFile, String name, Modpack modpack) {
profile.getRepository().markVersionAsModpack(name);
public static Task<?> getInstallTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, Modpack modpack, @Nullable String iconUrl) {
return getInstallTask(repository, zipFile, instanceId, modpack, iconUrl, null);
}
/// Creates an install task that respects optional-file selection.
///
/// @param repository the target repository
/// @param zipFile the modpack archive
/// @param instanceId the new instance id
/// @param modpack the parsed modpack
/// @param iconUrl the optional icon URL, or `null`
/// @param excludedFiles keys of optional files the user chose not to install; `null` means install all.
/// When non-null, must not contain `null` elements.
/// @return the install task
public static Task<?> getInstallTask(
HMCLGameRepository repository,
Path zipFile,
GameInstanceID instanceId,
Modpack modpack,
@Nullable String iconUrl,
@Nullable Set<String> excludedFiles) {
ExceptionalRunnable<?> success = () -> {
HMCLGameRepository repository = profile.getRepository();
repository.refreshVersions();
VersionSetting vs = repository.specializeVersionSetting(name);
repository.undoMark(name);
if (vs != null)
vs.setGameDirType(GameDirectoryType.VERSION_FOLDER);
repository.refresh();
repository.getInstance(instanceId).enableIsolation();
};
ExceptionalConsumer<Exception, ?> failure = ex -> {
@@ -208,111 +218,181 @@ public final class ModpackHelper {
};
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
return modpack.getInstallTask(profile.getDependency(), zipFile, name)
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMultiMCPostInstallTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
.withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download"));
.thenComposeAsync(createMultiMCPostInstallTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
.withStage("hmcl.modpack")
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
else if (modpack.getManifest() instanceof McbbsModpackManifest)
return modpack.getInstallTask(profile.getDependency(), zipFile, name)
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMcbbsPostInstallTask(profile, (McbbsModpackManifest) modpack.getManifest(), name))
.withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download"));
.thenComposeAsync(createMcbbsPostInstallTask(repository, (McbbsModpackManifest) modpack.getManifest(), instanceId))
.withStage("hmcl.modpack")
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
else
return modpack.getInstallTask(profile.getDependency(), zipFile, name)
.whenComplete(Schedulers.javafx(), success, failure)
.withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download"));
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.withStage("hmcl.modpack")
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
}
public static Task<Void> getUpdateTask(Profile profile, ServerModpackManifest manifest, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
/// Updates a server modpack with backup, repository refresh, and modpack stage hints.
public static Task<Void> getUpdateTask(HMCLGameRepository repository, ServerModpackManifest manifest, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
switch (configuration.getType()) {
case ServerModpackRemoteInstallTask.MODPACK_TYPE:
return new ModpackUpdateTask(profile.getRepository(), name, new ServerModpackRemoteInstallTask(profile.getDependency(), manifest, name))
.withStagesHint(Arrays.asList("hmcl.modpack", "hmcl.modpack.download"));
case ServerModpackRemoteInstallTask.MODPACK_TYPE: {
HMCLGameInstance instance = repository.getInstance(instanceId);
return new ModpackUpdateTask(
instance,
new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instance))
.thenComposeAsync(repository.refreshAsync())
.withStage("hmcl.modpack")
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
}
default:
throw new UnsupportedModpackException();
}
}
public static Task<?> getUpdateTask(Profile profile, Path zipFile, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
Modpack modpack = ModpackHelper.readModpackManifest(zipFile, charset);
ModpackProvider provider = getProviderByType(configuration.getType());
public static Task<?> getUpdateTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
return getUpdateTask(repository, zipFile, instanceId, configuration, null);
}
/// Creates an update task that respects optional-file selection.
///
/// @param repository the target repository
/// @param zipFile the modpack archive
/// @param instanceId the instance to update
/// @param configuration the existing modpack configuration
/// @param excludedFiles keys of optional files the user chose not to install; `null` means install all.
/// When non-null, must not contain `null` elements.
/// @return the update task
public static Task<?> getUpdateTask(
HMCLGameRepository repository,
Path zipFile,
GameInstanceID instanceId,
ModpackConfiguration<?> configuration,
@Nullable Set<String> excludedFiles)
throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
Modpack modpack = ModpackHelper.readModpackManifest(zipFile);
@Nullable ModpackProvider provider = getProviderByType(configuration.getType());
if (provider == null) {
throw new UnsupportedModpackException();
}
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack)
.thenComposeAsync(() -> createMultiMCPostUpdateTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name));
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles)
.thenComposeAsync(() -> createMultiMCPostUpdateTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
.thenComposeAsync(repository.refreshAsync())
.withStage("hmcl.modpack")
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
else
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack);
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles)
.thenComposeAsync(repository.refreshAsync())
.withStage("hmcl.modpack")
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
}
public static void toVersionSetting(MultiMCInstanceConfiguration c, VersionSetting vs) {
vs.setUsesGlobal(false);
vs.setGameDirType(GameDirectoryType.VERSION_FOLDER);
public static void toGameSettings(MultiMCInstanceConfiguration c, GameSettings.Instance setting) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
if (c.isOverrideJavaLocation()) {
vs.setJavaDir(Lang.nonNull(c.getJavaPath(), ""));
setting.getOverrideProperties().add(GameSettings.PROPERTY_JAVA_TYPE);
setting.getOverrideProperties().add(GameSettings.PROPERTY_CUSTOM_JAVA_PATH);
setting.javaTypeProperty().setValue(JavaVersionType.CUSTOM);
setting.customJavaPathProperty().setValue(Objects.requireNonNullElse(c.getJavaPath(), ""));
}
if (c.isOverrideMemory()) {
vs.setPermSize(Optional.ofNullable(c.getPermGen()).map(Object::toString).orElse(""));
setting.getOverrideProperties().addAll(List.of(
GameSettings.PROPERTY_AUTO_MEMORY,
GameSettings.PROPERTY_PERM_SIZE,
GameSettings.PROPERTY_MAX_MEMORY,
GameSettings.PROPERTY_MIN_MEMORY
));
setting.permSizeProperty().setValue(Optional.ofNullable(c.getPermGen()).map(Object::toString).orElse(""));
if (c.getMaxMemory() != null)
vs.setMaxMemory(c.getMaxMemory());
vs.setMinMemory(c.getMinMemory());
setting.maxMemoryProperty().setValue(c.getMaxMemory());
setting.minMemoryProperty().setValue(c.getMinMemory());
}
if (c.isOverrideCommands()) {
vs.setWrapper(Lang.nonNull(c.getWrapperCommand(), ""));
vs.setPreLaunchCommand(Lang.nonNull(c.getPreLaunchCommand(), ""));
setting.getOverrideProperties().addAll(List.of(
GameSettings.PROPERTY_COMMAND_WRAPPER,
GameSettings.PROPERTY_PRE_LAUNCH_COMMAND
));
setting.commandWrapperProperty().setValue(Objects.requireNonNullElse(c.getWrapperCommand(), ""));
setting.preLaunchCommandProperty().setValue(Objects.requireNonNullElse(c.getPreLaunchCommand(), ""));
}
if (c.isOverrideJavaArgs()) {
vs.setJavaArgs(Lang.nonNull(c.getJvmArgs(), ""));
setting.getOverrideProperties().add(GameSettings.PROPERTY_JVM_OPTIONS);
setting.jvmOptionsProperty().setValue(Objects.requireNonNullElse(c.getJvmArgs(), ""));
}
if (c.isOverrideConsole()) {
vs.setShowLogs(c.isShowConsole());
setting.getOverrideProperties().add(GameSettings.PROPERTY_SHOW_LOGS);
setting.showLogsProperty().setValue(c.isShowConsole());
}
if (c.isOverrideWindow()) {
vs.setFullscreen(c.isFullscreen());
setting.getOverrideProperties().addAll(List.of(
GameSettings.PROPERTY_WINDOW_TYPE,
GameSettings.PROPERTY_WIDTH,
GameSettings.PROPERTY_HEIGHT
));
setting.windowTypeProperty().setValue(c.isFullscreen() ? GameWindowType.FULLSCREEN : GameWindowType.WINDOWED);
if (c.getWidth() != null)
vs.setWidth(c.getWidth());
setting.widthProperty().setValue(c.getWidth().doubleValue());
if (c.getHeight() != null)
vs.setHeight(c.getHeight());
setting.heightProperty().setValue(c.getHeight().doubleValue());
}
}
private static void applyCommandAndJvmSettings(MultiMCInstanceConfiguration c, VersionSetting vs) {
private static void applyCommandAndJvmSettings(MultiMCInstanceConfiguration c, GameSettings.Instance setting) {
if (c.isOverrideCommands()) {
vs.setWrapper(Lang.nonNull(c.getWrapperCommand(), ""));
vs.setPreLaunchCommand(Lang.nonNull(c.getPreLaunchCommand(), ""));
setting.getOverrideProperties().addAll(List.of(
GameSettings.PROPERTY_COMMAND_WRAPPER,
GameSettings.PROPERTY_PRE_LAUNCH_COMMAND
));
setting.commandWrapperProperty().setValue(Lang.nonNull(c.getWrapperCommand(), ""));
setting.preLaunchCommandProperty().setValue(Lang.nonNull(c.getPreLaunchCommand(), ""));
}
if (c.isOverrideJavaArgs()) {
vs.setJavaArgs(Lang.nonNull(c.getJvmArgs(), ""));
setting.getOverrideProperties().add(GameSettings.PROPERTY_JVM_OPTIONS);
setting.jvmOptionsProperty().setValue(Lang.nonNull(c.getJvmArgs(), ""));
}
}
private static Task<Void> createMultiMCPostUpdateTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) {
private static Task<Void> createMultiMCPostUpdateTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, GameInstanceID instanceId) {
return Task.runAsync(Schedulers.javafx(), () -> {
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
ModpackHelper.applyCommandAndJvmSettings(manifest, vs);
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
ModpackHelper.applyCommandAndJvmSettings(manifest, setting);
});
}
private static Task<Void> createMultiMCPostInstallTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) {
private static Task<Void> createMultiMCPostInstallTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, GameInstanceID instanceId) {
return Task.runAsync(Schedulers.javafx(), () -> {
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
ModpackHelper.toVersionSetting(manifest, vs);
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
ModpackHelper.toGameSettings(manifest, setting);
});
}
private static Task<Void> createMcbbsPostInstallTask(Profile profile, McbbsModpackManifest manifest, String version) {
private static Task<Void> createMcbbsPostInstallTask(HMCLGameRepository repository, McbbsModpackManifest manifest, GameInstanceID instanceId) {
return Task.runAsync(Schedulers.javafx(), () -> {
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
if (manifest.getLaunchInfo().getMinMemory() > vs.getMaxMemory())
vs.setMaxMemory(manifest.getLaunchInfo().getMinMemory());
GameSettings.Effective effective = repository.getEffectiveGameSettings(instanceId);
if (manifest.getLaunchInfo().getMinMemory() > effective.getMaxMemory()) {
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
setting.getOverrideProperties().addAll(List.of(
GameSettings.PROPERTY_AUTO_MEMORY,
GameSettings.PROPERTY_MIN_MEMORY,
GameSettings.PROPERTY_MAX_MEMORY,
GameSettings.PROPERTY_PERM_SIZE
));
setting.autoMemoryProperty().setValue(effective.getInheritable(GameSettings::autoMemoryProperty));
setting.minMemoryProperty().setValue(effective.getInheritable(GameSettings::minMemoryProperty));
setting.maxMemoryProperty().setValue(manifest.getLaunchInfo().getMinMemory());
setting.permSizeProperty().setValue(effective.getInheritable(GameSettings::permSizeProperty));
}
});
}
}
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import fi.iki.elonen.NanoHTTPD;
@@ -22,13 +7,15 @@ import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.OAuth;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
@@ -37,21 +24,51 @@ import java.util.concurrent.ExecutionException;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Lang.thread;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
private final int port;
private final CompletableFuture<String> future = new CompletableFuture<>();
private final String codeVerifier;
private final String state;
public static String lastlyOpenedURL;
private String idToken;
private OAuthServer(int port) {
super(port);
super("localhost", port);
this.port = port;
var encoder = Base64.getUrlEncoder().withoutPadding();
var random = new SecureRandom();
{
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
// https://datatracker.ietf.org/doc/html/rfc6749#section-10.12
byte[] bytes = new byte[32];
random.nextBytes(bytes);
this.state = encoder.encodeToString(bytes);
}
{
// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
byte[] bytes = new byte[64];
random.nextBytes(bytes);
this.codeVerifier = encoder.encodeToString(bytes);
}
}
@Override
public String getCodeVerifier() {
return codeVerifier;
}
@Override
public String getState() {
return state;
}
@Override
@@ -93,19 +110,32 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
String parameters = session.getQueryParameterString();
Map<String, String> query = mapOf(NetworkUtils.parseQuery(parameters));
if (query.containsKey("code")) {
idToken = query.get("id_token");
future.complete(query.get("code"));
String code = query.get("code");
if (code != null) {
if (this.state.equals(query.get("state"))) {
idToken = query.get("id_token");
future.complete(code);
} else if (query.containsKey("state")) {
LOG.warning("Failed to authenticate: invalid state in parameters");
future.completeExceptionally(new AuthenticationException("Failed to authenticate: invalid state"));
} else {
LOG.warning("Failed to authenticate: missing state in parameters");
future.completeExceptionally(new AuthenticationException("Failed to authenticate: missing state"));
}
} else {
LOG.warning("Error: " + parameters);
future.completeExceptionally(new AuthenticationException("failed to authenticate"));
LOG.warning("Failed to authenticate: missing authorization code in parameters");
future.completeExceptionally(new AuthenticationException("Failed to authenticate: missing authorization code"));
}
String html;
try {
html = IOUtils.readFullyAsString(OAuthServer.class.getResourceAsStream("/assets/microsoft_auth.html"))
.replace("%style%", Themes.getColorScheme().toStyleSheet().replace("-monet", "--monet"))
.replace("%lang%", Locale.getDefault().toLanguageTag())
.replace("%close-page%", i18n("account.methods.microsoft.close_page"));
.replace("%success%", i18n("message.success"))
.replace("%ok%", i18n("button.ok"))
.replace("%close_page%", i18n("account.methods.microsoft.close_page"));
} catch (IOException e) {
LOG.error("Failed to load html", e);
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_HTML, "");
@@ -121,9 +151,18 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
return newFixedLengthResponse(Response.Status.OK, "text/html; charset=UTF-8", html);
}
@Override
public void close() {
if (!future.isDone())
future.completeExceptionally(new AuthenticationException("OAuth server is closing"));
stop();
}
public static class Factory implements OAuth.Callback {
public final EventManager<GrantDeviceCodeEvent> onGrantDeviceCode = new EventManager<>();
public final EventManager<OpenBrowserEvent> onOpenBrowser = new EventManager<>();
public final EventManager<LoginCompletedDeviceCodeEvent> onLoginCompletedDeviceCode = new EventManager<>();
public final EventManager<OpenBrowserEvent> onOpenBrowserAuthorizationCode = new EventManager<>();
public final EventManager<OpenBrowserEvent> onOpenBrowserDevice = new EventManager<>();
@Override
public OAuth.Session startServer() throws IOException, AuthenticationException {
@@ -150,11 +189,18 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
}
@Override
public void openBrowser(String url) throws IOException {
lastlyOpenedURL = url;
FXUtils.openLink(url);
public void loginCompletedDeviceCode() {
onLoginCompletedDeviceCode.fireEvent(new LoginCompletedDeviceCodeEvent(this));
}
onOpenBrowser.fireEvent(new OpenBrowserEvent(this, url));
@Override
public void openBrowser(OAuth.GrantFlow grantFlow, String url) throws IOException {
lastlyOpenedURL = url;
switch (grantFlow) {
case AUTHORIZATION_CODE -> onOpenBrowserAuthorizationCode.fireEvent(new OpenBrowserEvent(this, url));
case DEVICE -> onOpenBrowserDevice.fireEvent(new OpenBrowserEvent(this, url));
}
}
@Override
@@ -162,17 +208,6 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
return System.getProperty("hmcl.microsoft.auth.id",
JarUtils.getAttribute("hmcl.microsoft.auth.id", ""));
}
@Override
public String getClientSecret() {
return System.getProperty("hmcl.microsoft.auth.secret",
JarUtils.getAttribute("hmcl.microsoft.auth.secret", ""));
}
@Override
public boolean isPublicClient() {
return true; // We have turned on the device auth flow.
}
}
public static class GrantDeviceCodeEvent extends Event {
@@ -194,6 +229,12 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
}
}
public static class LoginCompletedDeviceCodeEvent extends Event {
public LoginCompletedDeviceCodeEvent(Object source) {
super(source);
}
}
public static class OpenBrowserEvent extends Event {
private final String url;
@@ -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;
}
}
}
Loaded 100 of 1301 files, more files were not shown because too many files have changed in this diff. Show more