Compare commits

...
187 Commits
Author SHA1 Message Date
f921370c21 [release/3.10] 修复系统代理 JVM 参数格式 (#5591)
https://github.com/HMCL-dev/HMCL/pull/5265

Co-authored-by: Xirren <michaelqiancn@gmail.com>
Co-authored-by: xirren <xirren@outlook.com>
2026-02-20 23:13:37 +08:00
Glavo 61774c2efd [release/3.10] 修复部分请求头中 Accept-Language 字段包含 Java 版本的问题 (#5590)
https://github.com/HMCL-dev/HMCL/pull/5508
2026-02-20 23:13:19 +08:00
GlavoandCiiLu 4c209d1eb6 [release/3.10] fix: 安装整合包时任务弹窗可能空白 (#5589)
https://github.com/HMCL-dev/HMCL/pull/5550

Co-authored-by: CiiLu <109708109+CiiLu@users.noreply.github.com>
2026-02-20 23:12:42 +08:00
Glavo a1c9f1be7d [release/3.10] 修复 MultipleSourceVersionList 不会使用备用下载源的问题 (#5588)
https://github.com/HMCL-dev/HMCL/pull/5585
2026-02-20 23:11:24 +08:00
Glavo 3d1bd96d56 [release/3.10] 使用自定义 EasyTier 服务器节点 (#5511)
https://github.com/HMCL-dev/HMCL/pull/5504
2026-02-10 22:12:07 +08:00
Glavo d48a3c2109 [release/3.10] 修复 Unzipper 解压空 zip 文件时抛出异常的问题 (#5353)
https://github.com/HMCL-dev/HMCL/pull/5267
2026-01-28 21:47:26 +08:00
Glavoand辞庐 a0f9ba1976 [release/3.10] 点击实例列表条目的中央区域无法触发 Rippler 效果 (#5354)
https://github.com/HMCL-dev/HMCL/pull/5310

Co-authored-by: 辞庐 <109708109+CiiLu@users.noreply.github.com>
2026-01-28 21:46:56 +08:00
Glavoand辞庐 b8df46ef87 [release/3.10] 修复返回页面时可能卡进空白页面的问题 (#5355)
https://github.com/HMCL-dev/HMCL/pull/5305

Co-authored-by: 辞庐 <109708109+CiiLu@users.noreply.github.com>
2026-01-28 21:46:33 +08:00
Glavoand神奇的赵二 0c04a013c8 [release/3.10] 修复自动安装界面卡片上按钮无法点击的问题 (#5351)
https://github.com/HMCL-dev/HMCL/pull/5251

Co-authored-by: 神奇的赵二 (OrzMiku) <miku@ecy.pink>
2026-01-28 21:39:34 +08:00
Glavoand神奇的赵二 3cf0acc505 [release/3.10] 优化实例列表,限制宽度防止出现水平滚动条 (#5350)
https://github.com/HMCL-dev/HMCL/pull/5243

Co-authored-by: 神奇的赵二 (OrzMiku) <miku@ecy.pink>
2026-01-28 21:39:15 +08:00
Glavo 3be5ea409b [release/3.10] 修复使用 GIF 背景时调整背景不透明度导致启动器崩溃的问题 (#5349)
https://github.com/HMCL-dev/HMCL/pull/5262
2026-01-28 21:39:00 +08:00
Glavo 42f03411de [release/3.10] 修复 GameVersionNumber.getDefaultGameVersions() 包含未混淆版本的问题 (#5348)
https://github.com/HMCL-dev/HMCL/pull/5261
2026-01-28 21:38:35 +08:00
Glavoand辞庐 941e7cd858 [release/3.10] 修复 Cleanroom 本地安装完成后会删除安装器的问题 (#5347)
https://github.com/HMCL-dev/HMCL/pull/5257

Co-authored-by: 辞庐 <109708109+CiiLu@users.noreply.github.com>
2026-01-28 21:38:16 +08:00
Glavo 61c1b041cd [release/3.10] RemoteImageLoader 在图标尚未加载时应当将图像更新为占位符 (#5346)
https://github.com/HMCL-dev/HMCL/pull/5239
2026-01-28 21:37:27 +08:00
Glavo 7a48be5364 [release/3.10] 将世界管理界面“生成启动脚本”按钮折叠到二级菜单 (#5345)
https://github.com/HMCL-dev/HMCL/pull/5198
2026-01-28 21:37:06 +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
310 changed files with 16849 additions and 5531 deletions

No files matched your search

+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: Discord 服务器
url: https://discord.gg/jVvC7HfM6U
about: Hello Minecraft! Launcher 的官方 Discord 服务器。
-79
View File
@@ -1,79 +0,0 @@
name: Check Update
on:
workflow_dispatch:
# schedule:
# - cron: '30 * * * *'
permissions:
contents: write
jobs:
check-update:
if: ${{ github.repository_owner == 'HMCL-dev' }}
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
- channel: dev
task: checkUpdateDev
- channel: stable
task: checkUpdateStable
runs-on: ubuntu-latest
name: check-update-${{ matrix.channel }}
steps:
- uses: actions/checkout@v5
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '25'
- name: Fetch tags
run: git fetch --all --tags
- name: Fetch last version
run: ./gradlew ${{ matrix.task }} --no-daemon --info --stacktrace
- name: Check for existing tags
run: if [ -z "$(git tag -l "$HMCL_TAG_NAME")" ]; then echo "continue=true" >> $GITHUB_ENV; fi
- name: Download artifacts
if: ${{ env.continue == 'true' }}
run: |
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256"
env:
GH_DOWNLOAD_BASE_URL: https://github.com/HMCL-dev/HMCL/releases/download
- name: Generate release note
if: ${{ env.continue == 'true' }}
run: |
if [ "${{ matrix.channel }}" = "stable" ]; then
echo "**This version is a stable version.**" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
fi
echo "The full changelogs can be found on the website: https://docs.hmcl.net/changelog/${{ matrix.channel }}.html" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
echo "*Notice: changelogs are written in Chinese.*" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
echo "| File Name | SHA-256 Checksum |" >> RELEASE_NOTE
echo "| --- | --- |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE
env:
GH_DOWNLOAD_BASE_URL: https://github.com/HMCL-dev/HMCL/releases/download
- name: Create release
if: ${{ env.continue == 'true' }}
uses: softprops/action-gh-release@v2
with:
body_path: RELEASE_NOTE
files: |
HMCL-${{ env.HMCL_VERSION }}.exe
HMCL-${{ env.HMCL_VERSION }}.jar
HMCL-${{ env.HMCL_VERSION }}.sh
target_commitish: ${{ env.HMCL_COMMIT_SHA }}
name: ${{ env.HMCL_TAG_NAME }}
tag_name: ${{ env.HMCL_TAG_NAME }}
prerelease: true
-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
+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/*"
+125
View File
@@ -0,0 +1,125 @@
name: Create Release
on:
workflow_dispatch:
# schedule:
# - cron: '30 * * * *'
permissions:
contents: write
jobs:
check-update:
if: ${{ github.repository_owner == 'HMCL-dev' }}
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
- channel: dev
task: checkUpdateDev
- channel: stable
task: checkUpdateStable
runs-on: ubuntu-latest
name: check-update-${{ matrix.channel }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '25'
- name: Fetch last version
run: ./gradlew ${{ matrix.task }} --no-daemon --info --stacktrace
- name: Check for existing tags
run: if [ -z "$(git tag -l "$HMCL_TAG_NAME")" ]; then echo "continue=true" >> $GITHUB_ENV; fi
- name: Download artifacts
if: ${{ env.continue == 'true' }}
run: |
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256"
- name: Verify artifacts
if: ${{ env.continue == 'true' }}
run: |
export JAR_SHA256=$(cat HMCL-$HMCL_VERSION.jar.sha256 | tr -d '\n')
export EXE_SHA256=$(cat HMCL-$HMCL_VERSION.exe.sha256 | tr -d '\n')
export SH_SHA256=$(cat HMCL-$HMCL_VERSION.sh.sha256 | tr -d '\n')
echo "$JAR_SHA256 HMCL-$HMCL_VERSION.jar" | sha256sum -c
echo "$EXE_SHA256 HMCL-$HMCL_VERSION.exe" | sha256sum -c
echo "$SH_SHA256 HMCL-$HMCL_VERSION.sh" | sha256sum -c
- name: Generate release note
if: ${{ env.continue == 'true' }}
run: |
# GitHub Release Note
echo "&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
# CNB Release Note
echo "[更新日志](https://docs.hmcl.net/changelog/${{ matrix.channel }}.html#HMCL-$HMCL_VERSION)" >> CNB_RELEASE_NOTE
echo "" >> CNB_RELEASE_NOTE
echo "| 文件 | SHA-256 校验码 |" >> CNB_RELEASE_NOTE
echo "| :--- | --- |" >> CNB_RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.exe]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> CNB_RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.jar]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> CNB_RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.sh]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> CNB_RELEASE_NOTE
env:
GH_DOWNLOAD_BASE_URL: https://github.com/${{ github.repository }}/releases/download
CNB_DOWNLOAD_BASE_URL: https://cnb.cool/HMCL-dev/HMCL/-/releases/download
- name: Create GitHub release
if: ${{ env.continue == 'true' }}
run: |
gh release create "${{ env.HMCL_TAG_NAME }}" \
"HMCL-${{ env.HMCL_VERSION }}.exe" \
"HMCL-${{ env.HMCL_VERSION }}.jar" \
"HMCL-${{ env.HMCL_VERSION }}.sh" \
--target "${{ env.HMCL_COMMIT_SHA }}" \
--title "${{ env.HMCL_TAG_NAME }}" \
--notes-file RELEASE_NOTE \
--prerelease
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install git-cnb
if: ${{ env.continue == 'true' }}
run: go install "cnb.cool/looc/git-cnb@$GIT_CNB_VERSION"
env:
GIT_CNB_VERSION: '1.1.2'
- name: Create CNB release
if: ${{ env.continue == 'true' }}
run: |
echo "Uploading tags to CNB"
git fetch --tags
git push "https://cnb:${{ secrets.CNB_SYNC_TOKEN }}@cnb.cool/$CNB_REPO.git" "$HMCL_TAG_NAME"
echo "Creating CNB release"
~/go/bin/git-cnb release create \
--repo "$CNB_REPO" \
--tag "$HMCL_TAG_NAME" \
--name "HMCL $HMCL_VERSION" \
--body "$(cat CNB_RELEASE_NOTE)" \
--prerelease true
echo "Uploading HMCL-$HMCL_VERSION.jar"
~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.jar"
echo "Uploading HMCL-$HMCL_VERSION.exe"
~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.exe"
echo "Uploading HMCL-$HMCL_VERSION.sh"
~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.sh"
env:
CNB_TOKEN: ${{ secrets.CNB_SYNC_TOKEN }}
CNB_REPO: HMCL-dev/HMCL
+38 -5
View File
@@ -1,3 +1,4 @@
import org.jackhuang.hmcl.gradle.TerracottaConfigUpgradeTask
import org.jackhuang.hmcl.gradle.ci.GitHubActionUtils
import org.jackhuang.hmcl.gradle.ci.JenkinsUtils
import org.jackhuang.hmcl.gradle.l10n.CheckTranslations
@@ -58,6 +59,7 @@ dependencies {
implementation("libs:JFoenix")
implementation(libs.twelvemonkeys.imageio.webp)
implementation(libs.java.info)
implementation(libs.monet.fx)
if (launcherExe.isBlank()) {
implementation(libs.hmclauncher)
@@ -117,10 +119,6 @@ tasks.checkstyleMain {
exclude("**/org/jackhuang/hmcl/ui/image/apng/**")
}
tasks.compileJava {
options.compilerArgs.add("--add-exports=java.base/jdk.internal.loader=ALL-UNNAMED")
}
val addOpens = listOf(
"java.base/java.lang",
"java.base/java.lang.reflect",
@@ -128,15 +126,24 @@ val addOpens = listOf(
"javafx.base/com.sun.javafx.binding",
"javafx.base/com.sun.javafx.event",
"javafx.base/com.sun.javafx.runtime",
"javafx.base/javafx.beans.property",
"javafx.graphics/javafx.css",
"javafx.graphics/javafx.stage",
"javafx.graphics/com.sun.glass.ui",
"javafx.graphics/com.sun.javafx.stage",
"javafx.graphics/com.sun.javafx.util",
"javafx.graphics/com.sun.prism",
"javafx.controls/com.sun.javafx.scene.control",
"javafx.controls/com.sun.javafx.scene.control.behavior",
"javafx.graphics/com.sun.javafx.tk.quantum",
"javafx.controls/javafx.scene.control.skin",
"jdk.attach/sun.tools.attach",
)
tasks.compileJava {
options.compilerArgs.addAll(addOpens.map { "--add-exports=$it=ALL-UNNAMED" })
}
val hmclProperties = buildList {
add("hmcl.version" to project.version.toString())
add("hmcl.add-opens" to addOpens.joinToString(" "))
@@ -202,7 +209,8 @@ tasks.shadowJar {
"Main-Class" to "org.jackhuang.hmcl.Main",
"Multi-Release" to "true",
"Add-Opens" to addOpens.joinToString(" "),
"Enable-Native-Access" to "ALL-UNNAMED"
"Enable-Native-Access" to "ALL-UNNAMED",
"Enable-Final-Field-Mutation" to "ALL-UNNAMED",
)
if (launcherExe.isNotBlank()) {
@@ -233,6 +241,11 @@ tasks.processResources {
from(upsideDownTranslate.map { it.outputFile })
from(createLocaleNamesResourceBundle.map { it.outputDirectory })
}
inputs.property("terracotta_version", libs.versions.terracotta)
doLast {
upgradeTerracottaConfig.get().checkValid()
}
}
val makeExecutables by tasks.registering {
@@ -355,6 +368,26 @@ tasks.register<JavaExec>("run") {
}
}
// terracotta
val upgradeTerracottaConfig = tasks.register<TerracottaConfigUpgradeTask>("upgradeTerracottaConfig") {
val destination = layout.projectDirectory.file("src/main/resources/assets/terracotta.json")
val source = layout.projectDirectory.file("terracotta-template.json");
classifiers.set(listOf(
"windows-x86_64", "windows-arm64",
"macos-x86_64", "macos-arm64",
"linux-x86_64", "linux-arm64", "linux-loongarch64", "linux-riscv64",
"freebsd-x86_64"
))
version.set(libs.versions.terracotta)
downloadURL.set($$"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz")
templateFile.set(source)
outputFile.set(destination)
}
// Check Translations
tasks.register<CheckTranslations>("checkTranslations") {
@@ -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,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,171 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import com.jfoenix.skins.JFXPopupSkin;
import javafx.application.Platform;
import javafx.beans.DefaultProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.control.PopupControl;
import javafx.scene.control.Skin;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
import javafx.stage.Window;
/// JFXPopup is the material design implementation of a popup.
///
/// @author Shadi Shaheen
/// @version 2.0
/// @since 2017-03-01
@DefaultProperty(value = "popupContent")
public class JFXPopup extends PopupControl {
public enum PopupHPosition {
RIGHT, LEFT
}
public enum PopupVPosition {
TOP, BOTTOM
}
/// Creates empty popup.
public JFXPopup() {
this(null);
}
/// creates popup with a specified container and content
///
/// @param content the node that will be shown in the popup
public JFXPopup(Region content) {
setPopupContent(content);
initialize();
}
private void initialize() {
this.setAutoFix(false);
this.setAutoHide(true);
this.setHideOnEscape(true);
this.setConsumeAutoHidingEvents(false);
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
}
@Override
protected Skin<?> createDefaultSkin() {
return new JFXPopupSkin(this);
}
/***************************************************************************
* *
* Setters / Getters *
* *
**************************************************************************/
private final ObjectProperty<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) {
if (!isShowing()) {
if (node.getScene() == null || node.getScene().getWindow() == null) {
throw new IllegalStateException("Can not show popup. The node must be attached to a scene/window.");
}
Window parent = node.getScene().getWindow();
final Point2D origin = node.localToScene(0, 0);
final double anchorX = parent.getX() + origin.getX()
+ node.getScene().getX() + (hAlign == PopupHPosition.RIGHT ? ((Region) node).getWidth() : 0);
final double anchorY = parent.getY() + origin.getY()
+ node.getScene()
.getY() + (vAlign == PopupVPosition.BOTTOM ? ((Region) node).getHeight() : 0);
this.show(parent, anchorX, anchorY);
((JFXPopupSkin) getSkin()).reset(vAlign, hAlign, initOffsetX, initOffsetY);
Platform.runLater(() -> ((JFXPopupSkin) getSkin()).animate());
}
}
public void show(Window window, double x, double y, PopupVPosition vAlign, PopupHPosition hAlign, double initOffsetX, double initOffsetY) {
if (!isShowing()) {
if (window == null) {
throw new IllegalStateException("Can not show popup. The node must be attached to a scene/window.");
}
Window parent = window;
final double anchorX = parent.getX() + x + initOffsetX;
final double anchorY = parent.getY() + y + initOffsetY;
this.show(parent, anchorX, anchorY);
((JFXPopupSkin) getSkin()).reset(vAlign, hAlign, initOffsetX, initOffsetY);
Platform.runLater(() -> ((JFXPopupSkin) getSkin()).animate());
}
}
@Override
public void hide() {
super.hide();
((JFXPopupSkin) getSkin()).init();
}
/***************************************************************************
* *
* Stylesheet Handling *
* *
**************************************************************************/
/// Initialize the style class to 'jfx-popup'.
///
/// This is the selector class from which CSS can be used to style
/// this control.
private static final String DEFAULT_STYLE_CLASS = "jfx-popup";
}
@@ -0,0 +1,814 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import com.jfoenix.converters.RipplerMaskTypeConverter;
import com.jfoenix.utils.JFXNodeUtils;
import javafx.animation.*;
import javafx.beans.DefaultProperty;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.css.*;
import javafx.css.converter.BooleanConverter;
import javafx.css.converter.PaintConverter;
import javafx.css.converter.SizeConverter;
import javafx.geometry.Bounds;
import javafx.scene.CacheHint;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Shape;
import javafx.util.Duration;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* JFXRippler is the material design implementation of a ripple effect.
* the ripple effect can be applied to any node in the scene. JFXRippler is
* a {@link StackPane} container that holds a specified node (control node) and a ripple generator.
* <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 width2 = control.getLayoutBounds().getWidth() * control.getLayoutBounds().getWidth();
double height2 = control.getLayoutBounds().getHeight() * control.getLayoutBounds().getHeight();
return Math.min(Math.sqrt(width2 + height2), RIPPLE_MAX_RADIUS) * 1.1 + 5;
}
protected void setOverLayBounds(Rectangle overlay) {
overlay.setWidth(control.getLayoutBounds().getWidth());
overlay.setHeight(control.getLayoutBounds().getHeight());
}
/**
* init mouse listeners on the control
*/
protected void initControlListeners() {
// if the control got resized the overlay rect must be rest
control.layoutBoundsProperty().addListener(observable -> resetRippler());
if (getChildren().contains(control)) {
control.boundsInParentProperty().addListener(observable -> resetRippler());
}
control.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
if (event.getButton() == MouseButton.PRIMARY)
createRipple(event.getX(), event.getY());
});
// create fade out transition for the ripple
control.addEventHandler(MouseEvent.MOUSE_RELEASED, event -> {
if (event.getButton() == MouseButton.PRIMARY)
releaseRipple();
});
}
/**
* creates Ripple effect
*/
protected void createRipple(double x, double y) {
if (!isRipplerDisabled()) {
rippler.setGeneratorCenterX(x);
rippler.setGeneratorCenterY(y);
rippler.createRipple();
}
}
protected void releaseRipple() {
rippler.releaseRipple();
}
/**
* creates Ripple effect in the center of the control
*
* @return a runnable to release the ripple when needed
*/
public Runnable createManualRipple() {
if (!isRipplerDisabled()) {
rippler.setGeneratorCenterX(control.getLayoutBounds().getWidth() / 2);
rippler.setGeneratorCenterY(control.getLayoutBounds().getHeight() / 2);
rippler.createRipple();
return () -> {
// create fade out transition for the ripple
releaseRipple();
};
}
return () -> {
};
}
/// show/hide the ripple overlay
///
/// @param forceOverlay used to hold the overlay after ripple action
public void setOverlayVisible(boolean visible, boolean forceOverlay) {
this.forceOverlay = forceOverlay;
setOverlayVisible(visible);
}
/// show/hide the ripple overlay
/// NOTE: setting overlay visibility to false will reset forceOverlay to false
public void setOverlayVisible(boolean visible) {
if (visible) {
showOverlay();
} else {
forceOverlay = false;
hideOverlay();
}
}
/**
* this method will be set to private in future versions of JFoenix,
* user the method {@link #setOverlayVisible(boolean)}
*/
public void showOverlay() {
if (rippler.overlayRect != null) {
rippler.overlayRect.outAnimation.stop();
}
rippler.createOverlay();
rippler.overlayRect.inAnimation.play();
}
public void hideOverlay() {
if (!forceOverlay) {
if (rippler.overlayRect != null) {
rippler.overlayRect.inAnimation.stop();
}
if (rippler.overlayRect != null) {
rippler.overlayRect.outAnimation.play();
}
} else {
System.err.println("Ripple Overlay is forced!");
}
}
/**
* Generates ripples on the screen every 0.3 seconds or whenever
* the createRipple method is called. Ripples grow and fade out
* over 0.6 seconds
*/
protected final class RippleGenerator extends Group {
private double generatorCenterX = 0;
private double generatorCenterY = 0;
private OverLayRipple overlayRect;
private final AtomicBoolean generating = new AtomicBoolean(false);
private boolean cacheRipplerClip = false;
private boolean resetClip = false;
private final Queue<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();
}
}
}
}
}
void cacheRippleClip(boolean cached) {
cacheRipplerClip = cached;
}
void createOverlay() {
if (overlayRect == null) {
overlayRect = new OverLayRipple();
overlayRect.setClip(getMask());
getChildren().add(0, overlayRect);
overlayRect.fillProperty().bind(Bindings.createObjectBinding(() -> {
if (getRipplerFill() instanceof Color fill) {
return new Color(fill.getRed(),
fill.getGreen(),
fill.getBlue(),
0.2);
} else {
return Color.TRANSPARENT;
}
}, ripplerFillProperty()));
}
}
void setGeneratorCenterX(double generatorCenterX) {
this.generatorCenterX = generatorCenterX;
}
void setGeneratorCenterY(double generatorCenterY) {
this.generatorCenterY = generatorCenterY;
}
private final class OverLayRipple extends Rectangle {
// Overlay ripple animations
Animation inAnimation = new Timeline(new KeyFrame(Duration.millis(300),
new KeyValue(opacityProperty(), 1, Interpolator.EASE_IN)));
Animation outAnimation = new Timeline(new KeyFrame(Duration.millis(300),
new KeyValue(opacityProperty(), 0, Interpolator.EASE_OUT)));
OverLayRipple() {
super();
setOverLayBounds(this);
this.getStyleClass().add("jfx-rippler-overlay");
// update initial position
if (JFXRippler.this.getChildrenUnmodifiable().contains(control)) {
double diffMinX = Math.abs(control.getBoundsInLocal().getMinX() - control.getLayoutBounds().getMinX());
double diffMinY = Math.abs(control.getBoundsInLocal().getMinY() - control.getLayoutBounds().getMinY());
Bounds bounds = control.getBoundsInParent();
this.setX(bounds.getMinX() + diffMinX - snappedLeftInset());
this.setY(bounds.getMinY() + diffMinY - snappedTopInset());
}
// set initial attributes
setOpacity(0);
setCache(true);
setCacheHint(CacheHint.SPEED);
setCacheShape(true);
setManaged(false);
}
}
private final class Ripple extends Circle {
KeyValue[] outKeyValues;
Animation outAnimation = null;
Animation inAnimation = null;
private Ripple(double centerX, double centerY) {
super(centerX,
centerY,
getRipplerRadius() == Region.USE_COMPUTED_SIZE ?
computeRippleRadius() : getRipplerRadius(), null);
setCache(true);
setCacheHint(CacheHint.SPEED);
setCacheShape(true);
setManaged(false);
setSmooth(true);
KeyValue[] inKeyValues = new KeyValue[isRipplerRecenter() ? 4 : 2];
outKeyValues = new KeyValue[isRipplerRecenter() ? 5 : 3];
inKeyValues[0] = new KeyValue(scaleXProperty(), 0.9, RIPPLE_INTERPOLATOR);
inKeyValues[1] = new KeyValue(scaleYProperty(), 0.9, RIPPLE_INTERPOLATOR);
outKeyValues[0] = new KeyValue(this.scaleXProperty(), 1, RIPPLE_INTERPOLATOR);
outKeyValues[1] = new KeyValue(this.scaleYProperty(), 1, RIPPLE_INTERPOLATOR);
outKeyValues[2] = new KeyValue(this.opacityProperty(), 0, RIPPLE_INTERPOLATOR);
if (isRipplerRecenter()) {
double dx = (control.getLayoutBounds().getWidth() / 2 - centerX) / 1.55;
double dy = (control.getLayoutBounds().getHeight() / 2 - centerY) / 1.55;
inKeyValues[2] = outKeyValues[3] = new KeyValue(translateXProperty(),
Math.signum(dx) * Math.min(Math.abs(dx),
this.getRadius() / 2),
RIPPLE_INTERPOLATOR);
inKeyValues[3] = outKeyValues[4] = new KeyValue(translateYProperty(),
Math.signum(dy) * Math.min(Math.abs(dy),
this.getRadius() / 2),
RIPPLE_INTERPOLATOR);
}
inAnimation = new Timeline(new KeyFrame(Duration.ZERO,
new KeyValue(scaleXProperty(),
0,
RIPPLE_INTERPOLATOR),
new KeyValue(scaleYProperty(),
0,
RIPPLE_INTERPOLATOR),
new KeyValue(translateXProperty(),
0,
RIPPLE_INTERPOLATOR),
new KeyValue(translateYProperty(),
0,
RIPPLE_INTERPOLATOR),
new KeyValue(opacityProperty(),
1,
RIPPLE_INTERPOLATOR)
), new KeyFrame(Duration.millis(900), inKeyValues));
setScaleX(0);
setScaleY(0);
if (getRipplerFill() instanceof Color fill) {
Color circleColor = new Color(fill.getRed(),
fill.getGreen(),
fill.getBlue(),
0.3);
setStroke(circleColor);
setFill(circleColor);
} else {
setStroke(getRipplerFill());
setFill(getRipplerFill());
}
}
}
public void clear() {
getChildren().clear();
rippler.overlayRect = null;
generating.set(false);
}
}
private void resetOverLay() {
if (rippler.overlayRect != null) {
rippler.overlayRect.inAnimation.stop();
final RippleGenerator.OverLayRipple oldOverlay = rippler.overlayRect;
rippler.overlayRect.outAnimation.setOnFinished((finish) -> rippler.getChildren().remove(oldOverlay));
rippler.overlayRect.outAnimation.play();
rippler.overlayRect = null;
}
}
private void resetClip() {
this.rippler.resetClip = true;
}
protected void resetRippler() {
resetOverLay();
resetClip();
}
/***************************************************************************
* *
* Stylesheet Handling *
* *
**************************************************************************/
/**
* Initialize the style class to 'jfx-rippler'.
* <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,373 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import com.jfoenix.skins.JFXToggleButtonSkin;
import javafx.css.*;
import javafx.css.converter.BooleanConverter;
import javafx.css.converter.PaintConverter;
import javafx.scene.control.Skin;
import javafx.scene.control.ToggleButton;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* JFXToggleButton is the material design implementation of a toggle button.
* important CSS Selectors:
* <p>
* .jfx-toggle-button{
* -fx-toggle-color: color-value;
* -fx-untoggle-color: color-value;
* -fx-toggle-line-color: color-value;
* -fx-untoggle-line-color: color-value;
* }
* <p>
* To change the rippler color when toggled:
* <p>
* .jfx-toggle-button .jfx-rippler{
* -fx-rippler-fill: color-value;
* }
* <p>
* .jfx-toggle-button:selected .jfx-rippler{
* -fx-rippler-fill: color-value;
* }
*
* @author Shadi Shaheen
* @version 1.0
* @since 2016-03-09
*/
public class JFXToggleButton extends ToggleButton {
/**
* {@inheritDoc}
*/
public JFXToggleButton() {
initialize();
}
/**
* {@inheritDoc}
*/
@Override
protected Skin<?> createDefaultSkin() {
return new JFXToggleButtonSkin(this);
}
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
// it's up for the user to add this behavior
// toggleColor.addListener((o, oldVal, newVal) -> {
// // update line color in case not set by the user
// if(newVal instanceof Color)
// toggleLineColor.set(((Color)newVal).desaturate().desaturate().brighter());
// });
}
/***************************************************************************
* *
* styleable Properties *
* *
**************************************************************************/
/**
* Initialize the style class to 'jfx-toggle-button'.
* <p>
* This is the selector class from which CSS can be used to style
* this control.
*/
private static final String DEFAULT_STYLE_CLASS = "jfx-toggle-button";
/**
* default color used when the button is toggled
*/
private final StyleableObjectProperty<Paint> toggleColor = new SimpleStyleableObjectProperty<>(StyleableProperties.TOGGLE_COLOR,
JFXToggleButton.this,
"toggleColor",
Color.valueOf(
"#009688"));
public Paint getToggleColor() {
return toggleColor == null ? Color.valueOf("#009688") : toggleColor.get();
}
public StyleableObjectProperty<Paint> toggleColorProperty() {
return this.toggleColor;
}
public void setToggleColor(Paint color) {
this.toggleColor.set(color);
}
/**
* default color used when the button is not toggled
*/
private StyleableObjectProperty<Paint> untoggleColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNTOGGLE_COLOR,
JFXToggleButton.this,
"unToggleColor",
Color.valueOf(
"#FAFAFA"));
public Paint getUnToggleColor() {
return untoggleColor == null ? Color.valueOf("#FAFAFA") : untoggleColor.get();
}
public StyleableObjectProperty<Paint> unToggleColorProperty() {
return this.untoggleColor;
}
public void setUnToggleColor(Paint color) {
this.untoggleColor.set(color);
}
/**
* default line color used when the button is toggled
*/
private final StyleableObjectProperty<Paint> toggleLineColor = new SimpleStyleableObjectProperty<>(
StyleableProperties.TOGGLE_LINE_COLOR,
JFXToggleButton.this,
"toggleLineColor",
Color.valueOf("#77C2BB"));
public Paint getToggleLineColor() {
return toggleLineColor == null ? Color.valueOf("#77C2BB") : toggleLineColor.get();
}
public StyleableObjectProperty<Paint> toggleLineColorProperty() {
return this.toggleLineColor;
}
public void setToggleLineColor(Paint color) {
this.toggleLineColor.set(color);
}
/**
* default line color used when the button is not toggled
*/
private final StyleableObjectProperty<Paint> untoggleLineColor = new SimpleStyleableObjectProperty<>(
StyleableProperties.UNTOGGLE_LINE_COLOR,
JFXToggleButton.this,
"unToggleLineColor",
Color.valueOf("#999999"));
public Paint getUnToggleLineColor() {
return untoggleLineColor == null ? Color.valueOf("#999999") : untoggleLineColor.get();
}
public StyleableObjectProperty<Paint> unToggleLineColorProperty() {
return this.untoggleLineColor;
}
public void setUnToggleLineColor(Paint color) {
this.untoggleLineColor.set(color);
}
/**
* Default size of the toggle button.
*/
private final StyleableDoubleProperty size = new SimpleStyleableDoubleProperty(
StyleableProperties.SIZE,
JFXToggleButton.this,
"size",
10.0);
public double getSize() {
return size.get();
}
public StyleableDoubleProperty sizeProperty() {
return this.size;
}
public void setSize(double size) {
this.size.set(size);
}
/**
* Disable the visual indicator for focus
*/
private final StyleableBooleanProperty disableVisualFocus = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_VISUAL_FOCUS,
JFXToggleButton.this,
"disableVisualFocus",
false);
public final StyleableBooleanProperty disableVisualFocusProperty() {
return this.disableVisualFocus;
}
public final Boolean isDisableVisualFocus() {
return disableVisualFocus != null && this.disableVisualFocusProperty().get();
}
public final void setDisableVisualFocus(final Boolean disabled) {
this.disableVisualFocusProperty().set(disabled);
}
/**
* disable animation on button action
*/
private final StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION,
JFXToggleButton.this,
"disableAnimation",
!AnimationUtils.isAnimationEnabled());
public final StyleableBooleanProperty disableAnimationProperty() {
return this.disableAnimation;
}
public final Boolean isDisableAnimation() {
return disableAnimation != null && this.disableAnimationProperty().get();
}
public final void setDisableAnimation(final Boolean disabled) {
this.disableAnimationProperty().set(disabled);
}
private static final class StyleableProperties {
private static final CssMetaData<JFXToggleButton, Paint> TOGGLE_COLOR =
new CssMetaData<>("-jfx-toggle-color",
PaintConverter.getInstance(), Color.valueOf("#009688")) {
@Override
public boolean isSettable(JFXToggleButton control) {
return control.toggleColor == null || !control.toggleColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXToggleButton control) {
return control.toggleColorProperty();
}
};
private static final CssMetaData<JFXToggleButton, Paint> UNTOGGLE_COLOR =
new CssMetaData<>("-jfx-untoggle-color",
PaintConverter.getInstance(), Color.valueOf("#FAFAFA")) {
@Override
public boolean isSettable(JFXToggleButton control) {
return control.untoggleColor == null || !control.untoggleColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXToggleButton control) {
return control.unToggleColorProperty();
}
};
private static final CssMetaData<JFXToggleButton, Paint> TOGGLE_LINE_COLOR =
new CssMetaData<>("-jfx-toggle-line-color",
PaintConverter.getInstance(), Color.valueOf("#77C2BB")) {
@Override
public boolean isSettable(JFXToggleButton control) {
return control.toggleLineColor == null || !control.toggleLineColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXToggleButton control) {
return control.toggleLineColorProperty();
}
};
private static final CssMetaData<JFXToggleButton, Paint> UNTOGGLE_LINE_COLOR =
new CssMetaData<>("-jfx-untoggle-line-color",
PaintConverter.getInstance(), Color.valueOf("#999999")) {
@Override
public boolean isSettable(JFXToggleButton control) {
return control.untoggleLineColor == null || !control.untoggleLineColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXToggleButton control) {
return control.unToggleLineColorProperty();
}
};
private static final CssMetaData<JFXToggleButton, Number> SIZE =
new CssMetaData<>("-jfx-size",
StyleConverter.getSizeConverter(), 10.0) {
@Override
public boolean isSettable(JFXToggleButton control) {
return !control.size.isBound();
}
@Override
public StyleableProperty<Number> getStyleableProperty(JFXToggleButton control) {
return control.sizeProperty();
}
};
private static final CssMetaData<JFXToggleButton, Boolean> DISABLE_VISUAL_FOCUS =
new CssMetaData<>("-jfx-disable-visual-focus",
BooleanConverter.getInstance(), false) {
@Override
public boolean isSettable(JFXToggleButton control) {
return control.disableVisualFocus == null || !control.disableVisualFocus.isBound();
}
@Override
public StyleableBooleanProperty getStyleableProperty(JFXToggleButton control) {
return control.disableVisualFocusProperty();
}
};
private static final CssMetaData<JFXToggleButton, Boolean> DISABLE_ANIMATION =
new CssMetaData<>("-jfx-disable-animation",
BooleanConverter.getInstance(), false) {
@Override
public boolean isSettable(JFXToggleButton control) {
return control.disableAnimation == null || !control.disableAnimation.isBound();
}
@Override
public StyleableBooleanProperty getStyleableProperty(JFXToggleButton control) {
return control.disableAnimationProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables =
new ArrayList<>(ToggleButton.getClassCssMetaData());
Collections.addAll(styleables,
SIZE,
TOGGLE_COLOR,
UNTOGGLE_COLOR,
TOGGLE_LINE_COLOR,
UNTOGGLE_LINE_COLOR,
DISABLE_VISUAL_FOCUS,
DISABLE_ANIMATION
);
CHILD_STYLEABLES = Collections.unmodifiableList(styleables);
}
}
@Override
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
return getClassCssMetaData();
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.CHILD_STYLEABLES;
}
}
@@ -0,0 +1,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,122 @@
/*
* Copyright (c) 2016 JFoenix
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.jfoenix.effects;
import javafx.scene.Node;
import javafx.scene.effect.BlurType;
import javafx.scene.effect.DropShadow;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
/**
* it will create a shadow effect for a given node and a specified depth level.
* depth levels are {0,1,2,3,4,5}
*
* @author Shadi Shaheen
* @version 1.0
* @since 2016-03-09
*/
public final class JFXDepthManager {
private JFXDepthManager() {
throw new AssertionError();
}
private static final DropShadow[] depth = new DropShadow[] {
new DropShadow(BlurType.GAUSSIAN, Color.TRANSPARENT, 0, 0, 0, 0),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.12), 4, 0, 0, 1),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.16), 6, 0, 0, 2),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.20), 10, 0, 0, 3),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.24), 14, 0, 0, 4),
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.28), 20, 0, 0, 6)
};
/**
* this method is used to add shadow effect to the node,
* however the shadow is not real ( gets affected with node transformations)
* <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,228 @@
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package com.jfoenix.skins;
import com.jfoenix.controls.JFXCheckBox;
import com.jfoenix.controls.JFXRippler;
import com.jfoenix.controls.JFXRippler.RipplerMask;
import com.jfoenix.controls.JFXRippler.RipplerPos;
import com.jfoenix.transitions.CachedTransition;
import com.jfoenix.transitions.JFXFillTransition;
import com.jfoenix.utils.JFXNodeUtils;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.animation.Transition;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.VPos;
import javafx.scene.control.CheckBox;
import javafx.scene.control.skin.CheckBoxSkin;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.Border;
import javafx.scene.layout.BorderStroke;
import javafx.scene.layout.BorderStrokeStyle;
import javafx.scene.layout.BorderWidths;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.shape.SVGPath;
import javafx.util.Duration;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
public class JFXCheckBoxSkin extends CheckBoxSkin {
private final StackPane box = new StackPane();
private final StackPane mark = new StackPane();
private final double lineThick = 2.0;
private final double padding = 10.0;
private final JFXRippler rippler;
private final AnchorPane container = new AnchorPane();
private final double labelOffset = -8.0;
private final Transition transition;
private boolean invalid = true;
private JFXFillTransition select;
public JFXCheckBoxSkin(JFXCheckBox control) {
super(control);
this.box.setMinSize(18.0, 18.0);
this.box.setPrefSize(18.0, 18.0);
this.box.setMaxSize(18.0, 18.0);
this.box.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, new CornerRadii(2.0), Insets.EMPTY)));
this.box.setBorder(new Border(new BorderStroke(Themes.getColorScheme().getOnSurfaceVariant(), BorderStrokeStyle.SOLID, new CornerRadii(2.0), new BorderWidths(this.lineThick))));
StackPane boxContainer = new StackPane();
boxContainer.getChildren().add(this.box);
boxContainer.setPadding(new Insets(this.padding));
this.rippler = new JFXRippler(boxContainer, RipplerMask.CIRCLE, RipplerPos.BACK);
this.updateRippleColor();
SVGPath shape = new SVGPath();
shape.setContent("M384 690l452-452 60 60-512 512-238-238 60-60z");
this.mark.setShape(shape);
this.mark.setMaxSize(15.0, 12.0);
this.mark.setStyle("-fx-background-color:-monet-on-primary; -fx-border-color:-monet-on-primary; -fx-border-width:2px;");
this.mark.setVisible(false);
this.mark.setScaleX(0.0);
this.mark.setScaleY(0.0);
boxContainer.getChildren().add(this.mark);
this.container.getChildren().add(this.rippler);
AnchorPane.setRightAnchor(this.rippler, this.labelOffset);
control.selectedProperty().addListener((o, oldVal, newVal) -> {
this.updateRippleColor();
this.playSelectAnimation(newVal);
});
ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(control);
if (focusVisibleProperty == null)
focusVisibleProperty = control.focusedProperty();
focusVisibleProperty.addListener((o, oldVal, newVal) -> {
if (newVal) {
if (!this.getSkinnable().isPressed()) {
this.rippler.showOverlay();
}
} else {
this.rippler.hideOverlay();
}
});
control.pressedProperty().addListener((o, oldVal, newVal) -> this.rippler.hideOverlay());
this.updateChildren();
this.registerChangeListener(control.checkedColorProperty(), ignored -> {
if (select != null) {
select.stop();
}
this.createFillTransition();
updateColors();
});
this.registerChangeListener(control.unCheckedColorProperty(), ignored -> updateColors());
this.transition = new CheckBoxTransition();
this.createFillTransition();
}
private void updateRippleColor() {
var control = (JFXCheckBox) this.getSkinnable();
this.rippler.setRipplerFill(control.isSelected()
? control.getCheckedColor()
: control.getUnCheckedColor());
}
private void updateColors() {
var control = (JFXCheckBox) getSkinnable();
boolean isSelected = control.isSelected();
JFXNodeUtils.updateBackground(box.getBackground(), box, isSelected ? control.getCheckedColor() : Color.TRANSPARENT);
rippler.setRipplerFill(isSelected ? control.getCheckedColor() : control.getUnCheckedColor());
final BorderStroke borderStroke = box.getBorder().getStrokes().get(0);
box.setBorder(new Border(new BorderStroke(
isSelected ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
borderStroke.getTopStyle(),
borderStroke.getRadii(),
borderStroke.getWidths())));
}
protected void updateChildren() {
super.updateChildren();
if (this.container != null) {
this.getChildren().remove(1);
this.getChildren().add(this.container);
}
}
protected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
return super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset) + this.snapSizeX(this.box.minWidth(-1.0)) + this.labelOffset + 2.0 * this.padding;
}
protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
return super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset) + this.snapSizeY(this.box.prefWidth(-1.0)) + this.labelOffset + 2.0 * this.padding;
}
protected void layoutChildren(double x, double y, double w, double h) {
CheckBox checkBox = this.getSkinnable();
double boxWidth = this.snapSizeX(this.container.prefWidth(-1.0));
double boxHeight = this.snapSizeY(this.container.prefHeight(-1.0));
double computeWidth = Math.min(checkBox.prefWidth(-1.0), checkBox.minWidth(-1.0)) + this.labelOffset + 2.0 * this.padding;
double labelWidth = Math.min(computeWidth - boxWidth, w - this.snapSizeX(boxWidth)) + this.labelOffset + 2.0 * this.padding;
double labelHeight = Math.min(checkBox.prefHeight(labelWidth), h);
double maxHeight = Math.max(boxHeight, labelHeight);
double xOffset = computeXOffset(w, labelWidth + boxWidth, checkBox.getAlignment().getHpos()) + x;
double yOffset = computeYOffset(h, maxHeight, checkBox.getAlignment().getVpos()) + x;
if (this.invalid) {
if (this.getSkinnable().isSelected()) {
this.playSelectAnimation(true);
}
this.invalid = false;
}
this.layoutLabelInArea(xOffset + boxWidth, yOffset, labelWidth, maxHeight, checkBox.getAlignment());
this.container.resize(boxWidth, boxHeight);
this.positionInArea(this.container, xOffset, yOffset, boxWidth, maxHeight, 0.0, checkBox.getAlignment().getHpos(), checkBox.getAlignment().getVpos());
}
static double computeXOffset(double width, double contentWidth, HPos hpos) {
return switch (hpos) {
case LEFT -> 0.0;
case CENTER -> (width - contentWidth) / 2.0;
case RIGHT -> width - contentWidth;
};
}
static double computeYOffset(double height, double contentHeight, VPos vpos) {
return switch (vpos) {
case TOP -> 0.0;
case CENTER -> (height - contentHeight) / 2.0;
case BOTTOM -> height - contentHeight;
default -> 0.0;
};
}
private void playSelectAnimation(Boolean selection) {
if (selection == null) {
selection = false;
}
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
this.transition.setRate(selection ? 1.0 : -1.0);
this.select.setRate(selection ? 1.0 : -1.0);
this.transition.play();
this.select.play();
this.box.setBorder(new Border(new BorderStroke(
selection ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
BorderStrokeStyle.SOLID,
new CornerRadii(2.0),
new BorderWidths(this.lineThick))));
}
private void createFillTransition() {
this.select = new JFXFillTransition(Duration.millis(120.0), this.box, Color.TRANSPARENT,
(Color) ((JFXCheckBox) this.getSkinnable()).getCheckedColor());
this.select.setInterpolator(Interpolator.EASE_OUT);
}
private final class CheckBoxTransition extends CachedTransition {
CheckBoxTransition() {
super(JFXCheckBoxSkin.this.mark,
new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(JFXCheckBoxSkin.this.mark.visibleProperty(), false, Interpolator.EASE_OUT),
new KeyValue(JFXCheckBoxSkin.this.mark.scaleXProperty(), (double) 0.5F, Interpolator.EASE_OUT),
new KeyValue(JFXCheckBoxSkin.this.mark.scaleYProperty(), (double) 0.5F, Interpolator.EASE_OUT)),
new KeyFrame(Duration.millis(400.0),
new KeyValue(JFXCheckBoxSkin.this.mark.visibleProperty(), true, Interpolator.EASE_OUT),
new KeyValue(JFXCheckBoxSkin.this.mark.scaleXProperty(), (double) 0.5F, Interpolator.EASE_OUT),
new KeyValue(JFXCheckBoxSkin.this.mark.scaleYProperty(), (double) 0.5F, Interpolator.EASE_OUT)),
new KeyFrame(Duration.millis(1000.0),
new KeyValue(JFXCheckBoxSkin.this.mark.visibleProperty(), true, Interpolator.EASE_OUT),
new KeyValue(JFXCheckBoxSkin.this.mark.scaleXProperty(), 1, Interpolator.EASE_OUT),
new KeyValue(JFXCheckBoxSkin.this.mark.scaleYProperty(), 1, Interpolator.EASE_OUT))
)
);
this.setCycleDuration(Duration.seconds(0.12));
this.setDelay(Duration.seconds(0.05));
}
}
}
@@ -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,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,407 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.skins;
import com.jfoenix.controls.*;
import com.jfoenix.svg.SVGGlyph;
import com.jfoenix.transitions.JFXFillTransition;
import javafx.animation.*;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Rectangle2D;
import javafx.scene.Scene;
import javafx.scene.control.Tab;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.stage.*;
import javafx.util.Duration;
import org.jackhuang.hmcl.setting.StyleSheets;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @author Shadi Shaheen
*/
public class JFXCustomColorPickerDialog extends StackPane {
private final Stage dialog = new Stage();
// used for concurrency control and preventing FX-thread over use
private final AtomicInteger concurrencyController = new AtomicInteger(-1);
private final ObjectProperty<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::updateColor);
pickerDecorator.setPickOnBounds(false);
customScene = new Scene(pickerDecorator, Color.TRANSPARENT);
StyleSheets.init(customScene);
curvedColorPicker = new JFXCustomColorPicker();
StackPane pane = new StackPane(curvedColorPicker);
pane.setPadding(new Insets(18));
VBox container = new VBox();
container.getChildren().add(pane);
JFXTabPane tabs = new JFXTabPane();
JFXTextField rgbField = new JFXTextField();
JFXTextField hsbField = new JFXTextField();
JFXTextField hexField = new JFXTextField();
rgbField.getStyleClass().add("custom-color-field");
rgbField.setPromptText("RGB Color");
rgbField.textProperty().addListener((o, oldVal, newVal) -> updateColorFromUserInput(newVal));
hsbField.getStyleClass().add("custom-color-field");
hsbField.setPromptText("HSB Color");
hsbField.textProperty().addListener((o, oldVal, newVal) -> updateColorFromUserInput(newVal));
hexField.getStyleClass().add("custom-color-field");
hexField.setPromptText("#HEX Color");
hexField.textProperty().addListener((o, oldVal, newVal) -> updateColorFromUserInput(newVal));
StackPane tabContent = new StackPane();
tabContent.getChildren().add(rgbField);
tabContent.setMinHeight(100);
Tab rgbTab = new Tab("RGB");
rgbTab.setContent(tabContent);
Tab hsbTab = new Tab("HSB");
hsbTab.setContent(hsbField);
Tab hexTab = new Tab("HEX");
hexTab.setContent(hexField);
tabs.getTabs().add(hexTab);
tabs.getTabs().add(rgbTab);
tabs.getTabs().add(hsbTab);
curvedColorPicker.selectedPath.addListener((o, oldVal, newVal) -> {
if (paraTransition != null) {
paraTransition.stop();
}
Region tabsHeader = (Region) tabs.lookup(".tab-header-background");
pane.backgroundProperty().unbind();
tabsHeader.backgroundProperty().unbind();
JFXFillTransition fillTransition = new JFXFillTransition(Duration.millis(240),
pane,
(Color) oldVal.getFill(),
(Color) newVal.getFill());
JFXFillTransition tabsFillTransition = new JFXFillTransition(Duration.millis(240),
tabsHeader,
(Color) oldVal.getFill(),
(Color) newVal.getFill());
paraTransition = new ParallelTransition(fillTransition, tabsFillTransition);
paraTransition.setOnFinished((finish) -> {
tabsHeader.backgroundProperty().bind(Bindings.createObjectBinding(() -> {
return new Background(new BackgroundFill(newVal.getFill(), CornerRadii.EMPTY, Insets.EMPTY));
}, newVal.fillProperty()));
pane.backgroundProperty().bind(Bindings.createObjectBinding(() -> {
return new Background(new BackgroundFill(newVal.getFill(), CornerRadii.EMPTY, Insets.EMPTY));
}, newVal.fillProperty()));
});
paraTransition.play();
});
initRun = () -> {
// change tabs labels font color according to the selected color
pane.backgroundProperty().addListener((o, oldVal, newVal) -> {
if (concurrencyController.getAndSet(1) == -1) {
Color fontColor = ((Color) newVal.getFills().get(0).getFill()).grayscale()
.getRed() > 0.5 ? Color.valueOf(
"rgba(40, 40, 40, 0.87)") : Color.valueOf("rgba(255, 255, 255, 0.87)");
// for (Node tabNode : tabs.lookupAll(".tab")) {
// for (Node node : tabNode.lookupAll(".tab-label")) {
// ((Label) node).setTextFill(fontColor);
// }
// for (Node node : tabNode.lookupAll(".jfx-rippler")) {
// ((JFXRippler) node).setRipplerFill(fontColor);
// }
// }
// ((Pane) tabs.lookup(".tab-selected-line")).setBackground(new Background(new BackgroundFill(fontColor, CornerRadii.EMPTY, Insets.EMPTY)));
pickerDecorator.lookupAll(".jfx-decorator-button").forEach(button -> {
((JFXButton) button).setRipplerFill(fontColor);
((SVGGlyph) ((JFXButton) button).getGraphic()).setFill(fontColor);
});
Color newColor = (Color) newVal.getFills().get(0).getFill();
String hex = String.format("#%02X%02X%02X",
(int) (newColor.getRed() * 255),
(int) (newColor.getGreen() * 255),
(int) (newColor.getBlue() * 255));
String rgb = String.format("rgba(%d, %d, %d, 1)",
(int) (newColor.getRed() * 255),
(int) (newColor.getGreen() * 255),
(int) (newColor.getBlue() * 255));
String hsb = String.format("hsl(%d, %d%%, %d%%)",
(int) (newColor.getHue()),
(int) (newColor.getSaturation() * 100),
(int) (newColor.getBrightness() * 100));
if (!userChange) {
systemChange = true;
rgbField.setText(rgb);
hsbField.setText(hsb);
hexField.setText(hex);
systemChange = false;
}
concurrencyController.getAndSet(-1);
}
});
// initial selected colors
Platform.runLater(() -> {
pane.setBackground(new Background(new BackgroundFill(curvedColorPicker.getColor(curvedColorPicker.getSelectedIndex()),
CornerRadii.EMPTY,
Insets.EMPTY)));
((Region) tabs.lookup(".tab-header-background")).setBackground(new Background(new BackgroundFill(
curvedColorPicker.getColor(curvedColorPicker.getSelectedIndex()),
CornerRadii.EMPTY,
Insets.EMPTY)));
Region tabsHeader = (Region) tabs.lookup(".tab-header-background");
pane.backgroundProperty().unbind();
tabsHeader.backgroundProperty().unbind();
tabsHeader.backgroundProperty().bind(Bindings.createObjectBinding(() -> {
return new Background(new BackgroundFill(curvedColorPicker.selectedPath.get().getFill(),
CornerRadii.EMPTY,
Insets.EMPTY));
}, curvedColorPicker.selectedPath.get().fillProperty()));
pane.backgroundProperty().bind(Bindings.createObjectBinding(() -> {
return new Background(new BackgroundFill(curvedColorPicker.selectedPath.get().getFill(),
CornerRadii.EMPTY,
Insets.EMPTY));
}, curvedColorPicker.selectedPath.get().fillProperty()));
// bind text field line color
rgbField.focusColorProperty().bind(Bindings.createObjectBinding(() -> {
return pane.getBackground().getFills().get(0).getFill();
}, pane.backgroundProperty()));
hsbField.focusColorProperty().bind(Bindings.createObjectBinding(() -> {
return pane.getBackground().getFills().get(0).getFill();
}, pane.backgroundProperty()));
hexField.focusColorProperty().bind(Bindings.createObjectBinding(() -> {
return pane.getBackground().getFills().get(0).getFill();
}, pane.backgroundProperty()));
((Pane) pickerDecorator.lookup(".jfx-decorator-buttons-container")).backgroundProperty()
.bind(Bindings.createObjectBinding(() -> {
return new Background(new BackgroundFill(
pane.getBackground()
.getFills()
.get(0)
.getFill(),
CornerRadii.EMPTY,
Insets.EMPTY));
}, pane.backgroundProperty()));
((Pane) pickerDecorator.lookup(".jfx-decorator-content-container")).borderProperty()
.bind(Bindings.createObjectBinding(() -> {
return new Border(new BorderStroke(
pane.getBackground()
.getFills()
.get(0)
.getFill(),
BorderStrokeStyle.SOLID,
CornerRadii.EMPTY,
new BorderWidths(0,
4,
4,
4)));
}, pane.backgroundProperty()));
});
};
container.getChildren().add(tabs);
this.getChildren().add(container);
this.setPadding(new Insets(0));
dialog.setScene(customScene);
final EventHandler<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 {
curvedColorPicker.setColor(Color.valueOf(colorWebString));
} catch (IllegalArgumentException ignored) {
// if color is not valid then do nothing
}
userChange = false;
}
}
private void close() {
dialog.setScene(null);
dialog.close();
}
public void setCurrentColor(Color currentColor) {
this.currentColorProperty.set(currentColor);
}
Color getCurrentColor() {
return currentColorProperty.get();
}
ObjectProperty<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);
}
}
@@ -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"));
}
}
@@ -20,26 +20,24 @@
*/
package com.jfoenix.skins;
import com.jfoenix.adapters.skins.ListViewSkin;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.effects.JFXDepthManager;
import javafx.scene.control.ListCell;
import javafx.scene.control.skin.ListViewSkin;
import javafx.scene.control.skin.VirtualFlow;
import javafx.scene.layout.Region;
import org.jackhuang.hmcl.ui.FXUtils;
// https://github.com/HMCL-dev/HMCL/issues/4720
public class JFXListViewSkin<T> extends ListViewSkin<T> {
private final VirtualFlow<ListCell<T>> flow;
@SuppressWarnings("unchecked")
public JFXListViewSkin(final JFXListView<T> listView) {
super(listView);
flow = (VirtualFlow<ListCell<T>>) getChildren().get(0);
VirtualFlow<ListCell<T>> flow = getVirtualFlow();
JFXDepthManager.setDepth(flow, listView.depthProperty().get());
listView.depthProperty().addListener((o, oldVal, newVal) -> JFXDepthManager.setDepth(flow, newVal));
FXUtils.smoothScrolling(flow);
if (!Boolean.TRUE.equals(listView.getProperties().get("no-smooth-scrolling"))) {
FXUtils.smoothScrolling(flow);
}
}
@Override
@@ -47,47 +45,4 @@ public class JFXListViewSkin<T> extends ListViewSkin<T> {
return 200;
}
@Override
protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
final int itemsCount = getSkinnable().getItems().size();
if (getSkinnable().maxHeightProperty().isBound() || itemsCount <= 0) {
return super.computePrefHeight(width, topInset, rightInset, bottomInset, leftInset);
}
final double fixedCellSize = getSkinnable().getFixedCellSize();
double computedHeight = fixedCellSize != Region.USE_COMPUTED_SIZE ?
fixedCellSize * itemsCount + snapVerticalInsets() : estimateHeight();
double height = super.computePrefHeight(width, topInset, rightInset, bottomInset, leftInset);
if (height > computedHeight) {
height = computedHeight;
}
if (getSkinnable().getMaxHeight() > 0 && computedHeight > getSkinnable().getMaxHeight()) {
return getSkinnable().getMaxHeight();
}
return height;
}
private double estimateHeight() {
// compute the border/padding for the list
double borderWidth = snapVerticalInsets();
// compute the gap between list cells
JFXListView<T> listview = (JFXListView<T>) getSkinnable();
double gap = listview.isExpanded() ? ((JFXListView<T>) getSkinnable()).getVerticalGap() * (getSkinnable().getItems()
.size()) : 0;
// compute the height of each list cell
double cellsHeight = 0;
for (int i = 0; i < flow.getCellCount(); i++) {
ListCell<T> cell = flow.getCell(i);
cellsHeight += cell.getHeight();
}
return cellsHeight + gap + borderWidth;
}
private double snapVerticalInsets() {
return getSkinnable().snappedBottomInset() + getSkinnable().snappedTopInset();
}
}
@@ -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,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,186 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.skins;
import com.jfoenix.controls.JFXRippler;
import com.jfoenix.controls.JFXRippler.RipplerMask;
import com.jfoenix.controls.JFXRippler.RipplerPos;
import com.jfoenix.controls.JFXToggleButton;
import com.jfoenix.effects.JFXDepthManager;
import com.jfoenix.transitions.JFXAnimationTimer;
import com.jfoenix.transitions.JFXKeyFrame;
import com.jfoenix.transitions.JFXKeyValue;
import javafx.animation.Interpolator;
import javafx.geometry.Insets;
import javafx.scene.Cursor;
import javafx.scene.control.skin.ToggleButtonSkin;
import javafx.scene.layout.StackPane;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Line;
import javafx.scene.shape.StrokeLineCap;
import javafx.util.Duration;
/**
* <h1>Material Design ToggleButton Skin</h1>
*
* @author Shadi Shaheen
* @version 1.0
* @since 2016-03-09
*/
public class JFXToggleButtonSkin extends ToggleButtonSkin {
private Runnable releaseManualRippler = null;
private JFXAnimationTimer timer;
private final Circle circle;
private final Line line;
public JFXToggleButtonSkin(JFXToggleButton toggleButton) {
super(toggleButton);
double circleRadius = toggleButton.getSize();
line = new Line();
line.setStroke(getSkinnable().isSelected() ? toggleButton.getToggleLineColor() : toggleButton.getUnToggleLineColor());
line.setStartX(0);
line.setStartY(0);
line.setEndX(circleRadius * 2 + 2);
line.setEndY(0);
line.setStrokeWidth(circleRadius * 1.5);
line.setStrokeLineCap(StrokeLineCap.ROUND);
line.setSmooth(true);
circle = new Circle();
circle.setFill(getSkinnable().isSelected() ? toggleButton.getToggleColor() : toggleButton.getUnToggleColor());
circle.setCenterX(-circleRadius);
circle.setCenterY(0);
circle.setRadius(circleRadius);
circle.setSmooth(true);
JFXDepthManager.setDepth(circle, 1);
StackPane circlePane = new StackPane();
circlePane.getChildren().add(circle);
circlePane.setPadding(new Insets(circleRadius * 1.5));
JFXRippler rippler = new JFXRippler(circlePane, RipplerMask.CIRCLE, RipplerPos.BACK);
rippler.setRipplerFill(getSkinnable().isSelected() ? toggleButton.getToggleLineColor() : toggleButton.getUnToggleLineColor());
rippler.setTranslateX(computeTranslation(circleRadius, line));
final StackPane main = new StackPane();
main.getChildren().setAll(line, rippler);
main.setCursor(Cursor.HAND);
// show focus traversal effect
getSkinnable().armedProperty().addListener((o, oldVal, newVal) -> {
if (newVal) {
releaseManualRippler = rippler.createManualRipple();
} else if (releaseManualRippler != null) {
releaseManualRippler.run();
}
});
toggleButton.focusedProperty().addListener((o, oldVal, newVal) -> {
if (!toggleButton.isDisableVisualFocus()) {
if (newVal) {
if (!getSkinnable().isPressed()) {
rippler.setOverlayVisible(true);
}
} else {
rippler.setOverlayVisible(false);
}
}
});
toggleButton.pressedProperty().addListener(observable -> rippler.setOverlayVisible(false));
// add change listener to selected property
getSkinnable().selectedProperty().addListener(observable -> {
rippler.setRipplerFill(toggleButton.isSelected() ? toggleButton.getToggleLineColor() : toggleButton.getUnToggleLineColor());
if (!toggleButton.isDisableAnimation()) {
timer.reverseAndContinue();
} else {
rippler.setTranslateX(computeTranslation(circleRadius, line));
}
});
getSkinnable().setGraphic(main);
timer = new JFXAnimationTimer(
new JFXKeyFrame(Duration.millis(100),
JFXKeyValue.builder()
.setTarget(rippler.translateXProperty())
.setEndValueSupplier(() -> computeTranslation(circleRadius, line))
.setInterpolator(Interpolator.EASE_BOTH)
.setAnimateCondition(() -> !((JFXToggleButton) getSkinnable()).isDisableAnimation())
.build(),
JFXKeyValue.builder()
.setTarget(line.strokeProperty())
.setEndValueSupplier(() -> getSkinnable().isSelected() ?
((JFXToggleButton) getSkinnable()).getToggleLineColor()
: ((JFXToggleButton) getSkinnable()).getUnToggleLineColor())
.setInterpolator(Interpolator.EASE_BOTH)
.setAnimateCondition(() -> !((JFXToggleButton) getSkinnable()).isDisableAnimation())
.build(),
JFXKeyValue.builder()
.setTarget(circle.fillProperty())
.setEndValueSupplier(() -> getSkinnable().isSelected() ?
((JFXToggleButton) getSkinnable()).getToggleColor()
: ((JFXToggleButton) getSkinnable()).getUnToggleColor())
.setInterpolator(Interpolator.EASE_BOTH)
.setAnimateCondition(() -> !((JFXToggleButton) getSkinnable()).isDisableAnimation())
.build()
)
);
timer.setCacheNodes(circle, line);
registerChangeListener(toggleButton.toggleColorProperty(), observableValue -> {
if (getSkinnable().isSelected()) {
circle.setFill(((JFXToggleButton) getSkinnable()).getToggleColor());
}
});
registerChangeListener(toggleButton.unToggleColorProperty(), observableValue -> {
if (!getSkinnable().isSelected()) {
circle.setFill(((JFXToggleButton) getSkinnable()).getUnToggleColor());
}
});
registerChangeListener(toggleButton.toggleLineColorProperty(), observableValue -> {
if (getSkinnable().isSelected()) {
line.setStroke(((JFXToggleButton) getSkinnable()).getToggleLineColor());
}
});
registerChangeListener(toggleButton.unToggleColorProperty(), observableValue -> {
if (!getSkinnable().isSelected()) {
line.setStroke(((JFXToggleButton) getSkinnable()).getUnToggleLineColor());
}
});
}
private double computeTranslation(double circleRadius, Line line) {
return (getSkinnable().isSelected() ? 1 : -1) * ((line.getLayoutBounds().getWidth() / 2) - circleRadius + 2);
}
@Override
public void dispose() {
super.dispose();
timer.dispose();
timer = null;
}
}
@@ -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,64 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.utils;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import java.util.Locale;
/// @author Shadi Shaheen
/// @version 1.0
/// @since 2017-02-11
public final class JFXNodeUtils {
public static void updateBackground(Background newBackground, Region nodeToUpdate) {
updateBackground(newBackground, nodeToUpdate, Color.BLACK);
}
public static void updateBackground(Background newBackground, Region nodeToUpdate, Paint fill) {
if (newBackground != null && !newBackground.getFills().isEmpty()) {
final BackgroundFill[] fills = new BackgroundFill[newBackground.getFills().size()];
for (int i = 0; i < newBackground.getFills().size(); i++) {
BackgroundFill bf = newBackground.getFills().get(i);
fills[i] = new BackgroundFill(fill, bf.getRadii(), bf.getInsets());
}
nodeToUpdate.setBackground(new Background(fills));
}
}
public static String colorToHex(Color c) {
if (c != null) {
return String.format((Locale) null, "#%02X%02X%02X",
Math.round(c.getRed() * 255),
Math.round(c.getGreen() * 255),
Math.round(c.getBlue() * 255));
} else {
return null;
}
}
private JFXNodeUtils() {
}
}
@@ -20,11 +20,13 @@ package org.jackhuang.hmcl;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.value.ObservableBooleanValue;
import javafx.geometry.Rectangle2D;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.ButtonType;
import javafx.scene.input.Clipboard;
import javafx.scene.input.DataFormat;
import javafx.stage.Screen;
import javafx.stage.Stage;
import org.jackhuang.hmcl.setting.ConfigHolder;
import org.jackhuang.hmcl.setting.SambaException;
@@ -54,6 +56,7 @@ import java.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.Objects;
@@ -79,6 +82,18 @@ 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));
if (Screen.getScreens().isEmpty()) {
LOG.info("No screen");
} else {
StringBuilder builder = new StringBuilder("Screens:");
int count = 0;
for (Screen screen : Screen.getScreens()) {
builder.append("\n - Screen ").append(++count).append(": ");
appendScreen(builder, screen);
}
LOG.info(builder.toString());
}
try {
try {
ConfigHolder.init();
@@ -131,6 +146,38 @@ public final class Launcher extends Application {
}
}
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);
}
@@ -111,6 +111,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";
@@ -25,20 +25,20 @@ 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.java.JavaRuntime;
import org.jackhuang.hmcl.mod.ModAdviser;
import org.jackhuang.hmcl.mod.Modpack;
import org.jackhuang.hmcl.mod.ModpackConfiguration;
import org.jackhuang.hmcl.mod.ModpackProvider;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.setting.VersionSetting;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemInfo;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
@@ -55,8 +55,8 @@ import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class HMCLGameRepository extends DefaultGameRepository {
private final Profile profile;
@@ -174,7 +174,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
Files.copy(fromJson, toJson);
JsonUtils.writeToJsonFile(toJson, fromVersion.setId(dstId));
JsonUtils.writeToJsonFile(toJson, fromVersion.setId(dstId).setJar(dstId));
VersionSetting oldVersionSetting = getVersionSetting(srcId).clone();
GameDirectoryType originalGameDirType = oldVersionSetting.getGameDirType();
@@ -338,7 +338,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
GameVersionNumber versionNumber = GameVersionNumber.asGameVersion(gameVersion);
if (versionNumber.isAprilFools()) {
return VersionIconType.APRIL_FOOLS.getIcon();
} else if (versionNumber instanceof GameVersionNumber.Snapshot) {
} else if (versionNumber instanceof GameVersionNumber.LegacySnapshot) {
return VersionIconType.COMMAND.getIcon();
} else if (versionNumber instanceof GameVersionNumber.Old) {
return VersionIconType.CRAFT_TABLE.getIcon();
@@ -387,7 +387,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
vs.setUsesGlobal(true);
}
public LaunchOptions getLaunchOptions(String version, JavaRuntime javaVersion, Path gameDir, List<String> javaAgents, List<String> javaArguments, boolean makeLaunchScript) {
public LaunchOptions.Builder 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()
@@ -418,7 +418,6 @@ public final class HMCLGameRepository extends DefaultGameRepository {
.setWidth(vs.getWidth())
.setHeight(vs.getHeight())
.setFullscreen(vs.isFullscreen())
.setServerIp(vs.getServerIp())
.setWrapper(vs.getWrapper())
.setPreLaunchCommand(vs.getPreLaunchCommand())
.setPostExitCommand(vs.getPostExitCommand())
@@ -428,12 +427,17 @@ public final class HMCLGameRepository extends DefaultGameRepository {
.setNativesDir(vs.getNativesDir())
.setProcessPriority(vs.getProcessPriority())
.setRenderer(vs.getRenderer())
.setEnableDebugLogOutput(vs.isEnableDebugLogOutput())
.setUseNativeGLFW(vs.isUseNativeGLFW())
.setUseNativeOpenAL(vs.isUseNativeOpenAL())
.setDaemon(!makeLaunchScript && vs.getLauncherVisibility().isDaemon())
.setJavaAgents(javaAgents)
.setJavaArguments(javaArguments);
if (StringUtils.isNotBlank(vs.getServerIp())) {
builder.setQuickPlayOption(new QuickPlayOption.MultiPlayer(vs.getServerIp()));
}
if (config().hasProxy()) {
builder.setProxyType(config().getProxyType());
builder.setProxyHost(config().getProxyHost());
@@ -460,7 +464,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
if (vs.isAutoMemory() && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx")))
builder.setMaxMemory(null);
return builder.create();
return builder;
}
@Override
@@ -79,7 +79,7 @@ public final class HMCLModpackProvider implements ModpackProvider {
private final static class HMCLModpack extends Modpack {
@Override
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name) {
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name, String iconUrl) {
return new HMCLModpackInstallTask(((HMCLGameRepository) dependencyManager.getGameRepository()).getProfile(), zipFile, this, name);
}
}
@@ -22,6 +22,7 @@ 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;
@@ -51,6 +52,7 @@ import org.jackhuang.hmcl.util.versioning.VersionNumber;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.nio.file.AccessDeniedException;
@@ -60,7 +62,6 @@ import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
import java.lang.ref.WeakReference;
import static javafx.application.Platform.runLater;
import static javafx.application.Platform.setImplicitExit;
@@ -81,6 +82,8 @@ public final class LauncherHelper {
private final VersionSetting setting;
private LauncherVisibility launcherVisibility;
private boolean showLogs;
private QuickPlayOption quickPlayOption;
private boolean disableOfflineSkin = false;
public LauncherHelper(Profile profile, Account account, String selectedVersion) {
this.profile = Objects.requireNonNull(profile);
@@ -111,6 +114,14 @@ public final class LauncherHelper {
launcherVisibility = LauncherVisibility.KEEP;
}
public void setQuickPlayOption(QuickPlayOption quickPlayOption) {
this.quickPlayOption = quickPlayOption;
}
public void setDisableOfflineSkin() {
disableOfflineSkin = true;
}
public void launch() {
FXUtils.checkFxUserThread();
@@ -188,8 +199,15 @@ public final class LauncherHelper {
.thenComposeAsync(() -> gameVersion.map(s -> new GameVerificationFixTask(dependencyManager, s, version.get())).orElse(null))
.thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in"))
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
LaunchOptions launchOptions = repository.getLaunchOptions(
LaunchOptions.Builder launchOptionsBuilder = repository.getLaunchOptions(
selectedVersion, javaVersionRef.get(), profile.getGameDir(), javaAgents, javaArguments, scriptFile != null);
if (disableOfflineSkin) {
launchOptionsBuilder.setDaemon(false);
}
if (quickPlayOption != null) {
launchOptionsBuilder.setQuickPlayOption(quickPlayOption);
}
LaunchOptions launchOptions = launchOptionsBuilder.create();
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModsDirectory(selectedVersion), 10));
@@ -639,10 +657,13 @@ public final class LauncherHelper {
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);
@@ -17,21 +17,19 @@
*/
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.io.Zipper;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.management.ManagementFactory;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.nio.file.PathMatcher;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
@@ -43,7 +41,9 @@ public final class LogExporter {
private LogExporter() {
}
public static CompletableFuture<Void> exportLogs(Path zipFile, DefaultGameRepository gameRepository, String versionId, String logs, String launchScript) {
public static CompletableFuture<Void> exportLogs(
Path zipFile, DefaultGameRepository gameRepository, String versionId, String logs, String launchScript,
PathMatcher logMatcher) {
Path runDirectory = gameRepository.getRunDirectory(versionId);
Path baseDirectory = gameRepository.getBaseDirectory();
List<String> versions = new ArrayList<>();
@@ -65,10 +65,10 @@ public final class LogExporter {
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);
processLogs(runDirectory.resolve("liteconfig"), "*.log", "liteconfig", zipper, logMatcher);
processLogs(runDirectory.resolve("logs"), "*.log", "logs", zipper, logMatcher);
processLogs(runDirectory, "*.log", "runDirectory", zipper, logMatcher);
processLogs(runDirectory.resolve("crash-reports"), "*.txt", "crash-reports", zipper, logMatcher);
zipper.putTextFile(LOG.getLogs(), "hmcl.log");
zipper.putTextFile(logs, "minecraft.log");
@@ -86,14 +86,11 @@ public 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) {
@@ -188,7 +188,7 @@ public final class ModpackHelper {
});
}
public static Task<?> getInstallTask(Profile profile, Path zipFile, String name, Modpack modpack) {
public static Task<?> getInstallTask(Profile profile, Path zipFile, String name, Modpack modpack, String iconUrl) {
profile.getRepository().markVersionAsModpack(name);
ExceptionalRunnable<?> success = () -> {
@@ -208,17 +208,17 @@ public final class ModpackHelper {
};
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
return modpack.getInstallTask(profile.getDependency(), zipFile, name)
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMultiMCPostInstallTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
.withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download"));
else if (modpack.getManifest() instanceof McbbsModpackManifest)
return modpack.getInstallTask(profile.getDependency(), zipFile, name)
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMcbbsPostInstallTask(profile, (McbbsModpackManifest) modpack.getManifest(), name))
.withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download"));
else
return modpack.getInstallTask(profile.getDependency(), zipFile, name)
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
.whenComplete(Schedulers.javafx(), success, failure)
.withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download"));
}
@@ -152,6 +152,12 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
@Override
public void openBrowser(String url) throws IOException {
lastlyOpenedURL = url;
try {
Thread.sleep(1500);
} catch (InterruptedException ignored) {
}
FXUtils.openLink(url);
onOpenBrowser.fireEvent(new OpenBrowserEvent(this, url));
@@ -414,6 +414,12 @@ public final class Accounts {
return i18n("account.failed.no_character");
} else if (exception instanceof ServerDisconnectException) {
if (exception.getCause() instanceof SSLException) {
if (exception.getCause().getMessage() != null && exception.getCause().getMessage().contains("Remote host terminated")) {
return i18n("account.failed.connect_authentication_server");
}
if (exception.getCause().getMessage() != null && (exception.getCause().getMessage().contains("No name matching") || exception.getCause().getMessage().contains("No subject alternative DNS name matching"))) {
return i18n("account.failed.dns");
}
return i18n("account.failed.ssl");
} else {
return i18n("account.failed.connect_authentication_server");
@@ -465,7 +471,7 @@ public final class Accounts {
} else if (exception instanceof MicrosoftService.NoMinecraftJavaEditionProfileException) {
return i18n("account.methods.microsoft.error.no_character");
} else if (exception instanceof MicrosoftService.NoXuiException) {
return i18n("account.methods.microsoft.error.add_family_probably");
return i18n("account.methods.microsoft.error.add_family");
} else if (exception instanceof OAuthServer.MicrosoftAuthenticationNotSupportedException) {
return i18n("account.methods.microsoft.snapshot");
} else if (exception instanceof OAuthAccount.WrongAccountException) {
@@ -34,12 +34,12 @@ import org.hildan.fxgson.factories.JavaFxPropertyTypeAdapterFactory;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.theme.ThemeColor;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.gson.*;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import org.jetbrains.annotations.Nullable;
import java.lang.reflect.*;
import java.net.Proxy;
import java.nio.file.Path;
import java.util.*;
@@ -276,19 +276,34 @@ public final class Config extends ObservableSetting {
// UI
@SerializedName("themeBrightness")
private final StringProperty themeBrightness = new SimpleStringProperty("light");
public StringProperty themeBrightnessProperty() {
return themeBrightness;
}
public String getThemeBrightness() {
return themeBrightness.get();
}
public void setThemeBrightness(String themeBrightness) {
this.themeBrightness.set(themeBrightness);
}
@SerializedName("theme")
private final ObjectProperty<Theme> theme = new SimpleObjectProperty<>();
private final ObjectProperty<ThemeColor> themeColor = new SimpleObjectProperty<>(ThemeColor.DEFAULT);
public ObjectProperty<Theme> themeProperty() {
return theme;
public ObjectProperty<ThemeColor> themeColorProperty() {
return themeColor;
}
public Theme getTheme() {
return theme.get();
public ThemeColor getThemeColor() {
return themeColor.get();
}
public void setTheme(Theme theme) {
this.theme.set(theme);
public void setThemeColor(ThemeColor themeColor) {
this.themeColor.set(themeColor);
}
@SerializedName("fontFamily")
@@ -478,7 +493,7 @@ public final class Config extends ObservableSetting {
}
@SerializedName("downloadType")
private final StringProperty downloadType = new SimpleStringProperty(DownloadProviders.DEFAULT_RAW_PROVIDER_ID);
private final StringProperty downloadType = new SimpleStringProperty(DownloadProviders.DEFAULT_DIRECT_PROVIDER_ID);
public StringProperty downloadTypeProperty() {
return downloadType;
@@ -508,7 +523,7 @@ public final class Config extends ObservableSetting {
}
@SerializedName("versionListSource")
private final StringProperty versionListSource = new SimpleStringProperty("balanced");
private final StringProperty versionListSource = new SimpleStringProperty(DownloadProviders.DEFAULT_AUTO_PROVIDER_ID);
public StringProperty versionListSourceProperty() {
return versionListSource;
@@ -21,9 +21,10 @@ import javafx.beans.InvalidationListener;
import org.jackhuang.hmcl.download.*;
import org.jackhuang.hmcl.task.DownloadException;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.io.ResponseCodeException;
import javax.net.ssl.SSLHandshakeException;
@@ -31,104 +32,88 @@ import java.io.FileNotFoundException;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.nio.file.AccessDeniedException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CancellationException;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class DownloadProviders {
private DownloadProviders() {
}
private static final DownloadProviderWrapper provider;
public static final String DEFAULT_AUTO_PROVIDER_ID = "balanced";
public static final String DEFAULT_DIRECT_PROVIDER_ID = "mojang";
public static final Map<String, DownloadProvider> providersById;
public static final Map<String, DownloadProvider> rawProviders;
private static final AdaptedDownloadProvider fileDownloadProvider = new AdaptedDownloadProvider();
private static final DownloadProviderWrapper PROVIDER_WRAPPER;
private static final MojangDownloadProvider MOJANG;
private static final BMCLAPIDownloadProvider BMCLAPI;
public static final String DEFAULT_PROVIDER_ID = "balanced";
public static final String DEFAULT_RAW_PROVIDER_ID = "bmclapi";
@SuppressWarnings("unused")
private static final InvalidationListener observer;
private static final DownloadProvider DEFAULT_PROVIDER;
public static final Map<String, DownloadProvider> DIRECT_PROVIDERS;
public static final Map<String, DownloadProvider> AUTO_PROVIDERS;
static {
String bmclapiRoot = "https://bmclapi2.bangbang93.com";
String bmclapiRootOverride = System.getProperty("hmcl.bmclapi.override");
if (bmclapiRootOverride != null) bmclapiRoot = bmclapiRootOverride;
String bmclapiRoot = System.getProperty("hmcl.bmclapi.override", "https://bmclapi2.bangbang93.com");
BMCLAPIDownloadProvider bmclapiRaw = new BMCLAPIDownloadProvider(bmclapiRoot);
MOJANG = new MojangDownloadProvider();
BMCLAPI = new BMCLAPIDownloadProvider(bmclapiRoot);
rawProviders = Map.of(
"mojang", MOJANG,
"bmclapi", BMCLAPI
DownloadProvider mojang = new MojangDownloadProvider();
DownloadProvider bmclapi = new AutoDownloadProvider(bmclapiRaw, mojang);
DEFAULT_PROVIDER = mojang;
DIRECT_PROVIDERS = Lang.mapOf(
pair("mojang", mojang),
pair("bmclapi", bmclapi)
);
AdaptedDownloadProvider fileProvider = new AdaptedDownloadProvider();
fileProvider.setDownloadProviderCandidates(List.of(BMCLAPI, MOJANG));
BalancedDownloadProvider balanced = new BalancedDownloadProvider(MOJANG, BMCLAPI);
AUTO_PROVIDERS = Lang.mapOf(
pair("balanced", LocaleUtils.IS_CHINA_MAINLAND ? bmclapi : mojang),
pair("official", LocaleUtils.IS_CHINA_MAINLAND ? new AutoDownloadProvider(
List.of(mojang, bmclapiRaw),
List.of(bmclapiRaw, mojang)
) : mojang),
pair("mirror", bmclapi)
);
providersById = Map.of(
"official", new AutoDownloadProvider(MOJANG, fileProvider),
"balanced", new AutoDownloadProvider(balanced, fileProvider),
"mirror", new AutoDownloadProvider(BMCLAPI, fileProvider));
observer = FXUtils.observeWeak(() -> {
FetchTask.setDownloadExecutorConcurrency(
config().getAutoDownloadThreads() ? DEFAULT_CONCURRENCY : config().getDownloadThreads());
}, config().autoDownloadThreadsProperty(), config().downloadThreadsProperty());
provider = new DownloadProviderWrapper(MOJANG);
PROVIDER_WRAPPER = new DownloadProviderWrapper(DEFAULT_PROVIDER);
}
static void init() {
InvalidationListener onChangeDownloadSource = observable -> {
String versionListSource = Objects.requireNonNullElse(config().getVersionListSource(), "");
if (config().isAutoChooseDownloadType()) {
DownloadProvider currentDownloadProvider = providersById.get(versionListSource);
if (currentDownloadProvider == null)
currentDownloadProvider = Objects.requireNonNull(providersById.get(DEFAULT_PROVIDER_ID),
"default provider is null");
InvalidationListener onChangeDownloadThreads = observable -> {
FetchTask.setDownloadExecutorConcurrency(config().getAutoDownloadThreads()
? DEFAULT_CONCURRENCY
: config().getDownloadThreads());
};
config().autoDownloadThreadsProperty().addListener(onChangeDownloadThreads);
config().downloadThreadsProperty().addListener(onChangeDownloadThreads);
onChangeDownloadThreads.invalidated(null);
provider.setProvider(currentDownloadProvider);
InvalidationListener onChangeDownloadSource = observable -> {
if (config().isAutoChooseDownloadType()) {
String versionListSource = config().getVersionListSource();
DownloadProvider downloadProvider = versionListSource != null
? AUTO_PROVIDERS.getOrDefault(versionListSource, DEFAULT_PROVIDER)
: DEFAULT_PROVIDER;
PROVIDER_WRAPPER.setProvider(downloadProvider);
} else {
provider.setProvider(fileDownloadProvider);
String downloadType = config().getDownloadType();
PROVIDER_WRAPPER.setProvider(downloadType != null
? DIRECT_PROVIDERS.getOrDefault(downloadType, DEFAULT_PROVIDER)
: DEFAULT_PROVIDER);
}
};
config().versionListSourceProperty().addListener(onChangeDownloadSource);
config().autoChooseDownloadTypeProperty().addListener(onChangeDownloadSource);
config().downloadTypeProperty().addListener(onChangeDownloadSource);
onChangeDownloadSource.invalidated(null);
FXUtils.onChangeAndOperate(config().downloadTypeProperty(), downloadType -> {
DownloadProvider primary = Objects.requireNonNullElseGet(
rawProviders.get(Objects.requireNonNullElse(downloadType, "")),
() -> rawProviders.get(DEFAULT_RAW_PROVIDER_ID));
List<DownloadProvider> providers = new ArrayList<>(rawProviders.size());
providers.add(primary);
for (DownloadProvider provider : rawProviders.values()) {
if (provider != primary)
providers.add(provider);
}
fileDownloadProvider.setDownloadProviderCandidates(providers);
});
}
/**
* Get current primary preferred download provider
*/
public static DownloadProvider getDownloadProvider() {
return provider;
return PROVIDER_WRAPPER;
}
public static String localizeErrorMessage(Throwable exception) {
@@ -149,7 +134,10 @@ public final class DownloadProviders {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.access_denied", ((AccessDeniedException) exception.getCause()).getFile());
} else if (exception.getCause() instanceof ArtifactMalformedException) {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.artifact_malformed");
} else if (exception.getCause() instanceof SSLHandshakeException) {
} else if (exception.getCause() instanceof SSLHandshakeException && !(exception.getCause().getMessage() != null && exception.getCause().getMessage().contains("Remote host terminated"))) {
if (exception.getCause().getMessage() != null && (exception.getCause().getMessage().contains("No name matching") || exception.getCause().getMessage().contains("No subject alternative DNS name matching"))) {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.dns.pollution");
}
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.ssl_handshake");
} else {
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.setting;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.text.Font;
import org.jackhuang.hmcl.Metadata;
@@ -94,31 +95,26 @@ public final class FontManager {
return null;
});
private static final ObjectProperty<FontReference> fontProperty;
private static final ObjectProperty<FontReference> font = new SimpleObjectProperty<>();
static {
updateFont();
LOG.info("Font: " + (font.get() != null ? font.get().family() : "System"));
}
private static void updateFont() {
String fontFamily = config().getLauncherFontFamily();
if (fontFamily == null)
fontFamily = System.getProperty("hmcl.font.override");
if (fontFamily == null)
fontFamily = System.getenv("HMCL_FONT");
FontReference fontReference;
if (fontFamily == null) {
Font defaultFont = DEFAULT_FONT.get();
fontReference = defaultFont != null ? new FontReference(defaultFont) : null;
} else
fontReference = new FontReference(fontFamily);
fontProperty = new SimpleObjectProperty<>(fontReference);
LOG.info("Font: " + (fontReference != null ? fontReference.getFamily() : "System"));
fontProperty.addListener((obs, oldValue, newValue) -> {
if (newValue != null)
config().setLauncherFontFamily(newValue.getFamily());
else
config().setLauncherFontFamily(null);
});
font.set(defaultFont != null ? new FontReference(defaultFont) : null);
} else {
font.set(new FontReference(fontFamily));
}
}
private static Font tryLoadDefaultFont(Path dir) {
@@ -231,64 +227,35 @@ public final class FontManager {
}
}
public static ObjectProperty<FontReference> fontProperty() {
return fontProperty;
public static ReadOnlyObjectProperty<FontReference> fontProperty() {
return font;
}
public static FontReference getFont() {
return fontProperty.get();
}
public static void setFont(FontReference font) {
fontProperty.set(font);
return font.get();
}
public static void setFontFamily(String fontFamily) {
setFont(fontFamily != null ? new FontReference(fontFamily) : null);
config().setLauncherFontFamily(fontFamily);
updateFont();
}
// https://github.com/HMCL-dev/HMCL/issues/4072
public static final class FontReference {
private final @NotNull String family;
private final @Nullable String style;
public record FontReference(@NotNull String family, @Nullable String style) {
public FontReference {
Objects.requireNonNull(family);
}
public FontReference(@NotNull String family) {
this.family = Objects.requireNonNull(family);
this.style = null;
this(family, null);
}
public FontReference(@NotNull Font font) {
this.family = font.getFamily();
this.style = font.getStyle();
}
public @NotNull String getFamily() {
return family;
}
public @Nullable String getStyle() {
return style;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof FontReference))
return false;
FontReference that = (FontReference) o;
return Objects.equals(family, that.family) && Objects.equals(style, that.style);
}
@Override
public int hashCode() {
return Objects.hash(family, style);
}
@Override
public String toString() {
return String.format("FontReference[family='%s', style='%s']", family, style);
this(font.getFamily(), font.getStyle());
}
}
private FontManager() {
}
}
@@ -19,131 +19,109 @@ package org.jackhuang.hmcl.setting;
import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import com.google.gson.annotations.SerializedName;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableSet;
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
import org.jackhuang.hmcl.util.javafx.PropertyUtils;
import org.jackhuang.hmcl.util.gson.ObservableSetting;
import org.jetbrains.annotations.Nullable;
import java.lang.reflect.Type;
import java.util.*;
@JsonAdapter(GlobalConfig.Serializer.class)
public final class GlobalConfig implements Observable {
@JsonAdapter(GlobalConfig.Adapter.class)
public final class GlobalConfig extends ObservableSetting {
@Nullable
public static GlobalConfig fromJson(String json) throws JsonParseException {
GlobalConfig loaded = Config.CONFIG_GSON.fromJson(json, GlobalConfig.class);
if (loaded == null) {
return null;
}
GlobalConfig instance = new GlobalConfig();
PropertyUtils.copyProperties(loaded, instance);
instance.unknownFields.putAll(loaded.unknownFields);
return instance;
return Config.CONFIG_GSON.fromJson(json, GlobalConfig.class);
}
private final IntegerProperty agreementVersion = new SimpleIntegerProperty();
private final IntegerProperty terracottaAgreementVersion = new SimpleIntegerProperty();
private final IntegerProperty platformPromptVersion = new SimpleIntegerProperty();
private final IntegerProperty logRetention = new SimpleIntegerProperty();
private final BooleanProperty enableOfflineAccount = new SimpleBooleanProperty(false);
private final StringProperty fontAntiAliasing = new SimpleStringProperty();
private final ObservableSet<String> userJava = FXCollections.observableSet(new LinkedHashSet<>());
private final ObservableSet<String> disabledJava = FXCollections.observableSet(new LinkedHashSet<>());
private final Map<String, Object> unknownFields = new HashMap<>();
private final transient ObservableHelper helper = new ObservableHelper(this);
public GlobalConfig() {
PropertyUtils.attachListener(this, helper);
}
@Override
public void addListener(InvalidationListener listener) {
helper.addListener(listener);
}
@Override
public void removeListener(InvalidationListener listener) {
helper.removeListener(listener);
register();
}
public String toJson() {
return Config.CONFIG_GSON.toJson(this);
}
public int getAgreementVersion() {
return agreementVersion.get();
}
@SerializedName("agreementVersion")
private final IntegerProperty agreementVersion = new SimpleIntegerProperty();
public IntegerProperty agreementVersionProperty() {
return agreementVersion;
}
public int getAgreementVersion() {
return agreementVersion.get();
}
public void setAgreementVersion(int agreementVersion) {
this.agreementVersion.set(agreementVersion);
}
public int getTerracottaAgreementVersion() {
return terracottaAgreementVersion.get();
}
@SerializedName("terracottaAgreementVersion")
private final IntegerProperty terracottaAgreementVersion = new SimpleIntegerProperty();
public IntegerProperty terracottaAgreementVersionProperty() {
return terracottaAgreementVersion;
}
public int getTerracottaAgreementVersion() {
return terracottaAgreementVersion.get();
}
public void setTerracottaAgreementVersion(int terracottaAgreementVersion) {
this.terracottaAgreementVersion.set(terracottaAgreementVersion);
}
public int getPlatformPromptVersion() {
return platformPromptVersion.get();
}
@SerializedName("platformPromptVersion")
private final IntegerProperty platformPromptVersion = new SimpleIntegerProperty();
public IntegerProperty platformPromptVersionProperty() {
return platformPromptVersion;
}
public int getPlatformPromptVersion() {
return platformPromptVersion.get();
}
public void setPlatformPromptVersion(int platformPromptVersion) {
this.platformPromptVersion.set(platformPromptVersion);
}
public int getLogRetention() {
return logRetention.get();
}
@SerializedName("logRetention")
private final IntegerProperty logRetention = new SimpleIntegerProperty(20);
public IntegerProperty logRetentionProperty() {
return logRetention;
}
public int getLogRetention() {
return logRetention.get();
}
public void setLogRetention(int logRetention) {
this.logRetention.set(logRetention);
}
public boolean isEnableOfflineAccount() {
return enableOfflineAccount.get();
}
@SerializedName("enableOfflineAccount")
private final BooleanProperty enableOfflineAccount = new SimpleBooleanProperty(false);
public BooleanProperty enableOfflineAccountProperty() {
return enableOfflineAccount;
}
public boolean isEnableOfflineAccount() {
return enableOfflineAccount.get();
}
public void setEnableOfflineAccount(boolean value) {
enableOfflineAccount.set(value);
}
@SerializedName("fontAntiAliasing")
private final StringProperty fontAntiAliasing = new SimpleStringProperty();
public StringProperty fontAntiAliasingProperty() {
return fontAntiAliasing;
}
@@ -156,89 +134,24 @@ public final class GlobalConfig implements Observable {
this.fontAntiAliasing.set(value);
}
@SerializedName("userJava")
private final ObservableSet<String> userJava = FXCollections.observableSet(new LinkedHashSet<>());
public ObservableSet<String> getUserJava() {
return userJava;
}
@SerializedName("disabledJava")
private final ObservableSet<String> disabledJava = FXCollections.observableSet(new LinkedHashSet<>());
public ObservableSet<String> getDisabledJava() {
return disabledJava;
}
public static final class Serializer implements JsonSerializer<GlobalConfig>, JsonDeserializer<GlobalConfig> {
private static final Set<String> knownFields = new HashSet<>(Arrays.asList(
"agreementVersion",
"terracottaAgreementVersion",
"platformPromptVersion",
"logRetention",
"userJava",
"disabledJava",
"enableOfflineAccount",
"fontAntiAliasing"
));
static final class Adapter extends ObservableSetting.Adapter<GlobalConfig> {
@Override
public JsonElement serialize(GlobalConfig src, Type typeOfSrc, JsonSerializationContext context) {
if (src == null) {
return JsonNull.INSTANCE;
}
JsonObject jsonObject = new JsonObject();
jsonObject.add("agreementVersion", context.serialize(src.getAgreementVersion()));
jsonObject.add("terracottaAgreementVersion", context.serialize(src.getTerracottaAgreementVersion()));
jsonObject.add("platformPromptVersion", context.serialize(src.getPlatformPromptVersion()));
jsonObject.add("logRetention", context.serialize(src.getLogRetention()));
jsonObject.add("fontAntiAliasing", context.serialize(src.getFontAntiAliasing()));
if (src.enableOfflineAccount.get())
jsonObject.addProperty("enableOfflineAccount", true);
if (!src.getUserJava().isEmpty())
jsonObject.add("userJava", context.serialize(src.getUserJava()));
if (!src.getDisabledJava().isEmpty())
jsonObject.add("disabledJava", context.serialize(src.getDisabledJava()));
for (Map.Entry<String, Object> entry : src.unknownFields.entrySet()) {
jsonObject.add(entry.getKey(), context.serialize(entry.getValue()));
}
return jsonObject;
}
@Override
public GlobalConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
if (!(json instanceof JsonObject)) return null;
JsonObject obj = (JsonObject) json;
GlobalConfig config = new GlobalConfig();
config.setAgreementVersion(Optional.ofNullable(obj.get("agreementVersion")).map(JsonElement::getAsInt).orElse(0));
config.setTerracottaAgreementVersion(Optional.ofNullable(obj.get("terracottaAgreementVersion")).map(JsonElement::getAsInt).orElse(0));
config.setPlatformPromptVersion(Optional.ofNullable(obj.get("platformPromptVersion")).map(JsonElement::getAsInt).orElse(0));
config.setLogRetention(Optional.ofNullable(obj.get("logRetention")).map(JsonElement::getAsInt).orElse(20));
config.setEnableOfflineAccount(Optional.ofNullable(obj.get("enableOfflineAccount")).map(JsonElement::getAsBoolean).orElse(false));
config.setFontAntiAliasing(Optional.ofNullable(obj.get("fontAntiAliasing")).map(JsonElement::getAsString).orElse(null));
JsonElement userJava = obj.get("userJava");
if (userJava != null && userJava.isJsonArray()) {
for (JsonElement element : userJava.getAsJsonArray()) {
config.userJava.add(element.getAsString());
}
}
JsonElement disabledJava = obj.get("disabledJava");
if (disabledJava != null && disabledJava.isJsonArray()) {
for (JsonElement element : disabledJava.getAsJsonArray()) {
config.disabledJava.add(element.getAsString());
}
}
for (Map.Entry<String, JsonElement> entry : obj.entrySet()) {
if (!knownFields.contains(entry.getKey())) {
config.unknownFields.put(entry.getKey(), context.deserialize(entry.getValue(), Object.class));
}
}
return config;
protected GlobalConfig createInstance() {
return new GlobalConfig();
}
}
}
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.setting;
import javafx.beans.InvalidationListener;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNull;
@@ -114,6 +115,8 @@ public final class ProxyManager {
config().hasProxyAuthProperty().addListener(updateAuthenticator);
config().proxyUserProperty().addListener(updateAuthenticator);
config().proxyPassProperty().addListener(updateAuthenticator);
FetchTask.notifyInitialized();
}
private static abstract class AbstractProxySelector extends ProxySelector {
@@ -22,6 +22,12 @@ import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.scene.paint.Color;
import org.glavo.monetfx.Brightness;
import org.glavo.monetfx.ColorRole;
import org.glavo.monetfx.ColorScheme;
import org.jackhuang.hmcl.theme.Theme;
import org.jackhuang.hmcl.theme.ThemeColor;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
import java.io.IOException;
@@ -33,7 +39,6 @@ import java.util.Arrays;
import java.util.Base64;
import java.util.Locale;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
@@ -42,6 +47,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class StyleSheets {
private static final int FONT_STYLE_SHEET_INDEX = 0;
private static final int THEME_STYLE_SHEET_INDEX = 1;
private static final int BRIGHTNESS_SHEET_INDEX = 2;
private static final ObservableList<String> stylesheets;
@@ -49,12 +55,16 @@ public final class StyleSheets {
String[] array = new String[]{
getFontStyleSheet(),
getThemeStyleSheet(),
getBrightnessStyleSheet(),
"/assets/css/root.css"
};
stylesheets = FXCollections.observableList(Arrays.asList(array));
FontManager.fontProperty().addListener(o -> stylesheets.set(FONT_STYLE_SHEET_INDEX, getFontStyleSheet()));
config().themeProperty().addListener(o -> stylesheets.set(THEME_STYLE_SHEET_INDEX, getThemeStyleSheet()));
Themes.colorSchemeProperty().addListener(o -> {
stylesheets.set(THEME_STYLE_SHEET_INDEX, getThemeStyleSheet());
stylesheets.set(BRIGHTNESS_SHEET_INDEX, getBrightnessStyleSheet());
});
}
private static String toStyleSheetUri(String styleSheet, String fallback) {
@@ -80,11 +90,11 @@ public final class StyleSheets {
final String defaultCss = "/assets/css/font.css";
final FontManager.FontReference font = FontManager.getFont();
if (font == null || "System".equals(font.getFamily()))
if (font == null || "System".equals(font.family()))
return defaultCss;
String fontFamily = font.getFamily();
String style = font.getStyle();
String fontFamily = font.family();
String style = font.style();
String weight = null;
String posture = null;
@@ -126,31 +136,55 @@ public final class StyleSheets {
return toStyleSheetUri(builder.toString(), defaultCss);
}
private static String rgba(Color color, double opacity) {
return String.format("rgba(%d, %d, %d, %.1f)",
(int) Math.ceil(color.getRed() * 256),
(int) Math.ceil(color.getGreen() * 256),
(int) Math.ceil(color.getBlue() * 256),
opacity);
private static String getBrightnessStyleSheet() {
return Themes.getColorScheme().getBrightness() == Brightness.LIGHT
? "/assets/css/brightness-light.css"
: "/assets/css/brightness-dark.css";
}
private static void addColor(StringBuilder builder, String name, Color color) {
builder.append(" ").append(name)
.append(": ").append(ThemeColor.getColorDisplayName(color)).append(";\n");
}
private static void addColor(StringBuilder builder, String name, Color color, double opacity) {
builder.append(" ").append(name)
.append(": ").append(ThemeColor.getColorDisplayNameWithOpacity(color, opacity)).append(";\n");
}
private static void addColor(StringBuilder builder, ColorScheme scheme, ColorRole role, double opacity) {
builder.append(" ").append(role.getVariableName()).append("-transparent-%02d".formatted((int) (100 * opacity)))
.append(": ").append(ThemeColor.getColorDisplayNameWithOpacity(scheme.getColor(role), opacity))
.append(";\n");
}
private static String getThemeStyleSheet() {
final String blueCss = "/assets/css/blue.css";
Theme theme = config().getTheme();
if (theme == null || theme.getPaint().equals(Theme.BLUE.getPaint()))
if (Theme.DEFAULT.equals(Themes.getTheme()))
return blueCss;
return toStyleSheetUri(".root {" +
"-fx-base-color:" + theme.getColor() + ';' +
"-fx-base-darker-color: derive(-fx-base-color, -10%);" +
"-fx-base-check-color: derive(-fx-base-color, 30%);" +
"-fx-rippler-color:" + rgba(theme.getPaint(), 0.3) + ';' +
"-fx-base-rippler-color: derive(" + rgba(theme.getPaint(), 0.3) + ", 100%);" +
"-fx-base-disabled-text-fill:" + rgba(theme.getForegroundColor(), 0.7) + ";" +
"-fx-base-text-fill:" + Theme.getColorDisplayName(theme.getForegroundColor()) + ";" +
"-theme-thumb:" + rgba(theme.getPaint(), 0.7) + ";" +
'}', blueCss);
ColorScheme scheme = Themes.getColorScheme();
StringBuilder builder = new StringBuilder();
builder.append("* {\n");
for (ColorRole colorRole : ColorRole.ALL) {
addColor(builder, colorRole.getVariableName(), scheme.getColor(colorRole));
}
addColor(builder, "-monet-primary-seed", scheme.getPrimaryColorSeed());
addColor(builder, scheme, ColorRole.PRIMARY, 0.5);
addColor(builder, scheme, ColorRole.SECONDARY_CONTAINER, 0.5);
addColor(builder, scheme, ColorRole.SURFACE, 0.5);
addColor(builder, scheme, ColorRole.SURFACE, 0.8);
addColor(builder, scheme, ColorRole.ON_SURFACE_VARIANT, 0.38);
addColor(builder, scheme, ColorRole.SURFACE_CONTAINER_LOW, 0.8);
addColor(builder, scheme, ColorRole.SECONDARY_CONTAINER, 0.8);
addColor(builder, scheme, ColorRole.INVERSE_SURFACE, 0.8);
builder.append("}\n");
return toStyleSheetUri(builder.toString(), blueCss);
}
public static void init(Scene scene) {
@@ -1,163 +0,0 @@
/*
* 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/>.
*/
package org.jackhuang.hmcl.setting;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.scene.paint.Color;
import java.io.IOException;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
@JsonAdapter(Theme.TypeAdapter.class)
public final class Theme {
public static final Theme BLUE = new Theme("blue", "#5C6BC0");
public static final Color BLACK = Color.web("#292929");
public static final Color[] SUGGESTED_COLORS = new Color[]{
Color.web("#3D6DA3"), // blue
Color.web("#283593"), // dark blue
Color.web("#43A047"), // green
Color.web("#E67E22"), // orange
Color.web("#9C27B0"), // purple
Color.web("#B71C1C") // red
};
public static Theme getTheme() {
Theme theme = config().getTheme();
return theme == null ? BLUE : theme;
}
private final Color paint;
private final String color;
private final String name;
Theme(String name, String color) {
this.name = name;
this.color = Objects.requireNonNull(color);
this.paint = Color.web(color);
}
public String getName() {
return name;
}
public String getColor() {
return color;
}
public Color getPaint() {
return paint;
}
public boolean isCustom() {
return name.startsWith("#");
}
public boolean isLight() {
return paint.grayscale().getRed() >= 0.5;
}
public Color getForegroundColor() {
return isLight() ? Color.BLACK : Color.WHITE;
}
public static Theme custom(String color) {
if (!color.startsWith("#"))
throw new IllegalArgumentException();
return new Theme(color, color);
}
public static Optional<Theme> getTheme(String name) {
if (name == null)
return Optional.empty();
else if (name.startsWith("#"))
try {
Color.web(name);
return Optional.of(custom(name));
} catch (IllegalArgumentException ignore) {
}
else {
String color = null;
switch (name.toLowerCase(Locale.ROOT)) {
case "blue":
return Optional.of(BLUE);
case "darker_blue":
color = "#283593";
break;
case "green":
color = "#43A047";
break;
case "orange":
color = "#E67E22";
break;
case "purple":
color = "#9C27B0";
break;
case "red":
color = "#F44336";
}
if (color != null)
return Optional.of(new Theme(name, color));
}
return Optional.empty();
}
public static String getColorDisplayName(Color c) {
return c != null ? String.format("#%02X%02X%02X", Math.round(c.getRed() * 255.0D), Math.round(c.getGreen() * 255.0D), Math.round(c.getBlue() * 255.0D)) : null;
}
private static ObjectBinding<Color> FOREGROUND_FILL;
public static ObjectBinding<Color> foregroundFillBinding() {
if (FOREGROUND_FILL == null)
FOREGROUND_FILL = Bindings.createObjectBinding(
() -> Theme.getTheme().getForegroundColor(),
config().themeProperty()
);
return FOREGROUND_FILL;
}
public static Color blackFill() {
return BLACK;
}
public static Color whiteFill() {
return Color.WHITE;
}
public static class TypeAdapter extends com.google.gson.TypeAdapter<Theme> {
@Override
public void write(JsonWriter out, Theme value) throws IOException {
out.value(value.getName().toLowerCase(Locale.ROOT));
}
@Override
public Theme read(JsonReader in) throws IOException {
return getTheme(in.nextString()).orElse(Theme.BLUE);
}
}
}
@@ -459,6 +459,20 @@ public final class VersionSetting implements Cloneable, Observable {
showLogsProperty.set(showLogs);
}
private final BooleanProperty enableDebugLogOutputProperty = new SimpleBooleanProperty(this, "enableDebugLogOutput", false);
public BooleanProperty enableDebugLogOutputProperty() {
return enableDebugLogOutputProperty;
}
public boolean isEnableDebugLogOutput() {
return enableDebugLogOutputProperty.get();
}
public void setEnableDebugLogOutput(boolean u) {
this.enableDebugLogOutputProperty.set(u);
}
// Minecraft settings.
private final StringProperty serverIpProperty = new SimpleStringProperty(this, "serverIp", "");
@@ -776,6 +790,7 @@ public final class VersionSetting implements Cloneable, Observable {
obj.addProperty("notCheckJVM", src.isNotCheckJVM());
obj.addProperty("notPatchNatives", src.isNotPatchNatives());
obj.addProperty("showLogs", src.isShowLogs());
obj.addProperty("enableDebugLogOutput", src.isEnableDebugLogOutput());
obj.addProperty("gameDir", src.getGameDir());
obj.addProperty("launcherVisibility", src.getLauncherVisibility().ordinal());
obj.addProperty("processPriority", src.getProcessPriority().ordinal());
@@ -847,6 +862,7 @@ public final class VersionSetting implements Cloneable, Observable {
vs.setNotCheckJVM(Optional.ofNullable(obj.get("notCheckJVM")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNotPatchNatives(Optional.ofNullable(obj.get("notPatchNatives")).map(JsonElement::getAsBoolean).orElse(false));
vs.setShowLogs(Optional.ofNullable(obj.get("showLogs")).map(JsonElement::getAsBoolean).orElse(false));
vs.setEnableDebugLogOutput(Optional.ofNullable(obj.get("enableDebugLogOutput")).map(JsonElement::getAsBoolean).orElse(false));
vs.setLauncherVisibility(parseJsonPrimitive(obj.getAsJsonPrimitive("launcherVisibility"), LauncherVisibility.class, LauncherVisibility.HIDE));
vs.setProcessPriority(parseJsonPrimitive(obj.getAsJsonPrimitive("processPriority"), ProcessPriority.class, ProcessPriority.NORMAL));
vs.setUseNativeGLFW(Optional.ofNullable(obj.get("useNativeGLFW")).map(JsonElement::getAsBoolean).orElse(false));
@@ -0,0 +1,191 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 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.terracotta;
import kala.compress.archivers.tar.TarArchiveEntry;
import org.jackhuang.hmcl.download.ArtifactMalformedException;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.DigestInputStream;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
public final class TerracottaBundle {
private final Path root;
private final List<URI> links;
private final FileDownloadTask.IntegrityCheck hash;
private final Map<String, FileDownloadTask.IntegrityCheck> files;
public TerracottaBundle(Path root, List<URI> links, FileDownloadTask.IntegrityCheck hash, Map<String, FileDownloadTask.IntegrityCheck> files) {
this.root = root;
this.links = links;
this.hash = hash;
this.files = files;
}
public Task<Path> download(AbstractTerracottaProvider.DownloadContext context) {
return Task.supplyAsync(() -> Files.createTempFile("terracotta-", ".tar.gz"))
.thenComposeAsync(Schedulers.javafx(), pkg -> {
FileDownloadTask download = new FileDownloadTask(links, pkg, hash) {
@Override
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
FetchTask.Context delegate = super.getContext(response, checkETag, bmclapiHash);
return new Context() {
@Override
public void withResult(boolean success) {
delegate.withResult(success);
}
@Override
public void write(byte[] buffer, int offset, int len) throws IOException {
context.checkCancellation();
delegate.write(buffer, offset, len);
}
@Override
public void close() throws IOException {
if (isSuccess()) {
context.checkCancellation();
}
delegate.close();
}
};
}
};
context.bindProgress(download.progressProperty());
return download.thenSupplyAsync(() -> pkg);
});
}
public Task<?> install(Path pkg) {
return Task.runAsync(() -> {
Files.createDirectories(root);
FileUtils.cleanDirectory(root);
try (TarFileTree tree = TarFileTree.open(pkg)) {
for (Map.Entry<String, FileDownloadTask.IntegrityCheck> entry : files.entrySet()) {
String file = entry.getKey();
FileDownloadTask.IntegrityCheck check = entry.getValue();
Path path = root.resolve(file);
TarArchiveEntry archive = tree.getEntry("/" + file);
if (archive == null) {
throw new ArtifactMalformedException(String.format("Expecting %s file in terracotta bundle.", file));
}
MessageDigest digest = DigestUtils.getDigest(check.getAlgorithm());
try (
InputStream is = tree.getInputStream(archive);
OutputStream os = new DigestOutputStream(Files.newOutputStream(path), digest)
) {
is.transferTo(os);
}
String hash = HexFormat.of().formatHex(digest.digest());
if (!check.getChecksum().equalsIgnoreCase(hash)) {
throw new ChecksumMismatchException(check.getAlgorithm(), check.getChecksum(), hash);
}
switch (OperatingSystem.CURRENT_OS) {
case LINUX, MACOS, FREEBSD -> Files.setPosixFilePermissions(path, FileUtils.parsePosixFilePermission(archive.getMode()));
}
}
}
}).whenComplete(exception -> {
if (exception != null) {
FileUtils.deleteDirectory(root);
}
});
}
public Path locate(String file) {
FileDownloadTask.IntegrityCheck check = files.get(file);
if (check == null) {
throw new AssertionError(String.format("Expecting %s file in terracotta bundle.", file));
}
return root.resolve(file).toAbsolutePath();
}
public AbstractTerracottaProvider.Status status() throws IOException {
if (Files.exists(root) && isLocalBundleValid()) {
return AbstractTerracottaProvider.Status.READY;
}
try {
if (TerracottaMetadata.hasLegacyVersionFiles()) {
return AbstractTerracottaProvider.Status.LEGACY_VERSION;
}
} catch (IOException e) {
Logger.LOG.warning("Cannot determine whether legacy versions exist.", e);
}
return AbstractTerracottaProvider.Status.NOT_EXIST;
}
private boolean isLocalBundleValid() throws IOException { // FIXME: Make control flow clearer.
long total = 0;
byte[] buffer = new byte[8192];
for (Map.Entry<String, FileDownloadTask.IntegrityCheck> entry : files.entrySet()) {
Path path = root.resolve(entry.getKey());
FileDownloadTask.IntegrityCheck check = entry.getValue();
if (!Files.isReadable(path)) {
return false;
}
MessageDigest digest = DigestUtils.getDigest(check.getAlgorithm());
try (InputStream is = new DigestInputStream(Files.newInputStream(path), digest)) {
int n;
while ((n = is.read(buffer)) >= 0) {
total += n;
if (total >= 50 * 1024 * 1024) { // >=50MB
return false;
}
}
}
if (!HexFormat.of().formatHex(digest.digest()).equalsIgnoreCase(check.getChecksum())) {
return false;
}
}
return true;
}
}
@@ -18,7 +18,6 @@
package org.jackhuang.hmcl.terracotta;
import com.google.gson.JsonObject;
import com.google.gson.reflect.TypeToken;
import javafx.application.Platform;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.beans.property.ReadOnlyObjectProperty;
@@ -29,27 +28,32 @@ import org.jackhuang.hmcl.task.DownloadException;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.InvocationDispatcher;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -70,9 +74,10 @@ public final class TerracottaManager {
switch (TerracottaMetadata.PROVIDER.status()) {
case NOT_EXIST -> setState(new TerracottaState.Uninitialized(false));
case LEGACY_VERSION -> setState(new TerracottaState.Uninitialized(true));
case READY -> launch(setState(new TerracottaState.Launching()));
case READY -> launch(setState(new TerracottaState.Launching()), false);
}
} catch (Exception e) {
LOG.warning("Cannot initialize Terracotta.", e);
compareAndSet(TerracottaState.Bootstrap.INSTANCE, new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
}
});
@@ -82,114 +87,145 @@ public final class TerracottaManager {
return STATE.getReadOnlyProperty();
}
static {
Lang.thread(() -> {
while (true) {
TerracottaState state = STATE_V.get();
if (!(state instanceof TerracottaState.PortSpecific portSpecific)) {
LockSupport.parkNanos(500_000);
private static final Thread DAEMON = Lang.thread(TerracottaManager::runBackground, "Terracotta Background Daemon", true);
@FXThread // Written in FXThread, read-only on background daemon
private static volatile boolean daemonRunning = false;
private static void runBackground() {
final long ACTIVE = TimeUnit.MILLISECONDS.toNanos(500);
final long BACKGROUND = TimeUnit.SECONDS.toMillis(15);
while (true) {
if (daemonRunning) {
LockSupport.parkNanos(ACTIVE);
} else {
long deadline = System.currentTimeMillis() + BACKGROUND;
do {
LockSupport.parkUntil(deadline);
} while (!daemonRunning && System.currentTimeMillis() < deadline - 100);
}
if (!(STATE_V.get() instanceof TerracottaState.PortSpecific state)) {
continue;
}
int port = state.port;
int index = state instanceof TerracottaState.Ready ready ? ready.index : Integer.MIN_VALUE;
TerracottaState next;
try {
TerracottaState.Ready object = HttpRequest.GET(String.format("http://127.0.0.1:%d/state", port))
.retry(5)
.getJson(TerracottaState.Ready.class);
if (object.index <= index) {
continue;
}
int port = portSpecific.port;
int index = state instanceof TerracottaState.Ready ready ? ready.index : Integer.MIN_VALUE;
TerracottaState next;
try {
next = new GetTask(URI.create(String.format("http://127.0.0.1:%d/state", port)))
.setSignificance(Task.TaskSignificance.MINOR)
.thenApplyAsync(jsonString -> {
TerracottaState.Ready object = JsonUtils.fromNonNullJson(jsonString, TypeToken.get(TerracottaState.Ready.class));
if (object.index <= index) {
return null;
}
object.port = port;
return object;
})
.setSignificance(Task.TaskSignificance.MINOR)
.run();
} catch (Exception e) {
LOG.warning("Cannot fetch state from Terracotta.", e);
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
}
if (next != null) {
compareAndSet(state, next);
}
LockSupport.parkNanos(500_000);
object.port = port;
next = object;
} catch (Exception e) {
LOG.warning("Cannot fetch state from Terracotta.", e);
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
}
}, "Terracotta Background Daemon", true);
compareAndSet(state, next);
}
}
public static boolean validate(Path file) {
return FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME);
}
public static TerracottaState.Preparing install(@Nullable Path file) {
@FXThread
public static void switchDaemon(boolean active) {
FXUtils.checkFxUserThread();
TerracottaState state = STATE_V.get();
if (!(state instanceof TerracottaState.Uninitialized ||
state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence() ||
state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())
) {
return null;
}
if (file != null && !FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME)) {
return null;
}
TerracottaState.Preparing preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1));
Task.supplyAsync(Schedulers.io(), () -> {
return file != null ? TarFileTree.open(file) : null;
}).thenComposeAsync(Schedulers.javafx(), tree -> {
return getProvider().install(preparing, tree).whenComplete(exception -> {
if (tree != null) {
tree.close();
}
if (exception != null) {
throw exception;
}
});
}).whenComplete(exception -> {
if (exception == null) {
try {
TerracottaMetadata.removeLegacyVersionFiles();
} catch (IOException e) {
LOG.warning("Unable to remove legacy terracotta files.", e);
}
TerracottaState.Launching launching = new TerracottaState.Launching();
if (compareAndSet(preparing, launching)) {
launch(launching);
}
} else if (exception instanceof CancellationException) {
} else if (exception instanceof ITerracottaProvider.ArchiveFileMissingException) {
LOG.warning("Cannot install terracotta from local package.", exception);
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
} else if (exception instanceof DownloadException) {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.NETWORK));
} else {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
boolean dr = daemonRunning;
if (dr != active) {
daemonRunning = active;
if (active) {
LockSupport.unpark(DAEMON);
}
}).start();
return compareAndSet(state, preparing) ? preparing : null;
}
}
private static ITerracottaProvider getProvider() {
ITerracottaProvider provider = TerracottaMetadata.PROVIDER;
private static AbstractTerracottaProvider getProvider() {
AbstractTerracottaProvider provider = TerracottaMetadata.PROVIDER;
if (provider == null) {
throw new AssertionError("Terracotta Provider must NOT be null.");
}
return provider;
}
public static TerracottaState recover(@Nullable Path file) {
public static boolean isInvalidBundle(Path file) {
return !FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME);
}
@FXThread
public static TerracottaState.Preparing download() {
FXUtils.checkFxUserThread();
TerracottaState state = STATE_V.get();
if (!(state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())
) {
return null;
}
TerracottaState.Preparing preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1), true);
Task.composeAsync(() -> getProvider().download(preparing))
.thenComposeAsync(pkg -> {
if (!preparing.requestInstallFence()) {
return null;
}
return getProvider().install(pkg).thenRunAsync(() -> {
TerracottaState.Launching launching = new TerracottaState.Launching();
if (compareAndSet(preparing, launching)) {
launch(launching, true);
}
});
}).whenComplete(exception -> {
if (exception instanceof CancellationException) {
// no-op
} else if (exception instanceof DownloadException) {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.NETWORK));
} else {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
}
}).start();
return compareAndSet(state, preparing) ? preparing : null;
}
@FXThread
public static TerracottaState.Preparing install(Path bundle) {
FXUtils.checkFxUserThread();
if (isInvalidBundle(bundle)) {
return null;
}
TerracottaState state = STATE_V.get();
TerracottaState.Preparing preparing;
if (state instanceof TerracottaState.Preparing previousPreparing && previousPreparing.requestInstallFence()) {
preparing = previousPreparing;
} else if (state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable()) {
preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1), false);
} else {
return null;
}
Task.composeAsync(() -> getProvider().install(bundle))
.thenRunAsync(() -> {
TerracottaState.Launching launching = new TerracottaState.Launching();
if (compareAndSet(preparing, launching)) {
launch(launching, true);
}
})
.whenComplete(exception -> {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
}).start();
return state != preparing && compareAndSet(state, preparing) ? preparing : null;
}
@FXThread
public static TerracottaState recover() {
FXUtils.checkFxUserThread();
TerracottaState state = STATE_V.get();
@@ -198,21 +234,23 @@ public final class TerracottaManager {
}
try {
// FIXME: A temporary limit has been employed in TerracottaBundle#checkExisting, making
// hash check accept 50MB at most. Calling it on JavaFX should be safe.
return switch (getProvider().status()) {
case NOT_EXIST, LEGACY_VERSION -> install(file);
case NOT_EXIST, LEGACY_VERSION -> download();
case READY -> {
TerracottaState.Launching launching = setState(new TerracottaState.Launching());
launch(launching);
launch(launching, false);
yield launching;
}
};
} catch (NullPointerException | IOException e) {
} catch (RuntimeException | IOException e) {
LOG.warning("Cannot determine Terracotta state.", e);
return setState(new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
}
}
private static void launch(TerracottaState.Launching state) {
private static void launch(TerracottaState.Launching state, boolean removeLegacy) {
Task.supplyAsync(() -> {
Path path = Files.createTempDirectory(String.format("hmcl-terracotta-%d", ThreadLocalRandom.current().nextLong())).resolve("http").toAbsolutePath();
ManagedProcess process = new ManagedProcess(new ProcessBuilder(getProvider().ofCommandLine(path)));
@@ -230,7 +268,7 @@ public final class TerracottaManager {
if (exitTime == -1) {
exitTime = System.currentTimeMillis();
} else if (System.currentTimeMillis() - exitTime >= 10000) {
throw new IllegalStateException("Process has exited for 10s.");
throw new IllegalStateException(String.format("Process has exited for 10s, code = %s", process.getExitCode()));
}
}
}
@@ -238,6 +276,9 @@ public final class TerracottaManager {
TerracottaState next;
if (exception == null) {
next = new TerracottaState.Unknown(port);
if (removeLegacy) {
TerracottaMetadata.removeLegacyVersionFiles();
}
} else {
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
}
@@ -272,24 +313,39 @@ public final class TerracottaManager {
public static TerracottaState.HostScanning setScanning() {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
new GetTask(NetworkUtils.toURI(String.format(
"http://127.0.0.1:%d/state/scanning?player=%s", portSpecific.port, getPlayerName()))
).setSignificance(Task.TaskSignificance.MINOR).start();
Task.supplyAsync(Schedulers.io(), TerracottaNodeList::fetch)
.thenComposeAsync(nodes -> {
List<Pair<String, String>> query = new ArrayList<>(nodes.size() + 1);
query.add(pair("player", getPlayerName()));
for (URI node : nodes) {
query.add(pair("public_nodes", node.toString()));
}
return new GetTask(NetworkUtils.withQuery(
"http://127.0.0.1:%d/state/scanning".formatted(portSpecific.port), query
)).setSignificance(Task.TaskSignificance.MINOR);
}).start();
return new TerracottaState.HostScanning(-1, -1, null);
}
return null;
}
public static Task<TerracottaState.GuestStarting> setGuesting(String room) {
public static Task<TerracottaState.GuestConnecting> setGuesting(String room) {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
return new GetTask(NetworkUtils.toURI(String.format(
"http://127.0.0.1:%d/state/guesting?room=%s&player=%s", portSpecific.port, room, getPlayerName()
)))
.setSignificance(Task.TaskSignificance.MINOR)
.thenSupplyAsync(() -> new TerracottaState.GuestStarting(-1, -1, null))
.setSignificance(Task.TaskSignificance.MINOR);
return Task.supplyAsync(Schedulers.io(), TerracottaNodeList::fetch)
.thenComposeAsync(nodes -> {
ArrayList<Pair<String, String>> query = new ArrayList<>(nodes.size() + 2);
query.add(pair("room", room));
query.add(pair("player", getPlayerName()));
for (URI node : nodes) {
query.add(pair("public_nodes", node.toString()));
}
return new GetTask(NetworkUtils.withQuery("http://127.0.0.1:%d/state/guesting".formatted(portSpecific.port), query))
.setSignificance(Task.TaskSignificance.MINOR)
.thenSupplyAsync(() -> new TerracottaState.GuestConnecting(-1, -1, null))
.setSignificance(Task.TaskSignificance.MINOR);
});
} else {
return null;
}
@@ -20,10 +20,10 @@ package org.jackhuang.hmcl.terracotta;
import com.google.gson.annotations.SerializedName;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
import org.jackhuang.hmcl.terracotta.provider.GeneralProvider;
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
import org.jackhuang.hmcl.terracotta.provider.MacOSProvider;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
@@ -32,6 +32,8 @@ import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OSVersion;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jackhuang.hmcl.util.versioning.VersionRange;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
@@ -40,11 +42,11 @@ import java.net.URI;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -52,46 +54,60 @@ public final class TerracottaMetadata {
private TerracottaMetadata() {
}
public record Link(@SerializedName("desc") LocalizedText description, String link) {
private record Options(String version, String classifier) {
public String replace(String value) {
return value.replace("${version}", version).replace("${classifier}", classifier);
}
}
@JsonSerializable
public record Link(
@SerializedName("desc") LocalizedText description,
@SerializedName("link") String link
) {
}
@JsonSerializable
private record Package(
@SerializedName("hash") String hash,
@SerializedName("files") Map<String, String> files
) {
}
@JsonSerializable
private record Config(
@SerializedName("version_legacy") String legacy,
@SerializedName("version_recent") List<String> recent,
@SerializedName("version_latest") String latest,
@SerializedName("classifiers") Map<String, String> classifiers,
@SerializedName("packages") Map<String, Package> pkgs,
@SerializedName("downloads") List<String> downloads,
@SerializedName("downloads_CN") List<String> downloadsCN,
@SerializedName("links") List<Link> links
) {
private @Nullable TerracottaNative of(String classifier) {
String hash = this.classifiers.get(classifier);
if (hash == null)
private @Nullable TerracottaBundle resolve(Options options) {
Package pkg = pkgs.get(options.classifier);
if (pkg == null) {
return null;
if (!hash.startsWith("sha256:"))
throw new IllegalArgumentException(String.format("Invalid hash value %s for classifier %s.", hash, classifier));
hash = hash.substring("sha256:".length());
List<URI> links = new ArrayList<>(this.downloads.size() + this.downloadsCN.size());
for (String download : LocaleUtils.IS_CHINA_MAINLAND
? Lang.merge(this.downloadsCN, this.downloads)
: Lang.merge(this.downloads, this.downloadsCN)) {
links.add(URI.create(download.replace("${version}", this.latest).replace("${classifier}", classifier)));
}
return new TerracottaNative(
Collections.unmodifiableList(links),
Metadata.DEPENDENCIES_DIRECTORY.resolve(
String.format("terracotta/%s/terracotta-%s-%s", this.latest, this.latest, classifier)
).toAbsolutePath(),
new FileDownloadTask.IntegrityCheck("SHA-256", hash)
Stream<String> stream = downloads.stream(), streamCN = downloadsCN.stream();
List<URI> links = (LocaleUtils.IS_CHINA_MAINLAND ? Stream.concat(streamCN, stream) : Stream.concat(stream, streamCN))
.map(link -> URI.create(options.replace(link)))
.toList();
Map<String, FileDownloadTask.IntegrityCheck> files = pkg.files.entrySet().stream().collect(Collectors.toUnmodifiableMap(
Map.Entry::getKey,
entry -> new FileDownloadTask.IntegrityCheck("SHA-512", entry.getValue())
));
return new TerracottaBundle(
Metadata.DEPENDENCIES_DIRECTORY.resolve(options.replace("terracotta/${version}")).toAbsolutePath(),
links, new FileDownloadTask.IntegrityCheck("SHA-512", pkg.hash),
files
);
}
}
public static final ITerracottaProvider PROVIDER;
public static final AbstractTerracottaProvider PROVIDER;
public static final String PACKAGE_NAME;
public static final List<Link> PACKAGE_LINKS;
public static final String FEEDBACK_LINK = NetworkUtils.withQuery("https://docs.hmcl.net/multiplayer/feedback.html", Map.of(
@@ -99,8 +115,6 @@ public final class TerracottaMetadata {
"launcher_version", Metadata.VERSION
));
private static final Pattern LEGACY;
private static final List<String> RECENT;
private static final String LATEST;
static {
@@ -111,95 +125,80 @@ public final class TerracottaMetadata {
throw new ExceptionInInitializerError(e);
}
LEGACY = Pattern.compile(config.legacy);
RECENT = config.recent;
LATEST = config.latest;
ProviderContext context = locateProvider(config);
PROVIDER = context != null ? context.provider() : null;
PACKAGE_NAME = context != null ? String.format("terracotta-%s-%s-pkg.tar.gz", config.latest, context.branch) : null;
if (context != null) {
List<Link> packageLinks = new ArrayList<>(config.links.size());
for (Link link : config.links) {
packageLinks.add(new Link(
link.description,
link.link.replace("${version}", LATEST)
.replace("${classifier}", context.branch)
));
}
Options options = new Options(config.latest, OperatingSystem.CURRENT_OS.getCheckedName() + "-" + Architecture.SYSTEM_ARCH.getCheckedName());
TerracottaBundle bundle = config.resolve(options);
AbstractTerracottaProvider provider;
if (bundle == null || (provider = locateProvider(bundle, options)) == null) {
PROVIDER = null;
PACKAGE_NAME = null;
PACKAGE_LINKS = null;
} else {
PROVIDER = provider;
PACKAGE_NAME = options.replace("terracotta-${version}-${classifier}-pkg.tar.gz");
List<Link> packageLinks = config.links.stream()
.map(link -> new Link(link.description, options.replace(link.link)))
.collect(Collectors.toList());
Collections.shuffle(packageLinks);
PACKAGE_LINKS = Collections.unmodifiableList(packageLinks);
} else {
PACKAGE_LINKS = null;
}
}
private record ProviderContext(ITerracottaProvider provider, String branch) {
ProviderContext(ITerracottaProvider provider, String system, String arch) {
this(provider, system + "-" + arch);
}
}
@Nullable
private static ProviderContext locateProvider(Config config) {
String arch = Architecture.SYSTEM_ARCH.getCheckedName();
private static AbstractTerracottaProvider locateProvider(TerracottaBundle bundle, Options options) {
String prefix = options.replace("terracotta-${version}-${classifier}");
// FIXME: As HMCL is a cross-platform application, developers may mistakenly locate
// non-existent files in non-native platform logic without assertion errors during debugging.
return switch (OperatingSystem.CURRENT_OS) {
case WINDOWS -> {
if (!OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_10))
yield null;
TerracottaNative target = config.of("windows-%s.exe".formatted(arch));
yield target != null
? new ProviderContext(new GeneralProvider(target), "windows", arch)
: null;
}
case LINUX -> {
TerracottaNative target = config.of("linux-%s".formatted(arch));
yield target != null
? new ProviderContext(new GeneralProvider(target), "linux", arch)
: null;
}
case MACOS -> {
TerracottaNative installer = config.of("macos-%s.pkg".formatted(arch));
TerracottaNative binary = config.of("macos-%s".formatted(arch));
yield installer != null && binary != null
? new ProviderContext(new MacOSProvider(installer, binary), "macos", arch)
: null;
yield new GeneralProvider(bundle, bundle.locate(prefix + ".exe"));
}
case LINUX, FREEBSD -> new GeneralProvider(bundle, bundle.locate(prefix));
case MACOS -> new MacOSProvider(
bundle, bundle.locate(prefix), bundle.locate(prefix + ".pkg")
);
default -> null;
};
}
public static void removeLegacyVersionFiles() throws IOException {
try (DirectoryStream<Path> terracotta = Files.newDirectoryStream(Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta").toAbsolutePath())) {
for (Path path : terracotta) {
String name = FileUtils.getName(path);
if (LATEST.equals(name) || RECENT.contains(name) || !LEGACY.matcher(name).matches()) {
continue;
}
public static void removeLegacyVersionFiles() {
try (DirectoryStream<Path> terracotta = collectLegacyVersionFiles()) {
if (terracotta == null)
return;
for (Path path : terracotta) {
try {
FileUtils.deleteDirectory(path);
} catch (IOException e) {
LOG.warning(String.format("Unable to remove legacy terracotta files: %s", path), e);
}
}
} catch (IOException e) {
LOG.warning("Unable to remove legacy terracotta files.", e);
}
}
public static boolean hasLegacyVersionFiles() throws IOException {
try (DirectoryStream<Path> terracotta = Files.newDirectoryStream(Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta").toAbsolutePath())) {
for (Path path : terracotta) {
String name = FileUtils.getName(path);
if (!LATEST.equals(name) && (RECENT.contains(name) || LEGACY.matcher(name).matches())) {
return true;
}
}
try (DirectoryStream<Path> terracotta = collectLegacyVersionFiles()) {
return terracotta != null && terracotta.iterator().hasNext();
}
}
return false;
private static @Nullable DirectoryStream<Path> collectLegacyVersionFiles() throws IOException {
Path terracottaDir = Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta");
if (Files.notExists(terracottaDir))
return null;
VersionRange<VersionNumber> range = VersionNumber.atMost(LATEST);
return Files.newDirectoryStream(terracottaDir, path -> {
String name = FileUtils.getName(path);
return !LATEST.equals(name) && range.contains(VersionNumber.asVersion(name));
});
}
}
@@ -1,147 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 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.terracotta;
import kala.compress.archivers.tar.TarArchiveEntry;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.concurrent.CancellationException;
public final class TerracottaNative {
private final List<URI> links;
private final FileDownloadTask.IntegrityCheck checking;
private final Path path;
public TerracottaNative(List<URI> links, Path path, FileDownloadTask.IntegrityCheck checking) {
this.links = links;
this.path = path;
this.checking = checking;
}
public Path getPath() {
return path;
}
public Task<?> install(ITerracottaProvider.Context context, @Nullable TarFileTree tree) {
if (tree == null) {
return new FileDownloadTask(links, path, checking) {
@Override
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
Context delegate = super.getContext(response, checkETag, bmclapiHash);
return new Context() {
@Override
public void withResult(boolean success) {
delegate.withResult(success);
}
@Override
public void write(byte[] buffer, int offset, int len) throws IOException {
if (!context.hasInstallFence()) {
throw new CancellationException("User has installed terracotta from local archives.");
}
delegate.write(buffer, offset, len);
}
@Override
public void close() throws IOException {
if (isSuccess() && !context.requestInstallFence()) {
throw new CancellationException();
}
delegate.close();
}
};
}
};
}
return Task.runAsync(() -> {
String name = FileUtils.getName(path);
TarArchiveEntry entry = tree.getRoot().getFiles().get(name);
if (entry == null) {
throw new ITerracottaProvider.ArchiveFileMissingException("Cannot exact entry: " + name);
}
if (!context.requestInstallFence()) {
throw new CancellationException();
}
Files.createDirectories(path.toAbsolutePath().getParent());
MessageDigest digest = DigestUtils.getDigest(checking.getAlgorithm());
try (
InputStream stream = tree.getInputStream(entry);
OutputStream os = Files.newOutputStream(path)
) {
stream.transferTo(new OutputStream() {
@Override
public void write(int b) throws IOException {
os.write(b);
digest.update((byte) b);
}
@Override
public void write(byte @NotNull [] buffer, int offset, int len) throws IOException {
os.write(buffer, offset, len);
digest.update(buffer, offset, len);
}
});
}
String checksum = HexFormat.of().formatHex(digest.digest());
if (!checksum.equalsIgnoreCase(checking.getChecksum())) {
Files.delete(path);
throw new ITerracottaProvider.ArchiveFileMissingException("Incorrect checksum (" + checking.getAlgorithm() + "), expected: " + checking.getChecksum() + ", actual: " + checksum);
}
});
}
public ITerracottaProvider.Status status() throws IOException {
if (Files.exists(path)) {
if (DigestUtils.digestToString(checking.getAlgorithm(), path).equalsIgnoreCase(checking.getChecksum())) {
return ITerracottaProvider.Status.READY;
}
}
try {
if (TerracottaMetadata.hasLegacyVersionFiles()) {
return ITerracottaProvider.Status.LEGACY_VERSION;
}
} catch (IOException e) {
Logger.LOG.warning("Cannot determine whether legacy versions exist.");
}
return ITerracottaProvider.Status.NOT_EXIST;
}
}
@@ -0,0 +1,103 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 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.terracotta;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
import org.jackhuang.hmcl.util.gson.Validation;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jetbrains.annotations.Nullable;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Glavo
public final class TerracottaNodeList {
private static final String NODE_LIST_URL = "https://terracotta.glavo.site/nodes";
@JsonSerializable
private record TerracottaNode(String url, @Nullable String region) implements Validation {
@Override
public void validate() throws JsonParseException, TolerableValidationException {
Validation.requireNonNull(url, "TerracottaNode.url cannot be null");
try {
new URI(url);
} catch (URISyntaxException e) {
throw new JsonParseException("Invalid URL: " + url, e);
}
}
}
private static volatile List<URI> list;
public static List<URI> fetch() {
List<URI> list = TerracottaNodeList.list;
if (list != null)
return list;
synchronized (TerracottaNodeList.class) {
list = TerracottaNodeList.list;
if (list != null)
return list;
try {
List<TerracottaNode> nodes = HttpRequest.GET(NODE_LIST_URL)
.getJson(JsonUtils.listTypeOf(TerracottaNode.class));
if (nodes == null) {
list = List.of();
LOG.info("No available Terracotta nodes found");
} else {
list = nodes.stream()
.filter(node -> {
if (node == null)
return false;
try {
node.validate();
} catch (Exception e) {
LOG.warning("Invalid terracotta node: " + node, e);
return false;
}
return StringUtils.isBlank(node.region) || LocaleUtils.IS_CHINA_MAINLAND == "CN".equalsIgnoreCase(node.region);
})
.map(it -> URI.create(it.url()))
.toList();
LOG.info("Terracotta node list: " + list);
}
} catch (Exception e) {
LOG.warning("Failed to fetch terracotta node list", e);
list = List.of();
}
TerracottaNodeList.list = list;
return list;
}
}
private TerracottaNodeList() {
}
}
@@ -21,15 +21,16 @@ import com.google.gson.JsonParseException;
import com.google.gson.annotations.SerializedName;
import javafx.beans.property.ReadOnlyDoubleProperty;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.beans.value.ObservableValue;
import javafx.beans.value.ObservableDoubleValue;
import org.jackhuang.hmcl.terracotta.profile.TerracottaProfile;
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
import org.jackhuang.hmcl.util.gson.JsonSubtype;
import org.jackhuang.hmcl.util.gson.JsonType;
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
import org.jackhuang.hmcl.util.gson.Validation;
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.atomic.AtomicBoolean;
public abstract sealed class TerracottaState {
@@ -63,32 +64,38 @@ public abstract sealed class TerracottaState {
}
}
public static final class Preparing extends TerracottaState implements ITerracottaProvider.Context {
public static final class Preparing extends TerracottaState implements AbstractTerracottaProvider.DownloadContext {
private final ReadOnlyDoubleWrapper progress;
private final AtomicBoolean installFence = new AtomicBoolean(false);
private final AtomicBoolean hasInstallFence;
Preparing(ReadOnlyDoubleWrapper progress) {
Preparing(ReadOnlyDoubleWrapper progress, boolean hasInstallFence) {
this.progress = progress;
this.hasInstallFence = new AtomicBoolean(hasInstallFence);
}
public ReadOnlyDoubleProperty progressProperty() {
return progress.getReadOnlyProperty();
}
@Override
public void bindProgress(ObservableValue<? extends Number> value) {
progress.bind(value);
}
@Override
public boolean requestInstallFence() {
return installFence.compareAndSet(false, true);
return hasInstallFence.compareAndSet(true, false);
}
public boolean hasInstallFence() {
return hasInstallFence.get();
}
@Override
public boolean hasInstallFence() {
return !installFence.get();
public void bindProgress(ObservableDoubleValue progress) {
this.progress.bind(progress);
}
@Override
public void checkCancellation() {
if (!hasInstallFence()) {
throw new CancellationException("User has installed terracotta from local archives.");
}
}
}
@@ -112,7 +119,7 @@ public abstract sealed class TerracottaState {
@JsonSubtype(clazz = HostScanning.class, name = "host-scanning"),
@JsonSubtype(clazz = HostStarting.class, name = "host-starting"),
@JsonSubtype(clazz = HostOK.class, name = "host-ok"),
@JsonSubtype(clazz = GuestStarting.class, name = "guest-connecting"),
@JsonSubtype(clazz = GuestConnecting.class, name = "guest-connecting"),
@JsonSubtype(clazz = GuestStarting.class, name = "guest-starting"),
@JsonSubtype(clazz = GuestOK.class, name = "guest-ok"),
@JsonSubtype(clazz = Exception.class, name = "exception"),
@@ -202,12 +209,34 @@ public abstract sealed class TerracottaState {
}
}
public static final class GuestStarting extends Ready {
GuestStarting(int port, int index, String state) {
public static final class GuestConnecting extends Ready {
GuestConnecting(int port, int index, String state) {
super(port, index, state);
}
}
public static final class GuestStarting extends Ready {
public enum Difficulty {
UNKNOWN,
EASIEST,
SIMPLE,
MEDIUM,
TOUGH
}
@SerializedName("difficulty")
private final Difficulty difficulty;
GuestStarting(int port, int index, String state, Difficulty difficulty) {
super(port, index, state);
this.difficulty = difficulty;
}
public Difficulty getDifficulty() {
return difficulty;
}
}
public static final class GuestOK extends Ready implements Validation {
@SerializedName("url")
private final String url;
@@ -0,0 +1,63 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 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.terracotta.provider;
import javafx.beans.value.ObservableDoubleValue;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.TerracottaBundle;
import org.jackhuang.hmcl.util.FXThread;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CancellationException;
public abstract class AbstractTerracottaProvider {
public enum Status {
NOT_EXIST,
LEGACY_VERSION,
READY
}
public interface DownloadContext {
@FXThread
void bindProgress(ObservableDoubleValue progress);
void checkCancellation() throws CancellationException;
}
protected final TerracottaBundle bundle;
protected AbstractTerracottaProvider(TerracottaBundle bundle) {
this.bundle = bundle;
}
public Status status() throws IOException {
return bundle.status();
}
public final Task<Path> download(DownloadContext progress) {
return bundle.download(progress);
}
public Task<?> install(Path pkg) throws IOException {
return bundle.install(pkg);
}
public abstract List<String> ofCommandLine(Path portTransfer);
}
@@ -17,51 +17,21 @@
*/
package org.jackhuang.hmcl.terracotta.provider;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.TerracottaNative;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import org.jackhuang.hmcl.terracotta.TerracottaBundle;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.util.List;
import java.util.Set;
public final class GeneralProvider implements ITerracottaProvider {
private final TerracottaNative target;
public final class GeneralProvider extends AbstractTerracottaProvider {
private final Path executable;
public GeneralProvider(TerracottaNative target) {
this.target = target;
public GeneralProvider(TerracottaBundle bundle, Path executable) {
super(bundle);
this.executable = executable;
}
@Override
public Status status() throws IOException {
return target.status();
}
@Override
public Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException {
Task<?> task = target.install(context, tree);
context.bindProgress(task.progressProperty());
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
task = task.thenRunAsync(() -> Files.setPosixFilePermissions(target.getPath(), Set.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE,
PosixFilePermission.GROUP_READ,
PosixFilePermission.GROUP_EXECUTE,
PosixFilePermission.OTHERS_READ,
PosixFilePermission.OTHERS_EXECUTE
)));
}
return task;
}
@Override
public List<String> ofCommandLine(Path path) {
return List.of(target.getPath().toString(), "--hmcl", path.toString());
public List<String> ofCommandLine(Path portTransfer) {
return List.of(executable.toString(), "--hmcl", portTransfer.toString());
}
}
@@ -1,77 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 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.terracotta.provider;
import javafx.beans.value.ObservableValue;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
public interface ITerracottaProvider {
enum Status {
NOT_EXIST,
LEGACY_VERSION,
READY
}
interface Context {
void bindProgress(ObservableValue<? extends Number> value);
boolean requestInstallFence();
boolean hasInstallFence();
}
abstract class ProviderException extends IOException {
public ProviderException(String message) {
super(message);
}
public ProviderException(String message, Throwable cause) {
super(message, cause);
}
public ProviderException(Throwable cause) {
super(cause);
}
}
final class ArchiveFileMissingException extends ProviderException {
public ArchiveFileMissingException(String message) {
super(message);
}
public ArchiveFileMissingException(String message, Throwable cause) {
super(message, cause);
}
public ArchiveFileMissingException(Throwable cause) {
super(cause);
}
}
Status status() throws IOException;
Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException;
List<String> ofCommandLine(Path path);
}
@@ -19,103 +19,77 @@ package org.jackhuang.hmcl.terracotta.provider;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.TerracottaNative;
import org.jackhuang.hmcl.terracotta.TerracottaBundle;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.PosixFilePermission;
import java.util.List;
import java.util.Set;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class MacOSProvider implements ITerracottaProvider {
public final TerracottaNative installer, binary;
public final class MacOSProvider extends AbstractTerracottaProvider {
private final Path executable, installer;
public MacOSProvider(TerracottaNative installer, TerracottaNative binary) {
public MacOSProvider(TerracottaBundle bundle, Path executable, Path installer) {
super(bundle);
this.executable = executable;
this.installer = installer;
this.binary = binary;
}
@Override
public Status status() throws IOException {
assert binary != null;
if (!Files.exists(Path.of("/Applications/terracotta.app"))) {
return Status.NOT_EXIST;
}
return binary.status();
return bundle.status();
}
@Override
public Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException {
assert installer != null && binary != null;
public Task<?> install(Path pkg) throws IOException {
return super.install(pkg).thenComposeAsync(() -> {
Path osascript = SystemUtils.which("osascript");
if (osascript == null) {
throw new IllegalStateException("Cannot locate 'osascript' system executable on MacOS for installing Terracotta.");
}
Task<?> installerTask = installer.install(context, tree);
Task<?> binaryTask = binary.install(context, tree);
context.bindProgress(installerTask.progressProperty().add(binaryTask.progressProperty()).multiply(0.4)); // (1 + 1) * 0.4 = 0.8
Path movedInstaller = Files.createTempDirectory(Metadata.HMCL_GLOBAL_DIRECTORY, "terracotta-pkg")
.toRealPath()
.resolve(FileUtils.getName(installer));
Files.copy(installer, movedInstaller, StandardCopyOption.REPLACE_EXISTING);
return Task.allOf(
installerTask.thenComposeAsync(() -> {
Path osascript = SystemUtils.which("osascript");
if (osascript == null) {
throw new IllegalStateException("Cannot locate 'osascript' system executable on MacOS for installing Terracotta.");
}
ManagedProcess process = new ManagedProcess(new ProcessBuilder(
osascript.toString(), "-e", String.format(
"do shell script \"installer -pkg '%s' -target /\" with prompt \"%s\" with administrator privileges",
movedInstaller, i18n("terracotta.sudo_installing")
)));
process.pumpInputStream(SystemUtils::onLogLine);
process.pumpErrorStream(SystemUtils::onLogLine);
Path pkg = Files.createTempDirectory(Metadata.HMCL_GLOBAL_DIRECTORY, "terracotta-pkg")
.toRealPath()
.resolve(FileUtils.getName(installer.getPath()));
Files.copy(installer.getPath(), pkg, StandardCopyOption.REPLACE_EXISTING);
return Task.fromCompletableFuture(process.getProcess().onExit()).thenRunAsync(() -> {
try {
FileUtils.cleanDirectory(movedInstaller.getParent());
} catch (IOException e) {
LOG.warning("Cannot remove temporary Terracotta package file.", e);
}
ManagedProcess process = new ManagedProcess(new ProcessBuilder(
osascript.toString(), "-e", String.format(
"do shell script \"installer -pkg '%s' -target /\" with prompt \"%s\" with administrator privileges",
pkg, i18n("terracotta.sudo_installing")
)));
process.pumpInputStream(SystemUtils::onLogLine);
process.pumpErrorStream(SystemUtils::onLogLine);
return Task.fromCompletableFuture(process.getProcess().onExit()).thenRunAsync(() -> {
try {
FileUtils.cleanDirectory(pkg.getParent());
} catch (IOException e) {
LOG.warning("Cannot remove temporary Terracotta package file.", e);
}
if (process.getExitCode() != 0) {
throw new IllegalStateException(String.format(
"Cannot install Terracotta %s: system installer exited with code %d",
pkg,
process.getExitCode()
));
}
});
}),
binaryTask.thenRunAsync(() -> Files.setPosixFilePermissions(binary.getPath(), Set.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE,
PosixFilePermission.GROUP_READ,
PosixFilePermission.GROUP_EXECUTE,
PosixFilePermission.OTHERS_READ,
PosixFilePermission.OTHERS_EXECUTE
)))
);
if (process.getExitCode() != 0) {
throw new IllegalStateException(String.format(
"Cannot install Terracotta %s: system installer exited with code %d", movedInstaller, process.getExitCode()
));
}
});
});
}
@Override
public List<String> ofCommandLine(Path path) {
assert binary != null;
return List.of(binary.getPath().toString(), "--hmcl", path.toString());
return List.of(executable.toString(), "--hmcl", path.toString());
}
}
@@ -0,0 +1,94 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 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.theme;
import org.glavo.monetfx.*;
import java.util.*;
/// @author Glavo
public final class Theme {
public static final Theme DEFAULT = new Theme(ThemeColor.DEFAULT, Brightness.DEFAULT, ColorStyle.FIDELITY, Contrast.DEFAULT);
private final ThemeColor primaryColorSeed;
private final Brightness brightness;
private final ColorStyle colorStyle;
private final Contrast contrast;
public Theme(ThemeColor primaryColorSeed,
Brightness brightness,
ColorStyle colorStyle,
Contrast contrast
) {
this.primaryColorSeed = primaryColorSeed;
this.brightness = brightness;
this.colorStyle = colorStyle;
this.contrast = contrast;
}
public ColorScheme toColorScheme() {
return ColorScheme.newBuilder()
.setPrimaryColorSeed(primaryColorSeed.color())
.setColorStyle(colorStyle)
.setBrightness(brightness)
.setSpecVersion(ColorSpecVersion.SPEC_2025)
.setContrast(contrast)
.build();
}
public ThemeColor primaryColorSeed() {
return primaryColorSeed;
}
public Brightness brightness() {
return brightness;
}
public ColorStyle colorStyle() {
return colorStyle;
}
public Contrast contrast() {
return contrast;
}
@Override
public boolean equals(Object obj) {
return obj == this || obj instanceof Theme that
&& this.primaryColorSeed.color().equals(that.primaryColorSeed.color())
&& this.brightness.equals(that.brightness)
&& this.colorStyle.equals(that.colorStyle)
&& this.contrast.equals(that.contrast);
}
@Override
public int hashCode() {
return Objects.hash(primaryColorSeed, brightness, colorStyle, contrast);
}
@Override
public String toString() {
return "Theme[" +
"primaryColorSeed=" + primaryColorSeed + ", " +
"brightness=" + brightness + ", " +
"colorStyle=" + colorStyle + ", " +
"contrast=" + contrast + ']';
}
}
@@ -0,0 +1,193 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 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.theme;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.WeakListener;
import javafx.beans.property.Property;
import javafx.scene.control.ColorPicker;
import javafx.scene.paint.Color;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.util.List;
import java.util.Objects;
/// @author Glavo
@JsonAdapter(ThemeColor.TypeAdapter.class)
@JsonSerializable
public record ThemeColor(@NotNull String name, @NotNull Color color) {
public static final ThemeColor DEFAULT = new ThemeColor("blue", Color.web("#5C6BC0"));
public static final List<ThemeColor> STANDARD_COLORS = List.of(
DEFAULT,
new ThemeColor("darker_blue", Color.web("#283593")),
new ThemeColor("green", Color.web("#43A047")),
new ThemeColor("orange", Color.web("#E67E22")),
new ThemeColor("purple", Color.web("#9C27B0")),
new ThemeColor("red", Color.web("#B71C1C"))
);
public static String getColorDisplayName(Color c) {
return c != null ? String.format("#%02X%02X%02X",
Math.round(c.getRed() * 255.0D),
Math.round(c.getGreen() * 255.0D),
Math.round(c.getBlue() * 255.0D))
: null;
}
public static String getColorDisplayNameWithOpacity(Color c, double opacity) {
return c != null ? String.format("#%02X%02X%02X%02X",
Math.round(c.getRed() * 255.0D),
Math.round(c.getGreen() * 255.0D),
Math.round(c.getBlue() * 255.0D),
Math.round(opacity * 255.0))
: null;
}
public static @Nullable ThemeColor of(String name) {
if (name == null)
return null;
if (!name.startsWith("#")) {
for (ThemeColor color : STANDARD_COLORS) {
if (name.equalsIgnoreCase(color.name()))
return color;
}
}
try {
return new ThemeColor(name, Color.web(name));
} catch (IllegalArgumentException e) {
return null;
}
}
@Contract("null -> null; !null -> !null")
public static ThemeColor of(Color color) {
return color != null ? new ThemeColor(getColorDisplayName(color), color) : null;
}
private static final class BidirectionalBinding implements InvalidationListener, WeakListener {
private final WeakReference<ColorPicker> colorPickerRef;
private final WeakReference<Property<ThemeColor>> propertyRef;
private final int hashCode;
private boolean updating = false;
private BidirectionalBinding(ColorPicker colorPicker, Property<ThemeColor> property) {
this.colorPickerRef = new WeakReference<>(colorPicker);
this.propertyRef = new WeakReference<>(property);
this.hashCode = System.identityHashCode(colorPicker) ^ System.identityHashCode(property);
}
@Override
public void invalidated(Observable sourceProperty) {
if (!updating) {
final ColorPicker colorPicker = colorPickerRef.get();
final Property<ThemeColor> property = propertyRef.get();
if (colorPicker == null || property == null) {
if (colorPicker != null) {
colorPicker.valueProperty().removeListener(this);
}
if (property != null) {
property.removeListener(this);
}
} else {
updating = true;
try {
if (property == sourceProperty) {
ThemeColor newValue = property.getValue();
colorPicker.setValue(newValue != null ? newValue.color() : null);
} else {
Color newValue = colorPicker.getValue();
property.setValue(newValue != null ? ThemeColor.of(newValue) : null);
}
} finally {
updating = false;
}
}
}
}
@Override
public boolean wasGarbageCollected() {
return colorPickerRef.get() == null || propertyRef.get() == null;
}
@Override
public int hashCode() {
return hashCode;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof BidirectionalBinding that))
return false;
final ColorPicker colorPicker = this.colorPickerRef.get();
final Property<ThemeColor> property = this.propertyRef.get();
final ColorPicker thatColorPicker = that.colorPickerRef.get();
final Property<?> thatProperty = that.propertyRef.get();
if (colorPicker == null || property == null || thatColorPicker == null || thatProperty == null)
return false;
return colorPicker == thatColorPicker && property == thatProperty;
}
}
public static void bindBidirectional(ColorPicker colorPicker, Property<ThemeColor> property) {
var binding = new BidirectionalBinding(colorPicker, property);
colorPicker.valueProperty().removeListener(binding);
property.removeListener(binding);
ThemeColor themeColor = property.getValue();
colorPicker.setValue(themeColor != null ? themeColor.color() : null);
colorPicker.valueProperty().addListener(binding);
property.addListener(binding);
}
static final class TypeAdapter extends com.google.gson.TypeAdapter<ThemeColor> {
@Override
public void write(JsonWriter out, ThemeColor value) throws IOException {
out.value(value.name());
}
@Override
public ThemeColor read(JsonReader in) throws IOException {
return Objects.requireNonNullElse(of(in.nextString()), ThemeColor.DEFAULT);
}
}
}
@@ -0,0 +1,134 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 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.theme;
import javafx.beans.Observable;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.binding.ObjectExpression;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.scene.paint.Color;
import org.glavo.monetfx.Brightness;
import org.glavo.monetfx.ColorScheme;
import org.glavo.monetfx.Contrast;
import org.glavo.monetfx.beans.property.ColorSchemeProperty;
import org.glavo.monetfx.beans.property.ReadOnlyColorSchemeProperty;
import org.glavo.monetfx.beans.property.SimpleColorSchemeProperty;
import org.jackhuang.hmcl.ui.FXUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
/// @author Glavo
public final class Themes {
private static final ObjectExpression<Theme> theme = new ObjectBinding<>() {
{
List<Observable> observables = new ArrayList<>();
observables.add(config().themeBrightnessProperty());
observables.add(config().themeColorProperty());
if (FXUtils.DARK_MODE != null) {
observables.add(FXUtils.DARK_MODE);
}
bind(observables.toArray(new Observable[0]));
}
private Brightness getBrightness() {
String themeBrightness = config().getThemeBrightness();
if (themeBrightness == null)
return Brightness.DEFAULT;
return switch (themeBrightness.toLowerCase(Locale.ROOT).trim()) {
case "auto" -> {
if (FXUtils.DARK_MODE != null) {
yield FXUtils.DARK_MODE.get() ? Brightness.DARK : Brightness.LIGHT;
} else {
yield Brightness.DEFAULT;
}
}
case "dark" -> Brightness.DARK;
case "light" -> Brightness.LIGHT;
default -> Brightness.DEFAULT;
};
}
@Override
protected Theme computeValue() {
ThemeColor themeColor = Objects.requireNonNullElse(config().getThemeColor(), ThemeColor.DEFAULT);
return new Theme(themeColor, getBrightness(), Theme.DEFAULT.colorStyle(), Contrast.DEFAULT);
}
};
private static final ColorSchemeProperty colorScheme = new SimpleColorSchemeProperty();
private static final BooleanBinding darkMode = Bindings.createBooleanBinding(
() -> colorScheme.get().getBrightness() == Brightness.DARK,
colorScheme
);
static {
ChangeListener<Theme> listener = (observable, oldValue, newValue) -> {
if (!Objects.equals(oldValue, newValue)) {
colorScheme.set(newValue != null ? newValue.toColorScheme() : Theme.DEFAULT.toColorScheme());
}
};
listener.changed(theme, null, theme.get());
theme.addListener(listener);
}
public static ObjectExpression<Theme> themeProperty() {
return theme;
}
public static Theme getTheme() {
return themeProperty().get();
}
public static ReadOnlyColorSchemeProperty colorSchemeProperty() {
return colorScheme;
}
public static ColorScheme getColorScheme() {
return colorScheme.get();
}
private static final ObjectBinding<Color> titleFill = Bindings.createObjectBinding(
() -> config().isTitleTransparent()
? getColorScheme().getOnSurface()
: getColorScheme().getOnPrimaryContainer(),
colorSchemeProperty(),
config().titleTransparentProperty()
);
public static ObservableValue<Color> titleFillProperty() {
return titleFill;
}
public static BooleanBinding darkModeProperty() {
return darkMode;
}
private Themes() {
}
}
@@ -19,6 +19,7 @@ package org.jackhuang.hmcl.ui;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
@@ -72,9 +73,10 @@ import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import static org.jackhuang.hmcl.setting.ConfigHolder.*;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class Controllers {
public static final String JAVA_VERSION_TIP = "javaVersion";
@@ -505,8 +507,8 @@ public final class Controllers {
return prompt(title, onResult, "");
}
public static CompletableFuture<String> prompt(String title, FutureCallback<String> onResult, String initialValue) {
InputDialogPane pane = new InputDialogPane(title, initialValue, onResult);
public static CompletableFuture<String> prompt(String title, FutureCallback<String> onResult, String initialValue, ValidatorBase... validators) {
InputDialogPane pane = new InputDialogPane(title, initialValue, onResult, validators);
dialog(pane);
return pane.getCompletableFuture();
}
@@ -0,0 +1,155 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 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.ui;
import com.jfoenix.controls.JFXDialog;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.event.EventHandler;
import javafx.scene.Node;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.construct.DialogAware;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.JFXDialogPane;
import org.jackhuang.hmcl.ui.decorator.Decorator;
import org.jetbrains.annotations.Nullable;
import java.util.Optional;
import java.util.function.Consumer;
public final class DialogUtils {
private DialogUtils() {
}
public static final String PROPERTY_DIALOG_INSTANCE = DialogUtils.class.getName() + ".dialog.instance";
public static final String PROPERTY_DIALOG_PANE_INSTANCE = DialogUtils.class.getName() + ".dialog.pane.instance";
public static final String PROPERTY_DIALOG_CLOSE_HANDLER = DialogUtils.class.getName() + ".dialog.closeListener";
public static final String PROPERTY_PARENT_PANE_REF = DialogUtils.class.getName() + ".dialog.parentPaneRef";
public static final String PROPERTY_PARENT_DIALOG_REF = DialogUtils.class.getName() + ".dialog.parentDialogRef";
public static void show(Decorator decorator, Node content) {
if (decorator.getDrawerWrapper() == null) {
Platform.runLater(() -> show(decorator, content));
return;
}
show(decorator.getDrawerWrapper(), content, (dialog) -> {
JFXDialogPane pane = (JFXDialogPane) dialog.getContent();
decorator.capableDraggingWindow(dialog);
decorator.forbidDraggingWindow(pane);
dialog.setDialogContainer(decorator.getDrawerWrapper());
});
}
public static void show(StackPane container, Node content) {
show(container, content, null);
}
public static void show(StackPane container, Node content, @Nullable Consumer<JFXDialog> onDialogCreated) {
FXUtils.checkFxUserThread();
JFXDialog dialog = (JFXDialog) container.getProperties().get(PROPERTY_DIALOG_INSTANCE);
JFXDialogPane dialogPane = (JFXDialogPane) container.getProperties().get(PROPERTY_DIALOG_PANE_INSTANCE);
if (dialog == null) {
dialog = new JFXDialog(AnimationUtils.isAnimationEnabled()
? JFXDialog.DialogTransition.CENTER
: JFXDialog.DialogTransition.NONE);
dialogPane = new JFXDialogPane();
dialog.setContent(dialogPane);
dialog.setDialogContainer(container);
dialog.setOverlayClose(false);
container.getProperties().put(PROPERTY_DIALOG_INSTANCE, dialog);
container.getProperties().put(PROPERTY_DIALOG_PANE_INSTANCE, dialogPane);
if (onDialogCreated != null) {
onDialogCreated.accept(dialog);
}
dialog.show();
}
content.getProperties().put(PROPERTY_PARENT_PANE_REF, dialogPane);
content.getProperties().put(PROPERTY_PARENT_DIALOG_REF, dialog);
dialogPane.push(content);
EventHandler<DialogCloseEvent> handler = event -> close(content);
content.getProperties().put(PROPERTY_DIALOG_CLOSE_HANDLER, handler);
content.addEventHandler(DialogCloseEvent.CLOSE, handler);
handleDialogShown(dialog, content);
}
private static void handleDialogShown(JFXDialog dialog, Node node) {
if (dialog.isVisible()) {
dialog.requestFocus();
if (node instanceof DialogAware dialogAware)
dialogAware.onDialogShown();
} else {
dialog.visibleProperty().addListener(new ChangeListener<>() {
@Override
public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) {
if (newValue) {
dialog.requestFocus();
if (node instanceof DialogAware dialogAware)
dialogAware.onDialogShown();
observable.removeListener(this);
}
}
});
}
}
@SuppressWarnings("unchecked")
public static void close(Node content) {
FXUtils.checkFxUserThread();
Optional.ofNullable(content.getProperties().get(PROPERTY_DIALOG_CLOSE_HANDLER))
.ifPresent(handler -> content.removeEventHandler(DialogCloseEvent.CLOSE, (EventHandler<DialogCloseEvent>) handler));
JFXDialogPane pane = (JFXDialogPane) content.getProperties().get(PROPERTY_PARENT_PANE_REF);
JFXDialog dialog = (JFXDialog) content.getProperties().get(PROPERTY_PARENT_DIALOG_REF);
if (dialog != null && pane != null) {
if (pane.size() == 1 && pane.peek().orElse(null) == content) {
dialog.setOnDialogClosed(e -> pane.pop(content));
dialog.close();
StackPane container = dialog.getDialogContainer();
if (container != null) {
container.getProperties().remove(PROPERTY_DIALOG_INSTANCE);
container.getProperties().remove(PROPERTY_DIALOG_PANE_INSTANCE);
container.getProperties().remove(PROPERTY_PARENT_DIALOG_REF);
container.getProperties().remove(PROPERTY_PARENT_PANE_REF);
}
} else {
pane.pop(content);
}
if (content instanceof DialogAware dialogAware) {
dialogAware.onDialogClosed();
}
}
}
}
@@ -57,7 +57,6 @@ import javafx.util.Callback;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.jackhuang.hmcl.setting.StyleSheets;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.task.CacheFileTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@@ -135,8 +134,10 @@ public final class FXUtils {
public static final @Nullable ObservableMap<String, Object> PREFERENCES;
public static final @Nullable ObservableBooleanValue DARK_MODE;
public static final @Nullable Boolean REDUCED_MOTION;
public static final @Nullable ReadOnlyObjectProperty<Color> ACCENT_COLOR;
public static final @Nullable MethodHandle TEXT_TRUNCATED_PROPERTY;
public static final @Nullable MethodHandle FOCUS_VISIBLE_PROPERTY;
static {
String jfxVersion = System.getProperty("javafx.version");
@@ -151,6 +152,7 @@ public final class FXUtils {
ObservableMap<String, Object> preferences = null;
ObservableBooleanValue darkMode = null;
ReadOnlyObjectProperty<Color> accentColorProperty = null;
Boolean reducedMotion = null;
if (JAVAFX_MAJOR_VERSION >= 22) {
try {
@@ -162,14 +164,19 @@ public final class FXUtils {
preferences = preferences0;
@SuppressWarnings("unchecked")
var colorSchemeProperty =
(ReadOnlyObjectProperty<? extends Enum<?>>)
lookup.findVirtual(preferencesClass, "colorSchemeProperty", MethodType.methodType(ReadOnlyObjectProperty.class))
.invoke(preferences);
var colorSchemeProperty = (ReadOnlyObjectProperty<? extends Enum<?>>)
lookup.findVirtual(preferencesClass, "colorSchemeProperty", MethodType.methodType(ReadOnlyObjectProperty.class))
.invoke(preferences);
darkMode = Bindings.createBooleanBinding(() ->
"DARK".equals(colorSchemeProperty.get().name()), colorSchemeProperty);
@SuppressWarnings("unchecked")
var accentColorProperty0 = (ReadOnlyObjectProperty<Color>)
lookup.findVirtual(preferencesClass, "accentColorProperty", MethodType.methodType(ReadOnlyObjectProperty.class))
.invoke(preferences);
accentColorProperty = accentColorProperty0;
if (JAVAFX_MAJOR_VERSION >= 24) {
reducedMotion = (boolean)
lookup.findVirtual(preferencesClass, "isReducedMotion", MethodType.methodType(boolean.class))
@@ -182,6 +189,7 @@ public final class FXUtils {
PREFERENCES = preferences;
DARK_MODE = darkMode;
REDUCED_MOTION = reducedMotion;
ACCENT_COLOR = accentColorProperty;
MethodHandle textTruncatedProperty = null;
if (JAVAFX_MAJOR_VERSION >= 23) {
@@ -196,6 +204,20 @@ public final class FXUtils {
}
}
TEXT_TRUNCATED_PROPERTY = textTruncatedProperty;
MethodHandle focusVisibleProperty = null;
if (JAVAFX_MAJOR_VERSION >= 19) {
try {
focusVisibleProperty = MethodHandles.publicLookup().findVirtual(
Node.class,
"focusVisibleProperty",
MethodType.methodType(ReadOnlyBooleanProperty.class)
);
} catch (Throwable e) {
LOG.warning("Failed to lookup focusVisibleProperty", e);
}
}
FOCUS_VISIBLE_PROPERTY = focusVisibleProperty;
}
public static final String DEFAULT_MONOSPACE_FONT = OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "Consolas" : "Monospace";
@@ -416,6 +438,20 @@ public final class FXUtils {
}
}
public static @Nullable ReadOnlyBooleanProperty focusVisibleProperty(Node node) {
if (FOCUS_VISIBLE_PROPERTY != null) {
try {
return (ReadOnlyBooleanProperty) FOCUS_VISIBLE_PROPERTY.invokeExact(node);
} catch (RuntimeException | Error e) {
throw e;
} catch (Throwable e) {
throw new RuntimeException(e);
}
} else {
return null;
}
}
private static final Duration TOOLTIP_FAST_SHOW_DELAY = Duration.millis(50);
private static final Duration TOOLTIP_SLOW_SHOW_DELAY = Duration.millis(500);
private static final Duration TOOLTIP_SHOW_DURATION = Duration.millis(5000);
@@ -1181,7 +1217,16 @@ public final class FXUtils {
public static Task<Image> getRemoteImageTask(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(url)
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth));
.setSignificance(Task.TaskSignificance.MINOR)
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth))
.setSignificance(Task.TaskSignificance.MINOR);
}
public static Task<Image> getRemoteImageTask(URI uri, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(uri)
.setSignificance(Task.TaskSignificance.MINOR)
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth))
.setSignificance(Task.TaskSignificance.MINOR);
}
public static ObservableValue<Image> newRemoteImage(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
@@ -1194,6 +1239,7 @@ public final class FXUtils {
LOG.warning("An exception encountered while loading remote image: " + url, exception);
}
})
.setSignificance(Task.TaskSignificance.MINOR)
.start();
return image;
}
@@ -1214,7 +1260,7 @@ public final class FXUtils {
public static JFXButton newToggleButton4(SVG icon) {
JFXButton button = new JFXButton();
button.getStyleClass().add("toggle-icon4");
button.setGraphic(icon.createIcon(Theme.blackFill(), -1));
button.setGraphic(icon.createIcon());
return button;
}
@@ -1295,17 +1341,11 @@ public final class FXUtils {
}
public static <T> Callback<ListView<T>, ListCell<T>> jfxListCellFactory(Function<T, Node> graphicBuilder) {
Holder<Object> lastCell = new Holder<>();
return view -> new JFXListCell<T>() {
@Override
public void updateItem(T item, boolean empty) {
super.updateItem(item, empty);
// https://mail.openjdk.org/pipermail/openjfx-dev/2022-July/034764.html
if (this == lastCell.value && !isVisible())
return;
lastCell.value = this;
if (!empty) {
setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
setGraphic(graphicBuilder.apply(item));
@@ -1350,7 +1390,7 @@ public final class FXUtils {
public static void onClicked(Node node, Runnable action) {
node.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> {
if (e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 1) {
if (e.getButton() == MouseButton.PRIMARY) {
action.run();
e.consume();
}
@@ -1396,16 +1436,6 @@ public final class FXUtils {
});
}
public static void clearFocus(Node node) {
Scene scene = node.getScene();
if (scene != null) {
Parent root = scene.getRoot();
if (root != null) {
root.requestFocus();
}
}
}
public static void copyOnDoubleClick(Labeled label) {
label.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> {
if (e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 2) {
@@ -1447,11 +1477,11 @@ public final class FXUtils {
for (int i = 0; i < children.getLength(); i++) {
org.w3c.dom.Node node = children.item(i);
if (node instanceof Element) {
Element element = (Element) node;
if (node instanceof Element element) {
if ("a".equals(element.getTagName())) {
String href = element.getAttribute("href");
Text text = new Text(element.getTextContent());
text.getStyleClass().add("hyperlink");
onClicked(text, () -> {
String link = href;
try {
@@ -1461,7 +1491,6 @@ public final class FXUtils {
hyperlinkAction.accept(link);
});
text.setCursor(Cursor.HAND);
text.setFill(Color.web("#0070E0"));
text.setUnderline(true);
texts.add(text);
} else if ("b".equals(element.getTagName())) {
@@ -24,11 +24,11 @@ import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
@@ -40,6 +40,7 @@ import org.jackhuang.hmcl.launch.ProcessListener;
import org.jackhuang.hmcl.setting.StyleSheets;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Log4jLevel;
@@ -50,9 +51,12 @@ import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.platform.*;
import java.io.IOException;
import java.lang.management.ManagementFactory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.FileTime;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
@@ -81,6 +85,7 @@ public class GameCrashWindow extends Stage {
private final ProcessListener.ExitType exitType;
private final LaunchOptions launchOptions;
private final View view;
private final StackPane stackPane;
private final List<Log> logs;
@@ -93,7 +98,7 @@ public class GameCrashWindow extends Stage {
this.logs = logs;
this.analyzer = LibraryAnalyzer.analyze(version, repository.getGameVersion(version).orElse(null));
memory = Optional.ofNullable(launchOptions.getMaxMemory()).map(i -> i + " MB").orElse("-");
memory = Optional.ofNullable(launchOptions.getMaxMemory()).map(i -> i + " " + i18n("settings.memory.unit.mib")).orElse("-");
total_memory = MEGABYTES.formatBytes(SystemInfo.getTotalMemorySize());
@@ -103,9 +108,10 @@ public class GameCrashWindow extends Stage {
this.view = new View();
this.stackPane = new StackPane(view);
this.feedbackTextFlow.getChildren().addAll(FXUtils.parseSegment(i18n("game.crash.feedback"), Controllers::onHyperlinkAction));
setScene(new Scene(view, 800, 480));
setScene(new Scene(stackPane, 800, 480));
StyleSheets.init(getScene());
setTitle(i18n("game.crash.title"));
FXUtils.setIcon(this);
@@ -269,22 +275,41 @@ public class GameCrashWindow extends Stage {
CompletableFuture.supplyAsync(() ->
logs.stream().map(Log::getLog).collect(Collectors.joining("\n")))
.thenComposeAsync(logs ->
LogExporter.exportLogs(logFile, repository, launchOptions.getVersionName(), logs, new CommandBuilder().addAll(managedProcess.getCommands()).toString()))
.handleAsync((result, exception) -> {
Alert alert;
.thenComposeAsync(logs -> {
long processStartTime = managedProcess.getProcess().info()
.startInstant()
.map(Instant::toEpochMilli).orElseGet(() -> {
try {
return ManagementFactory.getRuntimeMXBean().getStartTime();
} catch (Throwable e) {
LOG.warning("Failed to get process start time", e);
return 0L;
}
});
return LogExporter.exportLogs(logFile, repository, launchOptions.getVersionName(), logs,
new CommandBuilder().addAll(managedProcess.getCommands()).toString(),
path -> {
try {
FileTime lastModifiedTime = Files.getLastModifiedTime(path);
return lastModifiedTime.toMillis() >= processStartTime;
} catch (Throwable e) {
LOG.warning("Failed to read file attributes", e);
return false;
}
});
})
.handleAsync((result, exception) -> {
if (exception == null) {
FXUtils.showFileInExplorer(logFile);
alert = new Alert(Alert.AlertType.INFORMATION, i18n("settings.launcher.launcher_log.export.success", logFile));
var dialog = new MessageDialogPane.Builder(i18n("settings.launcher.launcher_log.export.success", logFile), i18n("message.success"), MessageDialogPane.MessageType.SUCCESS).ok(null).build();
DialogUtils.show(stackPane, dialog);
} else {
LOG.warning("Failed to export game crash info", exception);
alert = new Alert(Alert.AlertType.WARNING, i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(exception));
var dialog = new MessageDialogPane.Builder(i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(exception), i18n("message.error"), MessageDialogPane.MessageType.ERROR).ok(null).build();
DialogUtils.show(stackPane, dialog);
}
alert.setTitle(i18n("settings.launcher.launcher_log.export"));
alert.showAndWait();
return null;
}, Schedulers.javafx());
}
@@ -292,7 +317,7 @@ public class GameCrashWindow extends Stage {
private final class View extends VBox {
View() {
setStyle("-fx-background-color: white");
this.getStyleClass().add("game-crash-window");
HBox titlePane = new HBox();
{
@@ -395,10 +420,13 @@ public class GameCrashWindow extends Stage {
reasonTitle.getStyleClass().add("two-line-item-second-large-title");
ScrollPane reasonPane = new ScrollPane(reasonTextFlow);
reasonTextFlow.getStyleClass().add("crash-reason-text-flow");
reasonPane.setFitToWidth(true);
reasonPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
reasonPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
feedbackTextFlow.getStyleClass().add("crash-reason-text-flow");
gameDirPane.setPadding(new Insets(8));
VBox.setVgrow(gameDirPane, Priority.ALWAYS);
FXUtils.onChangeAndOperate(feedbackTextFlow.visibleProperty(), visible -> {
@@ -411,6 +439,7 @@ public class GameCrashWindow extends Stage {
}
HBox toolBar = new HBox();
VBox.setMargin(toolBar, new Insets(0, 0, 4, 0));
{
JFXButton exportGameCrashInfoButton = FXUtils.newRaisedButton(i18n("logwindow.export_game_crash_logs"));
exportGameCrashInfoButton.setOnAction(e -> exportGameCrashInfo());
@@ -422,7 +451,6 @@ public class GameCrashWindow extends Stage {
helpButton.setOnAction(e -> FXUtils.openLink(Metadata.CONTACT_URL));
FXUtils.installFastTooltip(helpButton, i18n("logwindow.help"));
toolBar.setPadding(new Insets(8));
toolBar.setSpacing(8);
toolBar.getStyleClass().add("jfx-tool-bar");
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.ui;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXRippler;
import javafx.beans.Observable;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
@@ -36,7 +37,6 @@ import javafx.scene.image.ImageView;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.util.i18n.I18n;
@@ -63,7 +63,7 @@ public class InstallerItem extends Control {
private final ObjectProperty<Runnable> onInstall = new SimpleObjectProperty<>(this, "onInstall");
private final ObjectProperty<Runnable> onRemove = new SimpleObjectProperty<>(this, "onRemove");
public interface State {
public sealed interface State {
}
public static final class InstallableState implements State {
@@ -73,46 +73,10 @@ public class InstallerItem extends Control {
}
}
public static final class IncompatibleState implements State {
private final String incompatibleItemName;
private final String incompatibleItemVersion;
public IncompatibleState(String incompatibleItemName, String incompatibleItemVersion) {
this.incompatibleItemName = incompatibleItemName;
this.incompatibleItemVersion = incompatibleItemVersion;
}
public String getIncompatibleItemName() {
return incompatibleItemName;
}
public String getIncompatibleItemVersion() {
return incompatibleItemVersion;
}
public record IncompatibleState(String incompatibleItemName, String incompatibleItemVersion) implements State {
}
public static final class InstalledState implements State {
private final String version;
private final boolean external;
private final boolean incompatibleWithGame;
public InstalledState(String version, boolean external, boolean incompatibleWithGame) {
this.version = version;
this.external = external;
this.incompatibleWithGame = incompatibleWithGame;
}
public String getVersion() {
return version;
}
public boolean isExternal() {
return external;
}
public boolean isIncompatibleWithGame() {
return incompatibleWithGame;
}
public record InstalledState(String version, boolean external, boolean incompatibleWithGame) implements State {
}
public enum Style {
@@ -336,10 +300,15 @@ public class InstallerItem extends Control {
}
pane.getStyleClass().add("installer-item");
RipplerContainer container = new RipplerContainer(pane);
getChildren().setAll(container);
container.setPosition(JFXRippler.RipplerPos.BACK);
StackPane paneWrapper = new StackPane();
paneWrapper.getStyleClass().add("installer-item-wrapper");
paneWrapper.getChildren().setAll(container);
getChildren().setAll(paneWrapper);
pane.pseudoClassStateChanged(LIST_ITEM, control.style == Style.LIST_ITEM);
pane.pseudoClassStateChanged(CARD, control.style == Style.CARD);
paneWrapper.pseudoClassStateChanged(CARD, control.style == Style.CARD);
if (control.iconType != null) {
ImageView view = new ImageView(control.iconType.getIcon());
@@ -368,21 +337,20 @@ public class InstallerItem extends Control {
statusLabel.textProperty().bind(Bindings.createStringBinding(() -> {
State state = control.resolvedStateProperty.get();
if (state instanceof InstalledState) {
InstalledState s = (InstalledState) state;
if (s.incompatibleWithGame) {
return i18n("install.installer.change_version", s.version);
if (state instanceof InstalledState installedState) {
if (installedState.incompatibleWithGame) {
return i18n("install.installer.change_version", installedState.version);
}
if (s.external) {
return i18n("install.installer.external_version", s.version);
if (installedState.external) {
return i18n("install.installer.external_version", installedState.version);
}
return i18n("install.installer.version", s.version);
return i18n("install.installer.version", installedState.version);
} else if (state instanceof InstallableState) {
return control.style == Style.CARD
? i18n("install.installer.do_not_install")
: i18n("install.installer.not_installed");
} else if (state instanceof IncompatibleState) {
return i18n("install.installer.incompatible", i18n("install.installer." + ((IncompatibleState) state).incompatibleItemName));
} else if (state instanceof IncompatibleState incompatibleState) {
return i18n("install.installer.incompatible", i18n("install.installer." + incompatibleState.incompatibleItemName));
} else {
throw new AssertionError("Unknown state type: " + state.getClass());
}
@@ -396,14 +364,14 @@ public class InstallerItem extends Control {
pane.getChildren().add(buttonsContainer);
JFXButton removeButton = new JFXButton();
removeButton.setGraphic(SVG.CLOSE.createIcon(Theme.blackFill(), -1));
removeButton.setGraphic(SVG.CLOSE.createIcon());
removeButton.getStyleClass().add("toggle-icon4");
if (control.id.equals(MINECRAFT.getPatchId())) {
removeButton.setVisible(false);
} else {
removeButton.visibleProperty().bind(Bindings.createBooleanBinding(() -> {
State state = control.resolvedStateProperty.get();
return state instanceof InstalledState && !((InstalledState) state).external;
return state instanceof InstalledState installedState && !installedState.external;
}, control.resolvedStateProperty));
}
removeButton.managedProperty().bind(removeButton.visibleProperty());
@@ -417,8 +385,8 @@ public class InstallerItem extends Control {
JFXButton installButton = new JFXButton();
installButton.graphicProperty().bind(Bindings.createObjectBinding(() ->
control.resolvedStateProperty.get() instanceof InstallableState ?
SVG.ARROW_FORWARD.createIcon(Theme.blackFill(), -1) :
SVG.UPDATE.createIcon(Theme.blackFill(), -1),
SVG.ARROW_FORWARD.createIcon() :
SVG.UPDATE.createIcon(),
control.resolvedStateProperty
));
installButton.getStyleClass().add("toggle-icon4");
@@ -23,10 +23,11 @@ import javafx.collections.ObservableList;
import javafx.event.Event;
import javafx.event.EventHandler;
import javafx.scene.control.Control;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import static org.jackhuang.hmcl.ui.construct.SpinnerPane.FAILED_ACTION;
public class ListPageBase<T> extends Control {
public class ListPageBase<T> extends Control implements TransitionPane.Cacheable {
private final ListProperty<T> items = new SimpleListProperty<>(this, "items", FXCollections.observableArrayList());
private final BooleanProperty loading = new SimpleBooleanProperty(this, "loading", false);
private final StringProperty failedReason = new SimpleStringProperty(this, "failed");
@@ -27,7 +27,6 @@ import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
public class ListPageSkin extends SkinBase<ListPage<?>> {
@@ -67,7 +66,7 @@ public class ListPageSkin extends SkinBase<ListPage<?>> {
JFXButton btnAdd = FXUtils.newRaisedButton("");
FXUtils.setLimitWidth(btnAdd, 40);
FXUtils.setLimitHeight(btnAdd, 40);
btnAdd.setGraphic(SVG.ADD.createIcon(Theme.whiteFill(), -1));
btnAdd.setGraphic(SVG.ADD.createIcon());
btnAdd.setOnAction(e -> skinnable.add());
JFXButton btnRefresh = new JFXButton();
@@ -75,7 +74,7 @@ public class ListPageSkin extends SkinBase<ListPage<?>> {
FXUtils.setLimitHeight(btnRefresh, 40);
btnRefresh.getStyleClass().add("jfx-button-raised-round");
btnRefresh.setButtonType(JFXButton.ButtonType.RAISED);
btnRefresh.setGraphic(SVG.REFRESH.createIcon(Theme.whiteFill(), -1));
btnRefresh.setGraphic(SVG.REFRESH.createIcon());
btnRefresh.setOnAction(e -> skinnable.refresh());
vBox.getChildren().setAll(btnAdd);
@@ -21,6 +21,7 @@ import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXCheckBox;
import com.jfoenix.controls.JFXComboBox;
import com.jfoenix.controls.JFXListView;
import com.sun.jna.Pointer;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.binding.Bindings;
@@ -28,26 +29,28 @@ import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.css.PseudoClass;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.control.*;
import javafx.scene.input.KeyCode;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.*;
import javafx.stage.Stage;
import javafx.stage.WindowEvent;
import org.jackhuang.hmcl.game.GameDumpGenerator;
import org.jackhuang.hmcl.game.Log;
import org.jackhuang.hmcl.setting.StyleSheets;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.NoneMultipleSelectionModel;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.CircularArrayList;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Log4jLevel;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.platform.*;
import org.jackhuang.hmcl.util.platform.windows.Dwmapi;
import org.jackhuang.hmcl.util.platform.windows.WinConstants;
import org.jackhuang.hmcl.util.platform.windows.WinTypes;
import java.io.IOException;
import java.nio.file.Files;
@@ -60,8 +63,8 @@ import java.util.stream.Collectors;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.Lang.thread;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author huangyuhui
@@ -84,6 +87,36 @@ public final class LogWindow extends Stage {
private final LogWindowImpl impl;
private final ManagedProcess gameProcess;
@SuppressWarnings("unused")
private Object windowsDarkModeListenerHolder;
{
if (OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_11) && NativeUtils.USE_JNA && Dwmapi.INSTANCE != null) {
this.addEventFilter(WindowEvent.WINDOW_SHOWN, new EventHandler<>() {
@Override
public void handle(WindowEvent event) {
LogWindow.this.removeEventFilter(WindowEvent.WINDOW_SHOWN, this);
windowsDarkModeListenerHolder = FXUtils.onWeakChangeAndOperate(Themes.darkModeProperty(), darkMode -> {
if (LogWindow.this.isShowing()) {
WindowsNativeUtils.getWindowHandle(LogWindow.this).ifPresent(handle -> {
if (handle == WinTypes.HANDLE.INVALID_VALUE)
return;
Dwmapi.INSTANCE.DwmSetWindowAttribute(
new WinTypes.HANDLE(Pointer.createConstant(handle)),
WinConstants.DWMWA_USE_IMMERSIVE_DARK_MODE,
new WinTypes.BOOLByReference(new WinTypes.BOOL(darkMode)),
WinTypes.BOOL.SIZE
);
});
}
});
}
});
}
}
public LogWindow(ManagedProcess gameProcess) {
this(gameProcess, new CircularArrayList<>());
}
@@ -164,10 +197,12 @@ public final class LogWindow extends Stage {
private final StringProperty[] buttonText = new StringProperty[LEVELS.length];
private final BooleanProperty[] showLevel = new BooleanProperty[LEVELS.length];
private final JFXComboBox<Integer> cboLines = new JFXComboBox<>();
private final StackPane stackPane = new StackPane();
LogWindowImpl() {
getStyleClass().add("log-window");
listView.getProperties().put("no-smooth-scrolling", true);
listView.setItems(FXCollections.observableList(new CircularArrayList<>(logs.size())));
for (int i = 0; i < LEVELS.length; i++) {
@@ -205,9 +240,8 @@ public final class LogWindow extends Stage {
}
Platform.runLater(() -> {
Alert alert = new Alert(Alert.AlertType.INFORMATION, i18n("settings.launcher.launcher_log.export.success", logFile));
alert.setTitle(i18n("settings.launcher.launcher_log.export"));
alert.showAndWait();
var dialog = new MessageDialogPane.Builder(i18n("settings.launcher.launcher_log.export.success", logFile), i18n("message.success"), MessageDialogPane.MessageType.SUCCESS).ok(null).build();
DialogUtils.show(stackPane, dialog);
});
FXUtils.showFileInExplorer(logFile);
@@ -231,9 +265,8 @@ public final class LogWindow extends Stage {
LOG.warning("Failed to create minecraft jstack dump", e);
Platform.runLater(() -> {
Alert alert = new Alert(Alert.AlertType.ERROR, i18n("logwindow.export_dump"));
alert.setTitle(i18n("message.error"));
alert.showAndWait();
var dialog = new MessageDialogPane.Builder(i18n("logwindow.export_dump") + "\n" + StringUtils.getStackTrace(e), i18n("message.error"), MessageDialogPane.MessageType.ERROR).ok(null).build();
DialogUtils.show(stackPane, dialog);
});
}
@@ -264,8 +297,9 @@ public final class LogWindow extends Stage {
VBox vbox = new VBox(3);
vbox.setPadding(new Insets(3, 0, 3, 0));
vbox.setStyle("-fx-background-color: white");
getChildren().setAll(vbox);
getSkinnable().stackPane.getChildren().setAll(vbox);
getChildren().setAll(getSkinnable().stackPane);
{
BorderPane borderPane = new BorderPane();
@@ -286,8 +320,7 @@ public final class LogWindow extends Stage {
HBox hBox = new HBox(3);
for (int i = 0; i < LEVELS.length; i++) {
ToggleButton button = new ToggleButton();
button.setStyle("-fx-background-color: " + FXUtils.toWeb(LEVELS[i].getColor()) + ";");
button.getStyleClass().add("log-toggle");
button.getStyleClass().addAll("log-toggle", LEVELS[i].name().toLowerCase(Locale.ROOT));
button.textProperty().bind(control.buttonText[i]);
button.setSelected(true);
control.showLevel[i].bind(button.selectedProperty());
@@ -309,8 +342,7 @@ public final class LogWindow extends Stage {
listView.setStyle("-fx-font-family: \"" + Lang.requireNonNullElse(config().getFontFamily(), FXUtils.DEFAULT_MONOSPACE_FONT)
+ "\"; -fx-font-size: " + config().getFontSize() + "px;");
Holder<Object> lastCell = new Holder<>();
listView.setCellFactory(x -> new ListCell<Log>() {
listView.setCellFactory(x -> new ListCell<>() {
{
x.setSelectionModel(new NoneMultipleSelectionModel<>());
getStyleClass().add("log-window-list-cell");
@@ -354,11 +386,6 @@ public final class LogWindow extends Stage {
protected void updateItem(Log item, boolean empty) {
super.updateItem(item, empty);
// https://mail.openjdk.org/pipermail/openjfx-dev/2022-July/034764.html
if (this == lastCell.value && !isVisible())
return;
lastCell.value = this;
pseudoClassStateChanged(EMPTY, empty);
pseudoClassStateChanged(FATAL, !empty && item.getLevel() == Log4jLevel.FATAL);
pseudoClassStateChanged(ERROR, !empty && item.getLevel() == Log4jLevel.ERROR);
@@ -153,24 +153,25 @@ public enum SVG {
return new Group(path);
}
public Node createIcon(ObservableValue<? extends Paint> fill, double size) {
public Node createIcon(double size) {
SVGPath p = new SVGPath();
p.getStyleClass().add("svg");
p.setContent(path);
if (fill != null)
p.fillProperty().bind(fill);
p.getStyleClass().add("svg");
return createIcon(p, size);
}
public Node createIcon(Paint fill, double size) {
public Node createIcon() {
SVGPath p = new SVGPath();
p.getStyleClass().add("svg");
p.setContent(path);
if (fill != null)
p.fillProperty().set(fill);
return createIcon(p, size);
p.getStyleClass().add("svg");
return createIcon(p, -1);
}
public Node createIcon(ObservableValue<? extends Paint> color) {
SVGPath p = new SVGPath();
p.setContent(path);
p.getStyleClass().add("svg");
p.fillProperty().bind(color);
return createIcon(p, -1);
}
}
@@ -25,7 +25,6 @@ import javafx.animation.Animation.Status;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.event.EventHandler;
import javafx.scene.control.IndexedCell;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.skin.VirtualFlow;
import javafx.scene.input.MouseEvent;
@@ -52,8 +51,10 @@ final class ScrollUtils {
}
}
private ScrollUtils() {
}
private static final double DEFAULT_SPEED = 1.0;
private static final double DEFAULT_TRACK_PAD_ADJUSTMENT = 7.0;
private static final double CUTOFF_DELTA = 0.01;
/**
* Determines if the given ScrollEvent comes from a trackpad.
@@ -68,16 +69,10 @@ final class ScrollUtils {
* @see ScrollEvent#getDeltaY()
*/
public static boolean isTrackPad(ScrollEvent event, ScrollDirection scrollDirection) {
switch (scrollDirection) {
case UP:
case DOWN:
return Math.abs(event.getDeltaY()) < 10;
case LEFT:
case RIGHT:
return Math.abs(event.getDeltaX()) < 10;
default:
return false;
}
return switch (scrollDirection) {
case UP, DOWN -> Math.abs(event.getDeltaY()) < 10;
case LEFT, RIGHT -> Math.abs(event.getDeltaX()) < 10;
};
}
/**
@@ -117,7 +112,7 @@ final class ScrollUtils {
* default speed value of 1.
*/
public static void addSmoothScrolling(ScrollPane scrollPane) {
addSmoothScrolling(scrollPane, 1);
addSmoothScrolling(scrollPane, DEFAULT_SPEED);
}
/**
@@ -126,7 +121,7 @@ final class ScrollUtils {
* with a default trackPadAdjustment of 7.
*/
public static void addSmoothScrolling(ScrollPane scrollPane, double speed) {
addSmoothScrolling(scrollPane, speed, 7);
addSmoothScrolling(scrollPane, speed, DEFAULT_TRACK_PAD_ADJUSTMENT);
}
/**
@@ -143,12 +138,12 @@ final class ScrollUtils {
/// @author Glavo
public static void addSmoothScrolling(VirtualFlow<?> virtualFlow) {
addSmoothScrolling(virtualFlow, 1);
addSmoothScrolling(virtualFlow, DEFAULT_SPEED);
}
/// @author Glavo
public static void addSmoothScrolling(VirtualFlow<?> virtualFlow, double speed) {
addSmoothScrolling(virtualFlow, speed, 7);
addSmoothScrolling(virtualFlow, speed, DEFAULT_TRACK_PAD_ADJUSTMENT);
}
/// @author Glavo
@@ -180,16 +175,16 @@ final class ScrollUtils {
}
};
if (scrollPane.getContent().getParent() != null) {
scrollPane.getContent().getParent().addEventHandler(MouseEvent.MOUSE_PRESSED, mouseHandler);
scrollPane.getContent().getParent().addEventFilter(MouseEvent.MOUSE_PRESSED, mouseHandler);
scrollPane.getContent().getParent().addEventHandler(ScrollEvent.ANY, scrollHandler);
}
scrollPane.getContent().parentProperty().addListener((observable, oldValue, newValue) -> {
if (oldValue != null) {
oldValue.removeEventHandler(MouseEvent.MOUSE_PRESSED, mouseHandler);
oldValue.removeEventFilter(MouseEvent.MOUSE_PRESSED, mouseHandler);
oldValue.removeEventHandler(ScrollEvent.ANY, scrollHandler);
}
if (newValue != null) {
newValue.addEventHandler(MouseEvent.MOUSE_PRESSED, mouseHandler);
newValue.addEventFilter(MouseEvent.MOUSE_PRESSED, mouseHandler);
newValue.addEventHandler(ScrollEvent.ANY, scrollHandler);
}
});
@@ -217,7 +212,7 @@ final class ScrollUtils {
break;
}
if (Math.abs(dy) < 0.001) {
if (Math.abs(dy) < CUTOFF_DELTA) {
timeline.stop();
}
}));
@@ -232,7 +227,6 @@ final class ScrollUtils {
final double[] derivatives = new double[FRICTIONS.length];
Timeline timeline = new Timeline();
Holder<ScrollDirection> scrollDirectionHolder = new Holder<>();
final EventHandler<MouseEvent> mouseHandler = event -> timeline.stop();
final EventHandler<ScrollEvent> scrollHandler = event -> {
if (event.getEventType() == ScrollEvent.SCROLL) {
@@ -240,7 +234,6 @@ final class ScrollUtils {
if (scrollDirection == ScrollDirection.LEFT || scrollDirection == ScrollDirection.RIGHT) {
return;
}
scrollDirectionHolder.value = scrollDirection;
double currentSpeed = isTrackPad(event, scrollDirection) ? speed / trackPadAdjustment : speed;
derivatives[0] += scrollDirection.intDirection * currentSpeed;
@@ -250,7 +243,7 @@ final class ScrollUtils {
event.consume();
}
};
virtualFlow.addEventHandler(MouseEvent.MOUSE_PRESSED, mouseHandler);
virtualFlow.addEventFilter(MouseEvent.MOUSE_PRESSED, mouseHandler);
virtualFlow.addEventFilter(ScrollEvent.ANY, scrollHandler);
timeline.getKeyFrames().add(new KeyFrame(DURATION, event -> {
@@ -262,20 +255,15 @@ final class ScrollUtils {
}
double dy = derivatives[derivatives.length - 1];
virtualFlow.scrollPixels(dy);
int cellCount = virtualFlow.getCellCount();
IndexedCell<?> firstVisibleCell = virtualFlow.getFirstVisibleCell();
double height = firstVisibleCell != null ? firstVisibleCell.getHeight() * cellCount : 0.0;
double delta = height > 0.0
? dy / height
: (scrollDirectionHolder.value == ScrollDirection.DOWN ? 0.001 : -0.001);
virtualFlow.setPosition(Math.min(Math.max(virtualFlow.getPosition() + delta, 0), 1));
if (Math.abs(dy) < 0.001) {
if (Math.abs(dy) < CUTOFF_DELTA) {
timeline.stop();
}
}));
timeline.setCycleCount(Animation.INDEFINITE);
}
private ScrollUtils() {
}
}
@@ -18,25 +18,26 @@
package org.jackhuang.hmcl.ui;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXListView;
import javafx.beans.binding.Bindings;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.ListCell;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import java.util.List;
public abstract class ToolbarListPageSkin<T extends ListPageBase<? extends Node>> extends SkinBase<T> {
public abstract class ToolbarListPageSkin<E, P extends ListPageBase<E>> extends SkinBase<P> {
public ToolbarListPageSkin(T skinnable) {
protected final JFXListView<E> listView;
public ToolbarListPageSkin(P skinnable) {
super(skinnable);
SpinnerPane spinnerPane = new SpinnerPane();
@@ -59,18 +60,12 @@ public abstract class ToolbarListPageSkin<T extends ListPageBase<? extends Node>
}
{
ScrollPane scrollPane = new ScrollPane();
ComponentList.setVgrow(scrollPane, Priority.ALWAYS);
scrollPane.setFitToWidth(true);
VBox content = new VBox();
Bindings.bindContent(content.getChildren(), skinnable.itemsProperty());
scrollPane.setContent(content);
FXUtils.smoothScrolling(scrollPane);
root.getContent().add(scrollPane);
this.listView = new JFXListView<>();
this.listView.setPadding(Insets.EMPTY);
this.listView.setCellFactory(listView -> createListCell((JFXListView<E>) listView));
ComponentList.setVgrow(listView, Priority.ALWAYS);
Bindings.bindContent(this.listView.getItems(), skinnable.itemsProperty());
root.getContent().add(listView);
}
spinnerPane.setContent(root);
@@ -85,37 +80,39 @@ public abstract class ToolbarListPageSkin<T extends ListPageBase<? extends Node>
return stackPane;
}
public static JFXButton createToolbarButton(String text, SVG svg, Runnable onClick) {
JFXButton ret = new JFXButton();
ret.getStyleClass().add("jfx-tool-bar-button");
ret.textFillProperty().bind(Theme.foregroundFillBinding());
ret.setGraphic(wrap(svg.createIcon(Theme.foregroundFillBinding(), -1)));
ret.setText(text);
ret.setOnAction(e -> onClick.run());
return ret;
}
public static JFXButton createToolbarButton2(String text, SVG svg, Runnable onClick) {
JFXButton ret = new JFXButton();
ret.getStyleClass().add("jfx-tool-bar-button");
ret.setGraphic(wrap(svg.createIcon(Theme.blackFill(), -1)));
ret.setGraphic(wrap(svg.createIcon()));
ret.setText(text);
ret.setOnAction(e -> {
onClick.run();
FXUtils.clearFocus(ret);
});
ret.setOnAction(e -> onClick.run());
return ret;
}
public static JFXButton createDecoratorButton(String tooltip, SVG svg, Runnable onClick) {
JFXButton ret = new JFXButton();
ret.getStyleClass().add("jfx-decorator-button");
ret.textFillProperty().bind(Theme.foregroundFillBinding());
ret.setGraphic(wrap(svg.createIcon(Theme.foregroundFillBinding(), -1)));
ret.setGraphic(wrap(svg.createIcon()));
FXUtils.installFastTooltip(ret, tooltip);
ret.setOnAction(e -> onClick.run());
return ret;
}
protected abstract List<Node> initializeToolbar(T skinnable);
protected abstract List<Node> initializeToolbar(P skinnable);
protected ListCell<E> createListCell(JFXListView<E> listView) {
return new ListCell<>() {
@Override
protected void updateItem(E item, boolean empty) {
super.updateItem(item, empty);
if (!empty && item instanceof Node node) {
setGraphic(node);
setText(null);
} else {
setGraphic(null);
setText(null);
}
}
};
}
}
@@ -19,8 +19,8 @@ package org.jackhuang.hmcl.ui;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXSpinner;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.control.ScrollPane;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.task.Schedulers;
@@ -48,7 +48,7 @@ public final class UpgradeDialog extends JFXDialogLayout {
maxHeightProperty().bind(Controllers.getScene().heightProperty().multiply(0.7));
setHeading(new Label(i18n("update.changelog")));
setBody(new ProgressIndicator());
setBody(new JFXSpinner());
String url = CHANGELOG_URL + remoteVersion.getChannel().channelName + ".html";
@@ -55,4 +55,8 @@ public class WeakListenerHolder {
public boolean remove(Object obj) {
return refs.remove(obj);
}
public void clear() {
refs.clear();
}
}
@@ -0,0 +1,60 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 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.ui;
import javafx.stage.Stage;
import javafx.stage.Window;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.util.OptionalLong;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Glavo
public final class WindowsNativeUtils {
public static OptionalLong getWindowHandle(Stage stage) {
try {
Class<?> windowStageClass = Class.forName("com.sun.javafx.tk.quantum.WindowStage");
Class<?> glassWindowClass = Class.forName("com.sun.glass.ui.Window");
Class<?> tkStageClass = Class.forName("com.sun.javafx.tk.TKStage");
Object tkStage = MethodHandles.privateLookupIn(Window.class, MethodHandles.lookup())
.findVirtual(Window.class, "getPeer", MethodType.methodType(tkStageClass))
.invoke(stage);
MethodHandles.Lookup windowStageLookup = MethodHandles.privateLookupIn(windowStageClass, MethodHandles.lookup());
MethodHandle getPlatformWindow = windowStageLookup.findVirtual(windowStageClass, "getPlatformWindow", MethodType.methodType(glassWindowClass));
Object platformWindow = getPlatformWindow.invoke(tkStage);
long handle = (long) MethodHandles.privateLookupIn(glassWindowClass, MethodHandles.lookup())
.findVirtual(glassWindowClass, "getNativeWindow", MethodType.methodType(long.class))
.invoke(platformWindow);
return OptionalLong.of(handle);
} catch (Throwable ex) {
LOG.warning("Failed to get window handle", ex);
return OptionalLong.empty();
}
}
private WindowsNativeUtils() {
}
}
@@ -68,6 +68,10 @@ public class AccountAdvancedListItem extends AdvancedListItem {
};
public AccountAdvancedListItem() {
this(null);
}
public AccountAdvancedListItem(Account account) {
tooltip = new Tooltip();
FXUtils.installFastTooltip(this, tooltip);
@@ -76,9 +80,13 @@ public class AccountAdvancedListItem extends AdvancedListItem {
setActionButtonVisible(false);
FXUtils.onScroll(this, Accounts.getAccounts(),
accounts -> accounts.indexOf(account.get()),
Accounts::setSelectedAccount);
if (account != null) {
this.accountProperty().set(account);
} else {
FXUtils.onScroll(this, Accounts.getAccounts(),
accounts -> accounts.indexOf(accountProperty().get()),
Accounts::setSelectedAccount);
}
}
public ObjectProperty<Account> accountProperty() {
@@ -36,7 +36,6 @@ import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount;
import org.jackhuang.hmcl.game.TexturesLoader;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
@@ -117,10 +116,10 @@ public final class AccountListItemSkin extends SkinBase<AccountListItem> {
});
btnMove.getStyleClass().add("toggle-icon4");
if (skinnable.getAccount().isPortable()) {
btnMove.setGraphic(SVG.PUBLIC.createIcon(Theme.blackFill(), -1));
btnMove.setGraphic(SVG.PUBLIC.createIcon());
FXUtils.installFastTooltip(btnMove, i18n("account.move_to_global"));
} else {
btnMove.setGraphic(SVG.OUTPUT.createIcon(Theme.blackFill(), -1));
btnMove.setGraphic(SVG.OUTPUT.createIcon());
FXUtils.installFastTooltip(btnMove, i18n("account.move_to_portable"));
}
spinnerMove.setContent(btnMove);
@@ -131,7 +130,7 @@ public final class AccountListItemSkin extends SkinBase<AccountListItem> {
spinnerRefresh.getStyleClass().setAll("small-spinner-pane");
if (skinnable.getAccount() instanceof MicrosoftAccount && Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) {
btnRefresh.setDisable(true);
FXUtils.installFastTooltip(spinnerRefresh, i18n("account.methods.microsoft.snapshot"));
FXUtils.installFastTooltip(spinnerRefresh, i18n("account.methods.microsoft.snapshot.tooltip"));
}
btnRefresh.setOnAction(e -> {
spinnerRefresh.showSpinner();
@@ -146,7 +145,7 @@ public final class AccountListItemSkin extends SkinBase<AccountListItem> {
.start();
});
btnRefresh.getStyleClass().add("toggle-icon4");
btnRefresh.setGraphic(SVG.REFRESH.createIcon(Theme.blackFill(), -1));
btnRefresh.setGraphic(SVG.REFRESH.createIcon());
FXUtils.installFastTooltip(btnRefresh, i18n("button.refresh"));
spinnerRefresh.setContent(btnRefresh);
right.getChildren().add(spinnerRefresh);
@@ -163,7 +162,7 @@ public final class AccountListItemSkin extends SkinBase<AccountListItem> {
}
});
btnUpload.getStyleClass().add("toggle-icon4");
btnUpload.setGraphic(SVG.CHECKROOM.createIcon(Theme.blackFill(), -1));
btnUpload.setGraphic(SVG.CHECKROOM.createIcon());
FXUtils.installFastTooltip(btnUpload, i18n("account.skin.upload"));
btnUpload.disableProperty().bind(Bindings.not(skinnable.canUploadSkin()));
spinnerUpload.setContent(btnUpload);
@@ -175,7 +174,7 @@ public final class AccountListItemSkin extends SkinBase<AccountListItem> {
spinnerCopyUUID.getStyleClass().add("small-spinner-pane");
btnUpload.getStyleClass().add("toggle-icon4");
btnCopyUUID.setOnAction(e -> FXUtils.copyText(skinnable.getAccount().getUUID().toString()));
btnCopyUUID.setGraphic(SVG.CONTENT_COPY.createIcon(Theme.blackFill(), -1));
btnCopyUUID.setGraphic(SVG.CONTENT_COPY.createIcon());
FXUtils.installFastTooltip(btnCopyUUID, i18n("account.copy_uuid"));
spinnerCopyUUID.setContent(btnCopyUUID);
right.getChildren().add(spinnerCopyUUID);
@@ -184,7 +183,7 @@ public final class AccountListItemSkin extends SkinBase<AccountListItem> {
btnRemove.setOnAction(e -> Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), skinnable::remove, null));
btnRemove.getStyleClass().add("toggle-icon4");
BorderPane.setAlignment(btnRemove, Pos.CENTER);
btnRemove.setGraphic(SVG.DELETE.createIcon(Theme.blackFill(), -1));
btnRemove.setGraphic(SVG.DELETE.createIcon());
FXUtils.installFastTooltip(btnRemove, i18n("button.delete"));
right.getChildren().add(btnRemove);
root.setRight(right);
@@ -39,7 +39,6 @@ import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
@@ -158,7 +157,7 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
e.consume();
});
btnRemove.getStyleClass().add("toggle-icon4");
btnRemove.setGraphic(SVG.CLOSE.createIcon(Theme.blackFill(), 14));
btnRemove.setGraphic(SVG.CLOSE.createIcon(14));
item.setRightGraphic(btnRemove);
ObservableValue<String> title = BindingMapping.of(server, AuthlibInjectorServer::getName);
@@ -25,11 +25,10 @@ import javafx.scene.layout.*;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.DialogAware;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.Lang;
import javax.net.ssl.SSLException;
@@ -88,6 +87,10 @@ public final class AddAuthlibInjectorServerPane extends TransitionPane implement
addServerPane.setBody(txtServerUrl);
addServerPane.setActions(lblCreationWarning, actions);
txtServerUrl.getValidators().addAll(new RequiredValidator(), new URLValidator());
FXUtils.setValidateWhileTextChanged(txtServerUrl, true);
btnAddNext.disableProperty().bind(txtServerUrl.activeValidatorProperty().isNotNull());
}
confirmServerPane = new JFXDialogLayout();
@@ -149,7 +152,6 @@ public final class AddAuthlibInjectorServerPane extends TransitionPane implement
this.setContent(addServerPane, ContainerAnimations.NONE);
lblCreationWarning.maxWidthProperty().bind(((FlowPane) lblCreationWarning.getParent()).widthProperty());
btnAddNext.disableProperty().bind(txtServerUrl.textProperty().isEmpty());
nextPane.hideSpinner();
onEscPressed(this, this::onAddCancel);
@@ -162,6 +164,12 @@ public final class AddAuthlibInjectorServerPane extends TransitionPane implement
private String resolveFetchExceptionMessage(Throwable exception) {
if (exception instanceof SSLException) {
if (exception.getMessage() != null && exception.getMessage().contains("Remote host terminated")) {
return i18n("account.failed.connect_injector_server");
}
if (exception.getMessage() != null && (exception.getMessage().contains("No name matching") || exception.getMessage().contains("No subject alternative DNS name matching"))) {
return i18n("account.failed.dns");
}
return i18n("account.failed.ssl");
} else if (exception instanceof IOException) {
return i18n("account.failed.connect_injector_server");
@@ -35,8 +35,6 @@ import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.control.TextInputControl;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.AccountFactory;
import org.jackhuang.hmcl.auth.CharacterSelector;
import org.jackhuang.hmcl.auth.NoSelectedCharacterException;
@@ -50,7 +48,6 @@ import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService;
import org.jackhuang.hmcl.game.OAuthServer;
import org.jackhuang.hmcl.game.TexturesLoader;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.TaskExecutor;
@@ -65,11 +62,7 @@ import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.*;
import java.util.concurrent.CountDownLatch;
import java.util.regex.Pattern;
@@ -287,72 +280,95 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
btnAccept.disableProperty().unbind();
detailsContainer.getChildren().remove(detailsPane);
lblErrorMessage.setText("");
lblErrorMessage.setVisible(true);
}
if (factory == Accounts.FACTORY_MICROSOFT) {
VBox vbox = new VBox(8);
if (!Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) {
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
FXUtils.onChangeAndOperate(deviceCode, deviceCode -> {
if (deviceCode != null) {
FXUtils.copyText(deviceCode.getUserCode());
hintPane.setSegment(i18n("account.methods.microsoft.manual", deviceCode.getUserCode(), deviceCode.getVerificationUri()));
} else {
hintPane.setSegment(i18n("account.methods.microsoft.hint"));
}
});
FXUtils.onClicked(hintPane, () -> {
if (deviceCode.get() != null) {
FXUtils.copyText(deviceCode.get().getUserCode());
}
});
detailsPane = vbox;
holder.add(Accounts.OAUTH_CALLBACK.onGrantDeviceCode.registerWeak(value -> {
runInFX(() -> deviceCode.set(value));
}));
FlowPane box = new FlowPane();
box.setHgap(8);
JFXHyperlink birthLink = new JFXHyperlink(i18n("account.methods.microsoft.birth"));
birthLink.setExternalLink("https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a");
JFXHyperlink profileLink = new JFXHyperlink(i18n("account.methods.microsoft.profile"));
profileLink.setExternalLink("https://account.live.com/editprof.aspx");
JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.microsoft.purchase"));
purchaseLink.setExternalLink(YggdrasilService.PURCHASE_URL);
JFXHyperlink deauthorizeLink = new JFXHyperlink(i18n("account.methods.microsoft.deauthorize"));
deauthorizeLink.setExternalLink("https://account.live.com/consent/Edit?client_id=000000004C794E0A");
JFXHyperlink forgotpasswordLink = new JFXHyperlink(i18n("account.methods.forgot_password"));
forgotpasswordLink.setExternalLink("https://account.live.com/ResetPassword.aspx");
JFXHyperlink createProfileLink = new JFXHyperlink(i18n("account.methods.microsoft.makegameidsettings"));
createProfileLink.setExternalLink("https://www.minecraft.net/msaprofile/mygames/editprofile");
JFXHyperlink bannedQueryLink = new JFXHyperlink(i18n("account.methods.ban_query"));
bannedQueryLink.setExternalLink("https://enforcement.xbox.com/enforcement/showenforcementhistory");
box.getChildren().setAll(profileLink, birthLink, purchaseLink, deauthorizeLink, forgotpasswordLink, createProfileLink, bannedQueryLink);
GridPane.setColumnSpan(box, 2);
if (!IntegrityChecker.isOfficial()) {
HintPane unofficialHint = new HintPane(MessageDialogPane.MessageType.WARNING);
unofficialHint.setText(i18n("unofficial.hint"));
vbox.getChildren().add(unofficialHint);
}
vbox.getChildren().addAll(hintPane, box);
btnAccept.setDisable(false);
} else {
if (Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) {
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING);
hintPane.setSegment(i18n("account.methods.microsoft.snapshot"));
JFXHyperlink officialWebsite = new JFXHyperlink(i18n("account.methods.microsoft.snapshot.website"));
officialWebsite.setExternalLink(Metadata.PUBLISH_URL);
vbox.getChildren().setAll(hintPane, officialWebsite);
btnAccept.setDisable(true);
vbox.getChildren().add(hintPane);
return;
}
detailsPane = vbox;
if (!IntegrityChecker.isOfficial()) {
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING);
hintPane.setSegment(i18n("unofficial.hint"));
vbox.getChildren().add(hintPane);
}
VBox codeBox = new VBox(8);
Label hint = new Label(i18n("account.methods.microsoft.code"));
Label code = new Label();
code.setMouseTransparent(true);
code.setStyle("-fx-font-size: 24");
codeBox.getChildren().addAll(hint, code);
codeBox.setAlignment(Pos.CENTER);
vbox.getChildren().add(codeBox);
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
HintPane errHintPane = new HintPane(MessageDialogPane.MessageType.ERROR);
errHintPane.setVisible(false);
errHintPane.setManaged(false);
codeBox.setVisible(false);
codeBox.setManaged(false);
FXUtils.onChangeAndOperate(deviceCode, deviceCode -> {
if (deviceCode != null) {
FXUtils.copyText(deviceCode.getUserCode());
code.setText(deviceCode.getUserCode());
hintPane.setSegment(i18n("account.methods.microsoft.manual", deviceCode.getVerificationUri()));
codeBox.setVisible(true);
codeBox.setManaged(true);
} else {
hintPane.setSegment(i18n("account.methods.microsoft.hint"));
codeBox.setVisible(false);
codeBox.setManaged(false);
}
});
lblErrorMessage.setVisible(false);
lblErrorMessage.textProperty().addListener((obs, oldVal, newVal) -> {
boolean hasError = !newVal.isEmpty();
errHintPane.setSegment(newVal);
errHintPane.setVisible(hasError);
errHintPane.setManaged(hasError);
hintPane.setVisible(!hasError);
hintPane.setManaged(!hasError);
codeBox.setVisible(!hasError && deviceCode.get() != null);
codeBox.setManaged(!hasError && deviceCode.get() != null);
});
FXUtils.onClicked(codeBox, () -> {
if (deviceCode.get() != null) FXUtils.copyText(deviceCode.get().getUserCode());
});
holder.add(Accounts.OAUTH_CALLBACK.onGrantDeviceCode.registerWeak(value ->
runInFX(() -> deviceCode.set(value))
));
HBox linkBox = new HBox();
JFXHyperlink profileLink = new JFXHyperlink(i18n("account.methods.microsoft.profile"));
profileLink.setExternalLink("https://account.live.com/editprof.aspx");
JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.microsoft.purchase"));
purchaseLink.setExternalLink(YggdrasilService.PURCHASE_URL);
JFXHyperlink deauthorizeLink = new JFXHyperlink(i18n("account.methods.microsoft.deauthorize"));
deauthorizeLink.setExternalLink("https://account.live.com/consent/Edit?client_id=000000004C794E0A");
JFXHyperlink forgotpasswordLink = new JFXHyperlink(i18n("account.methods.forgot_password"));
forgotpasswordLink.setExternalLink("https://account.live.com/ResetPassword.aspx");
linkBox.getChildren().setAll(profileLink, purchaseLink, deauthorizeLink, forgotpasswordLink);
vbox.getChildren().addAll(hintPane, errHintPane, linkBox);
btnAccept.setDisable(false);
} else {
detailsPane = new AccountDetailsInputPane(factory, btnAccept::fire);
btnAccept.disableProperty().bind(((AccountDetailsInputPane) detailsPane).validProperty().not());
}
detailsContainer.getChildren().add(detailsPane);
}
@@ -461,11 +477,14 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
onChangeAndOperate(cboServers.valueProperty(), server -> {
this.server = server;
linksContainer.getChildren().setAll(createHyperlinks(server));
if (txtUsername != null)
txtUsername.validate();
});
linksContainer.setMinWidth(USE_PREF_SIZE);
JFXButton btnAddServer = new JFXButton();
btnAddServer.setGraphic(SVG.ADD.createIcon(Theme.blackFill(), 20));
btnAddServer.setGraphic(SVG.ADD.createIcon(20));
btnAddServer.getStyleClass().add("toggle-icon4");
btnAddServer.setOnAction(e -> {
Controllers.dialog(new AddAuthlibInjectorServerPane());
@@ -591,6 +610,9 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
if ((factory instanceof AuthlibInjectorAccountFactory) && this.server != null) {
return !server.isNonEmailLogin();
}
if (factory instanceof BoundAuthlibInjectorAccountFactory bound) {
return !bound.getServer().isNonEmailLogin();
}
return false;
}
@@ -66,11 +66,11 @@ public class OAuthAccountLoginDialog extends DialogPane {
HBox box = new HBox(8);
JFXHyperlink birthLink = new JFXHyperlink(i18n("account.methods.microsoft.birth"));
birthLink.setOnAction(e -> FXUtils.openLink("https://support.microsoft.com/account-billing/how-to-change-a-birth-date-on-a-microsoft-account-837badbc-999e-54d2-2617-d19206b9540a"));
birthLink.setExternalLink("https://support.microsoft.com/account-billing/how-to-change-a-birth-date-on-a-microsoft-account-837badbc-999e-54d2-2617-d19206b9540a");
JFXHyperlink profileLink = new JFXHyperlink(i18n("account.methods.microsoft.profile"));
profileLink.setOnAction(e -> FXUtils.openLink("https://account.live.com/editprof.aspx"));
profileLink.setExternalLink("https://account.live.com/editprof.aspx");
JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.microsoft.purchase"));
purchaseLink.setOnAction(e -> FXUtils.openLink(YggdrasilService.PURCHASE_URL));
purchaseLink.setExternalLink(YggdrasilService.PURCHASE_URL);
box.getChildren().setAll(profileLink, birthLink, purchaseLink);
GridPane.setColumnSpan(box, 2);
@@ -24,6 +24,7 @@ import com.jfoenix.controls.JFXTextField;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.geometry.Insets;
import javafx.geometry.VPos;
import javafx.scene.control.Label;
import javafx.scene.input.DragEvent;
import javafx.scene.input.TransferMode;
@@ -159,7 +160,9 @@ public class OfflineAccountSkinPane extends StackPane {
FXUtils.onChangeAndOperate(skinItem.selectedDataProperty(), selectedData -> {
GridPane gridPane = new GridPane();
gridPane.setPadding(new Insets(0, 0, 0, 10));
// Increase bottom padding to prevent the prompt from overlapping with the dialog action area
gridPane.setPadding(new Insets(0, 0, 45, 10));
gridPane.setHgap(16);
gridPane.setVgap(8);
gridPane.getColumnConstraints().setAll(new ColumnConstraints(), FXUtils.getColumnHgrowing());
@@ -172,9 +175,23 @@ public class OfflineAccountSkinPane extends StackPane {
case LITTLE_SKIN:
HintPane hint = new HintPane(MessageDialogPane.MessageType.INFO);
hint.setText(i18n("account.skin.type.little_skin.hint"));
// Spanning two columns and expanding horizontally
GridPane.setColumnSpan(hint, 2);
GridPane.setHgrow(hint, Priority.ALWAYS);
hint.setMaxWidth(Double.MAX_VALUE);
// Force top alignment within cells (to avoid vertical offset caused by the baseline)
GridPane.setValignment(hint, VPos.TOP);
// Set a fixed height as the preferred height to prevent the GridPane from stretching or leaving empty space.
hint.setMaxHeight(Region.USE_PREF_SIZE);
hint.setMinHeight(Region.USE_PREF_SIZE);
gridPane.addRow(0, hint);
break;
case LOCAL_FILE:
gridPane.setPadding(new Insets(0, 0, 0, 10));
gridPane.addRow(0, new Label(i18n("account.skin.model")), modelCombobox);
gridPane.addRow(1, new Label(i18n("account.skin")), skinSelector);
gridPane.addRow(2, new Label(i18n("account.cape")), capeSelector);
@@ -82,7 +82,9 @@ public class TransitionPane extends StackPane {
duration, interpolator);
newAnimation.setOnFinished(e -> {
setMouseTransparent(false);
getChildren().remove(previousNode);
if (previousNode != currentNode) {
getChildren().remove(previousNode);
}
if (cacheHint != null) {
newView.setCache(false);
@@ -26,7 +26,6 @@ import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Paint;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
@@ -109,8 +108,8 @@ public class AdvancedListBox extends ScrollPane {
item.activeProperty().bind(tabHeader.getSelectionModel().selectedItemProperty().isEqualTo(tab));
item.setOnAction(e -> tabHeader.select(tab));
Node unselectedIcon = unselectedGraphic.createIcon((Paint) null, 20);
Node selectedIcon = selectedGraphic.createIcon((Paint) null, 20);
Node unselectedIcon = unselectedGraphic.createIcon(20);
Node selectedIcon = selectedGraphic.createIcon(20);
TransitionPane leftGraphic = new TransitionPane();
leftGraphic.setAlignment(Pos.CENTER);
@@ -21,7 +21,6 @@ import javafx.scene.Node;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Text;
import org.jackhuang.hmcl.util.Lang;
@@ -44,7 +43,6 @@ public class ClassTitle extends StackPane {
Rectangle rectangle = new Rectangle();
rectangle.widthProperty().bind(vbox.widthProperty());
rectangle.setHeight(1.0);
rectangle.setFill(Color.GRAY);
vbox.getChildren().add(rectangle);
getChildren().setAll(vbox);
getStyleClass().add("class-title");
@@ -30,7 +30,7 @@ import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.shape.Rectangle;
import javafx.util.Duration;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
@@ -79,7 +79,7 @@ final class ComponentListCell extends StackPane {
VBox groupNode = new VBox();
Node expandIcon = SVG.KEYBOARD_ARROW_DOWN.createIcon(Theme.blackFill(), 20);
Node expandIcon = SVG.KEYBOARD_ARROW_DOWN.createIcon(20);
expandIcon.setMouseTransparent(true);
HBox.setMargin(expandIcon, new Insets(0, 8, 0, 8));
@@ -99,12 +99,14 @@ final class ComponentListCell extends StackPane {
if (!overrideHeaderLeft) {
Label label = new Label();
label.textProperty().bind(list.titleProperty());
label.getStyleClass().add("title-label");
labelVBox.getChildren().add(label);
if (list.isHasSubtitle()) {
Label subtitleLabel = new Label();
subtitleLabel.textProperty().bind(list.subtitleProperty());
subtitleLabel.getStyleClass().add("subtitle-label");
subtitleLabel.textFillProperty().bind(Themes.colorSchemeProperty().getOnSurfaceVariant());
labelVBox.getChildren().add(subtitleLabel);
}
}
@@ -23,12 +23,10 @@ import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import javafx.stage.DirectoryChooser;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
@@ -46,7 +44,6 @@ public class FileItem extends BorderPane {
private final SimpleStringProperty name = new SimpleStringProperty(this, "name");
private final SimpleStringProperty title = new SimpleStringProperty(this, "title");
private final SimpleStringProperty tooltip = new SimpleStringProperty(this, "tooltip");
private final SimpleStringProperty path = new SimpleStringProperty(this, "path");
private final SimpleBooleanProperty convertToRelativePath = new SimpleBooleanProperty(this, "convertToRelativePath");
@@ -60,16 +57,12 @@ public class FileItem extends BorderPane {
setLeft(left);
JFXButton right = new JFXButton();
right.setGraphic(SVG.EDIT.createIcon(Theme.blackFill(), 16));
right.setGraphic(SVG.EDIT.createIcon(16));
right.getStyleClass().add("toggle-icon4");
right.setOnAction(e -> onExplore());
FXUtils.installFastTooltip(right, i18n("button.edit"));
setRight(right);
Tooltip tip = new Tooltip();
tip.textProperty().bind(tooltipProperty());
Tooltip.install(this, tip);
convertToRelativePath.addListener(onInvalidating(() -> path.set(processPath(path.get()))));
}
@@ -143,18 +136,6 @@ public class FileItem extends BorderPane {
this.title.set(title);
}
public String getTooltip() {
return tooltip.get();
}
public StringProperty tooltipProperty() {
return tooltip;
}
public void setTooltip(String tooltip) {
this.tooltip.set(tooltip);
}
public String getPath() {
return path.get();
}
@@ -27,7 +27,6 @@ import javafx.geometry.Pos;
import javafx.scene.layout.HBox;
import javafx.stage.DirectoryChooser;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
@@ -82,7 +81,7 @@ public class FileSelector extends HBox {
FXUtils.bindString(customField, valueProperty());
JFXButton selectButton = new JFXButton();
selectButton.setGraphic(SVG.FOLDER_OPEN.createIcon(Theme.blackFill(), 15));
selectButton.setGraphic(SVG.FOLDER_OPEN.createIcon(15));
selectButton.setOnAction(e -> {
if (directory) {
DirectoryChooser chooser = new DirectoryChooser();
@@ -28,6 +28,8 @@ import javafx.scene.layout.Region;
import javafx.scene.shape.Rectangle;
import org.jackhuang.hmcl.util.Lang;
// Referenced in root.css
@SuppressWarnings("unused")
public class FloatScrollBarSkin implements Skin<ScrollBar> {
private ScrollBar scrollBar;
private Region group;
@@ -26,10 +26,8 @@ import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import java.util.Locale;
@@ -46,29 +44,9 @@ public class HintPane extends VBox {
setFillWidth(true);
getStyleClass().addAll("hint", type.name().toLowerCase(Locale.ROOT));
SVG svg;
switch (type) {
case INFO:
svg = SVG.INFO;
break;
case ERROR:
svg = SVG.ERROR;
break;
case SUCCESS:
svg = SVG.CHECK_CIRCLE;
break;
case WARNING:
svg = SVG.WARNING;
break;
case QUESTION:
svg = SVG.HELP;
break;
default:
throw new IllegalArgumentException("Unrecognized message box message type " + type);
}
HBox hbox = new HBox(svg.createIcon(Theme.blackFill(), 16), new Text(type.getDisplayName()));
HBox hbox = new HBox(type.getIcon().createIcon(16), new Text(type.getDisplayName()));
hbox.setAlignment(Pos.CENTER_LEFT);
hbox.setSpacing(2);
flow.getChildren().setAll(label);
getChildren().setAll(hbox, flow);
label.textProperty().bind(text);
@@ -18,14 +18,13 @@
package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXPopup;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
public class IconedMenuItem extends IconedItem {
public final class IconedMenuItem extends IconedItem {
public IconedMenuItem(SVG icon, String text, Runnable action, JFXPopup popup) {
super(icon != null ? FXUtils.limitingSize(icon.createIcon(Theme.blackFill(), 14), 14, 14) : null, text);
super(icon != null ? FXUtils.limitingSize(icon.createIcon(14), 14, 14) : null, text);
getStyleClass().setAll("iconed-menu-item");
@@ -15,7 +15,6 @@ import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.util.StringUtils;
@@ -111,7 +110,7 @@ public class IconedTwoLineListItem extends HBox {
if (externalLinkButton == null) {
externalLinkButton = new JFXButton();
externalLinkButton.getStyleClass().add("toggle-icon4");
externalLinkButton.setGraphic(SVG.OPEN_IN_NEW.createIcon(Theme.blackFill(), -1));
externalLinkButton.setGraphic(SVG.OPEN_IN_NEW.createIcon());
externalLinkButton.setOnAction(e -> FXUtils.openLink(externalLink.get()));
}
return externalLinkButton;
@@ -32,7 +32,6 @@ import javafx.scene.image.ImageView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
@@ -54,16 +53,17 @@ public final class ImagePickerItem extends BorderPane {
imageView.setPreserveRatio(true);
JFXButton selectButton = new JFXButton();
selectButton.setGraphic(SVG.EDIT.createIcon(Theme.blackFill(), 20));
selectButton.setGraphic(SVG.EDIT.createIcon(20));
selectButton.onActionProperty().bind(onSelectButtonClicked);
selectButton.getStyleClass().add("toggle-icon4");
JFXButton deleteButton = new JFXButton();
deleteButton.setGraphic(SVG.CLOSE.createIcon(Theme.blackFill(), 20));
deleteButton.setGraphic(SVG.RESTORE.createIcon(20));
deleteButton.onActionProperty().bind(onDeleteButtonClicked);
deleteButton.getStyleClass().add("toggle-icon4");
FXUtils.installFastTooltip(selectButton, i18n("button.edit"));
FXUtils.installFastTooltip(deleteButton, i18n("button.reset"));
HBox hBox = new HBox();
hBox.getChildren().setAll(imageView, selectButton, deleteButton);
@@ -20,9 +20,11 @@ package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXTextField;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.FutureCallback;
import java.util.concurrent.CompletableFuture;
@@ -30,12 +32,22 @@ import java.util.concurrent.CompletableFuture;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class InputDialogPane extends JFXDialogLayout {
public class InputDialogPane extends JFXDialogLayout implements DialogAware {
private final CompletableFuture<String> future = new CompletableFuture<>();
private final JFXTextField textField;
private final Label lblCreationWarning;
private final SpinnerPane acceptPane;
private final JFXButton acceptButton;
public InputDialogPane(String text, String initialValue, FutureCallback<String> onResult, ValidatorBase... validators) {
this(text, initialValue, onResult);
if (validators != null && validators.length > 0) {
textField.getValidators().addAll(validators);
FXUtils.setValidateWhileTextChanged(textField, true);
acceptButton.disableProperty().bind(textField.activeValidatorProperty().isNotNull());
}
}
public InputDialogPane(String text, String initialValue, FutureCallback<String> onResult) {
textField = new JFXTextField(initialValue);
@@ -47,7 +59,7 @@ public class InputDialogPane extends JFXDialogLayout {
acceptPane = new SpinnerPane();
acceptPane.getStyleClass().add("small-spinner-pane");
JFXButton acceptButton = new JFXButton(i18n("button.ok"));
acceptButton = new JFXButton(i18n("button.ok"));
acceptButton.getStyleClass().add("dialog-accept");
acceptPane.setContent(acceptButton);
@@ -69,10 +81,15 @@ public class InputDialogPane extends JFXDialogLayout {
lblCreationWarning.setText(msg);
});
});
textField.setOnAction(event -> acceptButton.fire());
onEscPressed(this, cancelButton::fire);
}
@Override
public void onDialogShown() {
textField.requestFocus();
}
public CompletableFuture<String> getCompletableFuture() {
return future;
}
@@ -0,0 +1,68 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 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.ui.construct;
import com.jfoenix.controls.JFXCheckBox;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.util.Callback;
/// @author Glavo
public final class JFXCheckBoxTableCell<S, T> extends TableCell<S, T> {
public static <S> Callback<TableColumn<S, Boolean>, TableCell<S, Boolean>> forTableColumn(
final TableColumn<S, Boolean> column) {
return list -> new JFXCheckBoxTableCell<>();
}
private final JFXCheckBox checkBox = new JFXCheckBox();
private BooleanProperty booleanProperty;
public JFXCheckBoxTableCell() {
this.getStyleClass().add("jfx-checkbox-table-cell");
}
@Override
protected void updateItem(T item, boolean empty) {
super.updateItem(item, empty);
if (empty) {
setText(null);
setGraphic(null);
checkBox.disableProperty().unbind();
} else {
setGraphic(checkBox);
if (booleanProperty != null) {
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
}
if (getTableColumn().getCellObservableValue(getIndex()) instanceof BooleanProperty obsValue) {
booleanProperty = obsValue;
checkBox.selectedProperty().bindBidirectional(booleanProperty);
}
checkBox.disableProperty().bind(Bindings.not(
getTableView().editableProperty().and(
getTableColumn().editableProperty()).and(
editableProperty())
));
}
}
}
@@ -18,7 +18,6 @@
package org.jackhuang.hmcl.ui.construct;
import javafx.scene.control.Hyperlink;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
@@ -27,7 +26,7 @@ public final class JFXHyperlink extends Hyperlink {
public JFXHyperlink(String text) {
super(text);
setGraphic(SVG.OPEN_IN_NEW.createIcon(Theme.blackFill(), 16));
setGraphic(SVG.OPEN_IN_NEW.createIcon(16));
}
public void setExternalLink(String externalLink) {
@@ -24,17 +24,14 @@ import javafx.scene.control.ListCell;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.Holder;
public abstract class MDListCell<T> extends ListCell<T> {
private final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
private final StackPane container = new StackPane();
private final StackPane root = new StackPane();
private final Holder<Object> lastCell;
public MDListCell(JFXListView<T> listView, Holder<Object> lastCell) {
this.lastCell = lastCell;
public MDListCell(JFXListView<T> listView) {
setText(null);
setGraphic(null);
@@ -58,13 +55,6 @@ public abstract class MDListCell<T> extends ListCell<T> {
protected void updateItem(T item, boolean empty) {
super.updateItem(item, empty);
// https://mail.openjdk.org/pipermail/openjfx-dev/2022-July/034764.html
if (lastCell != null) {
if (this == lastCell.value && !isVisible())
return;
lastCell.value = this;
}
updateControl(item, empty);
if (empty) {
setGraphic(null);
@@ -29,7 +29,6 @@ import javafx.scene.control.Label;
import javafx.scene.control.Skin;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.HBox;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
@@ -39,6 +38,7 @@ public class MenuUpDownButton extends Control {
private final StringProperty text = new SimpleStringProperty(this, "text");
public MenuUpDownButton() {
this.getStyleClass().add("menu-up-down-button");
}
@Override
@@ -78,11 +78,10 @@ public class MenuUpDownButton extends Control {
HBox content = new HBox(8);
content.setAlignment(Pos.CENTER);
Label label = new Label();
label.setStyle("-fx-text-fill: black;");
label.textProperty().bind(control.text);
Node up = SVG.ARROW_DROP_UP.createIcon(Theme.blackFill(), 16);
Node down = SVG.ARROW_DROP_DOWN.createIcon(Theme.blackFill(), 16);
Node up = SVG.ARROW_DROP_UP.createIcon(16);
Node down = SVG.ARROW_DROP_DOWN.createIcon(16);
JFXButton button = new JFXButton();
button.setGraphic(content);
@@ -28,7 +28,6 @@ import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.text.TextFlow;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
@@ -47,11 +46,21 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class MessageDialogPane extends HBox {
public enum MessageType {
ERROR,
INFO,
WARNING,
QUESTION,
SUCCESS;
ERROR(SVG.ERROR),
INFO(SVG.INFO),
WARNING(SVG.WARNING),
QUESTION(SVG.HELP),
SUCCESS(SVG.CHECK_CIRCLE);
private final SVG icon;
MessageType(SVG icon) {
this.icon = icon;
}
public SVG getIcon() {
return icon;
}
public String getDisplayName() {
return i18n("message." + name().toLowerCase(Locale.ROOT));
@@ -71,34 +80,14 @@ public final class MessageDialogPane extends HBox {
graphic.setTranslateY(10);
graphic.setMinSize(40, 40);
graphic.setMaxSize(40, 40);
SVG svg;
switch (type) {
case INFO:
svg = SVG.INFO;
break;
case ERROR:
svg = SVG.ERROR;
break;
case SUCCESS:
svg = SVG.CHECK_CIRCLE;
break;
case WARNING:
svg = SVG.WARNING;
break;
case QUESTION:
svg = SVG.HELP;
break;
default:
throw new IllegalArgumentException("Unrecognized message box message type " + type);
}
graphic.setGraphic(svg.createIcon(Theme.blackFill(), 40));
graphic.setGraphic(type.getIcon().createIcon(40));
VBox vbox = new VBox();
HBox.setHgrow(vbox, Priority.ALWAYS);
{
StackPane titlePane = new StackPane();
titlePane.getStyleClass().addAll("jfx-layout-heading", "title");
titlePane.getChildren().setAll(new Label(title != null ? title : i18n("message.info")));
titlePane.getChildren().setAll(new Label(title != null ? title : type.getDisplayName()));
StackPane content = new StackPane();
content.getStyleClass().add("jfx-layout-body");
@@ -201,6 +190,7 @@ public final class MessageDialogPane extends HBox {
public Builder addCancel(String cancelText, @Nullable Runnable cancel) {
JFXButton btnCancel = new JFXButton(cancelText);
btnCancel.setButtonType(JFXButton.ButtonType.FLAT);
btnCancel.getStyleClass().add("dialog-cancel");
if (cancel != null) {
btnCancel.setOnAction(e -> cancel.run());
@@ -17,6 +17,7 @@
*/
package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXColorPicker;
import com.jfoenix.controls.JFXRadioButton;
import com.jfoenix.controls.JFXTextField;
import com.jfoenix.validation.base.ValidatorBase;
@@ -30,12 +31,15 @@ import javafx.scene.control.Toggle;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.theme.ThemeColor;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.StringUtils;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Consumer;
@@ -177,6 +181,7 @@ public final class MultiFileItem<T> extends VBox {
center.setWrapText(true);
center.getStyleClass().add("subtitle-label");
center.setStyle("-fx-font-size: 10;");
center.setPadding(new Insets(0, 0, 0, 15));
pane.setCenter(center);
}
@@ -301,17 +306,27 @@ public final class MultiFileItem<T> extends VBox {
}
public static final class PaintOption<T> extends Option<T> {
private final ColorPicker colorPicker = new ColorPicker();
private final ColorPicker colorPicker = new JFXColorPicker();
public PaintOption(String title, T data) {
super(title, data);
}
public PaintOption<T> setCustomColors(List<Color> colors) {
colorPicker.getCustomColors().setAll(colors);
return this;
}
public PaintOption<T> bindBidirectional(Property<Paint> property) {
FXUtils.bindPaint(colorPicker, property);
return this;
}
public PaintOption<T> bindThemeColorBidirectional(Property<ThemeColor> property) {
ThemeColor.bindBidirectional(colorPicker, property);
return this;
}
@Override
protected Node createItem(ToggleGroup group) {
BorderPane pane = new BorderPane();
Loaded 100 of 310 files, more files were not shown because too many files have changed in this diff. Show more