Compare commits

...
Author SHA1 Message Date
Glavoand辞庐 e57a41c2f3 [release/3.11] 迁移微软授权代码流 client_secret 到 PKCE (#5649)
https://github.com/HMCL-dev/HMCL/pull/5575

Co-authored-by: 辞庐 <109708109+CiiLu@users.noreply.github.com>
2026-02-25 21:14:39 +08:00
Glavo d63b0bb088 [release/3.11] 优化设备代码流登录流程 (#5650)
https://github.com/HMCL-dev/HMCL/pull/5610
2026-02-25 21:11:29 +08:00
Glavo 8e9ddfb248 [release/3.11] 修复 MultipleSourceVersionList 不会使用备用下载源的问题 (#5648)
https://github.com/HMCL-dev/HMCL/pull/5585
https://github.com/HMCL-dev/HMCL/pull/5602
2026-02-25 21:10:29 +08:00
Glavoand辞庐 1edd36819c [release/3.11] fix: 打开微软对话框有一个加载圈一闪而过 (#5647)
https://github.com/HMCL-dev/HMCL/pull/5584

Co-authored-by: 辞庐 <109708109+CiiLu@users.noreply.github.com>
2026-02-25 21:08:47 +08:00
a5f6028b3a [release/3.11] 修复微软登录报错的超链接显示 (#5646)
https://github.com/HMCL-dev/HMCL/pull/5549

Co-authored-by: Xirren <michaelqiancn@gmail.com>
Co-authored-by: xirren <xirren@outlook.com>
2026-02-25 21:02:56 +08:00
GlavoandCiiLu 52f57c783e [release/3.11] fix: 安装整合包时任务弹窗可能空白 (#5645)
https://github.com/HMCL-dev/HMCL/pull/5550

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

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

No files matched your search

+4 -4
View File
@@ -9,15 +9,15 @@ body:
value: |
提交前请确认:
* 该问题确实是 **HMCL 的错误**,而**不是 Minecraft 非正常退出**,如果你的 Minecraft 非正常退出,请前往 [QQ 群](https://docs.hmcl.net/groups.html)/[Discord 服务器](https://discord.gg/jVvC7HfM6U) 获取帮助。
* 你的启动器版本是**最新的预览版本**,可以点击 [此处](https://zkitefly.github.io/HMCL-Snapshot-Update/) 下载最新预览版本。
* 该问题确实是 **HMCL 的错误**,而**不是 Minecraft 非正常退出**。如果你的 Minecraft 非正常退出,请前往 [QQ 群](https://docs.hmcl.net/groups.html)/[Discord 服务器](https://discord.gg/jVvC7HfM6U) 获取帮助。
* 你的启动器版本是**最新的预览版本**。你可以从 [GitHub Actions](https://github.com/HMCL-dev/HMCL/actions/workflows/gradle.yml?query=branch%3Amain+event%3Apush) 或 [nightly.link](https://nightly.link/HMCL-dev/HMCL/workflows/gradle/main) 下载最新预览版本。
如果你的问题并不属于上述两类,你可以选取另一种 Issue 类型,或者直接前往 [QQ 群](https://docs.hmcl.net/groups.html)/[Discord 服务器](https://discord.gg/jVvC7HfM6U) 获取帮助。
Before submitting, please confirm:
* The issue is indeed **a bug of HMCL**, not **Minecraft abnormal exit**. If your Minecraft exits abnormally, please go to the [QQ group](https://docs.hmcl.net/groups.html) or [Discord server](https://discord.gg/jVvC7HfM6U) for help.
* Your launcher is the **latest nightly build**. You can click [here](https://zkitefly.github.io/HMCL-Snapshot-Update/en) to download the latest nightly build.
* Your launcher is the **latest nightly build**. You can download the latest nightly build from [GitHub Actions](https://github.com/HMCL-dev/HMCL/actions/workflows/gradle.yml?query=branch%3Amain+event%3Apush) or [nightly.link](https://nightly.link/HMCL-dev/HMCL/workflows/gradle/main).
If your issue does not fall into the above two categories, you can choose another type of issue or directly go to the [QQ group](https://docs.hmcl.net/groups.html) or [Discord server](https://discord.gg/jVvC7HfM6U) for help.
- type: textarea
@@ -38,7 +38,7 @@ body:
attributes:
label: 启动器崩溃报告 / 启动器日志文件 | Launcher Crash Report / Launcher Log File
description: |
如果你的启动器崩溃了,请将崩溃报告填入(或将文件拖入)下方。
如果你的启动器崩溃了,请将崩溃报告填入 (或将文件拖入) 下方。
如果你的启动器没有崩溃,请在遇到问题后**不要退出启动器**,在启动器的 “设置 → 通用 → 调试” 一栏中点击 “导出启动器日志”,并将导出的日志拖到下方的输入栏中。
**请注意:启动器崩溃报告或日志文件是诊断问题的重要依据,请务必上传!**
+1 -2
View File
@@ -28,7 +28,6 @@ val versionType = System.getenv("VERSION_TYPE") ?: if (isOfficial) "nightly" els
val versionRoot = System.getenv("VERSION_ROOT") ?: projectConfig.getProperty("versionRoot") ?: "3"
val microsoftAuthId = System.getenv("MICROSOFT_AUTH_ID") ?: ""
val microsoftAuthSecret = System.getenv("MICROSOFT_AUTH_SECRET") ?: ""
val curseForgeApiKey = System.getenv("CURSEFORGE_API_KEY") ?: ""
val launcherExe = System.getenv("HMCL_LAUNCHER_EXE") ?: ""
@@ -60,6 +59,7 @@ dependencies {
implementation(libs.twelvemonkeys.imageio.webp)
implementation(libs.java.info)
implementation(libs.monet.fx)
implementation(libs.nayuki.qrcodegen)
if (launcherExe.isBlank()) {
implementation(libs.hmclauncher)
@@ -152,7 +152,6 @@ val hmclProperties = buildList {
}
add("hmcl.version.type" to versionType)
add("hmcl.microsoft.auth.id" to microsoftAuthId)
add("hmcl.microsoft.auth.secret" to microsoftAuthSecret)
add("hmcl.curseforge.apikey" to curseForgeApiKey)
add("hmcl.authlib-injector.version" to libs.authlib.injector.get().version!!)
}
@@ -0,0 +1,357 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import com.jfoenix.converters.base.NodeConverter;
import com.jfoenix.skins.JFXComboBoxListViewSkin;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.ObservableList;
import javafx.css.*;
import javafx.css.converter.BooleanConverter;
import javafx.css.converter.PaintConverter;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.util.StringConverter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu;
/**
* JFXComboBox is the material design implementation of a combobox.
*
* @author Shadi Shaheen
* @version 1.0
* @since 2016-03-09
*/
public class JFXComboBox<T> extends ComboBox<T> {
/**
* {@inheritDoc}
*/
public JFXComboBox() {
initialize();
}
/**
* {@inheritDoc}
*/
public JFXComboBox(ObservableList<T> items) {
super(items);
initialize();
}
private void initialize() {
getStyleClass().add(DEFAULT_STYLE_CLASS);
this.setCellFactory(listView -> new JFXListCell<T>() {
@Override
public void updateItem(T item, boolean empty) {
super.updateItem(item, empty);
updateDisplayText(this, item, empty);
}
});
// had to refactor the code out of the skin class to allow
// customization of the button cell
this.setButtonCell(new ListCell<T>() {
{
// fixed clearing the combo box value is causing
// java prompt text to be shown because the button cell is not updated
JFXComboBox.this.valueProperty().addListener(observable -> {
if (JFXComboBox.this.getValue() == null) {
updateItem(null, true);
}
});
}
@Override
protected void updateItem(T item, boolean empty) {
updateDisplayText(this, item, empty);
this.setVisible(item != null || !empty);
}
});
useJFXContextMenu(editorProperty().get());
}
/**
* {@inheritDoc}
*/
@Override
protected Skin<?> createDefaultSkin() {
return new JFXComboBoxListViewSkin<T>(this);
}
/**
* Initialize the style class to 'jfx-combo-box'.
* <p>
* This is the selector class from which CSS can be used to style
* this control.
*/
private static final String DEFAULT_STYLE_CLASS = "jfx-combo-box";
/***************************************************************************
* *
* Node Converter Property *
* *
**************************************************************************/
/**
* Converts the user-typed input (when the ComboBox is
* {@link #editableProperty() editable}) to an object of type T, such that
* the input may be retrieved via the {@link #valueProperty() value} property.
*/
public ObjectProperty<NodeConverter<T>> nodeConverterProperty() {
return nodeConverter;
}
private ObjectProperty<NodeConverter<T>> nodeConverter = new SimpleObjectProperty<>(this, "nodeConverter",
JFXComboBox.<T>defaultNodeConverter());
public final void setNodeConverter(NodeConverter<T> value) {
nodeConverterProperty().set(value);
}
public final NodeConverter<T> getNodeConverter() {
return nodeConverterProperty().get();
}
private static <T> NodeConverter<T> defaultNodeConverter() {
return new NodeConverter<T>() {
@Override
public Node toNode(T object) {
if (object == null) {
return null;
}
StackPane selectedValueContainer = new StackPane();
selectedValueContainer.getStyleClass().add("combo-box-selected-value-container");
selectedValueContainer.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, null, null)));
Label selectedValueLabel = object instanceof Label ? new Label(((Label) object).getText()) : new Label(
object.toString());
selectedValueLabel.setTextFill(Color.BLACK);
selectedValueContainer.getChildren().add(selectedValueLabel);
StackPane.setAlignment(selectedValueLabel, Pos.CENTER_LEFT);
StackPane.setMargin(selectedValueLabel, new Insets(0, 0, 0, 5));
return selectedValueContainer;
}
@SuppressWarnings("unchecked")
@Override
public T fromNode(Node node) {
return (T) node;
}
@Override
public String toString(T object) {
if (object == null) {
return null;
}
if (object instanceof Label) {
return ((Label) object).getText();
}
return object.toString();
}
};
}
private boolean updateDisplayText(ListCell<T> cell, T item, boolean empty) {
if (empty) {
// create empty cell
if (cell == null) {
return true;
}
cell.setGraphic(null);
cell.setText(null);
return true;
} else if (item instanceof Node) {
Node currentNode = cell.getGraphic();
Node newNode = (Node) item;
// create a node from the selected node of the listview
// using JFXComboBox {@link #nodeConverterProperty() NodeConverter})
NodeConverter<T> nc = this.getNodeConverter();
Node node = nc == null ? null : nc.toNode(item);
if (currentNode == null || !currentNode.equals(newNode)) {
cell.setText(null);
cell.setGraphic(node == null ? newNode : node);
}
return node == null;
} else {
// run item through StringConverter if it isn't null
StringConverter<T> c = this.getConverter();
String s = item == null ? this.getPromptText() : (c == null ? item.toString() : c.toString(item));
cell.setText(s);
cell.setGraphic(null);
return s == null || s.isEmpty();
}
}
/***************************************************************************
* *
* styleable Properties *
* *
**************************************************************************/
/**
* set true to show a float the prompt text when focusing the field
*/
private StyleableBooleanProperty labelFloat = new SimpleStyleableBooleanProperty(StyleableProperties.LABEL_FLOAT,
JFXComboBox.this,
"lableFloat",
false);
public final StyleableBooleanProperty labelFloatProperty() {
return this.labelFloat;
}
public final boolean isLabelFloat() {
return this.labelFloatProperty().get();
}
public final void setLabelFloat(final boolean labelFloat) {
this.labelFloatProperty().set(labelFloat);
}
/**
* default color used when the field is unfocused
*/
private StyleableObjectProperty<Paint> unFocusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNFOCUS_COLOR,
JFXComboBox.this,
"unFocusColor",
Color.rgb(77,
77,
77));
public Paint getUnFocusColor() {
return unFocusColor == null ? Color.rgb(77, 77, 77) : unFocusColor.get();
}
public StyleableObjectProperty<Paint> unFocusColorProperty() {
return this.unFocusColor;
}
public void setUnFocusColor(Paint color) {
this.unFocusColor.set(color);
}
/**
* default color used when the field is focused
*/
private StyleableObjectProperty<Paint> focusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.FOCUS_COLOR,
JFXComboBox.this,
"focusColor",
Color.valueOf("#4059A9"));
public Paint getFocusColor() {
return focusColor == null ? Color.valueOf("#4059A9") : focusColor.get();
}
public StyleableObjectProperty<Paint> focusColorProperty() {
return this.focusColor;
}
public void setFocusColor(Paint color) {
this.focusColor.set(color);
}
private final static class StyleableProperties {
private static final CssMetaData<JFXComboBox<?>, Paint> UNFOCUS_COLOR = new CssMetaData<JFXComboBox<?>, Paint>(
"-jfx-unfocus-color",
PaintConverter.getInstance(),
Color.valueOf("#A6A6A6")) {
@Override
public boolean isSettable(JFXComboBox<?> control) {
return control.unFocusColor == null || !control.unFocusColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXComboBox<?> control) {
return control.unFocusColorProperty();
}
};
private static final CssMetaData<JFXComboBox<?>, Paint> FOCUS_COLOR = new CssMetaData<JFXComboBox<?>, Paint>(
"-jfx-focus-color",
PaintConverter.getInstance(),
Color.valueOf("#3f51b5")) {
@Override
public boolean isSettable(JFXComboBox<?> control) {
return control.focusColor == null || !control.focusColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXComboBox<?> control) {
return control.focusColorProperty();
}
};
private static final CssMetaData<JFXComboBox<?>, Boolean> LABEL_FLOAT = new CssMetaData<JFXComboBox<?>, Boolean>(
"-jfx-label-float",
BooleanConverter.getInstance(),
false) {
@Override
public boolean isSettable(JFXComboBox<?> control) {
return control.labelFloat == null || !control.labelFloat.isBound();
}
@Override
public StyleableBooleanProperty getStyleableProperty(JFXComboBox<?> control) {
return control.labelFloatProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(
Control.getClassCssMetaData());
Collections.addAll(styleables, UNFOCUS_COLOR, FOCUS_COLOR, LABEL_FLOAT);
CHILD_STYLEABLES = Collections.unmodifiableList(styleables);
}
}
// inherit the styleable properties from parent
private List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
@Override
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
if (STYLEABLES == null) {
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(
Control.getClassCssMetaData());
styleables.addAll(getClassCssMetaData());
styleables.addAll(Control.getClassCssMetaData());
STYLEABLES = Collections.unmodifiableList(styleables);
}
return STYLEABLES;
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.CHILD_STYLEABLES;
}
}
@@ -0,0 +1,297 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import com.jfoenix.skins.JFXPasswordFieldSkin;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.css.*;
import javafx.css.converter.BooleanConverter;
import javafx.css.converter.PaintConverter;
import javafx.scene.control.Control;
import javafx.scene.control.PasswordField;
import javafx.scene.control.Skin;
import javafx.scene.control.TextField;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu;
/**
* JFXPasswordField is the material design implementation of a password Field.
*
* @author Shadi Shaheen
* @version 1.0
* @since 2016-03-09
*/
public class JFXPasswordField extends PasswordField {
/**
* {@inheritDoc}
*/
public JFXPasswordField() {
initialize();
}
/**
* {@inheritDoc}
*/
@Override
protected Skin<?> createDefaultSkin() {
return new JFXPasswordFieldSkin(this);
}
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
if ("dalvik".equals(System.getProperty("java.vm.name").toLowerCase(Locale.ROOT))) {
this.setStyle("-fx-skin: \"com.jfoenix.android.skins.JFXPasswordFieldSkinAndroid\";");
}
useJFXContextMenu(this);
}
/**
* Initialize the style class to 'jfx-password-field'.
* <p>
* This is the selector class from which CSS can be used to style
* this control.
*/
private static final String DEFAULT_STYLE_CLASS = "jfx-password-field";
/***************************************************************************
* *
* Properties *
* *
**************************************************************************/
/**
* holds the current active validator on the password field in case of validation error
*/
private ReadOnlyObjectWrapper<ValidatorBase> activeValidator = new ReadOnlyObjectWrapper<>();
public ValidatorBase getActiveValidator() {
return activeValidator == null ? null : activeValidator.get();
}
public ReadOnlyObjectProperty<ValidatorBase> activeValidatorProperty() {
return this.activeValidator.getReadOnlyProperty();
}
/**
* list of validators that will validate the password value upon calling
* {{@link #validate()}
*/
private ObservableList<ValidatorBase> validators = FXCollections.observableArrayList();
public ObservableList<ValidatorBase> getValidators() {
return validators;
}
public void setValidators(ValidatorBase... validators) {
this.validators.addAll(validators);
}
/**
* validates the password value using the list of validators provided by the user
* {{@link #setValidators(ValidatorBase...)}
*
* @return true if the value is valid else false
*/
public boolean validate() {
for (ValidatorBase validator : validators) {
if (validator.getSrcControl() == null) {
validator.setSrcControl(this);
}
validator.validate();
if (validator.getHasErrors()) {
activeValidator.set(validator);
return false;
}
}
activeValidator.set(null);
return true;
}
public void resetValidation() {
pseudoClassStateChanged(ValidatorBase.PSEUDO_CLASS_ERROR, false);
activeValidator.set(null);
}
/***************************************************************************
* *
* styleable Properties *
* *
**************************************************************************/
/**
* set true to show a float the prompt text when focusing the field
*/
private StyleableBooleanProperty labelFloat = new SimpleStyleableBooleanProperty(StyleableProperties.LABEL_FLOAT, JFXPasswordField.this, "lableFloat", false);
public final StyleableBooleanProperty labelFloatProperty() {
return this.labelFloat;
}
public final boolean isLabelFloat() {
return this.labelFloatProperty().get();
}
public final void setLabelFloat(final boolean labelFloat) {
this.labelFloatProperty().set(labelFloat);
}
/**
* default color used when the field is unfocused
*/
private StyleableObjectProperty<Paint> unFocusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNFOCUS_COLOR, JFXPasswordField.this, "unFocusColor", Color.rgb(77, 77, 77));
public Paint getUnFocusColor() {
return unFocusColor == null ? Color.rgb(77, 77, 77) : unFocusColor.get();
}
public StyleableObjectProperty<Paint> unFocusColorProperty() {
return this.unFocusColor;
}
public void setUnFocusColor(Paint color) {
this.unFocusColor.set(color);
}
/**
* default color used when the field is focused
*/
private StyleableObjectProperty<Paint> focusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.FOCUS_COLOR, JFXPasswordField.this, "focusColor", Color.valueOf("#4059A9"));
public Paint getFocusColor() {
return focusColor == null ? Color.valueOf("#4059A9") : focusColor.get();
}
public StyleableObjectProperty<Paint> focusColorProperty() {
return this.focusColor;
}
public void setFocusColor(Paint color) {
this.focusColor.set(color);
}
/**
* disable animation on validation
*/
private StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION, JFXPasswordField.this, "disableAnimation", false);
public final StyleableBooleanProperty disableAnimationProperty() {
return this.disableAnimation;
}
public final Boolean isDisableAnimation() {
return disableAnimation != null && this.disableAnimationProperty().get();
}
public final void setDisableAnimation(final Boolean disabled) {
this.disableAnimationProperty().set(disabled);
}
private final static class StyleableProperties {
private static final CssMetaData<JFXPasswordField, Paint> UNFOCUS_COLOR = new CssMetaData<JFXPasswordField, Paint>("-jfx-unfocus-color", PaintConverter.getInstance(), Color.valueOf("#A6A6A6")) {
@Override
public boolean isSettable(JFXPasswordField control) {
return control.unFocusColor == null || !control.unFocusColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXPasswordField control) {
return control.unFocusColorProperty();
}
};
private static final CssMetaData<JFXPasswordField, Paint> FOCUS_COLOR = new CssMetaData<JFXPasswordField, Paint>("-jfx-focus-color", PaintConverter.getInstance(), Color.valueOf("#3f51b5")) {
@Override
public boolean isSettable(JFXPasswordField control) {
return control.focusColor == null || !control.focusColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXPasswordField control) {
return control.focusColorProperty();
}
};
private static final CssMetaData<JFXPasswordField, Boolean> LABEL_FLOAT = new CssMetaData<JFXPasswordField, Boolean>("-jfx-label-float", BooleanConverter.getInstance(), false) {
@Override
public boolean isSettable(JFXPasswordField control) {
return control.labelFloat == null || !control.labelFloat.isBound();
}
@Override
public StyleableBooleanProperty getStyleableProperty(JFXPasswordField control) {
return control.labelFloatProperty();
}
};
private static final CssMetaData<JFXPasswordField, Boolean> DISABLE_ANIMATION = new CssMetaData<JFXPasswordField, Boolean>("-fx-disable-animation", BooleanConverter.getInstance(), false) {
@Override
public boolean isSettable(JFXPasswordField control) {
return control.disableAnimation == null || !control.disableAnimation.isBound();
}
@Override
public StyleableBooleanProperty getStyleableProperty(JFXPasswordField control) {
return control.disableAnimationProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
Collections.addAll(styleables, UNFOCUS_COLOR, FOCUS_COLOR, LABEL_FLOAT, DISABLE_ANIMATION);
CHILD_STYLEABLES = Collections.unmodifiableList(styleables);
}
}
// inherit the styleable properties from parent
private List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
@Override
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
if (STYLEABLES == null) {
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
styleables.addAll(getClassCssMetaData());
styleables.addAll(TextField.getClassCssMetaData());
STYLEABLES = Collections.unmodifiableList(styleables);
}
return STYLEABLES;
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.CHILD_STYLEABLES;
}
}
@@ -24,8 +24,10 @@ import javafx.application.Platform;
import javafx.beans.DefaultProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.NodeOrientation;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.PopupControl;
import javafx.scene.control.Skin;
import javafx.scene.layout.Pane;
@@ -41,7 +43,12 @@ import javafx.stage.Window;
public class JFXPopup extends PopupControl {
public enum PopupHPosition {
RIGHT, LEFT
RIGHT,
LEFT;
public PopupHPosition getOpposite() {
return (this == RIGHT) ? LEFT : RIGHT;
}
}
public enum PopupVPosition {
@@ -120,19 +127,29 @@ public class JFXPopup extends PopupControl {
/// @param initOffsetX on the x-axis
/// @param initOffsetY on the y-axis
public void show(Node node, PopupVPosition vAlign, PopupHPosition hAlign, double initOffsetX, double initOffsetY) {
show(node, vAlign, hAlign, initOffsetX, initOffsetY, false);
}
public void show(Node node, PopupVPosition vAlign, PopupHPosition hAlign, double initOffsetX, double initOffsetY, boolean attachToNode) {
if (!isShowing()) {
if (node.getScene() == null || node.getScene().getWindow() == null) {
Scene scene = node.getScene();
if (scene == null || scene.getWindow() == null) {
throw new IllegalStateException("Can not show popup. The node must be attached to a scene/window.");
}
Window parent = node.getScene().getWindow();
Window parent = scene.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);
boolean isRTL = node.getEffectiveNodeOrientation() == NodeOrientation.RIGHT_TO_LEFT;
double anchorX = parent.getX() + scene.getX() + origin.getX() + (hAlign == PopupHPosition.RIGHT ? ((Region) node).getWidth() : 0);
double anchorY = parent.getY() + origin.getY() + scene.getY() + (vAlign == PopupVPosition.BOTTOM ? ((Region) node).getHeight() : 0);
if (attachToNode)
this.show(node, anchorX, anchorY);
else
this.show(parent, anchorX, anchorY);
((JFXPopupSkin) getSkin()).reset(vAlign, isRTL ? hAlign.getOpposite() : hAlign, isRTL ? -initOffsetX : initOffsetX, initOffsetY);
Platform.runLater(() -> ((JFXPopupSkin) getSkin()).animate());
}
}
@@ -0,0 +1,421 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.PauseTransition;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.WeakChangeListener;
import javafx.css.PseudoClass;
import javafx.event.Event;
import javafx.event.EventType;
import javafx.geometry.Bounds;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.animation.Motion;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicBoolean;
/// "Snackbars provide brief messages about app processes at the bottom of the screen"
/// (<a href="https://material.io/design/components/snackbars.html#">Material Design Guidelines</a>).
///
/// To show a snackbar you need to
/// <ol>
/// - Have a [Pane] (snackbarContainer) to show the snackbar on top of. Register it in
/// [the JFXSnackbar constructor][#JFXSnackbar(Pane)] or using the [#registerSnackbarContainer(Pane)] method.
/// - Have or create a [JFXSnackbar]. - Having one snackbar where you pass all your
/// [SnackbarEvents][JFXSnackbar.SnackbarEvent] will ensure that the [enqueuemethod][JFXSnackbar#enqueue(SnackbarEvent)] works as intended.
///
/// - Have something to show in the snackbar. A [JFXSnackbarLayout] is nice and pretty,
/// but any arbitrary [Node] will do.
/// - Create a [SnackbarEvent][JFXSnackbar.SnackbarEvent] specifying the contents and the
/// duration.
/// </ol>
///
/// Finally, with all those things prepared, show your snackbar using
/// [snackbar.enqueue(snackbarEvent);][JFXSnackbar#enqueue(SnackbarEvent)].
///
/// It's most convenient to create functions to do most of this (creating the layout and event) with the default
/// settings; that way all you need to do to show a snackbar is specify the message or just the message and the duration.
///
/// @see <a href="https://material.io/design/components/snackbars.html#"> The Material Design Snackbar</a>
public class JFXSnackbar extends Group {
private static final String DEFAULT_STYLE_CLASS = "jfx-snackbar";
private Pane snackbarContainer;
private final ChangeListener<? super Number> sizeListener = (o, oldVal, newVal) -> refreshPopup();
private final WeakChangeListener<? super Number> weakSizeListener = new WeakChangeListener<>(sizeListener);
private final AtomicBoolean processingQueue = new AtomicBoolean(false);
private final ConcurrentLinkedQueue<SnackbarEvent> eventQueue = new ConcurrentLinkedQueue<>();
private final ConcurrentHashMap.KeySetView<Object, Boolean> eventsSet = ConcurrentHashMap.newKeySet();
private final Pane content;
private PseudoClass activePseudoClass = null;
private PauseTransition pauseTransition;
/// This constructor assumes that you will eventually call the [#registerSnackbarContainer(Pane)] method before
/// calling the [#enqueue(SnackbarEvent)] method. Otherwise, how will the snackbar know where to show itself?
///
///
/// "Snackbars provide brief messages about app processes at the bottom of the screen"
/// (<a href="https://material.io/design/components/snackbars.html#">Material Design Guidelines</a>).
///
/// To show a snackbar you need to
///
/// - Have a [Pane] (snackbarContainer) to show the snackbar on top of. Register it in
/// [the JFXSnackbar constructor][#JFXSnackbar(Pane)] or using the [#registerSnackbarContainer(Pane)] method.
/// - Have or create a [JFXSnackbar]. - Having one snackbar where you pass all your
/// [SnackbarEvents][JFXSnackbar.SnackbarEvent] will ensure that the [enqueuemethod][JFXSnackbar#enqueue(SnackbarEvent)] works as intended.
/// - Have something to show in the snackbar. A [JFXSnackbarLayout] is nice and pretty,
/// but any arbitrary [Node] will do.
/// - Create a [SnackbarEvent][JFXSnackbar.SnackbarEvent] specifying the contents and the
/// duration.
///
/// Finally, with all those things prepared, show your snackbar using
/// [snackbar.enqueue(snackbarEvent);][JFXSnackbar#enqueue(SnackbarEvent)].
///
public JFXSnackbar() {
this(null);
}
/// "Snackbars provide brief messages about app processes at the bottom of the screen"
/// (<a href="https://material.io/design/components/snackbars.html#">Material Design Guidelines</a>).
///
/// To show a snackbar you need to
///
/// - Have a [Pane] (snackbarContainer) to show the snackbar on top of. Register it in
/// [the JFXSnackbar constructor][#JFXSnackbar(Pane)] or using the [#registerSnackbarContainer(Pane)] method.
/// - Have or create a [JFXSnackbar]. - Having one snackbar where you pass all your
/// [SnackbarEvents][JFXSnackbar.SnackbarEvent] will ensure that the [enqueuemethod][JFXSnackbar#enqueue(SnackbarEvent)] works as intended.
/// - Have something to show in the snackbar. A [JFXSnackbarLayout] is nice and pretty,
/// but any arbitrary [Node] will do.
/// - Create a [SnackbarEvent][JFXSnackbar.SnackbarEvent] specifying the contents and the
/// duration.
///
/// Finally, with all those things prepared, show your snackbar using
/// [snackbar.enqueue(snackbarEvent);][JFXSnackbar#enqueue(SnackbarEvent)].
///
/// @param snackbarContainer where the snackbar will appear. Using a single snackbar instead of many, will ensure that
/// the [#enqueue(SnackbarEvent)] method works correctly.
public JFXSnackbar(Pane snackbarContainer) {
initialize();
content = new StackPane();
content.getStyleClass().add("jfx-snackbar-content");
//wrap the content in a group so that the content is managed inside its own container
//but the group is not managed in the snackbarContainer so it does not affect any layout calculations
getChildren().add(content);
setManaged(false);
setVisible(false);
// register the container before resizing it
registerSnackbarContainer(snackbarContainer);
// resize the popup if its layout has been changed
layoutBoundsProperty().addListener((o, oldVal, newVal) -> refreshPopup());
addEventHandler(SnackbarEvent.SNACKBAR, this::enqueue);
}
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
}
// Setters / Getters
public Pane getPopupContainer() {
return snackbarContainer;
}
public void setPrefWidth(double width) {
content.setPrefWidth(width);
}
public double getPrefWidth() {
return content.getPrefWidth();
}
// Public API
public void registerSnackbarContainer(Pane snackbarContainer) {
if (snackbarContainer != null) {
if (this.snackbarContainer != null) {
//since listeners are added the container should be properly registered/unregistered
throw new IllegalArgumentException("Snackbar Container already set");
}
this.snackbarContainer = snackbarContainer;
this.snackbarContainer.getChildren().add(this);
this.snackbarContainer.heightProperty().addListener(weakSizeListener);
this.snackbarContainer.widthProperty().addListener(weakSizeListener);
}
}
public void unregisterSnackbarContainer(Pane snackbarContainer) {
if (snackbarContainer != null) {
if (this.snackbarContainer == null) {
throw new IllegalArgumentException("Snackbar Container not set");
}
this.snackbarContainer.getChildren().remove(this);
this.snackbarContainer.heightProperty().removeListener(weakSizeListener);
this.snackbarContainer.widthProperty().removeListener(weakSizeListener);
this.snackbarContainer = null;
}
}
private void show(SnackbarEvent event) {
content.getChildren().setAll(event.getContent());
openAnimation = getTimeline(event.getTimeout());
if (event.getPseudoClass() != null) {
activePseudoClass = event.getPseudoClass();
content.pseudoClassStateChanged(activePseudoClass, true);
}
openAnimation.play();
}
private Timeline openAnimation = null;
private Timeline getTimeline(Duration timeout) {
Timeline animation;
animation = new Timeline(
new KeyFrame(
Duration.ZERO,
e -> {
this.toBack();
this.setVisible(false);
},
new KeyValue(this.translateYProperty(), this.getLayoutBounds().getHeight(), Motion.EASE),
new KeyValue(this.opacityProperty(), 0, Motion.EASE)
),
new KeyFrame(
Duration.millis(10),
e -> {
this.toFront();
this.setVisible(true);
}
),
new KeyFrame(Duration.millis(300),
new KeyValue(this.opacityProperty(), 1, Motion.EASE),
new KeyValue(this.translateYProperty(), 0, Motion.EASE)
)
);
animation.setCycleCount(1);
pauseTransition = Duration.INDEFINITE.equals(timeout) ? null : new PauseTransition(timeout);
if (pauseTransition != null) {
animation.setOnFinished(finish -> {
pauseTransition.setOnFinished(done -> {
pauseTransition = null;
eventsSet.remove(currentEvent);
currentEvent = eventQueue.peek();
close();
});
pauseTransition.play();
});
}
return animation;
}
public void close() {
if (openAnimation != null) {
openAnimation.stop();
}
if (this.isVisible()) {
Timeline closeAnimation = new Timeline(
new KeyFrame(
Duration.ZERO,
e -> this.toFront(),
new KeyValue(this.opacityProperty(), 1, Motion.EASE),
new KeyValue(this.translateYProperty(), 0, Motion.EASE)
),
new KeyFrame(
Duration.millis(290),
e -> this.setVisible(true)
),
new KeyFrame(Duration.millis(300),
e -> {
this.toBack();
this.setVisible(false);
},
new KeyValue(this.translateYProperty(), this.getLayoutBounds().getHeight(), Motion.EASE),
new KeyValue(this.opacityProperty(), 0, Motion.EASE)
)
);
closeAnimation.setCycleCount(1);
closeAnimation.setOnFinished(e -> {
resetPseudoClass();
processSnackbar();
});
closeAnimation.play();
}
}
private SnackbarEvent currentEvent = null;
public SnackbarEvent getCurrentEvent() {
return currentEvent;
}
/**
* Shows {@link SnackbarEvent SnackbarEvents} one by one. The next event will be shown after the current event's duration.
*
* @param event the {@link SnackbarEvent event} to put in the queue.
*/
public void enqueue(SnackbarEvent event) {
synchronized (this) {
if (!eventsSet.contains(event)) {
eventsSet.add(event);
eventQueue.offer(event);
} else if (currentEvent == event && pauseTransition != null) {
pauseTransition.playFromStart();
}
}
if (processingQueue.compareAndSet(false, true)) {
Platform.runLater(() -> {
currentEvent = eventQueue.poll();
if (currentEvent != null) {
show(currentEvent);
}
});
}
}
private void resetPseudoClass() {
if (activePseudoClass != null) {
content.pseudoClassStateChanged(activePseudoClass, false);
activePseudoClass = null;
}
}
private void processSnackbar() {
currentEvent = eventQueue.poll();
if (currentEvent != null) {
eventsSet.remove(currentEvent);
show(currentEvent);
} else {
//The enqueue method and this listener should be executed sequentially on the FX Thread so there
//should not be a race condition
processingQueue.getAndSet(false);
}
}
private void refreshPopup() {
if (snackbarContainer == null) {
return;
}
Bounds contentBound = this.getLayoutBounds();
double offsetX = Math.ceil(snackbarContainer.getWidth() / 2) - Math.ceil(contentBound.getWidth() / 2);
double offsetY = snackbarContainer.getHeight() - contentBound.getHeight();
this.setLayoutX(offsetX);
this.setLayoutY(offsetY);
}
///////////////////////////////////////////////////////////////////////////
// Event API
///////////////////////////////////////////////////////////////////////////
/// Specifies _what_ and _how long_ to show a [JFXSnackbar].
///
/// The _what_ can be any arbitrary [Node]; the [JFXSnackbarLayout] is a great choice.
///
/// The _how long_ is specified in the form of a [javafx.util.Duration][javafx.util.Duration], not to be
/// confused with the [java.time.Duration].
public static class SnackbarEvent extends Event {
public static final EventType<SnackbarEvent> SNACKBAR = new EventType<>(Event.ANY, "SNACKBAR");
/// The amount of time the snackbar will show for, if not otherwise specified.
///
/// It's 1.5 seconds.
public static Duration DEFAULT_DURATION = Duration.seconds(1.5);
private final Node content;
private final PseudoClass pseudoClass;
private final Duration timeout;
/// Creates a [SnackbarEvent] with the [default duration][#DEFAULT_DURATION] and no pseudoClass.
///
/// @param content what you want shown in the snackbar; a [JFXSnackbarLayout] is a great choice.
public SnackbarEvent(Node content) {
this(content, DEFAULT_DURATION, null);
}
/// Creates a [SnackbarEvent] with the [default duration][#DEFAULT_DURATION]; you specify the contents and
/// pseudoClass.
///
/// @param content what you want shown in the snackbar; a [JFXSnackbarLayout] is a great choice.
public SnackbarEvent(Node content, PseudoClass pseudoClass) {
this(content, DEFAULT_DURATION, pseudoClass);
}
/// Creates a SnackbarEvent with no pseudoClass; you specify the contents and duration.
/// pseudoClass.
///
/// @param content what you want shown in the snackbar; a [JFXSnackbarLayout] is a great choice.
/// @param timeout the amount of time you want the snackbar to show for.
public SnackbarEvent(Node content, Duration timeout) {
this(content, timeout, null);
}
/// Creates a SnackbarEvent; you specify the contents, duration and pseudoClass.
///
/// If you don't need so much customization, try one of the other constructors.
///
/// @param content what you want shown in the snackbar; a [JFXSnackbarLayout] is a great choice.
/// @param timeout the amount of time you want the snackbar to show for.
public SnackbarEvent(Node content, Duration timeout, PseudoClass pseudoClass) {
super(SNACKBAR);
this.content = content;
this.pseudoClass = pseudoClass;
this.timeout = timeout;
}
public Node getContent() {
return content;
}
public PseudoClass getPseudoClass() {
return pseudoClass;
}
public Duration getTimeout() {
return timeout;
}
@Override
@SuppressWarnings("unchecked")
public EventType<? extends SnackbarEvent> getEventType() {
return (EventType<? extends SnackbarEvent>) super.getEventType();
}
public boolean isPersistent() {
return Duration.INDEFINITE.equals(getTimeout());
}
}
}
@@ -0,0 +1,110 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import javafx.beans.binding.Bindings;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.StackPane;
/// JFXSnackbarLayout default layout for snackbar content
///
/// @author Shadi Shaheen
/// @version 1.0
/// @since 2018-11-16
public class JFXSnackbarLayout extends BorderPane {
private final Label toast;
private JFXButton action;
private final StackPane actionContainer;
public JFXSnackbarLayout(String message) {
this(message, null, null);
}
public JFXSnackbarLayout(String message, String actionText, EventHandler<ActionEvent> actionHandler) {
initialize();
toast = new Label();
toast.setMinWidth(Control.USE_PREF_SIZE);
toast.getStyleClass().add("jfx-snackbar-toast");
toast.setWrapText(true);
toast.setText(message);
StackPane toastContainer = new StackPane(toast);
toastContainer.setPadding(new Insets(20));
actionContainer = new StackPane();
actionContainer.setPadding(new Insets(0, 10, 0, 0));
toast.prefWidthProperty().bind(Bindings.createDoubleBinding(() -> {
if (getPrefWidth() == -1) {
return getPrefWidth();
}
double actionWidth = actionContainer.isVisible() ? actionContainer.getWidth() : 0.0;
return prefWidthProperty().get() - actionWidth;
}, prefWidthProperty(), actionContainer.widthProperty(), actionContainer.visibleProperty()));
setLeft(toastContainer);
setRight(actionContainer);
if (actionText != null) {
action = new JFXButton();
action.setText(actionText);
action.setOnAction(actionHandler);
action.setMinWidth(Control.USE_PREF_SIZE);
action.setButtonType(JFXButton.ButtonType.FLAT);
action.getStyleClass().add("jfx-snackbar-action");
// actions will be added upon showing the snackbar if needed
actionContainer.getChildren().add(action);
if (!actionText.isEmpty()) {
action.setVisible(true);
actionContainer.setVisible(true);
actionContainer.setManaged(true);
// to force updating the layout bounds
action.setText("");
action.setText(actionText);
action.setOnAction(actionHandler);
} else {
actionContainer.setVisible(false);
actionContainer.setManaged(false);
action.setVisible(false);
}
}
}
private static final String DEFAULT_STYLE_CLASS = "jfx-snackbar-layout";
public String getToast() {
return toast.getText();
}
public void setToast(String toast) {
this.toast.setText(toast);
}
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
}
}
@@ -0,0 +1,297 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import com.jfoenix.skins.JFXTextAreaSkin;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.css.*;
import javafx.css.converter.BooleanConverter;
import javafx.css.converter.PaintConverter;
import javafx.scene.control.Control;
import javafx.scene.control.Skin;
import javafx.scene.control.TextArea;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu;
/**
* JFXTextArea is the material design implementation of a text area.
*
* @author Shadi Shaheen
* @version 1.0
* @since 2016-03-09
*/
public class JFXTextArea extends TextArea {
/**
* Initialize the style class to 'jfx-text-field'.
* <p>
* This is the selector class from which CSS can be used to style
* this control.
*/
private static final String DEFAULT_STYLE_CLASS = "jfx-text-area";
/**
* {@inheritDoc}
*/
public JFXTextArea() {
initialize();
}
/**
* {@inheritDoc}
*/
public JFXTextArea(String text) {
super(text);
initialize();
}
/**
* {@inheritDoc}
*/
@Override
protected Skin<?> createDefaultSkin() {
return new JFXTextAreaSkin(this);
}
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
if ("dalvik".equalsIgnoreCase(System.getProperty("java.vm.name"))) {
this.setStyle("-fx-skin: \"com.jfoenix.android.skins.JFXTextAreaSkinAndroid\";");
}
useJFXContextMenu(this);
}
/***************************************************************************
* *
* Properties *
* *
**************************************************************************/
/**
* holds the current active validator on the text area in case of validation error
*/
private ReadOnlyObjectWrapper<ValidatorBase> activeValidator = new ReadOnlyObjectWrapper<>();
public ValidatorBase getActiveValidator() {
return activeValidator == null ? null : activeValidator.get();
}
public ReadOnlyObjectProperty<ValidatorBase> activeValidatorProperty() {
return this.activeValidator.getReadOnlyProperty();
}
/**
* list of validators that will validate the text value upon calling
* {{@link #validate()}
*/
private ObservableList<ValidatorBase> validators = FXCollections.observableArrayList();
public ObservableList<ValidatorBase> getValidators() {
return validators;
}
public void setValidators(ValidatorBase... validators) {
this.validators.addAll(validators);
}
/**
* validates the text value using the list of validators provided by the user
* {{@link #setValidators(ValidatorBase...)}
*
* @return true if the value is valid else false
*/
public boolean validate() {
for (ValidatorBase validator : validators) {
if (validator.getSrcControl() == null) {
validator.setSrcControl(this);
}
validator.validate();
if (validator.getHasErrors()) {
activeValidator.set(validator);
return false;
}
}
activeValidator.set(null);
return true;
}
public void resetValidation() {
pseudoClassStateChanged(ValidatorBase.PSEUDO_CLASS_ERROR, false);
activeValidator.set(null);
}
/***************************************************************************
* *
* styleable Properties *
* *
**************************************************************************/
/**
* set true to show a float the prompt text when focusing the field
*/
private StyleableBooleanProperty labelFloat = new SimpleStyleableBooleanProperty(StyleableProperties.LABEL_FLOAT, JFXTextArea.this, "lableFloat", false);
public final StyleableBooleanProperty labelFloatProperty() {
return this.labelFloat;
}
public final boolean isLabelFloat() {
return this.labelFloatProperty().get();
}
public final void setLabelFloat(final boolean labelFloat) {
this.labelFloatProperty().set(labelFloat);
}
/**
* default color used when the text area is unfocused
*/
private StyleableObjectProperty<Paint> unFocusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNFOCUS_COLOR, JFXTextArea.this, "unFocusColor", Color.rgb(77, 77, 77));
public Paint getUnFocusColor() {
return unFocusColor == null ? Color.rgb(77, 77, 77) : unFocusColor.get();
}
public StyleableObjectProperty<Paint> unFocusColorProperty() {
return this.unFocusColor;
}
public void setUnFocusColor(Paint color) {
this.unFocusColor.set(color);
}
/**
* default color used when the text area is focused
*/
private StyleableObjectProperty<Paint> focusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.FOCUS_COLOR, JFXTextArea.this, "focusColor", Color.valueOf("#4059A9"));
public Paint getFocusColor() {
return focusColor == null ? Color.valueOf("#4059A9") : focusColor.get();
}
public StyleableObjectProperty<Paint> focusColorProperty() {
return this.focusColor;
}
public void setFocusColor(Paint color) {
this.focusColor.set(color);
}
/**
* disable animation on validation
*/
private StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION, JFXTextArea.this, "disableAnimation", false);
public final StyleableBooleanProperty disableAnimationProperty() {
return this.disableAnimation;
}
public final Boolean isDisableAnimation() {
return disableAnimation != null && this.disableAnimationProperty().get();
}
public final void setDisableAnimation(final Boolean disabled) {
this.disableAnimationProperty().set(disabled);
}
private final static class StyleableProperties {
private static final CssMetaData<JFXTextArea, Paint> UNFOCUS_COLOR = new CssMetaData<JFXTextArea, Paint>("-jfx-unfocus-color", PaintConverter.getInstance(), Color.rgb(77, 77, 77)) {
@Override
public boolean isSettable(JFXTextArea control) {
return control.unFocusColor == null || !control.unFocusColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXTextArea control) {
return control.unFocusColorProperty();
}
};
private static final CssMetaData<JFXTextArea, Paint> FOCUS_COLOR = new CssMetaData<JFXTextArea, Paint>("-jfx-focus-color", PaintConverter.getInstance(), Color.valueOf("#4059A9")) {
@Override
public boolean isSettable(JFXTextArea control) {
return control.focusColor == null || !control.focusColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXTextArea control) {
return control.focusColorProperty();
}
};
private static final CssMetaData<JFXTextArea, Boolean> LABEL_FLOAT = new CssMetaData<JFXTextArea, Boolean>("-jfx-label-float", BooleanConverter.getInstance(), false) {
@Override
public boolean isSettable(JFXTextArea control) {
return control.labelFloat == null || !control.labelFloat.isBound();
}
@Override
public StyleableBooleanProperty getStyleableProperty(JFXTextArea control) {
return control.labelFloatProperty();
}
};
private static final CssMetaData<JFXTextArea, Boolean> DISABLE_ANIMATION = new CssMetaData<JFXTextArea, Boolean>("-jfx-disable-animation", BooleanConverter.getInstance(), false) {
@Override
public boolean isSettable(JFXTextArea control) {
return control.disableAnimation == null || !control.disableAnimation.isBound();
}
@Override
public StyleableBooleanProperty getStyleableProperty(JFXTextArea control) {
return control.disableAnimationProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
Collections.addAll(styleables, UNFOCUS_COLOR, FOCUS_COLOR, LABEL_FLOAT, DISABLE_ANIMATION);
CHILD_STYLEABLES = Collections.unmodifiableList(styleables);
}
}
// inherit the styleable properties from parent
private List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
@Override
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
if (STYLEABLES == null) {
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
styleables.addAll(getClassCssMetaData());
styleables.addAll(TextArea.getClassCssMetaData());
STYLEABLES = Collections.unmodifiableList(styleables);
}
return STYLEABLES;
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.CHILD_STYLEABLES;
}
}
@@ -0,0 +1,299 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import com.jfoenix.skins.JFXTextFieldSkin;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.css.*;
import javafx.css.converter.BooleanConverter;
import javafx.css.converter.PaintConverter;
import javafx.scene.control.Control;
import javafx.scene.control.Skin;
import javafx.scene.control.TextField;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu;
/**
* JFXTextField is the material design implementation of a text Field.
*
* @author Shadi Shaheen
* @version 1.0
* @since 2016-03-09
*/
public class JFXTextField extends TextField {
/**
* Initialize the style class to 'jfx-text-field'.
* <p>
* This is the selector class from which CSS can be used to style
* this control.
*/
private static final String DEFAULT_STYLE_CLASS = "jfx-text-field";
/**
* {@inheritDoc}
*/
public JFXTextField() {
initialize();
}
/**
* {@inheritDoc}
*/
public JFXTextField(String text) {
super(text);
initialize();
}
/**
* {@inheritDoc}
*/
@Override
protected Skin<?> createDefaultSkin() {
return new JFXTextFieldSkin(this);
}
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
if ("dalvik".equalsIgnoreCase(System.getProperty("java.vm.name"))) {
this.setStyle("-fx-skin: \"com.jfoenix.android.skins.JFXTextFieldSkinAndroid\";");
}
useJFXContextMenu(this);
}
/***************************************************************************
* *
* Properties *
* *
**************************************************************************/
/**
* holds the current active validator on the text field in case of validation error
*/
private ReadOnlyObjectWrapper<ValidatorBase> activeValidator = new ReadOnlyObjectWrapper<>();
public ValidatorBase getActiveValidator() {
return activeValidator == null ? null : activeValidator.get();
}
public ReadOnlyObjectProperty<ValidatorBase> activeValidatorProperty() {
return this.activeValidator.getReadOnlyProperty();
}
/**
* list of validators that will validate the text value upon calling
* {{@link #validate()}
*/
private ObservableList<ValidatorBase> validators = FXCollections.observableArrayList();
public ObservableList<ValidatorBase> getValidators() {
return validators;
}
public void setValidators(ValidatorBase... validators) {
this.validators.addAll(validators);
}
/**
* validates the text value using the list of validators provided by the user
* {{@link #setValidators(ValidatorBase...)}
*
* @return true if the value is valid else false
*/
public boolean validate() {
for (ValidatorBase validator : validators) {
if (validator.getSrcControl() == null) {
validator.setSrcControl(this);
}
validator.validate();
if (validator.getHasErrors()) {
activeValidator.set(validator);
return false;
}
}
activeValidator.set(null);
return true;
}
public void resetValidation() {
pseudoClassStateChanged(ValidatorBase.PSEUDO_CLASS_ERROR, false);
activeValidator.set(null);
}
/***************************************************************************
* *
* styleable Properties *
* *
**************************************************************************/
/**
* set true to show a float the prompt text when focusing the field
*/
private StyleableBooleanProperty labelFloat = new SimpleStyleableBooleanProperty(StyleableProperties.LABEL_FLOAT, JFXTextField.this, "lableFloat", false);
public final StyleableBooleanProperty labelFloatProperty() {
return this.labelFloat;
}
public final boolean isLabelFloat() {
return this.labelFloatProperty().get();
}
public final void setLabelFloat(final boolean labelFloat) {
this.labelFloatProperty().set(labelFloat);
}
/**
* default color used when the field is unfocused
*/
private StyleableObjectProperty<Paint> unFocusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNFOCUS_COLOR, JFXTextField.this, "unFocusColor", Color.rgb(77, 77, 77));
public Paint getUnFocusColor() {
return unFocusColor == null ? Color.rgb(77, 77, 77) : unFocusColor.get();
}
public StyleableObjectProperty<Paint> unFocusColorProperty() {
return this.unFocusColor;
}
public void setUnFocusColor(Paint color) {
this.unFocusColor.set(color);
}
/**
* default color used when the field is focused
*/
private StyleableObjectProperty<Paint> focusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.FOCUS_COLOR, JFXTextField.this, "focusColor", Color.valueOf("#4059A9"));
public Paint getFocusColor() {
return focusColor == null ? Color.valueOf("#4059A9") : focusColor.get();
}
public StyleableObjectProperty<Paint> focusColorProperty() {
return this.focusColor;
}
public void setFocusColor(Paint color) {
this.focusColor.set(color);
}
/**
* disable animation on validation
*/
private StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION, JFXTextField.this, "disableAnimation", false);
public final StyleableBooleanProperty disableAnimationProperty() {
return this.disableAnimation;
}
public final Boolean isDisableAnimation() {
return disableAnimation != null && this.disableAnimationProperty().get();
}
public final void setDisableAnimation(final Boolean disabled) {
this.disableAnimationProperty().set(disabled);
}
private final static class StyleableProperties {
private static final CssMetaData<JFXTextField, Paint> UNFOCUS_COLOR = new CssMetaData<JFXTextField, Paint>("-jfx-unfocus-color", PaintConverter.getInstance(), Color.valueOf("#A6A6A6")) {
@Override
public boolean isSettable(JFXTextField control) {
return control.unFocusColor == null || !control.unFocusColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXTextField control) {
return control.unFocusColorProperty();
}
};
private static final CssMetaData<JFXTextField, Paint> FOCUS_COLOR = new CssMetaData<JFXTextField, Paint>("-jfx-focus-color", PaintConverter.getInstance(), Color.valueOf("#3f51b5")) {
@Override
public boolean isSettable(JFXTextField control) {
return control.focusColor == null || !control.focusColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXTextField control) {
return control.focusColorProperty();
}
};
private static final CssMetaData<JFXTextField, Boolean> LABEL_FLOAT = new CssMetaData<JFXTextField, Boolean>("-jfx-label-float", BooleanConverter.getInstance(), false) {
@Override
public boolean isSettable(JFXTextField control) {
return control.labelFloat == null || !control.labelFloat.isBound();
}
@Override
public StyleableBooleanProperty getStyleableProperty(JFXTextField control) {
return control.labelFloatProperty();
}
};
private static final CssMetaData<JFXTextField, Boolean> DISABLE_ANIMATION = new CssMetaData<JFXTextField, Boolean>("-jfx-disable-animation", BooleanConverter.getInstance(), false) {
@Override
public boolean isSettable(JFXTextField control) {
return control.disableAnimation == null || !control.disableAnimation.isBound();
}
@Override
public StyleableBooleanProperty getStyleableProperty(JFXTextField control) {
return control.disableAnimationProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
Collections.addAll(styleables, UNFOCUS_COLOR, FOCUS_COLOR, LABEL_FLOAT, DISABLE_ANIMATION);
CHILD_STYLEABLES = Collections.unmodifiableList(styleables);
}
}
// inherit the styleable properties from parent
private List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
@Override
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
if (STYLEABLES == null) {
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
styleables.addAll(getClassCssMetaData());
styleables.addAll(TextField.getClassCssMetaData());
STYLEABLES = Collections.unmodifiableList(styleables);
}
return STYLEABLES;
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.CHILD_STYLEABLES;
}
}
@@ -34,14 +34,17 @@ import javafx.geometry.Insets;
import javafx.geometry.Rectangle2D;
import javafx.scene.Scene;
import javafx.scene.control.Tab;
import javafx.scene.control.TextFormatter;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.stage.*;
import javafx.util.Duration;
import org.jackhuang.hmcl.setting.StyleSheets;
import org.jackhuang.hmcl.util.StringUtils;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Pattern;
/**
* @author Shadi Shaheen
@@ -95,14 +98,17 @@ public class JFXCustomColorPickerDialog extends StackPane {
rgbField.getStyleClass().add("custom-color-field");
rgbField.setPromptText("RGB Color");
rgbField.setTextFormatter(colorCharFormatter());
rgbField.textProperty().addListener((o, oldVal, newVal) -> updateColorFromUserInput(newVal));
hsbField.getStyleClass().add("custom-color-field");
hsbField.setPromptText("HSB Color");
hsbField.setTextFormatter(colorCharFormatter());
hsbField.textProperty().addListener((o, oldVal, newVal) -> updateColorFromUserInput(newVal));
hexField.getStyleClass().add("custom-color-field");
hexField.setPromptText("#HEX Color");
hexField.setTextFormatter(colorCharFormatter());
hexField.textProperty().addListener((o, oldVal, newVal) -> updateColorFromUserInput(newVal));
StackPane tabContent = new StackPane();
@@ -404,4 +410,20 @@ public class JFXCustomColorPickerDialog extends StackPane {
dialog.setMinWidth(minWidth);
dialog.setMinHeight(minHeight);
}
private static final Pattern COLOR_CHAR_PATTERN = Pattern.compile("[0-9a-zA-Z#(),%.\\s]*");
private static TextFormatter<String> colorCharFormatter() {
return new TextFormatter<>(change -> {
if (!change.isContentChange()) return change;
String ins = StringUtils.toHalfWidth(change.getText());
if (!COLOR_CHAR_PATTERN.matcher(ins).matches()) return null;
String full = StringUtils.toHalfWidth(change.getControlNewText());
long h = full.chars().filter(c -> c == '#').count();
if (h > 1 || (h == 1 && full.indexOf('#') != 0)) return null;
change.setText(ins);
return change;
});
}
}
File diff suppressed because it is too large. Load diff
@@ -50,10 +50,12 @@ public final class EntryPoint {
LOG.start(Metadata.HMCL_CURRENT_DIRECTORY.resolve("logs"));
setupJavaFXVMOptions();
checkDirectoryPath();
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && !isInsideMacAppBundle())
initIcon();
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
System.getProperties().putIfAbsent("apple.awt.application.appearance", "system");
if (!isInsideMacAppBundle())
initIcon();
}
checkJavaFX();
verifyJavaFX();
@@ -192,16 +194,6 @@ public final class EntryPoint {
}
}
private static void checkDirectoryPath() {
String currentDir = System.getProperty("user.dir", "");
if (currentDir.contains("!")) {
LOG.error("The current working path contains an exclamation mark: " + currentDir);
// No Chinese translation because both Swing and JavaFX cannot render Chinese character properly when exclamation mark exists in the path.
showErrorAndExit("Exclamation mark(!) is not allowed in the path where HMCL is in.\n"
+ "The path is " + currentDir);
}
}
private static void checkJavaFX() {
try {
SelfDependencyPatcher.patch();
@@ -253,7 +253,7 @@ public final class Launcher extends Application {
if (Files.exists(mcDir))
files.add(mcDir.toString());
String command = new CommandBuilder().add("sudo", "chown", "-R", userName).addAll(files).toString();
String command = new CommandBuilder().addAll("sudo", "chown", "-R", userName).addAll(files).toString();
ButtonType copyAndExit = new ButtonType(i18n("button.copy_and_exit"));
if (showAlert(AlertType.ERROR,
@@ -46,6 +46,7 @@ import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.Proxy;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
@@ -321,6 +322,8 @@ public final class HMCLGameRepository extends DefaultGameRepository {
return VersionIconType.FABRIC.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.QUILT))
return VersionIconType.QUILT.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC))
return VersionIconType.LEGACY_FABRIC.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE))
return VersionIconType.NEO_FORGE.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.FORGE))
@@ -419,6 +422,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
.setHeight(vs.getHeight())
.setFullscreen(vs.isFullscreen())
.setWrapper(vs.getWrapper())
.setProxyOption(getProxyOption())
.setPreLaunchCommand(vs.getPreLaunchCommand())
.setPostExitCommand(vs.getPostExitCommand())
.setNoGeneratedJVMArgs(vs.isNoJVMArgs())
@@ -438,17 +442,6 @@ public final class HMCLGameRepository extends DefaultGameRepository {
builder.setQuickPlayOption(new QuickPlayOption.MultiPlayer(vs.getServerIp()));
}
if (config().hasProxy()) {
builder.setProxyType(config().getProxyType());
builder.setProxyHost(config().getProxyHost());
builder.setProxyPort(config().getProxyPort());
if (config().hasProxyAuth()) {
builder.setProxyUser(config().getProxyUser());
builder.setProxyPass(config().getProxyPass());
}
}
Path json = getModpackConfiguration(version);
if (Files.exists(json)) {
try {
@@ -558,4 +551,39 @@ public final class HMCLGameRepository extends DefaultGameRepository {
return minimum;
}
}
public static ProxyOption getProxyOption() {
if (!config().hasProxy() || config().getProxyType() == null) {
return ProxyOption.Default.INSTANCE;
}
return switch (config().getProxyType()) {
case DIRECT -> ProxyOption.Direct.INSTANCE;
case HTTP, SOCKS -> {
String proxyHost = config().getProxyHost();
int proxyPort = config().getProxyPort();
if (StringUtils.isBlank(proxyHost) || proxyPort < 0 || proxyPort > 0xFFFF) {
yield ProxyOption.Default.INSTANCE;
}
String proxyUser = config().getProxyUser();
String proxyPass = config().getProxyPass();
if (StringUtils.isBlank(proxyUser)) {
proxyUser = null;
proxyPass = null;
} else if (proxyPass == null) {
proxyPass = "";
}
if (config().getProxyType() == Proxy.Type.HTTP) {
yield new ProxyOption.Http(proxyHost, proxyPort, proxyUser, proxyPass);
} else {
yield new ProxyOption.Socks(proxyHost, proxyPort, proxyUser, proxyPass);
}
}
default -> ProxyOption.Default.INSTANCE;
};
}
}
@@ -325,13 +325,13 @@ public final class LauncherHelper {
message = i18n("launch.failed.command_too_long");
} else if (ex instanceof ExecutionPolicyLimitException) {
Controllers.prompt(new PromptDialogPane.Builder(i18n("launch.failed.execution_policy"),
(result, resolve, reject) -> {
(result, handler) -> {
if (CommandBuilder.setExecutionPolicy()) {
LOG.info("Set the ExecutionPolicy for the scope 'CurrentUser' to 'RemoteSigned'");
resolve.run();
handler.resolve();
} else {
LOG.warning("Failed to set ExecutionPolicy");
reject.accept(i18n("launch.failed.execution_policy.failed_to_set"));
handler.reject(i18n("launch.failed.execution_policy.failed_to_set"));
}
})
.addQuestion(new PromptDialogPane.Builder.HintQuestion(i18n("launch.failed.execution_policy.hint")))
@@ -389,7 +389,7 @@ public final class LauncherHelper {
int targetJavaVersionMajor = Integer.parseInt(setting.getJavaVersion());
GameJavaVersion minimumJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
if (minimumJavaVersion != null && targetJavaVersionMajor < minimumJavaVersion.getMajorVersion()) {
if (minimumJavaVersion != null && targetJavaVersionMajor < minimumJavaVersion.majorVersion()) {
Controllers.dialog(
i18n("launch.failed.java_version_too_low"),
i18n("message.error"),
@@ -634,7 +634,7 @@ public final class LauncherHelper {
private static CompletableFuture<JavaRuntime> downloadJava(GameJavaVersion javaVersion, Profile profile) {
CompletableFuture<JavaRuntime> future = new CompletableFuture<>();
Controllers.dialog(new MessageDialogPane.Builder(
i18n("launch.advice.require_newer_java_version", javaVersion.getMajorVersion()),
i18n("launch.advice.require_newer_java_version", javaVersion.majorVersion()),
i18n("message.warning"),
MessageType.QUESTION)
.yesOrNo(() -> {
@@ -64,7 +64,7 @@ public final class LogExporter {
}
return CompletableFuture.runAsync(() -> {
try (Zipper zipper = new Zipper(zipFile)) {
try (Zipper zipper = new Zipper(zipFile, true)) {
processLogs(runDirectory.resolve("liteconfig"), "*.log", "liteconfig", zipper, logMatcher);
processLogs(runDirectory.resolve("logs"), "*.log", "logs", zipper, logMatcher);
processLogs(runDirectory, "*.log", "runDirectory", zipper, logMatcher);
@@ -22,27 +22,28 @@ import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.OAuth;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.IOException;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.security.SecureRandom;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Lang.thread;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
private final int port;
private final CompletableFuture<String> future = new CompletableFuture<>();
private final String codeVerifier;
private final String state;
public static String lastlyOpenedURL;
@@ -52,6 +53,34 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
super(port);
this.port = port;
var encoder = Base64.getUrlEncoder().withoutPadding();
var random = new SecureRandom();
{
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
// https://datatracker.ietf.org/doc/html/rfc6749#section-10.12
byte[] bytes = new byte[32];
random.nextBytes(bytes);
this.state = encoder.encodeToString(bytes);
}
{
// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
byte[] bytes = new byte[64];
random.nextBytes(bytes);
this.codeVerifier = encoder.encodeToString(bytes);
}
}
@Override
public String getCodeVerifier() {
return codeVerifier;
}
@Override
public String getState() {
return state;
}
@Override
@@ -93,19 +122,32 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
String parameters = session.getQueryParameterString();
Map<String, String> query = mapOf(NetworkUtils.parseQuery(parameters));
if (query.containsKey("code")) {
idToken = query.get("id_token");
future.complete(query.get("code"));
String code = query.get("code");
if (code != null) {
if (this.state.equals(query.get("state"))) {
idToken = query.get("id_token");
future.complete(code);
} else if (query.containsKey("state")) {
LOG.warning("Failed to authenticate: invalid state in parameters");
future.completeExceptionally(new AuthenticationException("Failed to authenticate: invalid state"));
} else {
LOG.warning("Failed to authenticate: missing state in parameters");
future.completeExceptionally(new AuthenticationException("Failed to authenticate: missing state"));
}
} else {
LOG.warning("Error: " + parameters);
future.completeExceptionally(new AuthenticationException("failed to authenticate"));
LOG.warning("Failed to authenticate: missing authorization code in parameters");
future.completeExceptionally(new AuthenticationException("Failed to authenticate: missing authorization code"));
}
String html;
try {
html = IOUtils.readFullyAsString(OAuthServer.class.getResourceAsStream("/assets/microsoft_auth.html"))
.replace("%style%", Themes.getTheme().toColorScheme().toStyleSheet().replace("-monet", "--monet"))
.replace("%lang%", Locale.getDefault().toLanguageTag())
.replace("%close-page%", i18n("account.methods.microsoft.close_page"));
.replace("%success%", i18n("message.success"))
.replace("%ok%", i18n("button.ok"))
.replace("%close_page%", i18n("account.methods.microsoft.close_page"));
} catch (IOException e) {
LOG.error("Failed to load html", e);
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_HTML, "");
@@ -123,7 +165,8 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
public static class Factory implements OAuth.Callback {
public final EventManager<GrantDeviceCodeEvent> onGrantDeviceCode = new EventManager<>();
public final EventManager<OpenBrowserEvent> onOpenBrowser = new EventManager<>();
public final EventManager<OpenBrowserEvent> onOpenBrowserAuthorizationCode = new EventManager<>();
public final EventManager<OpenBrowserEvent> onOpenBrowserDevice = new EventManager<>();
@Override
public OAuth.Session startServer() throws IOException, AuthenticationException {
@@ -150,17 +193,13 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
}
@Override
public void openBrowser(String url) throws IOException {
public void openBrowser(OAuth.GrantFlow grantFlow, String url) throws IOException {
lastlyOpenedURL = url;
try {
Thread.sleep(1500);
} catch (InterruptedException ignored) {
switch (grantFlow) {
case AUTHORIZATION_CODE -> onOpenBrowserAuthorizationCode.fireEvent(new OpenBrowserEvent(this, url));
case DEVICE -> onOpenBrowserDevice.fireEvent(new OpenBrowserEvent(this, url));
}
FXUtils.openLink(url);
onOpenBrowser.fireEvent(new OpenBrowserEvent(this, url));
}
@Override
@@ -168,17 +207,6 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
return System.getProperty("hmcl.microsoft.auth.id",
JarUtils.getAttribute("hmcl.microsoft.auth.id", ""));
}
@Override
public String getClientSecret() {
return System.getProperty("hmcl.microsoft.auth.secret",
JarUtils.getAttribute("hmcl.microsoft.auth.secret", ""));
}
@Override
public boolean isPublicClient() {
return true; // We have turned on the device auth flow.
}
}
public static class GrantDeviceCodeEvent extends Event {
@@ -30,7 +30,9 @@ import org.jackhuang.hmcl.util.platform.Platform;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -57,7 +59,7 @@ public final class HMCLJavaRepository implements JavaRepository {
}
public Path getJavaDir(Platform platform, GameJavaVersion gameJavaVersion) {
return getJavaDir(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.getComponent());
return getJavaDir(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.component());
}
@Override
@@ -66,7 +68,7 @@ public final class HMCLJavaRepository implements JavaRepository {
}
public Path getManifestFile(Platform platform, GameJavaVersion gameJavaVersion) {
return getManifestFile(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.getComponent());
return getManifestFile(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.component());
}
public boolean isInstalled(Platform platform, String name) {
@@ -74,7 +76,7 @@ public final class HMCLJavaRepository implements JavaRepository {
}
public boolean isInstalled(Platform platform, GameJavaVersion gameJavaVersion) {
return isInstalled(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.getComponent());
return isInstalled(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.component());
}
public @Nullable Path getJavaExecutable(Platform platform, String name) {
@@ -94,7 +96,7 @@ public final class HMCLJavaRepository implements JavaRepository {
}
public @Nullable Path getJavaExecutable(Platform platform, GameJavaVersion gameJavaVersion) {
return getJavaExecutable(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.getComponent());
return getJavaExecutable(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.component());
}
private static void getAllJava(List<JavaRuntime> list, Platform platform, Path platformRoot, boolean isManaged) {
@@ -169,7 +171,7 @@ public final class HMCLJavaRepository implements JavaRepository {
Map<String, Object> update = new LinkedHashMap<>();
update.put("provider", "mojang");
update.put("component", gameJavaVersion.getComponent());
update.put("component", gameJavaVersion.component());
Map<String, JavaLocalFiles.Local> files = new LinkedHashMap<>();
result.remoteFiles.getFiles().forEach((path, file) -> {
@@ -36,7 +36,9 @@ public enum VersionIconType {
FURNACE("/assets/img/furnace.png"),
QUILT("/assets/img/quilt.png"),
APRIL_FOOLS("/assets/img/april_fools.png"),
CLEANROOM("/assets/img/cleanroom.png");
CLEANROOM("/assets/img/cleanroom.png"),
LEGACY_FABRIC("/assets/img/legacyfabric.png")
;
// Please append new items at last
@@ -33,6 +33,7 @@ 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;
@@ -44,13 +45,15 @@ import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.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;
@@ -310,11 +313,18 @@ public final class TerracottaManager {
public static TerracottaState.HostScanning setScanning() {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
String uri = NetworkUtils.withQuery(String.format("http://127.0.0.1:%d/state/scanning", portSpecific.port), Map.of(
"player", getPlayerName()
));
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();
new GetTask(uri).setSignificance(Task.TaskSignificance.MINOR).start();
return new TerracottaState.HostScanning(-1, -1, null);
}
return null;
@@ -323,15 +333,19 @@ public final class TerracottaManager {
public static Task<TerracottaState.GuestConnecting> setGuesting(String room) {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
String uri = NetworkUtils.withQuery(String.format("http://127.0.0.1:%d/state/guesting", portSpecific.port), Map.of(
"room", room,
"player", getPlayerName()
));
return new GetTask(uri)
.setSignificance(Task.TaskSignificance.MINOR)
.thenSupplyAsync(() -> new TerracottaState.GuestConnecting(-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;
}
@@ -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() {
}
}
@@ -17,6 +17,7 @@
*/
package org.jackhuang.hmcl.theme;
import com.sun.jna.Pointer;
import javafx.beans.Observable;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
@@ -24,7 +25,10 @@ import javafx.beans.binding.ObjectBinding;
import javafx.beans.binding.ObjectExpression;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.event.EventHandler;
import javafx.scene.paint.Color;
import javafx.stage.Stage;
import javafx.stage.WindowEvent;
import org.glavo.monetfx.Brightness;
import org.glavo.monetfx.ColorScheme;
import org.glavo.monetfx.Contrast;
@@ -32,13 +36,23 @@ import org.glavo.monetfx.beans.property.ColorSchemeProperty;
import org.glavo.monetfx.beans.property.ReadOnlyColorSchemeProperty;
import org.glavo.monetfx.beans.property.SimpleColorSchemeProperty;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.ui.WindowsNativeUtils;
import org.jackhuang.hmcl.util.platform.NativeUtils;
import org.jackhuang.hmcl.util.platform.OSVersion;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jackhuang.hmcl.util.platform.windows.Dwmapi;
import org.jackhuang.hmcl.util.platform.windows.WinConstants;
import org.jackhuang.hmcl.util.platform.windows.WinReg;
import org.jackhuang.hmcl.util.platform.windows.WinTypes;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.nio.file.Path;
import java.time.Duration;
import java.util.*;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Glavo
public final class Themes {
@@ -65,7 +79,7 @@ public final class Themes {
if (FXUtils.DARK_MODE != null) {
yield FXUtils.DARK_MODE.get() ? Brightness.DARK : Brightness.LIGHT;
} else {
yield Brightness.DEFAULT;
yield getDefaultBrightness();
}
}
case "dark" -> Brightness.DARK;
@@ -97,6 +111,66 @@ public final class Themes {
theme.addListener(listener);
}
private static Brightness defaultBrightness;
private static Brightness getDefaultBrightness() {
if (defaultBrightness != null)
return defaultBrightness;
LOG.info("Detecting system theme brightness");
Brightness brightness = Brightness.DEFAULT;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
WinReg reg = WinReg.INSTANCE;
if (reg != null) {
Object appsUseLightTheme = reg.queryValue(WinReg.HKEY.HKEY_CURRENT_USER, "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", "AppsUseLightTheme");
if (appsUseLightTheme instanceof Integer value) {
brightness = value == 0 ? Brightness.DARK : Brightness.LIGHT;
}
}
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
try {
String result = SystemUtils.run("/usr/bin/defaults", "read", "-g", "AppleInterfaceStyle").trim();
brightness = "Dark".equalsIgnoreCase(result) ? Brightness.DARK : Brightness.LIGHT;
} catch (Exception e) {
// If the key does not exist, it means Light mode is used
brightness = Brightness.LIGHT;
}
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX) {
Path dbusSend = SystemUtils.which("dbus-send");
if (dbusSend != null) {
try {
String[] result = SystemUtils.run(List.of(
FileUtils.getAbsolutePath(dbusSend),
"--session",
"--print-reply=literal",
"--reply-timeout=1000",
"--dest=org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
"org.freedesktop.portal.Settings.Read",
"string:org.freedesktop.appearance",
"string:color-scheme"
), Duration.ofSeconds(2)).trim().split(" ");
if (result.length > 0) {
String value = result[result.length - 1];
// 1: prefer dark
// 2: prefer light
if ("1".equals(value)) {
brightness = Brightness.DARK;
} else if ("2".equals(value)) {
brightness = Brightness.LIGHT;
}
}
} catch (Exception e) {
LOG.warning("Failed to get system theme from D-Bus", e);
}
}
}
LOG.info("Detected system theme brightness: " + brightness);
return defaultBrightness = brightness;
}
public static ObjectExpression<Theme> themeProperty() {
return theme;
}
@@ -129,6 +203,39 @@ public final class Themes {
return darkMode;
}
public static void applyNativeDarkMode(Stage stage) {
if (OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_11) && NativeUtils.USE_JNA && Dwmapi.INSTANCE != null) {
ChangeListener<Boolean> listener = FXUtils.onWeakChange(Themes.darkModeProperty(), darkMode -> {
if (stage.isShowing()) {
WindowsNativeUtils.getWindowHandle(stage).ifPresent(handle -> {
if (handle == WinTypes.HANDLE.INVALID_VALUE)
return;
Dwmapi.INSTANCE.DwmSetWindowAttribute(
new WinTypes.HANDLE(Pointer.createConstant(handle)),
WinConstants.DWMWA_USE_IMMERSIVE_DARK_MODE,
new WinTypes.BOOLByReference(new WinTypes.BOOL(darkMode)),
WinTypes.BOOL.SIZE
);
});
}
});
stage.getProperties().put("Themes.applyNativeDarkMode.listener", listener);
if (stage.isShowing()) {
listener.changed(null, false, Themes.darkModeProperty().get());
} else {
stage.addEventFilter(WindowEvent.WINDOW_SHOWN, new EventHandler<>() {
@Override
public void handle(WindowEvent event) {
stage.removeEventFilter(WindowEvent.WINDOW_SHOWN, this);
listener.changed(null, false, Themes.darkModeProperty().get());
}
});
}
}
}
private Themes() {
}
}
@@ -19,7 +19,7 @@ package org.jackhuang.hmcl.ui;
import org.jackhuang.hmcl.auth.*;
import org.jackhuang.hmcl.ui.account.ClassicAccountLoginDialog;
import org.jackhuang.hmcl.ui.account.OAuthAccountLoginDialog;
import org.jackhuang.hmcl.ui.account.MicrosoftAccountLoginPane;
import java.util.Optional;
import java.util.concurrent.CancellationException;
@@ -49,10 +49,10 @@ public final class DialogController {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<AuthInfo> res = new AtomicReference<>(null);
runInFX(() -> {
OAuthAccountLoginDialog pane = new OAuthAccountLoginDialog((OAuthAccount) account, it -> {
MicrosoftAccountLoginPane pane = new MicrosoftAccountLoginPane(account, it -> {
res.set(it);
latch.countDown();
}, latch::countDown);
}, latch::countDown, false);
Controllers.dialog(pane);
});
latch.await();
@@ -18,7 +18,8 @@
package org.jackhuang.hmcl.ui;
import com.jfoenix.controls.*;
import javafx.animation.*;
import javafx.animation.Animation;
import javafx.animation.Interpolator;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
@@ -26,7 +27,10 @@ import javafx.beans.WeakInvalidationListener;
import javafx.beans.WeakListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
import javafx.beans.value.*;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableBooleanValue;
import javafx.beans.value.ObservableValue;
import javafx.beans.value.WeakChangeListener;
import javafx.collections.ObservableMap;
import javafx.event.Event;
import javafx.event.EventDispatcher;
@@ -39,20 +43,23 @@ import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.*;
import javafx.scene.control.skin.VirtualFlow;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.*;
import javafx.scene.layout.*;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.stage.*;
import javafx.stage.FileChooser;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.util.Callback;
import javafx.util.Duration;
import javafx.util.StringConverter;
@@ -61,9 +68,13 @@ import org.jackhuang.hmcl.task.CacheFileTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.construct.IconedMenuItem;
import org.jackhuang.hmcl.ui.construct.MenuSeparator;
import org.jackhuang.hmcl.ui.construct.PopupMenu;
import org.jackhuang.hmcl.ui.image.ImageLoader;
import org.jackhuang.hmcl.ui.image.ImageUtils;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.ResourceNotFoundError;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.javafx.ExtendedProperties;
@@ -88,12 +99,14 @@ import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.ref.WeakReference;
import java.net.*;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLConnection;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.util.List;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.*;
@@ -102,8 +115,8 @@ import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.Lang.thread;
import static org.jackhuang.hmcl.util.Lang.tryCast;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class FXUtils {
private FXUtils() {
@@ -412,6 +425,20 @@ public final class FXUtils {
return pane;
}
public static void limitCellWidth(ListView<?> listView, ListCell<?> cell) {
ReadOnlyDoubleProperty widthProperty;
if (listView.lookup(".clipped-container") instanceof Region clippedContainer) {
widthProperty = clippedContainer.widthProperty();
} else {
widthProperty = listView.widthProperty();
}
cell.maxWidthProperty().bind(widthProperty);
cell.prefWidthProperty().bind(widthProperty);
cell.minWidthProperty().bind(widthProperty);
}
public static void smoothScrolling(ScrollPane scrollPane) {
if (AnimationUtils.isAnimationEnabled())
ScrollUtils.addSmoothScrolling(scrollPane);
@@ -1264,8 +1291,8 @@ public final class FXUtils {
return button;
}
public static Label newSafeTruncatedLabel(String text) {
Label label = new Label(text);
public static Label newSafeTruncatedLabel() {
Label label = new Label();
label.setTextOverrun(OverrunStyle.CENTER_WORD_ELLIPSIS);
showTooltipWhenTruncated(label);
return label;
@@ -1397,6 +1424,15 @@ public final class FXUtils {
});
}
public static void onSecondaryButtonClicked(Node node, Runnable action) {
node.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> {
if (e.getButton() == MouseButton.SECONDARY) {
action.run();
e.consume();
}
});
}
public static <N extends Parent> N prepareNode(N node) {
Scene dummyScene = new Scene(node);
StyleSheets.init(dummyScene);
@@ -1592,4 +1628,39 @@ public final class FXUtils {
? JFXPopup.PopupVPosition.BOTTOM // Show menu below the button, expanding downward
: JFXPopup.PopupVPosition.TOP; // Show menu above the button, expanding upward
}
public static void useJFXContextMenu(TextInputControl control) {
control.setContextMenu(null);
PopupMenu menu = new PopupMenu();
JFXPopup popup = new JFXPopup(menu);
popup.setAutoHide(true);
control.setOnContextMenuRequested(e -> {
boolean hasNoSelection = control.getSelectedText().isEmpty();
IconedMenuItem undo = new IconedMenuItem(SVG.UNDO, i18n("menu.undo"), control::undo, popup);
IconedMenuItem redo = new IconedMenuItem(SVG.REDO, i18n("menu.redo"), control::redo, popup);
IconedMenuItem cut = new IconedMenuItem(SVG.CONTENT_CUT, i18n("menu.cut"), control::cut, popup);
IconedMenuItem copy = new IconedMenuItem(SVG.CONTENT_COPY, i18n("menu.copy"), control::copy, popup);
IconedMenuItem paste = new IconedMenuItem(SVG.CONTENT_PASTE, i18n("menu.paste"), control::paste, popup);
IconedMenuItem delete = new IconedMenuItem(SVG.DELETE, i18n("menu.deleteselection"), () -> control.replaceSelection(""), popup);
IconedMenuItem selectall = new IconedMenuItem(SVG.SELECT_ALL, i18n("menu.selectall"), control::selectAll, popup);
menu.getContent().setAll(undo, redo, new MenuSeparator(), cut, copy, paste, delete, new MenuSeparator(), selectall);
undo.setDisable(!control.isUndoable());
redo.setDisable(!control.isRedoable());
cut.setDisable(hasNoSelection);
delete.setDisable(hasNoSelection);
copy.setDisable(hasNoSelection);
paste.setDisable(!Clipboard.getSystemClipboard().hasString());
selectall.setDisable(control.getText() == null || control.getText().isEmpty());
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(control, popup);
popup.show(control, vPosition, JFXPopup.PopupHPosition.LEFT, e.getX(), vPosition == JFXPopup.PopupVPosition.TOP ? e.getY() : e.getY() - control.getHeight());
e.consume();
});
}
}
@@ -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.theme.Themes;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.util.Lang;
@@ -90,6 +91,8 @@ public class GameCrashWindow extends Stage {
private final List<Log> logs;
public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType exitType, DefaultGameRepository repository, Version version, LaunchOptions launchOptions, List<Log> logs) {
Themes.applyNativeDarkMode(this);
this.managedProcess = managedProcess;
this.exitType = exitType;
this.repository = repository;
@@ -92,37 +92,18 @@ public class InstallerItem extends Control {
this.id = id;
this.style = style;
switch (id) {
case "game":
iconType = VersionIconType.GRASS;
break;
case "fabric":
case "fabric-api":
iconType = VersionIconType.FABRIC;
break;
case "forge":
iconType = VersionIconType.FORGE;
break;
case "cleanroom":
iconType = VersionIconType.CLEANROOM;
break;
case "liteloader":
iconType = VersionIconType.CHICKEN;
break;
case "optifine":
iconType = VersionIconType.OPTIFINE;
break;
case "quilt":
case "quilt-api":
iconType = VersionIconType.QUILT;
break;
case "neoforge":
iconType = VersionIconType.NEO_FORGE;
break;
default:
iconType = null;
break;
}
iconType = switch (id) {
case "game" -> VersionIconType.GRASS;
case "fabric", "fabric-api" -> VersionIconType.FABRIC;
case "legacyfabric", "legacyfabric-api" -> VersionIconType.LEGACY_FABRIC;
case "forge" -> VersionIconType.FORGE;
case "cleanroom" -> VersionIconType.CLEANROOM;
case "liteloader" -> VersionIconType.CHICKEN;
case "optifine" -> VersionIconType.OPTIFINE;
case "quilt", "quilt-api" -> VersionIconType.QUILT;
case "neoforge" -> VersionIconType.NEO_FORGE;
default -> null;
};
}
public String getLibraryId() {
@@ -201,6 +182,8 @@ public class InstallerItem extends Control {
InstallerItem fabricApi = new InstallerItem(FABRIC_API, style);
InstallerItem forge = new InstallerItem(FORGE, style);
InstallerItem cleanroom = new InstallerItem(CLEANROOM, style);
InstallerItem legacyfabric = new InstallerItem(LEGACY_FABRIC, style);
InstallerItem legacyfabricApi = new InstallerItem(LEGACY_FABRIC_API, style);
InstallerItem neoForge = new InstallerItem(NEO_FORGE, style);
InstallerItem liteLoader = new InstallerItem(LITELOADER, style);
InstallerItem optiFine = new InstallerItem(OPTIFINE, style);
@@ -208,11 +191,12 @@ public class InstallerItem extends Control {
InstallerItem quiltApi = new InstallerItem(QUILT_API, style);
Map<InstallerItem, Set<InstallerItem>> incompatibleMap = new HashMap<>();
mutualIncompatible(incompatibleMap, forge, fabric, quilt, neoForge, cleanroom);
addIncompatibles(incompatibleMap, liteLoader, fabric, quilt, neoForge, cleanroom);
addIncompatibles(incompatibleMap, optiFine, fabric, quilt, neoForge, cleanroom);
addIncompatibles(incompatibleMap, fabricApi, forge, quiltApi, neoForge, liteLoader, optiFine, cleanroom);
addIncompatibles(incompatibleMap, quiltApi, forge, fabric, fabricApi, neoForge, liteLoader, optiFine, cleanroom);
mutualIncompatible(incompatibleMap, forge, fabric, quilt, neoForge, cleanroom, legacyfabric);
addIncompatibles(incompatibleMap, liteLoader, fabric, quilt, neoForge, cleanroom, legacyfabric);
addIncompatibles(incompatibleMap, optiFine, fabric, quilt, neoForge, cleanroom, liteLoader, legacyfabric);
addIncompatibles(incompatibleMap, fabricApi, forge, quiltApi, neoForge, liteLoader, optiFine, cleanroom, legacyfabric, legacyfabricApi);
addIncompatibles(incompatibleMap, quiltApi, forge, fabric, fabricApi, neoForge, liteLoader, optiFine, cleanroom, legacyfabric, legacyfabricApi);
addIncompatibles(incompatibleMap, legacyfabricApi, forge, fabric, fabricApi, neoForge, liteLoader, optiFine, cleanroom, quilt, quiltApi);
for (Map.Entry<InstallerItem, Set<InstallerItem>> entry : incompatibleMap.entrySet()) {
InstallerItem item = entry.getKey();
@@ -246,7 +230,7 @@ public class InstallerItem extends Control {
game.versionProperty.set(new InstalledState(gameVersion, false, false));
}
InstallerItem[] all = {game, forge, neoForge, liteLoader, optiFine, fabric, fabricApi, quilt, quiltApi, cleanroom};
InstallerItem[] all = {game, forge, neoForge, liteLoader, optiFine, fabric, fabricApi, quilt, quiltApi, legacyfabric, legacyfabricApi, cleanroom};
for (InstallerItem item : all) {
if (!item.resolvedStateProperty.isBound()) {
@@ -263,9 +247,9 @@ public class InstallerItem extends Control {
if (gameVersion == null) {
this.libraries = all;
} else if (gameVersion.equals("1.12.2")) {
this.libraries = new InstallerItem[]{game, forge, cleanroom, liteLoader, optiFine};
} else if (GameVersionNumber.compare(gameVersion, "1.13") < 0) {
this.libraries = new InstallerItem[]{game, forge, liteLoader, optiFine};
this.libraries = new InstallerItem[]{game, forge, cleanroom, liteLoader, legacyfabric, legacyfabricApi, optiFine};
} else if (GameVersionNumber.compare(gameVersion, "1.13.2") <= 0) {
this.libraries = new InstallerItem[]{game, forge, liteLoader, optiFine, legacyfabric, legacyfabricApi};
} else {
this.libraries = new InstallerItem[]{game, forge, neoForge, optiFine, fabric, fabricApi, quilt, quiltApi};
}
@@ -300,11 +284,15 @@ public class InstallerItem extends Control {
}
pane.getStyleClass().add("installer-item");
RipplerContainer container = new RipplerContainer(pane);
container.setPosition(JFXRippler.RipplerPos.FRONT);
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());
@@ -355,6 +343,7 @@ public class InstallerItem extends Control {
BorderPane.setAlignment(statusLabel, Pos.CENTER_LEFT);
HBox buttonsContainer = new HBox();
buttonsContainer.setPickOnBounds(false);
buttonsContainer.setSpacing(8);
buttonsContainer.setAlignment(Pos.CENTER);
pane.getChildren().add(buttonsContainer);
@@ -1,49 +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.ui;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.scene.Node;
import javafx.scene.control.Skin;
public abstract class ListPage<T extends Node> extends ListPageBase<T> {
private final BooleanProperty refreshable = new SimpleBooleanProperty(this, "refreshable", false);
public abstract void add();
public void refresh() {
}
@Override
protected Skin<?> createDefaultSkin() {
return new ListPageSkin(this);
}
public boolean isRefreshable() {
return refreshable.get();
}
public BooleanProperty refreshableProperty() {
return refreshable;
}
public void setRefreshable(boolean refreshable) {
this.refreshable.set(refreshable);
}
}
@@ -1,108 +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.ui;
import com.jfoenix.controls.JFXButton;
import javafx.beans.binding.Bindings;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
public class ListPageSkin extends SkinBase<ListPage<?>> {
public ListPageSkin(ListPage<?> skinnable) {
super(skinnable);
SpinnerPane spinnerPane = new SpinnerPane();
spinnerPane.getStyleClass().add("large-spinner-pane");
Pane placeholder = new Pane();
VBox list = new VBox();
StackPane contentPane = new StackPane();
{
ScrollPane scrollPane = new ScrollPane();
{
scrollPane.setFitToWidth(true);
list.maxWidthProperty().bind(scrollPane.widthProperty());
list.setSpacing(10);
VBox content = new VBox();
content.getChildren().setAll(list, placeholder);
Bindings.bindContent(list.getChildren(), skinnable.itemsProperty());
scrollPane.setContent(content);
FXUtils.smoothScrolling(scrollPane);
}
VBox vBox = new VBox();
{
vBox.getStyleClass().add("card-list");
vBox.setAlignment(Pos.BOTTOM_RIGHT);
vBox.setPickOnBounds(false);
JFXButton btnAdd = FXUtils.newRaisedButton("");
FXUtils.setLimitWidth(btnAdd, 40);
FXUtils.setLimitHeight(btnAdd, 40);
btnAdd.setGraphic(SVG.ADD.createIcon());
btnAdd.setOnAction(e -> skinnable.add());
JFXButton btnRefresh = new JFXButton();
FXUtils.setLimitWidth(btnRefresh, 40);
FXUtils.setLimitHeight(btnRefresh, 40);
btnRefresh.getStyleClass().add("jfx-button-raised-round");
btnRefresh.setButtonType(JFXButton.ButtonType.RAISED);
btnRefresh.setGraphic(SVG.REFRESH.createIcon());
btnRefresh.setOnAction(e -> skinnable.refresh());
vBox.getChildren().setAll(btnAdd);
FXUtils.onChangeAndOperate(skinnable.refreshableProperty(),
refreshable -> {
if (refreshable) {
list.setPadding(new Insets(10, 10, 15 + 40 + 15 + 40 + 15, 10));
vBox.getChildren().setAll(btnRefresh, btnAdd);
} else {
list.setPadding(new Insets(10, 10, 15 + 40 + 15, 10));
vBox.getChildren().setAll(btnAdd);
}
});
}
// Keep a blank space to prevent buttons from blocking up mod items.
BorderPane group = new BorderPane();
group.setPickOnBounds(false);
group.setBottom(vBox);
placeholder.minHeightProperty().bind(vBox.heightProperty());
contentPane.getChildren().setAll(scrollPane, group);
}
spinnerPane.loadingProperty().bind(skinnable.loadingProperty());
spinnerPane.setContent(contentPane);
getChildren().setAll(spinnerPane);
}
}
@@ -21,7 +21,6 @@ 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;
@@ -29,7 +28,6 @@ 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;
@@ -38,7 +36,6 @@ 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;
@@ -48,9 +45,6 @@ import org.jackhuang.hmcl.ui.construct.NoneMultipleSelectionModel;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
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;
@@ -87,41 +81,13 @@ 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<>());
}
public LogWindow(ManagedProcess gameProcess, CircularArrayList<Log> logs) {
Themes.applyNativeDarkMode(this);
this.logs = logs;
this.impl = new LogWindowImpl();
setScene(new Scene(impl, 800, 480));
@@ -18,22 +18,17 @@
package org.jackhuang.hmcl.ui;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Pos;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Paint;
import javafx.scene.shape.SVGPath;
/**
* All vector icons used in the launcher.
* <p>
* Unless otherwise stated,
* these icons are from <a href="https://fonts.google.com/icons?icon.size=24&icon.color=%235f6368&icon.query=list&icon.set=Material+Symbols">Material Symbols</a>,
* with a style of outlined, a weight of 400, a grade of 0, and an optical size of 24 px.
* The view boxes of all icons are normalized to {@code 0 0 24 24}.
*/
/// All vector icons used in the launcher.
///
/// Unless otherwise stated,
/// these icons are from <a href="https://fonts.google.com/icons?icon.size=24&icon.color=%235f6368&icon.query=list&icon.set=Material+Symbols">Material Symbols</a>,
/// with a style of outlined, a weight of 400, a grade of 0, and an optical size of 24 px.
/// The view boxes of all icons are normalized to `0 0 24 24`.
public enum SVG {
NONE(""), // Empty Icon
ADD("M11 13H5V11H11V5H13V11H19V13H13V19H11V13Z"),
ADD_CIRCLE("M11 17H13V13H17V11H13V7H11V11H7V13H11V17ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"),
ALPHA_CIRCLE("M11,7H13A2,2 0 0,1 15,9V17H13V13H11V17H9V9A2,2 0 0,1 11,7M11,9V11H13V9H11M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2Z"), // Not Material
@@ -50,7 +45,9 @@ public enum SVG {
CHECKROOM("M3 20Q2.575 20 2.2875 19.7125T2 19Q2 18.75 2.1 18.5375T2.4 18.2L11 11.75V10Q11 9.575 11.3 9.2875T12.025 9Q12.65 9 13.075 8.55T13.5 7.475Q13.5 6.85 13.0625 6.425T12 6Q11.375 6 10.9375 6.4375T10.5 7.5H8.5Q8.5 6.05 9.525 5.025T12 4Q13.45 4 14.475 5.0125T15.5 7.475Q15.5 8.65 14.8125 9.575T13 10.85V11.75L21.6 18.2Q21.8 18.325 21.9 18.5375T22 19Q22 19.425 21.7125 19.7125T21 20H3ZM6 18H18L12 13.5 6 18Z"),
CHECK_CIRCLE("M10.6 16.6 17.65 9.55 16.25 8.15 10.6 13.8 7.75 10.95 6.35 12.35 10.6 16.6ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"),
CLOSE("M6.4 19 5 17.6 10.6 12 5 6.4 6.4 5 12 10.6 17.6 5 19 6.4 13.4 12 19 17.6 17.6 19 12 13.4 6.4 19Z"),
CONTENT_CUT("M19 21l-7-7-2.35 2.35q.2.375.275.8T10 18q0 1.65-1.175 2.825T6 22q-1.65 0-2.825-1.175T2 18t1.175-2.825T6 14q.425 0 .85.075t.8.275L10 12 7.65 9.65q-.375.2-.8.275T6 10q-1.65 0-2.825-1.175T2 6q0-1.65 1.175-2.825T6 2q1.65 0 2.825 1.175T10 6q0 .425-.075.85t-.275.8L22 20v1H19Zm-4-10-2-2 6-6h3v1l-7 7ZM7.4125 7.4125Q8 6.825 8 6t-.5875-1.4125Q6.825 4 6 4t-1.4125.5875Q4 5.175 4 6t.5875 1.4125T6 8t1.4125-.5875ZM12.35 12.35q.15-.15.15-.35t-.15-.35-.35-.15-.35.15-.15.35.15.35.35.15.35-.15ZM7.4125 19.4125Q8 18.825 8 18t-.5875-1.4125Q6.825 16 6 16t-1.4125.5875Q4 17.175 4 18t.5875 1.4125Q5.175 20 6 20t1.4125-.5875Z"),
CONTENT_COPY("M9 18Q8.175 18 7.5875 17.4125T7 16V4Q7 3.175 7.5875 2.5875T9 2H18Q18.825 2 19.4125 2.5875T20 4V16Q20 16.825 19.4125 17.4125T18 18H9ZM9 16H18V4H9V16ZM5 22Q4.175 22 3.5875 21.4125T3 20V6H5V20H16V22H5ZM9 16V4 16Z"),
CONTENT_PASTE("M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h4.175q.275-.875 1.075-1.437T12 1q1 0 1.788.563T14.85 3H19q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm0-2h14V5h-2v3H7V5H5zm7-14q.425 0 .713-.288T13 4t-.288-.712T12 3t-.712.288T11 4t.288.713T12 5"),
CREATE_NEW_FOLDER("M14 16h2V14h2V12H16V10H14v2H12v2h2v2ZM4 20q-.825 0-1.4125-.5875T2 18V6q0-.825.5875-1.4125T4 4h6l2 2h8q.825 0 1.4125.5875T22 8V18q0 .825-.5875 1.4125T20 20H4Zm0-2H20V8H11.175l-2-2H4V18ZV6 18Z"),
DELETE("M7 21Q6.175 21 5.5875 20.4125T5 19V6H4V4H9V3H15V4H20V6H19V19Q19 19.825 18.4125 20.4125T17 21H7ZM17 6H7V19H17V6ZM9 17H11V8H9V17ZM13 17H15V8H13V17ZM7 6V19 6Z"),
DELETE_FOREVER("M9.4 16.5 12 13.9 14.6 16.5 16 15.1 13.4 12.5 16 9.9 14.6 8.5 12 11.1 9.4 8.5 8 9.9 10.6 12.5 8 15.1 9.4 16.5ZM7 21Q6.175 21 5.5875 20.4125T5 19V6H4V4H9V3H15V4H20V6H19V19Q19 19.825 18.4125 20.4125T17 21H7ZM17 6H7V19H17V6ZM7 6V19 6Z"),
@@ -90,8 +87,7 @@ public enum SVG {
LOCATION_CITY("M3 21V7H9V5l3-3 3 3v6h6V21H3Zm2-2H7V17H5v2Zm0-4H7V13H5v2Zm0-4H7V9H5v2Zm6 8h2V17H11v2Zm0-4h2V13H11v2Zm0-4h2V9H11v2Zm0-4h2V5H11V7Zm6 12h2V17H17v2Zm0-4h2V13H17v2Z"),
MENU("M3 18V16H21V18H3ZM3 13V11H21V13H3ZM3 8V6H21V8H3Z"),
MICROSOFT("M4 20H22v2H4V13H20v7h2V4H20v7H4V4h7V20h2V4h9V2H2V22H4"), // Not Material
MINIMIZE("M6 21V19H18V21H6Z"),
MOJANG("M13.9658 0C12.9552.828 12.7686 2.195 12.7007 3.418 12.7219 4.4201 14.0423 4.7174 14.6538 4.0082 15.0912 2.6579 14.3692 1.2739 13.9658 0ZM10.913 2.9297C10.7559 3.6983 10.6284 4.4669 10.4925 5.2355 8.9894 3.9913 7.1505 3.0825 5.142 3.2948 3.4944 3.4646.9429 2.6961.1404 4.6452-.1229 8.314.0722 12.0124.034 15.6896-.0891 16.9126.8957 18.1101 2.1781 17.9699 6.3989 18.0039 10.6283 18.0172 14.8491 17.9661 16.1145 18.102 16.8067 16.9554 16.9851 15.8768 13.2696 16.4374 9.1761 16.9296 5.7111 15.1292 2.5986 13.5836 2.246 8.3139 5.5581 6.798 9.3203 5.1759 13.8596 8.0383 14.9424 11.7877 15.6133 12.1105 16.2844 12.4247 16.9596 12.7432 16.6241 10.3483 16.6537 7.8005 15.5793 5.5882 15.0571 4.7474 14.0975 6.2714 13.3799 5.6217 12.416 4.8659 11.7495 3.8129 10.913 2.9297Z"), // Not Material
MINIMIZE_CENTER("M6 13v-2h12v2H6Z"), // Not Material
MORE_HORIZ("M6 14Q5.175 14 4.5875 13.4125T4 12Q4 11.175 4.5875 10.5875T6 10Q6.825 10 7.4125 10.5875T8 12Q8 12.825 7.4125 13.4125T6 14ZM12 14Q11.175 14 10.5875 13.4125T10 12Q10 11.175 10.5875 10.5875T12 10Q12.825 10 13.4125 10.5875T14 12Q14 12.825 13.4125 13.4125T12 14ZM18 14Q17.175 14 16.5875 13.4125T16 12Q16 11.175 16.5875 10.5875T18 10Q18.825 10 19.4125 10.5875T20 12Q20 12.825 19.4125 13.4125T18 14Z"),
MORE_VERT("M12 20Q11.175 20 10.5875 19.4125T10 18Q10 17.175 10.5875 16.5875T12 16Q12.825 16 13.4125 16.5875T14 18Q14 18.825 13.4125 19.4125T12 20ZM12 14Q11.175 14 10.5875 13.4125T10 12Q10 11.175 10.5875 10.5875T12 10Q12.825 10 13.4125 10.5875T14 12Q14 12.825 13.4125 13.4125T12 14ZM12 8Q11.175 8 10.5875 7.4125T10 6Q10 5.175 10.5875 4.5875T12 4Q12.825 4 13.4125 4.5875T14 6Q14 6.825 13.4125 7.4125T12 8Z"),
OPEN_IN_NEW("M5 21Q4.175 21 3.5875 20.4125T3 19V5Q3 4.175 3.5875 3.5875T5 3H12V5H5V19H19V12H21V19Q21 19.825 20.4125 20.4125T19 21H5ZM9.7 15.7 8.3 14.3 17.6 5H14V3H21V10H19V6.4L9.7 15.7Z"),
@@ -102,6 +98,7 @@ public enum SVG {
PERSON("M12 12Q10.35 12 9.175 10.825T8 8Q8 6.35 9.175 5.175T12 4Q13.65 4 14.825 5.175T16 8Q16 9.65 14.825 10.825T12 12ZM4 20V17.2Q4 16.35 4.4375 15.6375T5.6 14.55Q7.15 13.775 8.75 13.3875T12 13Q13.65 13 15.25 13.3875T18.4 14.55Q19.125 14.925 19.5625 15.6375T20 17.2V20H4ZM6 18H18V17.2Q18 16.925 17.8625 16.7T17.5 16.35Q16.15 15.675 14.775 15.3375T12 15Q10.6 15 9.225 15.3375T6.5 16.35Q6.275 16.475 6.1375 16.7T6 17.2V18ZM12 10Q12.825 10 13.4125 9.4125T14 8Q14 7.175 13.4125 6.5875T12 6Q11.175 6 10.5875 6.5875T10 8Q10 8.825 10.5875 9.4125T12 10ZM12 8ZM12 18Z"),
PUBLIC("M12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM11 19.95V18Q10.175 18 9.5875 17.4125T9 16V15L4.2 10.2Q4.125 10.65 4.0625 11.1T4 12Q4 15.025 5.9875 17.3T11 19.95ZM17.9 17.4Q18.925 16.275 19.4625 14.8875T20 12Q20 9.55 18.6375 7.525T15 4.6V5Q15 5.825 14.4125 6.4125T13 7H11V9Q11 9.425 10.7125 9.7125T10 10H8V12H14Q14.425 12 14.7125 12.2875T15 13V16H16Q16.65 16 17.175 16.3875T17.9 17.4Z"),
REFRESH("M12 20Q8.65 20 6.325 17.675T4 12Q4 8.65 6.325 6.325T12 4Q13.725 4 15.3 4.7125T18 6.75V4H20V11H13V9H17.2Q16.4 7.6 15.0125 6.8T12 6Q9.5 6 7.75 7.75T6 12Q6 14.5 7.75 16.25T12 18Q13.925 18 15.475 16.9T17.65 14H19.75Q19.05 16.65 16.9 18.325T12 20Z"),
REDO("M9.9 19q-2.425 0-4.163-1.575T4 13.5t1.738-3.925T9.9 8h6.3l-2.6-2.6L15 4l5 5l-5 5l-1.4-1.4l2.6-2.6H9.9q-1.575 0-2.738 1T6 13.5T7.163 16T9.9 17H17v2z"),
RELEASE_CIRCLE("M9,7H13A2,2 0 0,1 15,9V11C15,11.84 14.5,12.55 13.76,12.85L15,17H13L11.8,13H11V17H9V7M11,9V11H13V9H11M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12C4,16.41 7.58,20 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4Z"), // Not Material
RESTORE("M12 21Q8.55 21 5.9875 18.7125T3.05 13H5.1Q5.45 15.6 7.4125 17.3T12 19Q14.925 19 16.9625 16.9625T19 12Q19 9.075 16.9625 7.0375T12 5Q10.275 5 8.775 5.8T6.25 8H9V10H3V4H5V6.35Q6.275 4.75 8.1125 3.875T12 3Q13.875 3 15.5125 3.7125T18.3625 5.6375Q19.575 6.85 20.2875 8.4875T21 12Q21 13.875 20.2875 15.5125T18.3625 18.3625Q17.15 19.575 15.5125 20.2875T12 21Z"), // Not Material
ROCKET_LAUNCH("M5.65 10.025 7.6 10.85Q7.95 10.15 8.325 9.5T9.15 8.2L7.75 7.925 5.65 10.025ZM9.2 12.1 12.05 14.925Q13.1 14.525 14.3 13.7T16.55 11.825Q18.3 10.075 19.2875 7.9375T20.15 4Q18.35 3.875 16.2 4.8625T12.3 7.6Q11.25 8.65 10.425 9.85T9.2 12.1ZM13.65 10.475Q13.075 9.9 13.075 9.0625T13.65 7.65Q14.225 7.075 15.075 7.075T16.5 7.65Q17.075 8.225 17.075 9.0625T16.5 10.475Q15.925 11.05 15.075 11.05T13.65 10.475ZM14.125 18.5 16.225 16.4 15.95 15Q15.3 15.45 14.65 15.8125T13.3 16.525L14.125 18.5ZM21.95 2.175Q22.425 5.2 21.3625 8.0625T17.7 13.525L18.2 16Q18.3 16.5 18.15 16.975T17.65 17.8L13.45 22 11.35 17.075 7.075 12.8 2.15 10.7 6.325 6.5Q6.675 6.15 7.1625 6T8.15 5.95L10.625 6.45Q13.225 3.85 16.075 2.775T21.95 2.175ZM3.925 15.975Q4.8 15.1 6.0625 15.0875T8.2 15.95Q9.075 16.825 9.0625 18.0875T8.175 20.225Q7.55 20.85 6.0875 21.3T2.05 22.1Q2.4 19.525 2.85 18.0625T3.925 15.975ZM5.35 17.375Q5.1 17.625 4.85 18.2875T4.5 19.625Q5.175 19.525 5.8375 19.2875T6.75 18.8Q7.05 18.5 7.075 18.075T6.8 17.35Q6.5 17.05 6.075 17.0625T5.35 17.375Z"),
@@ -117,10 +114,12 @@ public enum SVG {
STADIA_CONTROLLER_FILL("M4.725 20q-1.5 0-2.5625-1.075T1.05 16.325q0-.225.025-.45t.075-.45l2.1-8.4q.35-1.35 1.425-2.1875T7.125 4h9.75q1.375 0 2.45.8375T20.75 7.025l2.1 8.4q.05.225.0875.4625t.0375.4625q0 1.525-1.0875 2.5875T19.275 20q-1.05 0-1.95-.55t-1.35-1.5l-.7-1.45q-.125-.25-.375-.375T14.375 16H9.625q-.275 0-.525.125t-.375.375l-.7 1.45q-.45.95-1.35 1.5T4.725 20ZM13.5 11q.425 0 .7125-.2875T14.5 10t-.2875-.7125T13.5 9t-.7125.2875T12.5 10t.2875.7125T13.5 11Zm2-2q.425 0 .7125-.2875T16.5 8q0-.425-.2875-.7125T15.5 7q-.425 0-.7125.2875T14.5 8t.2875.7125T15.5 9Zm0 4q.425 0 .7125-.2875T16.5 12q0-.425-.2875-.7125T15.5 11q-.425 0-.7125.2875T14.5 12t.2875.7125T15.5 13Zm2-2q.425 0 .7125-.2875T18.5 10q0-.425-.2875-.7125T17.5 9q-.425 0-.7125.2875T16.5 10q0 .425.2875.7125T17.5 11Zm-9 1.5q.325 0 .5375-.2125T9.25 11.75v-1h1q.325 0 .5375-.2125T11 10t-.2125-.5375T10.25 9.25h-1v-1q0-.325-.2125-.5375T8.5 7.5q-.325 0-.5375.2125T7.75 8.25v1h-1q-.325 0-.5375.2125T6 10q0 .325.2125.5375T6.75 10.75h1v1q0 .325.2125.5375T8.5 12.5Z"),
STYLE("M3.975 19.8 3.125 19.45Q2.35 19.125 2.0875 18.325T2.175 16.75L3.975 12.85V19.8ZM7.975 22Q7.15 22 6.5625 21.4125T5.975 20V14L8.625 21.35Q8.7 21.525 8.775 21.6875T8.975 22H7.975ZM13.125 21.9Q12.325 22.2 11.575 21.825T10.525 20.65L6.075 8.45Q5.775 7.65 6.125 6.8875T7.275 5.85L14.825 3.1Q15.625 2.8 16.375 3.175T17.425 4.35L21.875 16.55Q22.175 17.35 21.825 18.1125T20.675 19.15L13.125 21.9ZM10.975 10Q11.4 10 11.6875 9.7125T11.975 9Q11.975 8.575 11.6875 8.2875T10.975 8Q10.55 8 10.2625 8.2875T9.975 9Q9.975 9.425 10.2625 9.7125T10.975 10ZM12.425 20 19.975 17.25 15.525 5 7.975 7.75 12.425 20ZM7.975 7.75 15.525 5 7.975 7.75Z"),
STYLE_FILL("M3.975 19.8l-.85-.35q-.775-.325-1.0375-1.125T2.175 16.75l1.8-3.9V19.8Zm4 2.2q-.825 0-1.4125-.5875T5.975 20V14l2.65 7.35q.075.175.15.3375t.2.3125h-1Zm5.15-.1q-.8.3-1.55-.075t-1.05-1.175L6.075 8.45q-.3-.8.05-1.5625T7.275 5.85l7.55-2.75q.8-.3 1.55.075t1.05 1.175l4.45 12.2q.3.8-.05 1.5625T20.675 19.15l-7.55 2.75ZM10.975 10q.425 0 .7125-.2875T11.975 9q0-.425-.2875-.7125T10.975 8t-.7125.2875T9.975 9t.2875.7125T10.975 10Z"),
TEXTURE("M4.4-3Q3.925-3.1 3.5125-3.5125T3-4.4L19.6-21Q20.125-20.875 20.5-20.4875T21.025-19.6L4.4-3ZM3-9.3V-12.1L11.9-21H14.7L3-9.3ZM3-17V-19Q3-19.825 3.5875-20.4125T5-21H7L3-17ZM17-3 21-7V-5Q21-4.175 20.4125-3.5875T19-3H17ZM9.3-3 21-14.7V-11.9L12.1-3H9.3Z"),
TEXTURE("M4.4 21q-.475-.1-.8875-.5125T3 19.6L19.6 3q.525.125.9.5125t.525.8875L4.4 21ZM3 14.7v-2.8L11.9 3h2.8L3 14.7ZM3 7V5q0-.825.5875-1.4125T5 3h2L3 7Zm14 14 4-4v2q0 .825-.5875 1.4125T19 21h-2Zm-7.7 0L21 9.3v2.8L12.1 21H9.3Z"),
TRIP("M4 21Q3.175 21 2.5875 20.4125T2 19V8Q2 7.175 2.5875 6.5875T4 6H8V4Q8 3.175 8.5875 2.5875T10 2H14Q14.825 2 15.4125 2.5875T16 4V6H20Q20.825 6 21.4125 6.5875T22 8V19Q22 19.825 21.4125 20.4125T20 21H4ZM10 6H14V4H10V6ZM6 8H4V19H6V8ZM16 19V8H8V19H16ZM18 8V19H20V8H18ZM12 13.5Z"),
TUNE("M11 21V15H13V17H21V19H13V21H11ZM3 19V17H9V19H3ZM7 15V13H3V11H7V9H9V15H7ZM11 13V11H21V13H11ZM15 9V3H17V5H21V7H17V9H15ZM3 7V5H13V7H3Z"),
UNFOLD_MORE("M12 21 7.5 16.5l1.45-1.45L12 18.1l3.05-3.05 1.45 1.45L12 21ZM8.95 9.05 7.5 7.6 12 3.1l4.5 4.5-1.45 1.45L12 6 8.95 9.05Z"),
UPDATE("M12 21Q10.125 21 8.4875 20.2875T5.6375 18.3625Q4.425 17.15 3.7125 15.5125T3 12Q3 10.125 3.7125 8.4875T5.6375 5.6375Q6.85 4.425 8.4875 3.7125T12 3Q14.05 3 15.8875 3.875T19 6.35V4H21V10H15V8H17.75Q16.725 6.6 15.225 5.8T12 5Q9.075 5 7.0375 7.0375T5 12Q5 14.925 7.0375 16.9625T12 19Q14.625 19 16.5875 17.3T18.9 13H20.95Q20.575 16.425 18.0125 18.7125T12 21ZM14.8 16.2 11 12.4V7H13V11.6L16.2 14.8 14.8 16.2Z"),
UNDO("M7 19v-2h7.1q1.575 0 2.738-1T18 13.5T16.838 11T14.1 10H7.8l2.6 2.6L9 14L4 9l5-5l1.4 1.4L7.8 8h6.3q2.425 0 4.163 1.575T20 13.5t-1.737 3.925T14.1 19z"),
VISIBILITY("M12 16q1.875 0 3.1875-1.3125T16.5 11.5 15.1875 8.3125 12 7 8.8125 8.3125 7.5 11.5t1.3125 3.1875T12 16Zm0-1.8q-1.125 0-1.9125-.7875T9.3 11.5t.7875-1.9125T12 8.8q1.125 0 1.9125.7875T14.7 11.5q0 1.125-.7875 1.9125T12 14.2ZM12 19q-3.65 0-6.65-2.0375T1 11.5Q2.35 8.075 5.35 6.0375T12 4q3.65 0 6.65 2.0375T23 11.5q-1.35 3.425-4.35 5.4625T12 19Zm0-7.5ZM12 17q2.825 0 5.1875-1.4875T20.8 11.5q-1.25-2.525-3.6125-4.0125T12 6 6.8125 7.4875 3.2 11.5q1.25 2.525 3.6125 4.0125T12 17Z"),
VISIBILITY_OFF("M16.1 13.3l-1.45-1.45q.225-1.175-.675-2.2t-2.325-.8L10.2 7.4q.425-.2.8625-.3T12 7q1.875 0 3.1875 1.3125T16.5 11.5q0 .5-.1.9375t-.3.8625Zm3.2 3.15-1.45-1.4q.95-.725 1.6875-1.5875T20.8 11.5q-1.25-2.525-3.5875-4.0125T12 6q-.725 0-1.425.1T9.2 6.4L7.65 4.85q1.025-.425 2.1-.6375T12 4q3.775 0 6.725 2.0875T23 11.5q-.575 1.475-1.5125 2.7375T19.3 16.45Zm.5 6.15-4.2-4.15q-.875.275-1.7625.4125T12 19q-3.775 0-6.725-2.0875T1 11.5q.525-1.325 1.325-2.4625T4.15 7L1.4 4.2 2.8 2.8 21.2 21.2l-1.4 1.4ZM5.55 8.4q-.725.65-1.325 1.425T3.2 11.5q1.25 2.525 3.5875 4.0125T12 17q.5 0 .975-.0625T13.95 16.8l-.9-.95q-.275.075-.525.1125T12 16q-1.875 0-3.1875-1.3125T7.5 11.5q0-.275.0375-.525T7.65 10.45L5.55 8.4Zm7.975 2.325ZM9.75 12.6Z"),
WARNING("M1 21 12 2 23 21H1ZM4.45 19H19.55L12 6 4.45 19ZM12 18Q12.425 18 12.7125 17.7125T13 17Q13 16.575 12.7125 16.2875T12 16Q11.575 16 11.2875 16.2875T11 17Q11 17.425 11.2875 17.7125T12 18ZM11 15H13V10H11V15ZM12 12.5Z"),
@@ -130,48 +129,40 @@ public enum SVG {
public static final double DEFAULT_SIZE = 24;
private final String path;
static void setSize(SVGPath path, double size) {
double scale = size / DEFAULT_SIZE;
path.setScaleX(scale);
path.setScaleY(scale);
}
SVG(String path) {
this.path = path;
private final String rawPath;
private String path;
SVG(String rawPath) {
this.rawPath = rawPath;
}
public String getPath() {
if (path == null)
// We move the current point so that SVGPath will treat 0 0 24 24 as the layout bounds
path = "M24 24ZM0 0Z" + rawPath;
return path;
}
private static Node createIcon(SVGPath path, double size) {
if (size < 0) {
StackPane pane = new StackPane(path);
pane.setAlignment(Pos.CENTER);
return pane;
}
double scale = size / 24;
path.setScaleX(scale);
path.setScaleY(scale);
return new Group(path);
public SVGPath createIcon() {
var path = new SVGPath();
path.getStyleClass().add("svg");
path.setContent(getPath());
return path;
}
public Node createIcon(double size) {
SVGPath p = new SVGPath();
p.setContent(path);
p.getStyleClass().add("svg");
return createIcon(p, size);
public SVGContainer createIcon(double size) {
return new SVGContainer(this, size);
}
public Node createIcon() {
SVGPath p = new SVGPath();
p.setContent(path);
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);
public SVGPath createIcon(ObservableValue<? extends Paint> color) {
SVGPath path = createIcon();
path.fillProperty().bind(color);
return path;
}
}
@@ -0,0 +1,175 @@
/*
* 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.ui;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.scene.Parent;
import javafx.scene.shape.SVGPath;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.animation.Motion;
/// A lightweight wrapper for displaying [SVG] icons.
///
/// @author Glavo
public final class SVGContainer extends Parent {
private static final String DEFAULT_STYLE_CLASS = "svg-container";
private final SVGPath path = new SVGPath();
private SVG icon = SVG.NONE;
private double iconSize = SVG.DEFAULT_SIZE;
private SVGPath tempPath;
private Timeline timeline;
{
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
this.path.getStyleClass().add("svg");
}
/// Creates an SVGContainer with the default icon and the default icon size.
public SVGContainer() {
this(SVG.NONE, SVG.DEFAULT_SIZE);
}
/// Creates an SVGContainer showing the given icon using the default icon size.
///
/// @param icon the [SVG] icon to display
public SVGContainer(SVG icon) {
this(icon, SVG.DEFAULT_SIZE);
}
/// Creates an SVGContainer with a custom icon size. The initial icon is
/// [SVG#NONE].
///
/// @param iconSize the icon size
public SVGContainer(double iconSize) {
this(SVG.NONE, iconSize);
}
/// Creates an SVGContainer with the specified icon and size.
///
/// @param icon the [SVG] icon to display
/// @param iconSize the icon size
public SVGContainer(SVG icon, double iconSize) {
setIconSizeImpl(iconSize);
setIcon(icon);
}
public double getIconSize() {
return iconSize;
}
private void setIconSizeImpl(double newSize) {
this.iconSize = newSize;
SVG.setSize(path, newSize);
if (tempPath != null)
SVG.setSize(tempPath, newSize);
}
public void setIconSize(double newSize) {
setIconSizeImpl(newSize);
requestLayout();
}
/// Gets the currently displayed icon.
public SVG getIcon() {
return icon;
}
/// Sets the icon to display without animation.
public void setIcon(SVG newIcon) {
setIcon(newIcon, Duration.ZERO);
}
/// Sets the icon to display with a cross-fade animation.
public void setIcon(SVG newIcon, Duration animationDuration) {
if (timeline != null) {
timeline.stop();
timeline = null;
}
SVG oldIcon = this.icon;
this.icon = newIcon;
if (animationDuration.equals(Duration.ZERO)) {
path.setContent(newIcon.getPath());
path.setOpacity(1);
if (getChildren().size() != 1)
getChildren().setAll(path);
} else {
if (tempPath == null) {
tempPath = new SVGPath();
tempPath.getStyleClass().add("svg");
SVG.setSize(tempPath, iconSize);
} else
tempPath.setOpacity(1);
tempPath.setContent(oldIcon.getPath());
getChildren().setAll(path, tempPath);
path.setOpacity(0);
path.setContent(newIcon.getPath());
timeline = new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(path.opacityProperty(), 0, Motion.LINEAR),
new KeyValue(tempPath.opacityProperty(), 1, Motion.LINEAR)
),
new KeyFrame(animationDuration,
new KeyValue(path.opacityProperty(), 1, Motion.LINEAR),
new KeyValue(tempPath.opacityProperty(), 0, Motion.LINEAR)
)
);
timeline.setOnFinished(e -> {
getChildren().setAll(path);
timeline = null;
});
timeline.play();
}
}
// Parent
@Override
public double prefWidth(double height) {
return iconSize;
}
@Override
public double prefHeight(double width) {
return iconSize;
}
@Override
public double minHeight(double width) {
return iconSize;
}
@Override
public double minWidth(double height) {
return iconSize;
}
@Override
protected void layoutChildren() {
// Do nothing
}
}
@@ -25,6 +25,8 @@ import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ListCell;
import javafx.scene.control.SkinBase;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
@@ -40,14 +42,11 @@ public abstract class ToolbarListPageSkin<E, P extends ListPageBase<E>> extends
public ToolbarListPageSkin(P skinnable) {
super(skinnable);
SpinnerPane spinnerPane = new SpinnerPane();
spinnerPane.loadingProperty().bind(skinnable.loadingProperty());
spinnerPane.failedReasonProperty().bind(skinnable.failedReasonProperty());
spinnerPane.onFailedActionProperty().bind(skinnable.onFailedActionProperty());
spinnerPane.getStyleClass().add("large-spinner-pane");
ComponentList root = new ComponentList();
root.getStyleClass().add("no-padding");
StackPane container = new StackPane();
container.getChildren().add(root);
StackPane.setMargin(root, new Insets(10));
List<Node> toolbarButtons = initializeToolbar(skinnable);
@@ -59,31 +58,33 @@ public abstract class ToolbarListPageSkin<E, P extends ListPageBase<E>> extends
root.getContent().add(toolbar);
}
SpinnerPane spinnerPane = new SpinnerPane();
spinnerPane.loadingProperty().bind(skinnable.loadingProperty());
spinnerPane.failedReasonProperty().bind(skinnable.failedReasonProperty());
spinnerPane.onFailedActionProperty().bind(skinnable.onFailedActionProperty());
ComponentList.setVgrow(spinnerPane, Priority.ALWAYS);
{
this.listView = new JFXListView<>();
this.listView.setPadding(Insets.EMPTY);
this.listView.setCellFactory(listView -> createListCell((JFXListView<E>) listView));
ComponentList.setVgrow(listView, Priority.ALWAYS);
this.listView.getStyleClass().add("no-horizontal-scrollbar");
Bindings.bindContent(this.listView.getItems(), skinnable.itemsProperty());
root.getContent().add(listView);
FXUtils.ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
spinnerPane.setContent(listView);
}
spinnerPane.setContent(root);
root.getContent().add(spinnerPane);
getChildren().setAll(spinnerPane);
}
public static Node wrap(Node node) {
StackPane stackPane = new StackPane();
stackPane.setPadding(new Insets(0, 5, 0, 2));
stackPane.getChildren().setAll(node);
return stackPane;
getChildren().setAll(container);
}
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()));
ret.setGraphic(svg.createIcon(20));
ret.setText(text);
ret.setOnAction(e -> onClick.run());
return ret;
@@ -92,7 +93,7 @@ public abstract class ToolbarListPageSkin<E, P extends ListPageBase<E>> extends
public static JFXButton createDecoratorButton(String tooltip, SVG svg, Runnable onClick) {
JFXButton ret = new JFXButton();
ret.getStyleClass().add("jfx-decorator-button");
ret.setGraphic(wrap(svg.createIcon()));
ret.setGraphic(svg.createIcon(20));
FXUtils.installFastTooltip(ret, tooltip);
ret.setOnAction(e -> onClick.run());
return ret;
@@ -20,14 +20,19 @@ package org.jackhuang.hmcl.ui;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.beans.value.WeakChangeListener;
import javafx.collections.ListChangeListener;
import javafx.collections.WeakListChangeListener;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.event.EventPriority;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
public class WeakListenerHolder {
public final class WeakListenerHolder {
private final List<Object> refs = new ArrayList<>(0);
public WeakListenerHolder() {
@@ -48,6 +53,22 @@ public class WeakListenerHolder {
return new WeakListChangeListener<>(listener);
}
public <T extends Event> void registerWeak(EventManager<T> manager, Consumer<T> consumer) {
refs.add(manager.registerWeak(consumer));
}
public <T extends Event> void registerWeak(EventManager<T> manager, Consumer<T> consumer, EventPriority priority) {
refs.add(manager.registerWeak(consumer, priority));
}
public <T> void onWeakChange(ObservableValue<T> value, Consumer<T> consumer) {
refs.add(FXUtils.onWeakChange(value, consumer));
}
public <T> void onWeakChangeAndOperate(ObservableValue<T> value, Consumer<T> consumer) {
refs.add(FXUtils.onWeakChangeAndOperate(value, consumer));
}
public void add(Object obj) {
refs.add(obj);
}
@@ -21,6 +21,7 @@ import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Pos;
import javafx.scene.canvas.Canvas;
import javafx.scene.control.Tooltip;
import org.jackhuang.hmcl.auth.Account;
@@ -76,9 +77,10 @@ public class AccountAdvancedListItem extends AdvancedListItem {
FXUtils.installFastTooltip(this, tooltip);
canvas = new Canvas(32, 32);
setLeftGraphic(canvas);
canvas.setMouseTransparent(true);
AdvancedListItem.setAlignment(canvas, Pos.CENTER);
setActionButtonVisible(false);
setLeftGraphic(canvas);
if (account != null) {
this.accountProperty().set(account);
@@ -17,7 +17,6 @@
*/
package org.jackhuang.hmcl.ui.account;
import com.jfoenix.controls.JFXButton;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ListProperty;
@@ -54,7 +53,6 @@ import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import java.util.Locale;
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
import static org.jackhuang.hmcl.ui.versions.VersionPage.wrap;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.createSelectedItemPropertyFor;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -130,35 +128,25 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
AdvancedListItem microsoftItem = new AdvancedListItem();
microsoftItem.getStyleClass().add("navigation-drawer-item");
microsoftItem.setActionButtonVisible(false);
microsoftItem.setTitle(i18n("account.methods.microsoft"));
microsoftItem.setLeftGraphic(wrap(SVG.MICROSOFT));
microsoftItem.setOnAction(e -> Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_MICROSOFT)));
microsoftItem.setLeftIcon(SVG.MICROSOFT);
microsoftItem.setOnAction(e -> Controllers.dialog(new MicrosoftAccountLoginPane()));
AdvancedListItem offlineItem = new AdvancedListItem();
offlineItem.getStyleClass().add("navigation-drawer-item");
offlineItem.setActionButtonVisible(false);
offlineItem.setTitle(i18n("account.methods.offline"));
offlineItem.setLeftGraphic(wrap(SVG.PERSON));
offlineItem.setLeftIcon(SVG.PERSON);
offlineItem.setOnAction(e -> Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_OFFLINE)));
VBox boxAuthServers = new VBox();
authServerItems = MappedObservableList.create(skinnable.authServersProperty(), server -> {
AdvancedListItem item = new AdvancedListItem();
item.getStyleClass().add("navigation-drawer-item");
item.setLeftGraphic(wrap(SVG.DRESSER));
item.setLeftIcon(SVG.DRESSER);
item.setOnAction(e -> Controllers.dialog(new CreateAccountPane(server)));
JFXButton btnRemove = new JFXButton();
btnRemove.setOnAction(e -> {
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> {
skinnable.authServersProperty().remove(server);
}, null);
e.consume();
});
btnRemove.getStyleClass().add("toggle-icon4");
btnRemove.setGraphic(SVG.CLOSE.createIcon(14));
item.setRightGraphic(btnRemove);
item.setRightAction(SVG.CLOSE, () -> Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> {
skinnable.authServersProperty().remove(server);
}, null));
ObservableValue<String> title = BindingMapping.of(server, AuthlibInjectorServer::getName);
item.titleProperty().bind(title);
@@ -206,8 +194,7 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
addAuthServerItem.getStyleClass().add("navigation-drawer-item");
addAuthServerItem.setTitle(i18n("account.injector.add"));
addAuthServerItem.setSubtitle(i18n("account.methods.authlib_injector"));
addAuthServerItem.setActionButtonVisible(false);
addAuthServerItem.setLeftGraphic(wrap(SVG.ADD_CIRCLE));
addAuthServerItem.setLeftIcon(SVG.ADD_CIRCLE);
addAuthServerItem.setOnAction(e -> Controllers.dialog(new AddAuthlibInjectorServerPane()));
VBox.setMargin(addAuthServerItem, new Insets(0, 0, 12, 0));
}
@@ -0,0 +1,79 @@
/*
* 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.ui.account;
import com.jfoenix.controls.JFXPopup;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.AdvancedListBox;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class AccountListPopupMenu extends StackPane {
public static void show(Node owner, JFXPopup.PopupVPosition vAlign, JFXPopup.PopupHPosition hAlign,
double initOffsetX, double initOffsetY) {
var menu = new AccountListPopupMenu();
JFXPopup popup = new JFXPopup(menu);
popup.show(owner, vAlign, hAlign, initOffsetX, initOffsetY);
}
@SuppressWarnings("FieldCanBeLocal")
private final BooleanBinding isEmpty = Bindings.isEmpty(Accounts.getAccounts());
@SuppressWarnings("FieldCanBeLocal")
private final InvalidationListener listener;
public AccountListPopupMenu() {
AdvancedListBox box = new AdvancedListBox();
box.getStyleClass().add("no-padding");
box.setPrefWidth(220);
box.setPrefHeight(-1);
box.setMaxHeight(260);
listener = o -> {
box.clear();
for (Account account : Accounts.getAccounts()) {
AccountAdvancedListItem item = new AccountAdvancedListItem(account);
item.setOnAction(e -> {
Accounts.setSelectedAccount(account);
if (getScene().getWindow() instanceof JFXPopup popup)
popup.hide();
});
box.add(item);
}
};
listener.invalidated(null);
Accounts.getAccounts().addListener(new WeakInvalidationListener(listener));
Label placeholder = new Label(i18n("account.empty"));
placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: -monet-on-surface-variant; -fx-font-style: italic;");
FXUtils.onChangeAndOperate(isEmpty, empty -> {
getChildren().setAll(empty ? placeholder : box);
});
}
}
@@ -118,6 +118,7 @@ public final class AddAuthlibInjectorServerPane extends TransitionPane implement
lblServerWarning.setStyle("-fx-text-fill: red;");
GridPane.setColumnIndex(lblServerWarning, 0);
GridPane.setRowIndex(lblServerWarning, 2);
lblServerWarning.managedProperty().bind(lblServerWarning.visibleProperty());
GridPane.setColumnSpan(lblServerWarning, 2);
body.getChildren().setAll(
@@ -23,9 +23,7 @@ import javafx.application.Platform;
import javafx.beans.NamedArg;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
@@ -45,7 +43,6 @@ import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccountFactory;
import org.jackhuang.hmcl.auth.offline.OfflineAccountFactory;
import org.jackhuang.hmcl.auth.yggdrasil.GameProfile;
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.task.Schedulers;
@@ -54,7 +51,6 @@ import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.upgrade.IntegrityChecker;
import org.jackhuang.hmcl.util.StringUtils;
@@ -85,13 +81,11 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
private final JFXButton btnAccept;
private final SpinnerPane spinner;
private final Node body;
private final HBox actions;
private Node detailsPane; // AccountDetailsInputPane for Offline / Mojang / authlib-injector, Label for Microsoft
private final Pane detailsContainer;
private final BooleanProperty logging = new SimpleBooleanProperty();
private final ObjectProperty<OAuthServer.GrantDeviceCodeEvent> deviceCode = new SimpleObjectProperty<>();
private final WeakListenerHolder holder = new WeakListenerHolder();
private TaskExecutor loginTask;
@@ -146,10 +140,10 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
btnCancel.setOnAction(e -> onCancel());
onEscPressed(this, btnCancel::fire);
HBox hbox = new HBox(spinner, btnCancel);
hbox.setAlignment(Pos.CENTER_RIGHT);
actions = new HBox(spinner, btnCancel);
actions.setAlignment(Pos.CENTER_RIGHT);
setActions(lblErrorMessage, hbox);
setActions(lblErrorMessage, actions);
}
if (showMethodSwitcher) {
@@ -226,7 +220,6 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
Runnable doCreate = () -> {
logging.set(true);
deviceCode.set(null);
loginTask = Task.supplyAsync(() -> factory.create(new DialogCharacterSelector(), username, password, null, additionalData))
.whenComplete(Schedulers.javafx(), account -> {
@@ -280,93 +273,16 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
btnAccept.disableProperty().unbind();
detailsContainer.getChildren().remove(detailsPane);
lblErrorMessage.setText("");
lblErrorMessage.setVisible(true);
setActions(lblErrorMessage, actions);
}
if (factory == Accounts.FACTORY_MICROSOFT) {
VBox vbox = new VBox(8);
detailsPane = vbox;
if (Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) {
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING);
hintPane.setSegment(i18n("account.methods.microsoft.snapshot"));
vbox.getChildren().add(hintPane);
return;
}
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);
detailsPane = new MicrosoftAccountLoginPane(true);
setActions();
} else {
detailsPane = new AccountDetailsInputPane(factory, btnAccept::fire);
btnAccept.disableProperty().bind(((AccountDetailsInputPane) detailsPane).validProperty().not());
setActions(lblErrorMessage, actions);
}
detailsContainer.getChildren().add(detailsPane);
@@ -0,0 +1,303 @@
package org.jackhuang.hmcl.ui.account;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXSpinner;
import io.nayuki.qrcodegen.QrCode;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.css.PseudoClass;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Group;
import javafx.scene.control.Label;
import javafx.scene.layout.FlowPane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.shape.SVGPath;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.AuthInfo;
import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.OAuth;
import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.upgrade.IntegrityChecker;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.QrCodeUtils;
import org.jackhuang.hmcl.util.StringUtils;
import java.util.concurrent.CancellationException;
import java.util.function.Consumer;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class MicrosoftAccountLoginPane extends JFXDialogLayout implements DialogAware {
private final Account accountToRelogin;
private final Consumer<AuthInfo> loginCallback;
private final Runnable cancelCallback;
@SuppressWarnings("FieldCanBeLocal")
private final WeakListenerHolder holder = new WeakListenerHolder();
private final ObjectProperty<Step> step = new SimpleObjectProperty<>();
private TaskExecutor browserTaskExecutor;
private TaskExecutor deviceTaskExecutor;
private final JFXButton btnLogin;
private final SpinnerPane loginButtonSpinner;
public MicrosoftAccountLoginPane() {
this(false);
}
public MicrosoftAccountLoginPane(boolean bodyonly) {
this(null, null, null, bodyonly);
}
public MicrosoftAccountLoginPane(Account account, Consumer<AuthInfo> callback, Runnable onCancel, boolean bodyonly) {
this.accountToRelogin = account;
this.loginCallback = callback;
this.cancelCallback = onCancel;
getStyleClass().add("microsoft-login-dialog");
if (bodyonly) {
this.pseudoClassStateChanged(PseudoClass.getPseudoClass("bodyonly"), true);
} else {
Label heading = new Label(accountToRelogin != null ? i18n("account.login.refresh") : i18n("account.create.microsoft"));
heading.getStyleClass().add("header-label");
setHeading(heading);
}
this.setMaxWidth(650);
onEscPressed(this, this::onCancel);
btnLogin = new JFXButton(i18n("account.login"));
btnLogin.getStyleClass().add("dialog-accept");
loginButtonSpinner = new SpinnerPane();
loginButtonSpinner.getStyleClass().add("small-spinner-pane");
loginButtonSpinner.setContent(btnLogin);
JFXButton btnCancel = new JFXButton(i18n("button.cancel"));
btnCancel.getStyleClass().add("dialog-cancel");
btnCancel.setOnAction(e -> onCancel());
setActions(loginButtonSpinner, btnCancel);
holder.registerWeak(Accounts.OAUTH_CALLBACK.onOpenBrowserAuthorizationCode, event -> Platform.runLater(() -> {
if (step.get() instanceof Step.StartAuthorizationCodeLogin)
step.set(new Step.WaitForOpenBrowser(event.getUrl()));
}));
holder.registerWeak(Accounts.OAUTH_CALLBACK.onGrantDeviceCode, event -> Platform.runLater(() -> {
if (step.get() instanceof Step.StartDeviceCodeLogin)
step.set(new Step.WaitForScanQrCode(event.getUserCode(), event.getVerificationUri()));
}));
this.step.set(Accounts.OAUTH_CALLBACK.getClientId().isEmpty()
? new Step.Init()
: new Step.StartAuthorizationCodeLogin());
FXUtils.onChangeAndOperate(step, this::onStep);
}
private void onStep(Step currentStep) {
VBox rootContainer = new VBox(10);
setBody(rootContainer);
rootContainer.setAlignment(Pos.TOP_CENTER);
if (Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) {
var snapshotHint = new HintPane(MessageDialogPane.MessageType.WARNING);
snapshotHint.setSegment(i18n("account.methods.microsoft.snapshot"));
rootContainer.getChildren().add(snapshotHint);
btnLogin.setDisable(true);
loginButtonSpinner.setLoading(false);
return;
}
if (!IntegrityChecker.isOfficial()) {
var unofficialHintPane = new HintPane(MessageDialogPane.MessageType.WARNING);
unofficialHintPane.setSegment(i18n("unofficial.hint"));
rootContainer.getChildren().add(unofficialHintPane);
}
if (currentStep instanceof Step.Init) {
btnLogin.setOnAction(e -> this.step.set(new Step.StartAuthorizationCodeLogin()));
loginButtonSpinner.setLoading(false);
var hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
hintPane.setText(i18n("account.methods.microsoft.hint"));
rootContainer.getChildren().add(hintPane);
} else if (currentStep instanceof Step.StartAuthorizationCodeLogin) {
loginButtonSpinner.setLoading(false);
cancelAllTasks();
rootContainer.getChildren().add(new JFXSpinner());
browserTaskExecutor = Task.supplyAsync(() -> Accounts.FACTORY_MICROSOFT.create(null, null, null, null, OAuth.GrantFlow.AUTHORIZATION_CODE))
.whenComplete(Schedulers.javafx(), this::onLoginCompleted)
.executor(true);
} else if (currentStep instanceof Step.StartDeviceCodeLogin) {
loginButtonSpinner.setLoading(true);
cancelAllTasks();
rootContainer.getChildren().add(new JFXSpinner());
deviceTaskExecutor = Task.supplyAsync(() -> Accounts.FACTORY_MICROSOFT.create(null, null, null, null, OAuth.GrantFlow.DEVICE))
.whenComplete(Schedulers.javafx(), this::onLoginCompleted)
.executor(true);
} else if (currentStep instanceof Step.WaitForOpenBrowser wait) {
btnLogin.setOnAction(e -> {
FXUtils.openLink(wait.url());
loginButtonSpinner.setLoading(true);
});
loginButtonSpinner.setLoading(false);
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
hintPane.setSegment(
i18n("account.methods.microsoft.methods.browser.hint", StringUtils.escapeXmlAttribute(wait.url()), wait.url()),
FXUtils::copyText
);
rootContainer.getChildren().add(hintPane);
} else if (currentStep instanceof Step.WaitForScanQrCode wait) {
loginButtonSpinner.setLoading(true);
String scanUri = "https://www.microsoft.com/link".equals(wait.verificationUri())
? "https://www.microsoft.com/link?otc=" + wait.userCode()
: wait.verificationUri();
var deviceHint = new HintPane(MessageDialogPane.MessageType.INFO);
deviceHint.setSegment(i18n("account.methods.microsoft.methods.device.hint",
StringUtils.escapeXmlAttribute(scanUri),
wait.verificationUri(),
wait.userCode()
));
var qrCode = new SVGPath();
qrCode.fillProperty().bind(Themes.colorSchemeProperty().getPrimary());
qrCode.setContent(QrCodeUtils.toSVGPath(QrCode.encodeText(scanUri, QrCode.Ecc.MEDIUM)));
qrCode.setScaleX(3);
qrCode.setScaleY(3);
var lblCode = new Label(wait.userCode());
lblCode.getStyleClass().add("code-label");
lblCode.setStyle("-fx-font-family: \"" + Lang.requireNonNullElse(config().getFontFamily(), FXUtils.DEFAULT_MONOSPACE_FONT) + "\";");
var codeBox = new StackPane(lblCode);
codeBox.getStyleClass().add("code-box");
codeBox.setCursor(Cursor.HAND);
FXUtils.onClicked(codeBox, () -> FXUtils.copyText(wait.userCode()));
codeBox.setMaxWidth(USE_PREF_SIZE);
rootContainer.getChildren().addAll(deviceHint, new Group(qrCode), codeBox);
} else if (currentStep instanceof Step.LoginFailed failed) {
btnLogin.setOnAction(e -> this.step.set(new Step.StartAuthorizationCodeLogin()));
loginButtonSpinner.setLoading(false);
cancelAllTasks();
HintPane errHintPane = new HintPane(MessageDialogPane.MessageType.ERROR);
errHintPane.setSegment(failed.message());
rootContainer.getChildren().add(errHintPane);
}
var linkBox = new FlowPane(8, 8);
linkBox.setAlignment(Pos.CENTER_LEFT);
linkBox.setPrefWrapLength(500);
if (currentStep instanceof Step.Init || currentStep instanceof Step.StartAuthorizationCodeLogin || currentStep instanceof Step.WaitForOpenBrowser) {
JFXHyperlink useQrCode = new JFXHyperlink(i18n("account.methods.microsoft.methods.device"));
useQrCode.setOnAction(e -> this.step.set(new Step.StartDeviceCodeLogin()));
linkBox.getChildren().add(useQrCode);
} else if (currentStep instanceof Step.StartDeviceCodeLogin || currentStep instanceof Step.WaitForScanQrCode) {
JFXHyperlink userBrowser = new JFXHyperlink(i18n("account.methods.microsoft.methods.browser"));
userBrowser.setOnAction(e -> this.step.set(new Step.StartAuthorizationCodeLogin()));
linkBox.getChildren().add(userBrowser);
}
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);
linkBox.getChildren().addAll(profileLink, purchaseLink);
rootContainer.getChildren().add(linkBox);
setBody(rootContainer);
}
private void cancelAllTasks() {
if (browserTaskExecutor != null) browserTaskExecutor.cancel();
if (deviceTaskExecutor != null) deviceTaskExecutor.cancel();
}
private void onCancel() {
cancelAllTasks();
if (cancelCallback != null) cancelCallback.run();
fireEvent(new DialogCloseEvent());
}
private void onLoginCompleted(MicrosoftAccount account, Exception exception) {
if (exception == null) {
if (accountToRelogin != null) Accounts.getAccounts().remove(accountToRelogin);
int oldIndex = Accounts.getAccounts().indexOf(account);
if (oldIndex == -1) {
Accounts.getAccounts().add(account);
} else {
Accounts.getAccounts().remove(oldIndex);
Accounts.getAccounts().add(oldIndex, account);
}
Accounts.setSelectedAccount(account);
if (loginCallback != null) {
try {
loginCallback.accept(account.logIn());
} catch (AuthenticationException e) {
this.step.set(new Step.LoginFailed(Accounts.localizeErrorMessage(e)));
return;
}
}
fireEvent(new DialogCloseEvent());
} else if (!(exception instanceof CancellationException)) {
this.step.set(new Step.LoginFailed(Accounts.localizeErrorMessage(exception)));
}
}
private sealed interface Step {
final class Init implements Step {
}
final class StartAuthorizationCodeLogin implements Step {
}
record WaitForOpenBrowser(String url) implements Step {
}
final class StartDeviceCodeLogin implements Step {
}
record WaitForScanQrCode(String userCode, String verificationUri) implements Step {
}
record LoginFailed(String message) implements Step {
}
}
}
@@ -1,109 +0,0 @@
package org.jackhuang.hmcl.ui.account;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.control.Label;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.auth.AuthInfo;
import org.jackhuang.hmcl.auth.OAuthAccount;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService;
import org.jackhuang.hmcl.game.OAuthServer;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.DialogPane;
import org.jackhuang.hmcl.ui.construct.HintPane;
import org.jackhuang.hmcl.ui.construct.JFXHyperlink;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import java.util.function.Consumer;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class OAuthAccountLoginDialog extends DialogPane {
private final OAuthAccount account;
private final Consumer<AuthInfo> success;
private final Runnable failed;
private final ObjectProperty<OAuthServer.GrantDeviceCodeEvent> deviceCode = new SimpleObjectProperty<>();
private final WeakListenerHolder holder = new WeakListenerHolder();
public OAuthAccountLoginDialog(OAuthAccount account, Consumer<AuthInfo> success, Runnable failed) {
this.account = account;
this.success = success;
this.failed = failed;
setTitle(i18n("account.login.refresh"));
VBox vbox = new VBox(8);
Label usernameLabel = new Label(account.getUsername());
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
FXUtils.onChangeAndOperate(deviceCode, deviceCode -> {
if (deviceCode != null) {
FXUtils.copyText(deviceCode.getUserCode());
hintPane.setSegment(
"<b>" + i18n("account.login.refresh.microsoft.hint") + "</b>\n"
+ i18n("account.methods.microsoft.manual", deviceCode.getUserCode(), deviceCode.getVerificationUri())
);
} else {
hintPane.setSegment(
"<b>" + i18n("account.login.refresh.microsoft.hint") + "</b>\n"
+ i18n("account.methods.microsoft.hint")
);
}
});
FXUtils.onClicked(hintPane, () -> {
if (deviceCode.get() != null) {
FXUtils.copyText(deviceCode.get().getUserCode());
}
});
HBox box = new HBox(8);
JFXHyperlink birthLink = new JFXHyperlink(i18n("account.methods.microsoft.birth"));
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.setExternalLink("https://account.live.com/editprof.aspx");
JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.microsoft.purchase"));
purchaseLink.setExternalLink(YggdrasilService.PURCHASE_URL);
box.getChildren().setAll(profileLink, birthLink, purchaseLink);
GridPane.setColumnSpan(box, 2);
vbox.getChildren().setAll(usernameLabel, hintPane, box);
setBody(vbox);
holder.add(Accounts.OAUTH_CALLBACK.onGrantDeviceCode.registerWeak(this::onGrantDeviceCode));
}
private void onGrantDeviceCode(OAuthServer.GrantDeviceCodeEvent event) {
FXUtils.runInFX(() -> {
deviceCode.set(event);
});
}
@Override
protected void onAccept() {
setLoading();
Task.supplyAsync(account::logInWhenCredentialsExpired)
.whenComplete(Schedulers.javafx(), (authInfo, exception) -> {
if (exception == null) {
success.accept(authInfo);
onSuccess();
} else {
LOG.info("Failed to login when credentials expired: " + account, exception);
onFailure(Accounts.localizeErrorMessage(exception));
}
}).start();
}
@Override
protected void onCancel() {
failed.run();
super.onCancel();
}
}
@@ -29,9 +29,6 @@ import javafx.scene.control.Label;
import javafx.scene.input.DragEvent;
import javafx.scene.input.TransferMode;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.ui.skin.SkinCanvas;
import org.jackhuang.hmcl.ui.skin.animation.SkinAniRunning;
import org.jackhuang.hmcl.ui.skin.animation.SkinAniWavingArms;
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
import org.jackhuang.hmcl.auth.offline.Skin;
import org.jackhuang.hmcl.auth.yggdrasil.TextureModel;
@@ -40,6 +37,9 @@ import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.skin.SkinCanvas;
import org.jackhuang.hmcl.ui.skin.animation.SkinAniRunning;
import org.jackhuang.hmcl.ui.skin.animation.SkinAniWavingArms;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.nio.file.Path;
@@ -48,8 +48,8 @@ import java.util.UUID;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.ui.FXUtils.stringConverter;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class OfflineAccountSkinPane extends StackPane {
private final OfflineAccount account;
@@ -110,6 +110,7 @@ public class OfflineAccountSkinPane extends StackPane {
cslApiField.setPromptText(i18n("account.skin.type.csl_api.location.hint"));
cslApiField.setValidators(new URLValidator());
FXUtils.setValidateWhileTextChanged(cslApiField, true);
skinItem.loadChildren(Arrays.asList(
new MultiFileItem.Option<>(i18n("message.default"), Skin.Type.DEFAULT),
@@ -218,6 +219,10 @@ public class OfflineAccountSkinPane extends StackPane {
cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
onEscPressed(this, cancelButton::fire);
acceptButton.disableProperty().bind(
skinItem.selectedDataProperty().isEqualTo(Skin.Type.CUSTOM_SKIN_LOADER_API)
.and(cslApiField.activeValidatorProperty().isNotNull()));
layout.setActions(littleSkinLink, acceptButton, cancelButton);
}
@@ -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);
@@ -18,7 +18,6 @@
package org.jackhuang.hmcl.ui.construct;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ScrollPane;
@@ -28,9 +27,8 @@ import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.versions.VersionPage;
import org.jackhuang.hmcl.ui.SVGContainer;
import org.jackhuang.hmcl.ui.animation.Motion;
import java.util.function.Consumer;
@@ -72,10 +70,9 @@ public class AdvancedListBox extends ScrollPane {
private AdvancedListItem createNavigationDrawerItem(String title, SVG leftGraphic) {
AdvancedListItem item = new AdvancedListItem();
item.getStyleClass().add("navigation-drawer-item");
item.setActionButtonVisible(false);
item.setTitle(title);
if (leftGraphic != null) {
item.setLeftGraphic(VersionPage.wrap(leftGraphic));
item.setLeftIcon(leftGraphic);
}
return item;
}
@@ -108,18 +105,11 @@ public class AdvancedListBox extends ScrollPane {
item.activeProperty().bind(tabHeader.getSelectionModel().selectedItemProperty().isEqualTo(tab));
item.setOnAction(e -> tabHeader.select(tab));
Node unselectedIcon = unselectedGraphic.createIcon(20);
Node selectedIcon = selectedGraphic.createIcon(20);
TransitionPane leftGraphic = new TransitionPane();
leftGraphic.setAlignment(Pos.CENTER);
FXUtils.setLimitWidth(leftGraphic, 30);
FXUtils.setLimitHeight(leftGraphic, 20);
leftGraphic.setPadding(Insets.EMPTY);
leftGraphic.setContent(item.isActive() ? selectedIcon : unselectedIcon, ContainerAnimations.NONE);
FXUtils.onChange(item.activeProperty(), active ->
leftGraphic.setContent(active ? selectedIcon : unselectedIcon, ContainerAnimations.FADE));
var leftGraphic = new SVGContainer(item.isActive() ? selectedGraphic : unselectedGraphic, AdvancedListItem.LEFT_ICON_SIZE);
leftGraphic.setMouseTransparent(true);
AdvancedListItem.setAlignment(leftGraphic, Pos.CENTER);
AdvancedListItem.setMargin(leftGraphic, AdvancedListItem.LEFT_ICON_MARGIN);
FXUtils.onChange(item.activeProperty(), active -> leftGraphic.setIcon(active ? selectedGraphic : unselectedGraphic, Motion.SHORT4));
item.setLeftGraphic(leftGraphic);
return add(item);
}
@@ -17,105 +17,134 @@
*/
package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXButton;
import javafx.beans.property.*;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.Skin;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.BorderPane;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.Pair;
import static org.jackhuang.hmcl.util.Pair.pair;
import org.jackhuang.hmcl.ui.SVG;
public class AdvancedListItem extends Control {
private final ObjectProperty<Node> leftGraphic = new SimpleObjectProperty<>(this, "leftGraphic");
private final ObjectProperty<Node> rightGraphic = new SimpleObjectProperty<>(this, "rightGraphic");
private final StringProperty title = new SimpleStringProperty(this, "title");
private final BooleanProperty active = new SimpleBooleanProperty(this, "active");
private final StringProperty subtitle = new SimpleStringProperty(this, "subtitle");
private final BooleanProperty actionButtonVisible = new SimpleBooleanProperty(this, "actionButtonVisible", true);
public static final double LEFT_GRAPHIC_SIZE = 32;
public static final double LEFT_ICON_SIZE = 20;
public static final Insets LEFT_ICON_MARGIN = new Insets(0, 6, 0, 6);
public static void setMargin(Node graphic, Insets margin) {
BorderPane.setMargin(graphic, margin);
}
public static void setAlignment(Node graphic, Pos alignment) {
BorderPane.setAlignment(graphic, alignment);
}
public AdvancedListItem() {
getStyleClass().add("advanced-list-item");
FXUtils.onClicked(this, () -> fireEvent(new ActionEvent()));
}
public Node getLeftGraphic() {
return leftGraphic.get();
}
private final ObjectProperty<Node> leftGraphic = new SimpleObjectProperty<>(this, "leftGraphic");
public ObjectProperty<Node> leftGraphicProperty() {
return leftGraphic;
}
public Node getLeftGraphic() {
return leftGraphic.get();
}
public void setLeftGraphic(Node leftGraphic) {
this.leftGraphic.set(leftGraphic);
}
public Node getRightGraphic() {
return rightGraphic.get();
public void setLeftIcon(SVG svg) {
Node icon = svg.createIcon(LEFT_ICON_SIZE);
icon.setMouseTransparent(true);
BorderPane.setMargin(icon, LEFT_ICON_MARGIN);
BorderPane.setAlignment(icon, Pos.CENTER);
leftGraphicProperty().set(icon);
}
private final ObjectProperty<Node> rightGraphic = new SimpleObjectProperty<>(this, "rightGraphic");
public ObjectProperty<Node> rightGraphicProperty() {
return rightGraphic;
}
public Node getRightGraphic() {
return rightGraphic.get();
}
public void setRightGraphic(Node rightGraphic) {
this.rightGraphic.set(rightGraphic);
}
public String getTitle() {
return title.get();
public void setRightAction(SVG icon, Runnable action) {
var button = new JFXButton();
button.setOnAction(e -> {
action.run();
e.consume();
});
button.getStyleClass().add("toggle-icon4");
button.setGraphic(icon.createIcon(14));
setAlignment(button, Pos.CENTER);
setRightGraphic(button);
}
private final StringProperty title = new SimpleStringProperty(this, "title");
public StringProperty titleProperty() {
return title;
}
public String getTitle() {
return title.get();
}
public void setTitle(String title) {
this.title.set(title);
}
public boolean isActive() {
return active.get();
}
private final StringProperty subtitle = new SimpleStringProperty(this, "subtitle");
public BooleanProperty activeProperty() {
return active;
}
public void setActive(boolean active) {
this.active.set(active);
public StringProperty subtitleProperty() {
return subtitle;
}
public String getSubtitle() {
return subtitle.get();
}
public StringProperty subtitleProperty() {
return subtitle;
}
public void setSubtitle(String subtitle) {
this.subtitle.set(subtitle);
}
public boolean isActionButtonVisible() {
return actionButtonVisible.get();
private final BooleanProperty active = new SimpleBooleanProperty(this, "active");
public BooleanProperty activeProperty() {
return active;
}
public BooleanProperty actionButtonVisibleProperty() {
return actionButtonVisible;
public boolean isActive() {
return active.get();
}
public void setActionButtonVisible(boolean actionButtonVisible) {
this.actionButtonVisible.set(actionButtonVisible);
public void setActive(boolean active) {
this.active.set(active);
}
private final ObjectProperty<EventHandler<ActionEvent>> onAction = new SimpleObjectProperty<>(this, "onAction") {
@Override
protected void invalidated() {
setEventHandler(ActionEvent.ACTION, get());
}
};
public final ObjectProperty<EventHandler<ActionEvent>> onActionProperty() {
return onAction;
}
@@ -128,32 +157,9 @@ public class AdvancedListItem extends Control {
return onActionProperty().get();
}
private ObjectProperty<EventHandler<ActionEvent>> onAction = new SimpleObjectProperty<EventHandler<ActionEvent>>(this, "onAction") {
@Override
protected void invalidated() {
setEventHandler(ActionEvent.ACTION, get());
}
};
@Override
protected Skin<?> createDefaultSkin() {
return new AdvancedListItemSkin(this);
}
public static Pair<Node, ImageView> createImageView(Image image) {
return createImageView(image, 32, 32);
}
public static Pair<Node, ImageView> createImageView(Image image, double width, double height) {
StackPane imageViewContainer = new StackPane();
FXUtils.setLimitWidth(imageViewContainer, width);
FXUtils.setLimitHeight(imageViewContainer, height);
ImageView imageView = new ImageView();
FXUtils.limitSize(imageView, width, height);
imageView.setPreserveRatio(true);
imageView.setImage(image);
imageViewContainer.getChildren().setAll(imageView);
return pair(imageViewContainer, imageView);
}
}
@@ -18,14 +18,12 @@
package org.jackhuang.hmcl.ui.construct;
import javafx.css.PseudoClass;
import javafx.geometry.Pos;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import org.jackhuang.hmcl.ui.FXUtils;
public class AdvancedListItemSkin extends SkinBase<AdvancedListItem> {
private final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
public AdvancedListItemSkin(AdvancedListItem skinnable) {
super(skinnable);
@@ -40,41 +38,14 @@ public class AdvancedListItemSkin extends SkinBase<AdvancedListItem> {
RipplerContainer container = new RipplerContainer(root);
HBox left = new HBox();
left.setAlignment(Pos.CENTER_LEFT);
left.setMouseTransparent(true);
TwoLineListItem item = new TwoLineListItem();
root.setCenter(item);
item.setMouseTransparent(true);
item.titleProperty().bind(skinnable.titleProperty());
item.subtitleProperty().bind(skinnable.subtitleProperty());
FXUtils.onChangeAndOperate(skinnable.leftGraphicProperty(),
newGraphic -> {
if (newGraphic == null) {
left.getChildren().clear();
} else {
left.getChildren().setAll(newGraphic);
}
});
root.setLeft(left);
HBox right = new HBox();
right.setAlignment(Pos.CENTER);
right.getStyleClass().add("toggle-icon4");
FXUtils.setLimitWidth(right, 40);
FXUtils.onChangeAndOperate(skinnable.rightGraphicProperty(),
newGraphic -> {
if (newGraphic == null) {
right.getChildren().clear();
} else {
right.getChildren().setAll(newGraphic);
}
});
FXUtils.onChangeAndOperate(skinnable.actionButtonVisibleProperty(),
visible -> root.setRight(visible ? right : null));
root.leftProperty().bind(skinnable.leftGraphicProperty());
root.rightProperty().bind(skinnable.rightGraphicProperty());
getChildren().setAll(container);
}
@@ -17,13 +17,8 @@
*/
package org.jackhuang.hmcl.ui.construct;
import javafx.beans.DefaultProperty;
import javafx.beans.InvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.css.PseudoClass;
@@ -33,88 +28,21 @@ import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import java.util.List;
import java.util.function.Supplier;
@DefaultProperty("content")
public class ComponentList extends Control {
private final StringProperty title = new SimpleStringProperty(this, "title", "Group");
private final StringProperty subtitle = new SimpleStringProperty(this, "subtitle", "");
private final IntegerProperty depth = new SimpleIntegerProperty(this, "depth", 0);
private boolean hasSubtitle = false;
public final ObservableList<Node> content = FXCollections.observableArrayList();
private Supplier<List<? extends Node>> lazyInitializer;
public class ComponentList extends Control implements NoPaddingComponent {
public ComponentList() {
getStyleClass().add("options-list");
}
public ComponentList(Supplier<List<? extends Node>> lazyInitializer) {
this();
this.lazyInitializer = lazyInitializer;
}
public String getTitle() {
return title.get();
}
public StringProperty titleProperty() {
return title;
}
public void setTitle(String title) {
this.title.set(title);
}
public String getSubtitle() {
return subtitle.get();
}
public StringProperty subtitleProperty() {
return subtitle;
}
public void setSubtitle(String subtitle) {
this.subtitle.set(subtitle);
}
public int getDepth() {
return depth.get();
}
public IntegerProperty depthProperty() {
return depth;
}
public void setDepth(int depth) {
this.depth.set(depth);
}
public boolean isHasSubtitle() {
return hasSubtitle;
}
public void setHasSubtitle(boolean hasSubtitle) {
this.hasSubtitle = hasSubtitle;
}
private final ObservableList<Node> content = FXCollections.observableArrayList();
public ObservableList<Node> getContent() {
return content;
}
void doLazyInit() {
if (lazyInitializer != null) {
this.getContent().setAll(lazyInitializer.get());
setNeedsLayout(true);
lazyInitializer = null;
}
}
@Override
public Orientation getContentBias() {
return Orientation.HORIZONTAL;
@@ -130,49 +58,72 @@ public class ComponentList extends Control {
private static final PseudoClass PSEUDO_CLASS_LAST = PseudoClass.getPseudoClass("last");
private final ObservableList<Node> list;
private final ObjectBinding<Node> firstItem;
private final ObjectBinding<Node> lastItem;
Skin(ComponentList control) {
super(control);
list = MappedObservableList.create(control.getContent(), node -> {
ComponentListCell cell = new ComponentListCell(node);
cell.getStyleClass().add("options-list-item");
if (node.getProperties().containsKey("ComponentList.vgrow")) {
VBox.setVgrow(cell, (Priority) node.getProperties().get("ComponentList.vgrow"));
Pane wrapper;
if (node instanceof ComponentSublist sublist) {
sublist.getStyleClass().remove("options-list");
sublist.getStyleClass().add("options-sublist");
wrapper = new ComponentSublistWrapper(sublist);
} else {
wrapper = new StackPane(node);
}
if (node.getProperties().containsKey("ComponentList.noPadding")) {
cell.getStyleClass().add("no-padding");
wrapper.getStyleClass().add("options-list-item");
if (node.getProperties().get("ComponentList.vgrow") instanceof Priority priority) {
VBox.setVgrow(wrapper, priority);
}
return cell;
if (node instanceof NoPaddingComponent || node.getProperties().containsKey("ComponentList.noPadding")) {
wrapper.getStyleClass().add("no-padding");
}
return wrapper;
});
firstItem = Bindings.valueAt(list, 0);
firstItem.addListener((observable, oldValue, newValue) -> {
if (newValue != null)
newValue.pseudoClassStateChanged(PSEUDO_CLASS_FIRST, true);
if (oldValue != null)
oldValue.pseudoClassStateChanged(PSEUDO_CLASS_FIRST, false);
});
if (!list.isEmpty())
list.get(0).pseudoClassStateChanged(PSEUDO_CLASS_FIRST, true);
lastItem = Bindings.valueAt(list, Bindings.subtract(Bindings.size(list), 1));
lastItem.addListener((observable, oldValue, newValue) -> {
if (newValue != null)
newValue.pseudoClassStateChanged(PSEUDO_CLASS_LAST, true);
if (oldValue != null)
oldValue.pseudoClassStateChanged(PSEUDO_CLASS_LAST, false);
});
if (!list.isEmpty())
list.get(list.size() - 1).pseudoClassStateChanged(PSEUDO_CLASS_LAST, true);
updateStyle();
list.addListener((InvalidationListener) o -> updateStyle());
VBox vbox = new VBox();
vbox.setFillWidth(true);
Bindings.bindContent(vbox.getChildren(), list);
node = vbox;
}
private Node prevFirstItem;
private Node prevLastItem;
private void updateStyle() {
Node firstItem;
Node lastItem;
if (list.isEmpty()) {
firstItem = null;
lastItem = null;
} else {
firstItem = list.get(0);
lastItem = list.get(list.size() - 1);
}
if (firstItem != prevFirstItem) {
if (prevFirstItem != null)
prevFirstItem.pseudoClassStateChanged(PSEUDO_CLASS_FIRST, false);
if (firstItem != null)
firstItem.pseudoClassStateChanged(PSEUDO_CLASS_FIRST, true);
prevFirstItem = firstItem;
}
if (lastItem != prevLastItem) {
if (prevLastItem != null)
prevLastItem.pseudoClassStateChanged(PSEUDO_CLASS_LAST, false);
if (lastItem != null)
lastItem.pseudoClassStateChanged(PSEUDO_CLASS_LAST, true);
prevLastItem = lastItem;
}
}
}
public static Node createComponentListTitle(String title) {
@@ -189,4 +140,8 @@ public class ComponentList extends Control {
public static void setVgrow(Node node, Priority priority) {
node.getProperties().put("ComponentList.vgrow", priority);
}
public static void setNoPadding(Node node) {
node.getProperties().put("ComponentList.noPadding", true);
}
}
@@ -1,215 +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.ui.construct;
import javafx.animation.*;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.shape.Rectangle;
import javafx.util.Duration;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.Motion;
/**
* @author huangyuhui
*/
final class ComponentListCell extends StackPane {
private final Node content;
private Animation expandAnimation;
private Rectangle clipRect;
private final BooleanProperty expanded = new SimpleBooleanProperty(this, "expanded", false);
ComponentListCell(Node content) {
this.content = content;
updateLayout();
}
private void updateClip(double newHeight) {
if (clipRect != null)
clipRect.setHeight(newHeight);
}
@Override
protected void layoutChildren() {
super.layoutChildren();
if (clipRect == null)
clipRect = new Rectangle(0, 0, getWidth(), getHeight());
else {
clipRect.setX(0);
clipRect.setY(0);
clipRect.setHeight(getHeight());
clipRect.setWidth(getWidth());
}
}
private void updateLayout() {
if (content instanceof ComponentList list) {
content.getStyleClass().remove("options-list");
content.getStyleClass().add("options-sublist");
getStyleClass().add("no-padding");
VBox groupNode = new VBox();
Node expandIcon = SVG.KEYBOARD_ARROW_DOWN.createIcon(20);
expandIcon.setMouseTransparent(true);
HBox.setMargin(expandIcon, new Insets(0, 8, 0, 8));
VBox labelVBox = new VBox();
labelVBox.setMouseTransparent(true);
labelVBox.setAlignment(Pos.CENTER_LEFT);
boolean overrideHeaderLeft = false;
if (list instanceof ComponentSublist) {
Node leftNode = ((ComponentSublist) list).getHeaderLeft();
if (leftNode != null) {
labelVBox.getChildren().setAll(leftNode);
overrideHeaderLeft = true;
}
}
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);
}
}
HBox header = new HBox();
header.setSpacing(16);
header.getChildren().add(labelVBox);
header.setPadding(new Insets(10, 16, 10, 16));
header.setAlignment(Pos.CENTER_LEFT);
HBox.setHgrow(labelVBox, Priority.ALWAYS);
if (list instanceof ComponentSublist) {
Node rightNode = ((ComponentSublist) list).getHeaderRight();
if (rightNode != null)
header.getChildren().add(rightNode);
}
header.getChildren().add(expandIcon);
RipplerContainer headerRippler = new RipplerContainer(header);
groupNode.getChildren().add(headerRippler);
VBox container = new VBox();
boolean hasPadding = !(list instanceof ComponentSublist subList) || subList.hasComponentPadding();
if (hasPadding) {
container.setPadding(new Insets(8, 16, 10, 16));
}
FXUtils.setLimitHeight(container, 0);
FXUtils.setOverflowHidden(container);
container.getChildren().setAll(content);
groupNode.getChildren().add(container);
headerRippler.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
if (event.getButton() != MouseButton.PRIMARY)
return;
event.consume();
if (expandAnimation != null && expandAnimation.getStatus() == Animation.Status.RUNNING) {
expandAnimation.stop();
}
boolean expanded = !isExpanded();
setExpanded(expanded);
if (expanded) {
list.doLazyInit();
list.layout();
}
Platform.runLater(() -> {
// FIXME: ComponentSubList without padding must have a 4 pixel padding for displaying a border radius.
double newAnimatedHeight = (list.prefHeight(list.getWidth()) + (hasPadding ? 8 + 10 : 4)) * (expanded ? 1 : -1);
double newHeight = expanded ? getHeight() + newAnimatedHeight : prefHeight(list.getWidth());
double contentHeight = expanded ? newAnimatedHeight : 0;
double targetRotate = expanded ? -180 : 0;
if (expanded) {
updateClip(newHeight);
}
if (AnimationUtils.isAnimationEnabled()) {
double currentRotate = expandIcon.getRotate();
Duration duration = Motion.LONG2.multiply(Math.abs(currentRotate - targetRotate) / 180.0);
Interpolator interpolator = Motion.EASE_IN_OUT_CUBIC_EMPHASIZED;
expandAnimation = new Timeline(
new KeyFrame(duration,
new KeyValue(container.minHeightProperty(), contentHeight, interpolator),
new KeyValue(container.maxHeightProperty(), contentHeight, interpolator),
new KeyValue(expandIcon.rotateProperty(), targetRotate, interpolator))
);
if (!expanded) {
expandAnimation.setOnFinished(e2 -> updateClip(newHeight));
}
expandAnimation.play();
} else {
container.setMinHeight(contentHeight);
container.setMaxHeight(contentHeight);
expandIcon.setRotate(targetRotate);
if (!expanded) {
updateClip(newHeight);
}
}
});
});
getChildren().setAll(groupNode);
} else {
getStyleClass().remove("no-padding");
getChildren().setAll(content);
}
}
public boolean isExpanded() {
return expanded.get();
}
public BooleanProperty expandedProperty() {
return expanded;
}
public void setExpanded(boolean expanded) {
this.expanded.set(expanded);
}
}
@@ -17,57 +17,100 @@
*/
package org.jackhuang.hmcl.ui.construct;
import javafx.beans.DefaultProperty;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.*;
import javafx.scene.Node;
@DefaultProperty("content")
import java.util.List;
import java.util.function.Supplier;
public class ComponentSublist extends ComponentList {
private final ObjectProperty<Node> headerLeft = new SimpleObjectProperty<>(this, "headerLeft");
private final ObjectProperty<Node> headerRight = new SimpleObjectProperty<>(this, "headerRight");
private final BooleanProperty componentPadding = new SimpleBooleanProperty(this, "componentPadding", true);
Supplier<List<? extends Node>> lazyInitializer;
public ComponentSublist() {
super();
}
public Node getHeaderLeft() {
return headerLeft.get();
public ComponentSublist(Supplier<List<? extends Node>> lazyInitializer) {
this.lazyInitializer = lazyInitializer;
}
public ObjectProperty<Node> headerLeftProperty() {
void doLazyInit() {
if (lazyInitializer != null) {
this.getContent().setAll(lazyInitializer.get());
setNeedsLayout(true);
lazyInitializer = null;
}
}
private final StringProperty title = new SimpleStringProperty(this, "title", "Group");
public StringProperty titleProperty() {
return title;
}
public String getTitle() {
return titleProperty().get();
}
public void setTitle(String title) {
titleProperty().set(title);
}
private StringProperty subtitle;
public StringProperty subtitleProperty() {
if (subtitle == null)
subtitle = new SimpleStringProperty(this, "subtitle", "");
return subtitle;
}
public String getSubtitle() {
return subtitleProperty().get();
}
public void setSubtitle(String subtitle) {
subtitleProperty().set(subtitle);
}
private boolean hasSubtitle = false;
public boolean isHasSubtitle() {
return hasSubtitle;
}
public void setHasSubtitle(boolean hasSubtitle) {
this.hasSubtitle = hasSubtitle;
}
private Node headerLeft;
public Node getHeaderLeft() {
return headerLeft;
}
public void setHeaderLeft(Node headerLeft) {
this.headerLeft.set(headerLeft);
this.headerLeft = headerLeft;
}
private Node headerRight;
public Node getHeaderRight() {
return headerRight.get();
}
public ObjectProperty<Node> headerRightProperty() {
return headerRight;
}
public void setHeaderRight(Node headerRight) {
this.headerRight.set(headerRight);
this.headerRight = headerRight;
}
private boolean componentPadding = true;
public boolean hasComponentPadding() {
return componentPadding.get();
}
public BooleanProperty componentPaddingProperty() {
return componentPadding;
}
public void setComponentPadding(boolean componentPadding) {
this.componentPadding.set(componentPadding);
this.componentPadding = componentPadding;
}
}
@@ -0,0 +1,148 @@
/*
* 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.ui.construct;
import javafx.animation.*;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.util.Duration;
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;
import org.jackhuang.hmcl.ui.animation.Motion;
/// @author Glavo
final class ComponentSublistWrapper extends VBox implements NoPaddingComponent {
private VBox container;
private Animation expandAnimation;
private boolean expanded = false;
ComponentSublistWrapper(ComponentSublist sublist) {
boolean noPadding = !sublist.hasComponentPadding();
this.getStyleClass().add("options-sublist-wrapper");
Node expandIcon = SVG.KEYBOARD_ARROW_DOWN.createIcon(20);
expandIcon.getStyleClass().add("expand-icon");
expandIcon.setMouseTransparent(true);
VBox labelVBox = new VBox();
labelVBox.setMouseTransparent(true);
labelVBox.setAlignment(Pos.CENTER_LEFT);
Node leftNode = sublist.getHeaderLeft();
if (leftNode == null) {
Label label = new Label();
label.textProperty().bind(sublist.titleProperty());
label.getStyleClass().add("title-label");
labelVBox.getChildren().add(label);
if (sublist.isHasSubtitle()) {
Label subtitleLabel = new Label();
subtitleLabel.textProperty().bind(sublist.subtitleProperty());
subtitleLabel.getStyleClass().add("subtitle-label");
subtitleLabel.textFillProperty().bind(Themes.colorSchemeProperty().getOnSurfaceVariant());
labelVBox.getChildren().add(subtitleLabel);
}
} else {
labelVBox.getChildren().setAll(leftNode);
}
HBox header = new HBox();
header.setSpacing(12);
header.getChildren().add(labelVBox);
header.setPadding(new Insets(10, 16, 10, 16));
header.setAlignment(Pos.CENTER_LEFT);
HBox.setHgrow(labelVBox, Priority.ALWAYS);
Node rightNode = sublist.getHeaderRight();
if (rightNode != null)
header.getChildren().add(rightNode);
header.getChildren().add(expandIcon);
RipplerContainer headerRippler = new RipplerContainer(header);
this.getChildren().add(headerRippler);
headerRippler.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
if (event.getButton() != MouseButton.PRIMARY)
return;
event.consume();
if (expandAnimation != null && expandAnimation.getStatus() == Animation.Status.RUNNING) {
expandAnimation.stop();
}
boolean expanded = !this.expanded;
this.expanded = expanded;
if (expanded) {
sublist.doLazyInit();
if (container == null) {
this.container = new VBox();
if (!noPadding) {
container.setPadding(new Insets(8, 16, 10, 16));
}
FXUtils.setLimitHeight(container, 0);
FXUtils.setOverflowHidden(container);
container.getChildren().setAll(sublist);
ComponentSublistWrapper.this.getChildren().add(container);
this.applyCss();
}
this.layout();
}
Platform.runLater(() -> {
// FIXME: ComponentSubList without padding must have a 4 pixel padding for displaying a border radius.
double contentHeight = expanded ? (sublist.prefHeight(sublist.getWidth()) + (noPadding ? 4 : 8 + 10)) : 0;
double targetRotate = expanded ? -180 : 0;
if (AnimationUtils.isAnimationEnabled()) {
double currentRotate = expandIcon.getRotate();
Duration duration = Motion.LONG2.multiply(Math.abs(currentRotate - targetRotate) / 180.0);
Interpolator interpolator = Motion.EASE_IN_OUT_CUBIC_EMPHASIZED;
expandAnimation = new Timeline(
new KeyFrame(duration,
new KeyValue(container.minHeightProperty(), contentHeight, interpolator),
new KeyValue(container.maxHeightProperty(), contentHeight, interpolator),
new KeyValue(expandIcon.rotateProperty(), targetRotate, interpolator))
);
expandAnimation.play();
} else {
container.setMinHeight(contentHeight);
container.setMaxHeight(contentHeight);
expandIcon.setRotate(targetRotate);
}
});
});
}
}
@@ -1,162 +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.ui.construct;
import com.jfoenix.controls.JFXButton;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import javafx.stage.DirectoryChooser;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class FileItem extends BorderPane {
private final Label lblPath = new Label();
private final SimpleStringProperty name = new SimpleStringProperty(this, "name");
private final SimpleStringProperty title = new SimpleStringProperty(this, "title");
private final SimpleStringProperty path = new SimpleStringProperty(this, "path");
private final SimpleBooleanProperty convertToRelativePath = new SimpleBooleanProperty(this, "convertToRelativePath");
public FileItem() {
VBox left = new VBox();
Label name = new Label();
name.textProperty().bind(nameProperty());
lblPath.getStyleClass().addAll("subtitle-label");
lblPath.textProperty().bind(path);
left.getChildren().addAll(name, lblPath);
setLeft(left);
JFXButton right = new JFXButton();
right.setGraphic(SVG.EDIT.createIcon(16));
right.getStyleClass().add("toggle-icon4");
right.setOnAction(e -> onExplore());
FXUtils.installFastTooltip(right, i18n("button.edit"));
setRight(right);
convertToRelativePath.addListener(onInvalidating(() -> path.set(processPath(path.get()))));
}
/**
* Converts the given path to absolute/relative(if possible) path according to {@link #convertToRelativePathProperty()}.
*/
private String processPath(String path) {
Path given;
try {
given = Path.of(path).toAbsolutePath().normalize();
} catch (IllegalArgumentException e) {
return path;
}
if (isConvertToRelativePath()) {
try {
return Metadata.CURRENT_DIRECTORY.relativize(given).normalize().toString();
} catch (IllegalArgumentException e) {
// the given path can't be relativized against current path
}
}
return given.normalize().toString();
}
public void onExplore() {
DirectoryChooser chooser = new DirectoryChooser();
if (path.get() != null) {
Path file;
try {
file = Path.of(path.get());
if (Files.exists(file)) {
if (Files.isRegularFile(file))
file = file.toAbsolutePath().normalize().getParent();
else if (Files.isDirectory(file))
file = file.toAbsolutePath().normalize();
chooser.setInitialDirectory(file.toFile());
}
} catch (InvalidPathException e) {
LOG.warning("Failed to resolve path: " + path.get());
}
}
chooser.titleProperty().bind(titleProperty());
var selectedDir = chooser.showDialog(Controllers.getStage());
if (selectedDir != null) {
path.set(processPath(selectedDir.toString()));
}
chooser.titleProperty().unbind();
}
public String getName() {
return name.get();
}
public StringProperty nameProperty() {
return name;
}
public void setName(String name) {
this.name.set(name);
}
public String getTitle() {
return title.get();
}
public StringProperty titleProperty() {
return title;
}
public void setTitle(String title) {
this.title.set(title);
}
public String getPath() {
return path.get();
}
public StringProperty pathProperty() {
return path;
}
public void setPath(String path) {
this.path.set(path);
}
public boolean isConvertToRelativePath() {
return convertToRelativePath.get();
}
public BooleanProperty convertToRelativePathProperty() {
return convertToRelativePath;
}
public void setConvertToRelativePath(boolean convertToRelativePath) {
this.convertToRelativePath.set(convertToRelativePath);
}
}
@@ -1,69 +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.ui.construct;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.effects.JFXDepthManager;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.scene.Cursor;
import javafx.scene.control.ListCell;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.ui.FXUtils;
public abstract class FloatListCell<T> extends ListCell<T> {
private final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
protected final StackPane pane = new StackPane();
public FloatListCell(JFXListView<T> listView) {
setText(null);
setGraphic(null);
pane.getStyleClass().add("card");
pane.setCursor(Cursor.HAND);
setPadding(new Insets(9, 9, 0, 9));
JFXDepthManager.setDepth(pane, 1);
FXUtils.onChangeAndOperate(selectedProperty(), selected -> {
pane.pseudoClassStateChanged(SELECTED, selected);
});
Region clippedContainer = (Region) listView.lookup(".clipped-container");
setPrefWidth(0);
if (clippedContainer != null) {
maxWidthProperty().bind(clippedContainer.widthProperty());
prefWidthProperty().bind(clippedContainer.widthProperty());
minWidthProperty().bind(clippedContainer.widthProperty());
}
}
@Override
protected void updateItem(T item, boolean empty) {
super.updateItem(item, empty);
updateControl(item, empty);
if (empty) {
setGraphic(null);
} else {
setGraphic(pane);
}
}
protected abstract void updateControl(T dataItem, boolean empty);
}
@@ -30,6 +30,7 @@ import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import java.util.Locale;
import java.util.function.Consumer;
public class HintPane extends VBox {
private final Text label = new Text();
@@ -66,7 +67,11 @@ public class HintPane extends VBox {
}
public void setSegment(String segment) {
flow.getChildren().setAll(FXUtils.parseSegment(segment, Controllers::onHyperlinkAction));
this.setSegment(segment, Controllers::onHyperlinkAction);
}
public void setSegment(String segment, Consumer<String> hyperlinkAction) {
flow.getChildren().setAll(FXUtils.parseSegment(segment, hyperlinkAction));
}
public void setChildren(Node... children) {
@@ -24,7 +24,7 @@ import org.jackhuang.hmcl.ui.SVG;
public final class IconedMenuItem extends IconedItem {
public IconedMenuItem(SVG icon, String text, Runnable action, JFXPopup popup) {
super(icon != null ? FXUtils.limitingSize(icon.createIcon(14), 14, 14) : null, text);
super(icon != null ? icon.createIcon(14) : null, text);
getStyleClass().setAll("iconed-menu-item");
@@ -1,118 +0,0 @@
package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXButton;
import javafx.beans.InvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.util.StringUtils;
public class IconedTwoLineListItem extends HBox {
private final StringProperty title = new SimpleStringProperty(this, "title");
private final ObservableList<Label> tags = FXCollections.observableArrayList();
private final StringProperty subtitle = new SimpleStringProperty(this, "subtitle");
private final StringProperty externalLink = new SimpleStringProperty(this, "externalLink");
private final ObjectProperty<Image> image = new SimpleObjectProperty<>(this, "image");
private final ImageView imageView = new ImageView();
private final TwoLineListItem twoLineListItem = new TwoLineListItem();
private JFXButton externalLinkButton;
@SuppressWarnings("FieldCanBeLocal")
private final InvalidationListener observer;
public IconedTwoLineListItem() {
setAlignment(Pos.CENTER);
setSpacing(16);
imageView.imageProperty().bind(image);
twoLineListItem.titleProperty().bind(title);
twoLineListItem.subtitleProperty().bind(subtitle);
HBox.setHgrow(twoLineListItem, Priority.ALWAYS);
Bindings.bindContent(twoLineListItem.getTags(), tags);
observer = FXUtils.observeWeak(() -> {
getChildren().clear();
if (image.get() != null) getChildren().add(imageView);
getChildren().add(twoLineListItem);
if (StringUtils.isNotBlank(externalLink.get())) getChildren().add(getExternalLinkButton());
}, image, externalLink);
}
public String getTitle() {
return title.get();
}
public StringProperty titleProperty() {
return title;
}
public void setTitle(String title) {
this.title.set(title);
}
public ObservableList<Label> getTags() {
return tags;
}
public String getSubtitle() {
return subtitle.get();
}
public StringProperty subtitleProperty() {
return subtitle;
}
public void setSubtitle(String subtitle) {
this.subtitle.set(subtitle);
}
public String getExternalLink() {
return externalLink.get();
}
public StringProperty externalLinkProperty() {
return externalLink;
}
public void setExternalLink(String externalLink) {
this.externalLink.set(externalLink);
}
public Image getImage() {
return image.get();
}
public ObjectProperty<Image> imageProperty() {
return image;
}
public void setImage(Image image) {
this.image.set(image);
}
public ImageView getImageView() {
return imageView;
}
public JFXButton getExternalLinkButton() {
if (externalLinkButton == null) {
externalLinkButton = new JFXButton();
externalLinkButton.getStyleClass().add("toggle-icon4");
externalLinkButton.setGraphic(SVG.OPEN_IN_NEW.createIcon());
externalLinkButton.setOnAction(e -> FXUtils.openLink(externalLink.get()));
}
return externalLinkButton;
}
}
@@ -72,13 +72,19 @@ public class InputDialogPane extends JFXDialogLayout implements DialogAware {
acceptButton.setOnAction(e -> {
acceptPane.showSpinner();
onResult.call(textField.getText(), () -> {
acceptPane.hideSpinner();
future.complete(textField.getText());
fireEvent(new DialogCloseEvent());
}, msg -> {
acceptPane.hideSpinner();
lblCreationWarning.setText(msg);
onResult.call(textField.getText(), new FutureCallback.ResultHandler() {
@Override
public void resolve() {
acceptPane.hideSpinner();
future.complete(textField.getText());
fireEvent(new DialogCloseEvent());
}
@Override
public void reject(String reason) {
acceptPane.hideSpinner();
lblCreationWarning.setText(reason);
}
});
});
textField.setOnAction(event -> acceptButton.fire());
@@ -0,0 +1,176 @@
/*
* 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.ui.construct;
import javafx.beans.property.*;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.OverrunStyle;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
/// @author Glavo
public class LineButton extends LineButtonBase {
private static final String DEFAULT_STYLE_CLASS = "line-button";
private static final int IDX_TRAILING_TEXT = IDX_TRAILING;
private static final int IDX_TRAILING_ICON = IDX_TRAILING + 1;
public static LineButton createNavigationButton() {
var button = new LineButton();
button.setTrailingIcon(SVG.ARROW_FORWARD);
return button;
}
public static LineButton createExternalLinkButton(String url) {
var button = new LineButton();
button.setTrailingIcon(SVG.OPEN_IN_NEW);
if (url != null) {
button.setOnAction(event -> FXUtils.openLink(url));
}
return button;
}
public LineButton() {
getStyleClass().add(DEFAULT_STYLE_CLASS);
container.setMouseTransparent(true);
}
private ObjectProperty<EventHandler<ActionEvent>> onAction;
public ObjectProperty<EventHandler<ActionEvent>> onActionProperty() {
if (onAction == null) {
onAction = new ObjectPropertyBase<>() {
@Override
public Object getBean() {
return LineButton.this;
}
@Override
public String getName() {
return "onAction";
}
@Override
protected void invalidated() {
setEventHandler(ActionEvent.ACTION, get());
}
};
}
return onAction;
}
public EventHandler<ActionEvent> getOnAction() {
return onActionProperty().get();
}
public void setOnAction(EventHandler<ActionEvent> value) {
onActionProperty().set(value);
}
private StringProperty trailingText;
public StringProperty trailingTextProperty() {
if (trailingText == null) {
trailingText = new StringPropertyBase() {
private Label trailingTextLabel;
@Override
public Object getBean() {
return LineButton.this;
}
@Override
public String getName() {
return "trailingText";
}
@Override
protected void invalidated() {
String message = get();
if (message != null && !message.isEmpty()) {
if (trailingTextLabel == null) {
trailingTextLabel = new Label();
trailingTextLabel.getStyleClass().add("trailing-label");
trailingTextLabel.setTextOverrun(OverrunStyle.CENTER_ELLIPSIS);
}
trailingTextLabel.setText(message);
setNode(IDX_TRAILING_TEXT, trailingTextLabel);
} else if (trailingTextLabel != null) {
trailingTextLabel.setText("");
setNode(IDX_TRAILING_TEXT, null);
}
}
};
}
return trailingText;
}
public String getTrailingText() {
return trailingText != null ? trailingText.get() : null;
}
public void setTrailingText(String trailingText) {
trailingTextProperty().set(trailingText);
}
private ObjectProperty<Node> trailingIcon;
public ObjectProperty<Node> trailingIconProperty() {
if (trailingIcon == null)
trailingIcon = new ObjectPropertyBase<>() {
@Override
public Object getBean() {
return LineButton.this;
}
@Override
public String getName() {
return "trailingIcon";
}
@Override
protected void invalidated() {
setNode(IDX_TRAILING_ICON, get());
}
};
return trailingIcon;
}
public Node getTrailingIcon() {
return trailingIcon != null ? trailingIcon.get() : null;
}
public void setTrailingIcon(Node trailingIcon) {
trailingIconProperty().set(trailingIcon);
}
public void setTrailingIcon(SVG rightIcon) {
setTrailingIcon(rightIcon, 20);
}
public void setTrailingIcon(SVG rightIcon, double size) {
Node rightIconNode = rightIcon.createIcon(size);
rightIconNode.getStyleClass().add("trailing-icon");
setTrailingIcon(rightIconNode);
}
}
@@ -0,0 +1,41 @@
/*
* 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.ui.construct;
import javafx.event.ActionEvent;
import org.jackhuang.hmcl.ui.FXUtils;
/// @author Glavo
public abstract class LineButtonBase extends LineComponent {
private static final String DEFAULT_STYLE_CLASS = "line-button-base";
protected final RipplerContainer ripplerContainer;
public LineButtonBase() {
this.getStyleClass().addAll(LineButtonBase.DEFAULT_STYLE_CLASS);
this.ripplerContainer = new RipplerContainer(container);
FXUtils.onClicked(this, this::fire);
this.getChildren().setAll(ripplerContainer);
}
public void fire() {
fireEvent(new ActionEvent());
}
}
@@ -0,0 +1,238 @@
/*
* 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.ui.construct;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ObjectPropertyBase;
import javafx.beans.property.StringProperty;
import javafx.beans.property.StringPropertyBase;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.ui.SVG;
import java.util.Arrays;
import java.util.Objects;
/// @author Glavo
public abstract class LineComponent extends StackPane implements NoPaddingComponent {
private static final String DEFAULT_STYLE_CLASS = "line-component";
private static final double MIN_HEIGHT = 48.0;
private static final PseudoClass PSEUDO_LARGER_TITLE = PseudoClass.getPseudoClass("large-title");
protected static final int IDX_LEADING = 0;
protected static final int IDX_TITLE = 1;
protected static final int IDX_TRAILING = 2;
public static final double SPACING = 12;
public static final double DEFAULT_ICON_SIZE = 20;
public static void setMargin(Node child, Insets value) {
HBox.setMargin(child, value);
}
protected final HBox container;
private final Label titleLabel;
private final VBox titleContainer;
public LineComponent() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
this.setMinHeight(MIN_HEIGHT);
this.container = new HBox(SPACING);
container.getStyleClass().add("line-component-container");
container.setAlignment(Pos.CENTER_LEFT);
this.titleLabel = new Label();
titleLabel.getStyleClass().add("title-label");
titleLabel.setMinWidth(Region.USE_PREF_SIZE);
this.titleContainer = new VBox(titleLabel);
titleContainer.getStyleClass().add("title-container");
titleContainer.setMouseTransparent(true);
titleContainer.setAlignment(Pos.CENTER_LEFT);
titleContainer.minWidthProperty().bind(titleLabel.prefWidthProperty());
HBox.setHgrow(titleContainer, Priority.ALWAYS);
this.setNode(IDX_TITLE, titleContainer);
this.getChildren().setAll(container);
}
private Node[] nodes = new Node[2];
protected void setNode(int idx, Node node) {
if (nodes.length <= idx)
nodes = Arrays.copyOf(nodes, idx + 1);
if (nodes[idx] != node) {
nodes[idx] = node;
container.getChildren().setAll(Arrays.stream(nodes).filter(Objects::nonNull).toArray(Node[]::new));
}
}
public void setLargeTitle(boolean largeTitle) {
pseudoClassStateChanged(PSEUDO_LARGER_TITLE, largeTitle);
}
private final StringProperty title = new StringPropertyBase() {
@Override
public Object getBean() {
return LineComponent.this;
}
@Override
public String getName() {
return "title";
}
@Override
protected void invalidated() {
titleLabel.setText(get());
}
};
public final StringProperty titleProperty() {
return title;
}
public String getTitle() {
return titleProperty().get();
}
public void setTitle(String title) {
titleProperty().set(title);
}
private StringProperty subtitle;
public final StringProperty subtitleProperty() {
if (subtitle == null) {
subtitle = new StringPropertyBase() {
private Label subtitleLabel;
@Override
public String getName() {
return "subtitle";
}
@Override
public Object getBean() {
return LineComponent.this;
}
@Override
protected void invalidated() {
String subtitle = get();
if (subtitle != null && !subtitle.isEmpty()) {
if (subtitleLabel == null) {
subtitleLabel = new Label();
subtitleLabel.setWrapText(true);
subtitleLabel.setMinHeight(Region.USE_PREF_SIZE);
subtitleLabel.getStyleClass().add("subtitle-label");
}
subtitleLabel.setText(subtitle);
if (titleContainer.getChildren().size() == 1)
titleContainer.getChildren().add(subtitleLabel);
} else if (subtitleLabel != null) {
subtitleLabel.setText(null);
if (titleContainer.getChildren().size() == 2)
titleContainer.getChildren().remove(1);
}
}
};
}
return subtitle;
}
public final String getSubtitle() {
return subtitle != null ? subtitle.get() : null;
}
public final void setSubtitle(String subtitle) {
subtitleProperty().set(subtitle);
}
private ObjectProperty<Node> leading;
public final ObjectProperty<Node> leadingProperty() {
if (leading == null) {
leading = new ObjectPropertyBase<>() {
@Override
public Object getBean() {
return LineComponent.this;
}
@Override
public String getName() {
return "leading";
}
@Override
protected void invalidated() {
setNode(IDX_LEADING, get());
}
};
}
return leading;
}
public final Node getLeading() {
return leadingProperty().get();
}
public final void setLeading(Node node) {
leadingProperty().set(node);
}
public void setLeading(Image icon) {
setLeading(icon, -1);
}
public void setLeading(Image icon, double size) {
var imageView = new ImageView(icon);
if (size > 0) {
imageView.setFitWidth(size);
imageView.setFitHeight(size);
imageView.setPreserveRatio(true);
imageView.setSmooth(true);
}
imageView.setMouseTransparent(true);
setNode(IDX_LEADING, imageView);
}
public void setLeading(SVG svg) {
setLeading(svg, DEFAULT_ICON_SIZE);
}
public void setLeading(SVG svg, double size) {
Node node = svg.createIcon(size);
node.setMouseTransparent(true);
setNode(IDX_LEADING, node);
}
}
@@ -0,0 +1,240 @@
/*
* 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.ui.construct;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.stage.DirectoryChooser;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class LineFileChooserButton extends LineButton {
private static final String DEFAULT_STYLE_CLASS = "line-file-select-button";
public LineFileChooserButton() {
getStyleClass().add(DEFAULT_STYLE_CLASS);
setTrailingIcon(SVG.EDIT);
}
/// Converts the given path to absolute/relative(if possible) path according to [#convertToRelativePathProperty()].
private String processPath(Path path) {
if (isConvertToRelativePath() && path.isAbsolute()) {
try {
return Metadata.CURRENT_DIRECTORY.relativize(path).normalize().toString();
} catch (IllegalArgumentException e) {
// the given path can't be relativized against current path
}
}
return path.normalize().toString();
}
@Override
public void fire() {
super.fire();
Stage owner = Controllers.getStage(); // TODO: Allow user to set owner stage
String windowTitle = getFileChooserTitle();
Path initialDirectory = null;
if (getLocation() != null) {
Path file;
try {
file = FileUtils.toAbsolute(Path.of(getLocation()));
if (Files.exists(file)) {
if (Files.isRegularFile(file))
initialDirectory = file.getParent();
else if (Files.isDirectory(file))
initialDirectory = file;
}
} catch (IllegalArgumentException e) {
LOG.warning("Failed to resolve path: " + getLocation());
}
}
Path path;
Type type = getType();
if (type == Type.OPEN_DIRECTORY) {
var directoryChooser = new DirectoryChooser();
if (windowTitle != null)
directoryChooser.setTitle(windowTitle);
if (initialDirectory != null)
directoryChooser.setInitialDirectory(initialDirectory.toFile());
path = FileUtils.toPath(directoryChooser.showDialog(owner));
} else {
var fileChooser = new FileChooser();
if (windowTitle != null)
fileChooser.setTitle(windowTitle);
if (initialDirectory != null)
fileChooser.setInitialDirectory(initialDirectory.toFile());
if (extensionFilters != null)
fileChooser.getExtensionFilters().setAll(extensionFilters);
fileChooser.setInitialFileName(getInitialFileName());
path = FileUtils.toPath(switch (type) {
case OPEN_FILE -> fileChooser.showOpenDialog(owner);
case SAVE_FILE -> fileChooser.showSaveDialog(owner);
default -> throw new AssertionError("Unknown Type: " + type);
});
}
if (path != null) {
setLocation(processPath(path));
}
}
private final StringProperty location = new StringPropertyBase() {
@Override
public Object getBean() {
return LineFileChooserButton.this;
}
@Override
public String getName() {
return "location";
}
@Override
protected void invalidated() {
setTrailingText(get());
}
};
public StringProperty locationProperty() {
return location;
}
public String getLocation() {
return locationProperty().get();
}
public void setLocation(String location) {
locationProperty().set(location);
}
private final StringProperty fileChooserTitle = new SimpleStringProperty(this, "fileChooserTitle");
public StringProperty fileChooserTitleProperty() {
return fileChooserTitle;
}
public String getFileChooserTitle() {
return fileChooserTitleProperty().get();
}
public void setFileChooserTitle(String fileChooserTitle) {
fileChooserTitleProperty().set(fileChooserTitle);
}
private ObjectProperty<Type> type;
public ObjectProperty<Type> typeProperty() {
if (type == null) {
type = new SimpleObjectProperty<>(this, "type", Type.OPEN_FILE);
}
return type;
}
public Type getType() {
return type != null ? type.get() : Type.OPEN_FILE;
}
public void setType(Type type) {
typeProperty().set(type);
}
private ObjectProperty<String> initialFileName;
public final ObjectProperty<String> initialFileNameProperty() {
if (initialFileName == null)
initialFileName = new SimpleObjectProperty<>(this, "initialFileName");
return initialFileName;
}
public final String getInitialFileName() {
return initialFileName != null ? initialFileName.get() : null;
}
public final void setInitialFileName(String value) {
initialFileNameProperty().set(value);
}
private ObservableList<FileChooser.ExtensionFilter> extensionFilters;
public ObservableList<FileChooser.ExtensionFilter> getExtensionFilters() {
if (extensionFilters == null)
extensionFilters = FXCollections.observableArrayList();
return extensionFilters;
}
private BooleanProperty convertToRelativePath;
public BooleanProperty convertToRelativePathProperty() {
if (convertToRelativePath == null)
convertToRelativePath = new BooleanPropertyBase(false) {
@Override
public Object getBean() {
return LineFileChooserButton.this;
}
@Override
public String getName() {
return "convertToRelativePath";
}
@Override
protected void invalidated() {
String location = getLocation();
if (location == null)
return;
try {
setLocation(processPath(FileUtils.toAbsolute(Path.of(getLocation()))));
} catch (IllegalArgumentException ignored) {
}
}
};
return convertToRelativePath;
}
public boolean isConvertToRelativePath() {
return convertToRelativePath != null && convertToRelativePath.get();
}
public void setConvertToRelativePath(boolean convertToRelativePath) {
convertToRelativePathProperty().set(convertToRelativePath);
}
public enum Type {
OPEN_FILE,
OPEN_DIRECTORY,
SAVE_FILE
}
}
@@ -0,0 +1,63 @@
/*
* 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.ui.construct;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ObjectPropertyBase;
import javafx.scene.Node;
/// @author Glavo
public class LinePane extends LineComponent {
private static final String DEFAULT_STYLE_CLASS = "line-pane";
public LinePane() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
}
private ObjectProperty<Node> right;
public ObjectProperty<Node> rightProperty() {
if (right == null) {
right = new ObjectPropertyBase<>() {
@Override
public Object getBean() {
return LinePane.this;
}
@Override
public String getName() {
return "right";
}
@Override
protected void invalidated() {
setNode(IDX_TRAILING, get());
}
};
}
return right;
}
public Node getRight() {
return rightProperty().get();
}
public void setRight(Node right) {
rightProperty().set(right);
}
}
@@ -0,0 +1,216 @@
/*
* 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.ui.construct;
import com.jfoenix.controls.JFXPopup;
import javafx.beans.InvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.css.PseudoClass;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import java.util.Collection;
import java.util.Objects;
import java.util.function.Function;
import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition;
/// @author Glavo
public final class LineSelectButton<T> extends LineButton {
private static final String DEFAULT_STYLE_CLASS = "line-select-button";
private static final PseudoClass SELECTED_PSEUDO_CLASS = PseudoClass.getPseudoClass("selected");
private JFXPopup popup;
public LineSelectButton() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
InvalidationListener updateTrailingText = observable -> {
T value = getValue();
if (value != null) {
Function<T, String> converter = getConverter();
setTrailingText(converter != null ? converter.apply(value) : value.toString());
} else {
setTrailingText(null);
}
};
converterProperty().addListener(updateTrailingText);
valueProperty().addListener(updateTrailingText);
setTrailingIcon(SVG.UNFOLD_MORE);
ripplerContainer.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
if (event.getButton() == MouseButton.SECONDARY) {
if (popup != null)
popup.hide();
event.consume();
}
});
}
@Override
public void fire() {
super.fire();
if (popup == null) {
PopupMenu popupMenu = new PopupMenu();
this.popup = new JFXPopup(popupMenu);
ripplerContainer.addEventFilter(ScrollEvent.ANY, ignored -> popup.hide());
Bindings.bindContent(popupMenu.getContent(), MappedObservableList.create(itemsProperty(), item -> {
VBox vbox = new VBox();
var itemTitleLabel = new Label();
itemTitleLabel.getStyleClass().add("title-label");
itemTitleLabel.textProperty().bind(Bindings.createStringBinding(() -> {
if (item == null)
return "";
Function<T, String> converter = getConverter();
return converter != null ? converter.apply(item) : Objects.toString(item, "");
}, converterProperty()));
var itemSubtitleLabel = new Label();
itemSubtitleLabel.getStyleClass().add("subtitle-label");
itemSubtitleLabel.textProperty().bind(Bindings.createStringBinding(() -> {
Function<T, String> descriptionConverter = getDescriptionConverter();
return descriptionConverter != null ? descriptionConverter.apply(item) : "";
}, descriptionConverterProperty()));
FXUtils.onChangeAndOperate(itemSubtitleLabel.textProperty(), text -> {
if (text == null || text.isEmpty()) {
vbox.getChildren().setAll(itemTitleLabel);
} else {
vbox.getChildren().setAll(itemTitleLabel, itemSubtitleLabel);
}
});
var wrapper = new StackPane(vbox);
wrapper.setAlignment(Pos.CENTER_LEFT);
wrapper.getStyleClass().add("menu-container");
wrapper.setMouseTransparent(true);
RipplerContainer ripplerContainer = new RipplerContainer(wrapper);
FXUtils.onClicked(ripplerContainer, () -> {
setValue(item);
popup.hide();
});
FXUtils.onChangeAndOperate(valueProperty(),
value -> wrapper.pseudoClassStateChanged(SELECTED_PSEUDO_CLASS, Objects.equals(value, item)));
return ripplerContainer;
}));
popup.showingProperty().addListener((observable, oldValue, newValue) ->
ripplerContainer.getRippler().setRipplerDisabled(newValue));
}
if (popup.isShowing()) {
popup.hide();
} else {
JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(this, popup);
popup.show(this, vPosition, JFXPopup.PopupHPosition.RIGHT,
0,
vPosition == JFXPopup.PopupVPosition.TOP ? this.getHeight() : -this.getHeight(),
true);
}
}
private final ObjectProperty<T> value = new SimpleObjectProperty<>(this, "value");
public ObjectProperty<T> valueProperty() {
return value;
}
public T getValue() {
return valueProperty().get();
}
public void setValue(T value) {
valueProperty().set(value);
}
private final ObjectProperty<Function<T, String>> converter = new SimpleObjectProperty<>(this, "converter");
public ObjectProperty<Function<T, String>> converterProperty() {
return converter;
}
public Function<T, String> getConverter() {
return converterProperty().get();
}
public void setConverter(Function<T, String> value) {
converterProperty().set(value);
}
private ObjectProperty<Function<T, String>> descriptionConverter;
public ObjectProperty<Function<T, String>> descriptionConverterProperty() {
if (descriptionConverter == null)
descriptionConverter = new SimpleObjectProperty<>(this, "descriptionConverter");
return descriptionConverter;
}
public Function<T, String> getDescriptionConverter() {
return descriptionConverterProperty().get();
}
public void setDescriptionConverter(Function<T, String> value) {
descriptionConverterProperty().set(value);
}
private final ListProperty<T> items = new SimpleListProperty<>(this, "items", FXCollections.emptyObservableList());
public ListProperty<T> itemsProperty() {
return items;
}
public void setItems(ObservableList<T> value) {
itemsProperty().set(value);
}
public void setItems(Collection<T> value) {
if (value instanceof ObservableList<T> observableList) {
this.setItems(observableList);
} else {
this.setItems(FXCollections.observableArrayList(value));
}
}
@SafeVarargs
public final void setItems(T... values) {
this.setItems(FXCollections.observableArrayList(values));
}
public ObservableList<T> getItems() {
return items.get();
}
}
@@ -0,0 +1,80 @@
/*
* 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.ui.construct;
import javafx.beans.property.StringProperty;
import javafx.beans.property.StringPropertyBase;
import javafx.scene.control.Label;
import org.jackhuang.hmcl.ui.FXUtils;
/// @author Glavo
public final class LineTextPane extends LineComponent {
private static final String DEFAULT_STYLE_CLASS = "line-text-pane";
public LineTextPane() {
this.getStyleClass().addAll(DEFAULT_STYLE_CLASS);
}
private StringProperty text;
public StringProperty textProperty() {
if (text == null) {
text = new StringPropertyBase() {
private Label rightLabel;
@Override
public Object getBean() {
return LineTextPane.this;
}
@Override
public String getName() {
return "text";
}
@Override
protected void invalidated() {
String text = get();
if (text != null && !text.isEmpty()) {
if (rightLabel == null) {
rightLabel = FXUtils.newSafeTruncatedLabel();
FXUtils.copyOnDoubleClick(rightLabel);
}
rightLabel.setText(text);
setNode(IDX_TRAILING, rightLabel);
} else {
if (rightLabel != null)
rightLabel.setText(null);
setNode(IDX_TRAILING, null);
}
}
};
}
return text;
}
public String getText() {
return textProperty().get();
}
public void setText(String text) {
textProperty().set(text);
}
}
@@ -0,0 +1,59 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXToggleButton;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import org.jackhuang.hmcl.ui.FXUtils;
public final class LineToggleButton extends LineButtonBase {
private static final String DEFAULT_STYLE_CLASS = "line-toggle-button";
private final JFXToggleButton toggleButton;
public LineToggleButton() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
this.toggleButton = new JFXToggleButton();
toggleButton.selectedProperty().bindBidirectional(selectedProperty());
toggleButton.setSize(8);
FXUtils.setLimitHeight(toggleButton, 30);
setNode(IDX_TRAILING, toggleButton);
}
@Override
public void fire() {
toggleButton.fire();
super.fire();
}
private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected");
public BooleanProperty selectedProperty() {
return selected;
}
public boolean isSelected() {
return selectedProperty().get();
}
public void setSelected(boolean selected) {
selectedProperty().set(selected);
}
}
@@ -26,7 +26,7 @@ import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.ui.FXUtils;
public abstract class MDListCell<T> extends ListCell<T> {
private final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
private final StackPane container = new StackPane();
private final StackPane root = new StackPane();
@@ -31,16 +31,12 @@ import javafx.scene.text.TextFlow;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.util.Lang;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Locale;
import java.util.Timer;
import java.util.TimerTask;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class MessageDialogPane extends HBox {
@@ -72,7 +68,7 @@ public final class MessageDialogPane extends HBox {
private @Nullable ButtonBase cancelButton;
public MessageDialogPane(@NotNull String text, @Nullable String title, @NotNull MessageType type) {
this.setSpacing(8);
this.setSpacing(16);
this.getStyleClass().add("jfx-dialog-layout");
Label graphic = new Label();
@@ -219,36 +215,6 @@ public final class MessageDialogPane extends HBox {
return this;
}
public Builder cancelOnTimeout(long timeoutMs) {
if (dialog.getCancelButton() == null) {
throw new IllegalStateException("Call ok/yesOrNo/actionOrCancel before calling cancelOnTimeout");
}
ButtonBase cancelButton = dialog.getCancelButton();
String originalText = cancelButton.getText();
Timer timer = Lang.getTimer();
timer.scheduleAtFixedRate(new TimerTask() {
long timeout = timeoutMs;
@Override
public void run() {
if (timeout <= 0) {
cancel();
runInFX(() -> {
cancelButton.fire();
});
return;
}
timeout -= 1000;
long currentTimeout = timeout;
runInFX(() -> cancelButton.setText(originalText + " (" + (currentTimeout / 1000) + ")"));
}
}, 1000, 1000);
return this;
}
public MessageDialogPane build() {
return dialog;
}
@@ -1,164 +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.ui.construct;
import com.jfoenix.controls.JFXColorPicker;
import com.jfoenix.controls.JFXRadioButton;
import javafx.beans.NamedArg;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.Toggle;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import org.jackhuang.hmcl.ui.FXUtils;
import java.util.Collection;
import java.util.Optional;
import java.util.function.Consumer;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class MultiColorItem extends ComponentList {
private final StringProperty customTitle = new SimpleStringProperty(this, "customTitle", i18n("selector.custom"));
private final StringProperty chooserTitle = new SimpleStringProperty(this, "chooserTitle", i18n("selector.choose_file"));
private final ToggleGroup group = new ToggleGroup();
private final JFXColorPicker colorPicker = new JFXColorPicker();
private final JFXRadioButton radioCustom = new JFXRadioButton();
private final BorderPane custom = new BorderPane();
private final VBox pane = new VBox();
private final boolean hasCustom;
private Consumer<Toggle> toggleSelectedListener;
private Consumer<Color> colorConsumer;
public MultiColorItem(@NamedArg(value = "hasCustom", defaultValue = "true") boolean hasCustom) {
this.hasCustom = hasCustom;
radioCustom.textProperty().bind(customTitleProperty());
radioCustom.setToggleGroup(group);
colorPicker.disableProperty().bind(radioCustom.selectedProperty().not());
colorPicker.setOnAction(e -> Optional.ofNullable(colorConsumer).ifPresent(c -> c.accept(colorPicker.getValue())));
custom.setLeft(radioCustom);
custom.setStyle("-fx-padding: 3;");
HBox right = new HBox();
right.setSpacing(3);
right.getChildren().addAll(colorPicker);
custom.setRight(right);
FXUtils.setLimitHeight(custom, 40);
pane.setStyle("-fx-padding: 0 0 10 0;");
pane.setSpacing(8);
if (hasCustom)
pane.getChildren().add(custom);
getContent().add(pane);
group.selectedToggleProperty().addListener((a, b, newValue) -> {
if (toggleSelectedListener != null)
toggleSelectedListener.accept(newValue);
});
}
public Node createChildren(String title) {
return createChildren(title, null);
}
public Node createChildren(String title, Object userData) {
return createChildren(title, "", userData);
}
public Node createChildren(String title, String subtitle, Object userData) {
BorderPane pane = new BorderPane();
pane.setStyle("-fx-padding: 3;");
JFXRadioButton left = new JFXRadioButton(title);
left.setToggleGroup(group);
left.setUserData(userData);
pane.setLeft(left);
Label right = new Label(subtitle);
right.getStyleClass().add("subtitle-label");
right.setStyle("-fx-font-size: 10;");
pane.setRight(right);
return pane;
}
public void loadChildren(Collection<Node> list) {
pane.getChildren().setAll(list);
if (hasCustom)
pane.getChildren().add(custom);
}
public ToggleGroup getGroup() {
return group;
}
public String getCustomTitle() {
return customTitle.get();
}
public StringProperty customTitleProperty() {
return customTitle;
}
public void setCustomTitle(String customTitle) {
this.customTitle.set(customTitle);
}
public String getChooserTitle() {
return chooserTitle.get();
}
public StringProperty chooserTitleProperty() {
return chooserTitle;
}
public void setChooserTitle(String chooserTitle) {
this.chooserTitle.set(chooserTitle);
}
public void setCustomUserData(Object userData) {
radioCustom.setUserData(userData);
}
public boolean isCustomToggle(Toggle toggle) {
return radioCustom == toggle;
}
public void setToggleSelectedListener(Consumer<Toggle> consumer) {
toggleSelectedListener = consumer;
}
public void setOnColorPickerChanged(Consumer<Color> consumer) {
colorConsumer = consumer;
}
public void setColor(Color color) {
colorPicker.setValue(color);
}
}
@@ -0,0 +1,24 @@
/*
* 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.ui.construct;
/// Marker interface for no padding in [ComponentList].
///
/// @author Glavo
interface NoPaddingComponent {
}
@@ -1,113 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXToggleButton;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.StringUtils;
public class OptionToggleButton extends StackPane {
private final StringProperty title = new SimpleStringProperty();
private final StringProperty subtitle = new SimpleStringProperty();
private final BooleanProperty selected = new SimpleBooleanProperty();
public OptionToggleButton() {
getProperties().put("ComponentList.noPadding", true);
BorderPane pane = new BorderPane();
pane.setPadding(new Insets(8, 8, 8, 16));
RipplerContainer container = new RipplerContainer(pane);
getChildren().setAll(container);
VBox left = new VBox();
left.setMouseTransparent(true);
Label titleLabel = new Label();
titleLabel.textProperty().bind(title);
Label subtitleLabel = new Label();
subtitleLabel.setWrapText(true);
subtitleLabel.setMouseTransparent(true);
subtitleLabel.getStyleClass().add("subtitle");
subtitleLabel.textProperty().bind(subtitle);
pane.setCenter(left);
left.setAlignment(Pos.CENTER_LEFT);
JFXToggleButton toggleButton = new JFXToggleButton();
StackPane right = new StackPane(toggleButton);
right.setAlignment(Pos.CENTER);
pane.setRight(right);
toggleButton.selectedProperty().bindBidirectional(selected);
toggleButton.setSize(8);
FXUtils.setLimitHeight(toggleButton, 30);
FXUtils.onClicked(container, () -> toggleButton.setSelected(!toggleButton.isSelected()));
FXUtils.onChangeAndOperate(subtitleProperty(), subtitle -> {
if (StringUtils.isNotBlank(subtitle)) {
left.getChildren().setAll(titleLabel, subtitleLabel);
} else {
left.getChildren().setAll(titleLabel);
}
});
}
public String getTitle() {
return title.get();
}
public StringProperty titleProperty() {
return title;
}
public void setTitle(String title) {
this.title.set(title);
}
public String getSubtitle() {
return subtitle.get();
}
public StringProperty subtitleProperty() {
return subtitle;
}
public void setSubtitle(String subtitle) {
this.subtitle.set(subtitle);
}
public boolean isSelected() {
return selected.get();
}
public BooleanProperty selectedProperty() {
return selected;
}
public void setSelected(boolean selected) {
this.selected.set(selected);
}
}
@@ -0,0 +1,236 @@
/*
* 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.ui.construct;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.css.CssMetaData;
import javafx.css.Styleable;
import javafx.css.StyleableObjectProperty;
import javafx.css.StyleableProperty;
import javafx.css.converter.InsetsConverter;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.control.Skin;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
// TODO: We plan to replace ComponentList with this class, but we need to address some issues first
/// @author Glavo
public final class OptionsList extends Control {
public OptionsList() {
this.getStyleClass().add("options-list");
}
@Override
protected Skin<?> createDefaultSkin() {
return new OptionsListSkin(this);
}
private final ObservableList<Element> elements = FXCollections.observableArrayList();
public ObservableList<Element> getElements() {
return elements;
}
public void addTitle(String title) {
elements.add(new Title(title));
}
public void addNode(Node node) {
elements.add(new NodeElement(node));
}
public void addListElement(@NotNull Node node) {
elements.add(new ListElement(node));
}
public void addListElements(@NotNull Node... nodes) {
for (Node node : nodes) {
elements.add(new ListElement(node));
}
}
private final StyleableObjectProperty<Insets> contentPadding = new StyleableObjectProperty<>() {
@Override
public Object getBean() {
return OptionsList.this;
}
@Override
public String getName() {
return "contentPadding";
}
@Override
public javafx.css.CssMetaData<OptionsList, Insets> getCssMetaData() {
return StyleableProperties.CONTENT_PADDING;
}
};
public StyleableObjectProperty<Insets> contentPaddingProperty() {
return contentPadding;
}
public Insets getContentPadding() {
return contentPaddingProperty().get();
}
public void setContentPadding(Insets padding) {
contentPaddingProperty().set(padding);
}
private static final class StyleableProperties {
private static final CssMetaData<OptionsList, Insets> CONTENT_PADDING = new CssMetaData<>("-jfx-content-padding", InsetsConverter.getInstance()) {
@Override
public boolean isSettable(OptionsList styleable) {
return styleable.contentPadding == null || !styleable.contentPadding.isBound();
}
@Override
public StyleableProperty<Insets> getStyleableProperty(OptionsList styleable) {
return styleable.contentPaddingProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
static {
List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
Collections.addAll(styleables, CONTENT_PADDING);
STYLEABLES = List.copyOf(styleables);
}
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.STYLEABLES;
}
@Override
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
return getClassCssMetaData();
}
public static abstract class Element {
protected Node node;
Node getNode() {
if (node == null)
node = createNode();
return node;
}
protected abstract Node createNode();
}
public static final class Title extends Element {
private final @NotNull String title;
public Title(@NotNull String title) {
this.title = title;
}
@Override
protected Node createNode() {
Label label = new Label(title);
label.setPadding(new Insets(8, 0, 8, 0));
return label;
}
@Override
public boolean equals(Object obj) {
return this == obj || obj instanceof Title that && Objects.equals(this.title, that.title);
}
@Override
public int hashCode() {
return title.hashCode();
}
@Override
public String toString() {
return "Title[%s]".formatted(title);
}
}
public static final class NodeElement extends Element {
public NodeElement(@NotNull Node node) {
this.node = node;
}
@Override
protected Node createNode() {
return node;
}
@Override
public boolean equals(Object obj) {
return this == obj || obj instanceof NodeElement that && this.node.equals(that.node);
}
@Override
public int hashCode() {
return node.hashCode();
}
@Override
public String toString() {
return "NodeElement[node=%s]".formatted(node);
}
}
public static final class ListElement extends Element {
private final Node original;
public ListElement(@NotNull Node node) {
this.original = node;
}
@Override
protected Node createNode() {
if (original instanceof ComponentSublist sublist) {
return new ComponentSublistWrapper(sublist);
} else {
return original;
}
}
@Override
public boolean equals(Object obj) {
return this == obj || obj instanceof ListElement that && this.original.equals(that.original);
}
@Override
public int hashCode() {
return original.hashCode();
}
@Override
public String toString() {
return "ListElement[node=%s]".formatted(original);
}
}
}
@@ -0,0 +1,152 @@
/*
* 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.ui.construct;
import com.jfoenix.controls.JFXListView;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.collections.ObservableList;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ListCell;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.ui.FXUtils;
/// @author Glavo
public final class OptionsListSkin extends SkinBase<OptionsList> {
private final JFXListView<OptionsList.Element> listView;
private final ObjectBinding<ContentPaddings> contentPaddings;
OptionsListSkin(OptionsList control) {
super(control);
this.listView = new JFXListView<>();
listView.setItems(control.getElements());
listView.setCellFactory(listView1 -> new Cell());
this.contentPaddings = Bindings.createObjectBinding(() -> {
Insets padding = control.getContentPadding();
return padding == null ? ContentPaddings.EMPTY : new ContentPaddings(
new Insets(padding.getTop(), padding.getRight(), 0, padding.getLeft()),
new Insets(0, padding.getRight(), padding.getBottom(), padding.getLeft()),
new Insets(0, padding.getRight(), 0, padding.getLeft())
);
}, control.contentPaddingProperty());
this.getChildren().setAll(listView);
}
private record ContentPaddings(Insets first, Insets last, Insets middle) {
static final ContentPaddings EMPTY = new ContentPaddings(Insets.EMPTY, Insets.EMPTY, Insets.EMPTY);
}
private final class Cell extends ListCell<OptionsList.Element> {
private static final PseudoClass PSEUDO_CLASS_FIRST = PseudoClass.getPseudoClass("first");
private static final PseudoClass PSEUDO_CLASS_LAST = PseudoClass.getPseudoClass("last");
@SuppressWarnings("FieldCanBeLocal")
private final InvalidationListener updateStyleListener = o -> updateStyle();
private StackPane wrapper;
public Cell() {
FXUtils.limitCellWidth(listView, this);
WeakInvalidationListener weakListener = new WeakInvalidationListener(updateStyleListener);
listView.itemsProperty().addListener((o, oldValue, newValue) -> {
if (oldValue != null)
oldValue.removeListener(weakListener);
if (newValue != null)
newValue.addListener(weakListener);
weakListener.invalidated(o);
});
itemProperty().addListener(weakListener);
contentPaddings.addListener(weakListener);
}
@Override
protected void updateItem(OptionsList.Element item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
setGraphic(null);
} else if (item instanceof OptionsList.ListElement element) {
if (wrapper == null)
wrapper = createWrapper();
else
wrapper.getStyleClass().remove("no-padding");
Node node = element.getNode();
if (node instanceof NoPaddingComponent || node.getProperties().containsKey("ComponentList.noPadding"))
wrapper.getStyleClass().add("no-padding");
wrapper.getChildren().setAll(node);
setGraphic(wrapper);
} else {
setGraphic(item.getNode());
}
updateStyle();
}
private StackPane createWrapper() {
var wrapper = new StackPane();
wrapper.setAlignment(Pos.CENTER_LEFT);
wrapper.getStyleClass().add("options-list-item");
updateStyle();
return wrapper;
}
private void updateStyle() {
OptionsList.Element item = getItem();
int index = getIndex();
ObservableList<OptionsList.Element> items = getListView().getItems();
if (item == null || index < 0 || index >= items.size()) {
this.setPadding(Insets.EMPTY);
return;
}
boolean isFirst = index == 0;
boolean isLast = index == items.size() - 1;
ContentPaddings paddings = contentPaddings.get();
if (isFirst) {
this.setPadding(paddings.first);
} else if (isLast) {
this.setPadding(paddings.last);
} else {
this.setPadding(paddings.middle);
}
if (item instanceof OptionsList.ListElement && wrapper != null) {
wrapper.pseudoClassStateChanged(PSEUDO_CLASS_FIRST, isFirst || !(items.get(index - 1) instanceof OptionsList.ListElement));
wrapper.pseudoClassStateChanged(PSEUDO_CLASS_LAST, isLast || !(items.get(index + 1) instanceof OptionsList.ListElement));
}
}
}
}
@@ -78,6 +78,8 @@ public class PopupMenu extends Control {
ScrollPane scrollPane = new ScrollPane();
scrollPane.setFitToHeight(true);
scrollPane.setFitToWidth(true);
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scrollPane.vbarPolicyProperty().bind(new When(alwaysShowingVBar)
.then(ScrollPane.ScrollBarPolicy.ALWAYS)
.otherwise(ScrollPane.ScrollBarPolicy.AS_NEEDED));
@@ -117,11 +117,17 @@ public class PromptDialogPane extends DialogPane {
protected void onAccept() {
setLoading();
builder.callback.call(builder.questions, () -> {
future.complete(builder.questions);
runInFX(this::onSuccess);
}, msg -> {
runInFX(() -> onFailure(msg));
builder.callback.call(builder.questions, new FutureCallback.ResultHandler() {
@Override
public void resolve() {
future.complete(builder.questions);
runInFX(() -> onSuccess());
}
@Override
public void reject(String reason) {
runInFX(() -> onFailure(reason));
}
});
}
@@ -19,17 +19,12 @@ package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXRippler;
import javafx.animation.Transition;
import javafx.beans.DefaultProperty;
import javafx.beans.InvalidationListener;
import javafx.beans.NamedArg;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.css.*;
import javafx.css.converter.PaintConverter;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.CornerRadii;
@@ -37,39 +32,41 @@ import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.shape.Rectangle;
import javafx.util.Duration;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jackhuang.hmcl.util.Lang;
import java.util.ArrayList;
import java.util.List;
@DefaultProperty("container")
public class RipplerContainer extends StackPane {
private static final String DEFAULT_STYLE_CLASS = "rippler-container";
private static final Duration DURATION = Duration.millis(200);
private static final CornerRadii DEFAULT_RADII = new CornerRadii(3);
private static final Color DEFAULT_RIPPLER_FILL = Color.rgb(0, 200, 255);
private final ObjectProperty<Node> container = new SimpleObjectProperty<>(this, "container", null);
private final StyleableObjectProperty<Paint> ripplerFill = new SimpleStyleableObjectProperty<>(StyleableProperties.RIPPLER_FILL, this, "ripplerFill", null);
private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected", false);
private final Node container;
private final StackPane buttonContainer = new StackPane();
private final JFXRippler buttonRippler = new JFXRippler(new StackPane()) {
private static final Background DEFAULT_MASK_BACKGROUND = new Background(new BackgroundFill(Color.WHITE, DEFAULT_RADII, Insets.EMPTY));
@Override
protected Node getMask() {
StackPane mask = new StackPane();
mask.shapeProperty().bind(buttonContainer.shapeProperty());
mask.backgroundProperty().bind(Bindings.createObjectBinding(() -> new Background(new BackgroundFill(Color.WHITE, buttonContainer.getBackground() != null && buttonContainer.getBackground().getFills().size() > 0 ? buttonContainer.getBackground().getFills().get(0).getRadii() : defaultRadii, buttonContainer.getBackground() != null && buttonContainer.getBackground().getFills().size() > 0 ? buttonContainer.getBackground().getFills().get(0).getInsets() : Insets.EMPTY)), buttonContainer.backgroundProperty()));
mask.resize(buttonContainer.getWidth() - buttonContainer.snappedRightInset() - buttonContainer.snappedLeftInset(), buttonContainer.getHeight() - buttonContainer.snappedBottomInset() - buttonContainer.snappedTopInset());
mask.setBackground(DEFAULT_MASK_BACKGROUND);
mask.resize(
buttonContainer.getWidth() - buttonContainer.snappedRightInset() - buttonContainer.snappedLeftInset(),
buttonContainer.getHeight() - buttonContainer.snappedBottomInset() - buttonContainer.snappedTopInset()
);
return mask;
}
};
private final CornerRadii defaultRadii = new CornerRadii(3);
private Transition coverAnimation;
public RipplerContainer(@NamedArg("container") Node container) {
setContainer(container);
public RipplerContainer(Node container) {
this.container = container;
getStyleClass().add(DEFAULT_STYLE_CLASS);
buttonRippler.setPosition(JFXRippler.RipplerPos.BACK);
@@ -86,71 +83,79 @@ public class RipplerContainer extends StackPane {
setPickOnBounds(false);
buttonContainer.setPickOnBounds(false);
buttonRippler.ripplerFillProperty().bind(ripplerFillProperty());
containerProperty().addListener(o -> updateChildren());
updateChildren();
InvalidationListener listener = o -> {
if (isSelected()) setBackground(new Background(new BackgroundFill(getRipplerFill(), defaultRadii, null)));
else setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, defaultRadii, null)));
};
selectedProperty().addListener(listener);
selectedProperty().addListener((a, b, newValue) ->
pseudoClassStateChanged(PseudoClass.getPseudoClass("selected"), newValue));
ripplerFillProperty().addListener(listener);
setShape(Lang.apply(new Rectangle(), rectangle -> {
rectangle.widthProperty().bind(widthProperty());
rectangle.heightProperty().bind(heightProperty());
}));
var shape = new Rectangle();
shape.widthProperty().bind(widthProperty());
shape.heightProperty().bind(heightProperty());
setShape(shape);
EventHandler<MouseEvent> mouseEventHandler;
if (AnimationUtils.isAnimationEnabled()) {
setOnMouseEntered(e -> new Transition() {
{
setCycleDuration(DURATION);
setInterpolator(Motion.EASE_IN);
mouseEventHandler = event -> {
if (coverAnimation != null) {
coverAnimation.stop();
coverAnimation = null;
}
@Override
protected void interpolate(double frac) {
interpolateBackground(frac);
}
}.play());
if (event.getEventType() == MouseEvent.MOUSE_ENTERED) {
coverAnimation = new Transition() {
{
setCycleDuration(Motion.SHORT4);
setInterpolator(Motion.EASE_IN);
}
setOnMouseExited(e -> new Transition() {
{
setCycleDuration(DURATION);
setInterpolator(Motion.EASE_OUT);
@Override
protected void interpolate(double frac) {
interpolateBackground(frac);
}
};
} else {
coverAnimation = new Transition() {
{
setCycleDuration(Motion.SHORT4);
setInterpolator(Motion.EASE_OUT);
}
@Override
protected void interpolate(double frac) {
interpolateBackground(1 - frac);
}
};
}
@Override
protected void interpolate(double frac) {
interpolateBackground(1 - frac);
}
}.play());
coverAnimation.play();
};
} else {
setOnMouseEntered(e -> interpolateBackground(1));
setOnMouseExited(e -> interpolateBackground(0));
mouseEventHandler = event ->
interpolateBackground(event.getEventType() == MouseEvent.MOUSE_ENTERED ? 1 : 0);
}
addEventHandler(MouseEvent.MOUSE_ENTERED, mouseEventHandler);
addEventHandler(MouseEvent.MOUSE_EXITED, mouseEventHandler);
}
private void interpolateBackground(double frac) {
Color onSurface = Themes.getColorScheme().getOnSurface();
setBackground(new Background(new BackgroundFill(
Color.color(onSurface.getRed(), onSurface.getGreen(), onSurface.getBlue(), frac * 0.04),
CornerRadii.EMPTY, Insets.EMPTY)));
if (frac < 0.01) {
setBackground(null);
} else {
Color onSurface = Themes.getColorScheme().getOnSurface();
setBackground(new Background(new BackgroundFill(
Color.color(onSurface.getRed(), onSurface.getGreen(), onSurface.getBlue(), frac * 0.04),
CornerRadii.EMPTY, Insets.EMPTY)));
}
}
protected void updateChildren() {
if (buttonRippler.getPosition() == JFXRippler.RipplerPos.BACK)
getChildren().setAll(buttonContainer, getContainer());
else
getChildren().setAll(getContainer(), buttonContainer);
for (int i = 1; i < getChildren().size(); ++i)
getChildren().get(i).setPickOnBounds(false);
Node container = getContainer();
if (buttonRippler.getPosition() == JFXRippler.RipplerPos.BACK) {
getChildren().setAll(buttonContainer, container);
container.setPickOnBounds(false);
} else {
getChildren().setAll(container, buttonContainer);
buttonContainer.setPickOnBounds(false);
}
}
public void setPosition(JFXRippler.RipplerPos pos) {
@@ -158,40 +163,46 @@ public class RipplerContainer extends StackPane {
updateChildren();
}
public Node getContainer() {
return container.get();
public JFXRippler getRippler() {
return buttonRippler;
}
public ObjectProperty<Node> containerProperty() {
public Node getContainer() {
return container;
}
public void setContainer(Node container) {
this.container.set(container);
}
private final StyleableObjectProperty<Paint> ripplerFill = new StyleableObjectProperty<>(DEFAULT_RIPPLER_FILL) {
@Override
public Object getBean() {
return RipplerContainer.this;
}
public Paint getRipplerFill() {
return ripplerFill.get();
}
@Override
public String getName() {
return "ripplerFill";
}
@Override
public CssMetaData<? extends Styleable, Paint> getCssMetaData() {
return StyleableProperties.RIPPLER_FILL;
}
@Override
protected void invalidated() {
buttonRippler.setRipplerFill(get());
}
};
public StyleableObjectProperty<Paint> ripplerFillProperty() {
return ripplerFill;
}
public Paint getRipplerFill() {
return ripplerFillProperty().get();
}
public void setRipplerFill(Paint ripplerFill) {
this.ripplerFill.set(ripplerFill);
}
public boolean isSelected() {
return selected.get();
}
public BooleanProperty selectedProperty() {
return selected;
}
public void setSelected(boolean selected) {
this.selected.set(selected);
ripplerFillProperty().set(ripplerFill);
}
@Override
@@ -200,12 +211,28 @@ public class RipplerContainer extends StackPane {
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.FACTORY.getCssMetaData();
return StyleableProperties.STYLEABLES;
}
private final static class StyleableProperties {
private static final StyleablePropertyFactory<RipplerContainer> FACTORY = new StyleablePropertyFactory<>(StackPane.getClassCssMetaData());
private static final CssMetaData<RipplerContainer, Paint> RIPPLER_FILL = new CssMetaData<>("-jfx-rippler-fill", PaintConverter.getInstance(), DEFAULT_RIPPLER_FILL) {
@Override
public boolean isSettable(RipplerContainer styleable) {
return styleable.ripplerFill == null || !styleable.ripplerFill.isBound();
}
private static final CssMetaData<RipplerContainer, Paint> RIPPLER_FILL = FACTORY.createPaintCssMetaData("-jfx-rippler-fill", s -> s.ripplerFill, Color.rgb(0, 200, 255));
@Override
public StyleableProperty<Paint> getStyleableProperty(RipplerContainer styleable) {
return styleable.ripplerFillProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
static {
var styleables = new ArrayList<>(StackPane.getClassCssMetaData());
styleables.add(RIPPLER_FILL);
STYLEABLES = List.copyOf(styleables);
}
}
}
@@ -18,8 +18,6 @@
package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXSpinner;
import javafx.beans.DefaultProperty;
import javafx.beans.InvalidationListener;
import javafx.beans.property.*;
import javafx.event.Event;
import javafx.event.EventHandler;
@@ -33,14 +31,12 @@ import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
@DefaultProperty("content")
/// A spinner pane that can show spinner, failed reason, or content.
public class SpinnerPane extends Control {
private final ObjectProperty<Node> content = new SimpleObjectProperty<>(this, "content");
private final BooleanProperty loading = new SimpleBooleanProperty(this, "loading");
private final StringProperty failedReason = new SimpleStringProperty(this, "failedReason");
private static final String DEFAULT_STYLE_CLASS = "spinner-pane";
public SpinnerPane() {
getStyleClass().add("spinner-pane");
getStyleClass().add(DEFAULT_STYLE_CLASS);
}
public void showSpinner() {
@@ -52,108 +48,195 @@ public class SpinnerPane extends Control {
setLoading(false);
}
public Node getContent() {
return content.get();
private void updateContent() {
if (getSkin() instanceof Skin skin) {
skin.updateContent();
}
}
private ObjectProperty<Node> content;
public ObjectProperty<Node> contentProperty() {
if (content == null)
content = new ObjectPropertyBase<>() {
@Override
public Object getBean() {
return SpinnerPane.this;
}
@Override
public String getName() {
return "content";
}
@Override
protected void invalidated() {
updateContent();
}
};
return content;
}
public void setContent(Node content) {
this.content.set(content);
public Node getContent() {
return contentProperty().get();
}
public boolean isLoading() {
return loading.get();
public void setContent(Node content) {
contentProperty().set(content);
}
private BooleanProperty loading;
public BooleanProperty loadingProperty() {
if (loading == null)
loading = new BooleanPropertyBase() {
@Override
public Object getBean() {
return SpinnerPane.this;
}
@Override
public String getName() {
return "loading";
}
@Override
protected void invalidated() {
updateContent();
}
};
return loading;
}
public void setLoading(boolean loading) {
this.loading.set(loading);
public boolean isLoading() {
return loading != null && loading.get();
}
public String getFailedReason() {
return failedReason.get();
public void setLoading(boolean loading) {
loadingProperty().set(loading);
}
private StringProperty failedReason;
public StringProperty failedReasonProperty() {
if (failedReason == null)
failedReason = new StringPropertyBase() {
@Override
public Object getBean() {
return SpinnerPane.this;
}
@Override
public String getName() {
return "failedReason";
}
@Override
protected void invalidated() {
updateContent();
}
};
return failedReason;
}
public void setFailedReason(String failedReason) {
this.failedReason.set(failedReason);
public String getFailedReason() {
return failedReason != null ? failedReason.get() : null;
}
public void setFailedReason(String failedReason) {
failedReasonProperty().set(failedReason);
}
private ObjectProperty<EventHandler<Event>> onFailedAction;
public final ObjectProperty<EventHandler<Event>> onFailedActionProperty() {
if (onFailedAction == null) {
onFailedAction = new ObjectPropertyBase<>() {
@Override
public Object getBean() {
return SpinnerPane.this;
}
@Override
public String getName() {
return "onFailedAction";
}
@Override
protected void invalidated() {
setEventHandler(FAILED_ACTION, get());
}
};
}
return onFailedAction;
}
public final EventHandler<Event> getOnFailedAction() {
return onFailedAction != null ? onFailedAction.get() : null;
}
public final void setOnFailedAction(EventHandler<Event> value) {
onFailedActionProperty().set(value);
}
public final EventHandler<Event> getOnFailedAction() {
return onFailedActionProperty().get();
}
private final ObjectProperty<EventHandler<Event>> onFailedAction = new SimpleObjectProperty<EventHandler<Event>>(this, "onFailedAction") {
@Override
protected void invalidated() {
setEventHandler(FAILED_ACTION, get());
}
};
@Override
protected SkinBase<SpinnerPane> createDefaultSkin() {
return new Skin(this);
}
private static final class Skin extends SkinBase<SpinnerPane> {
private final JFXSpinner spinner = new JFXSpinner();
private final StackPane contentPane = new StackPane();
private final StackPane topPane = new StackPane();
private final TransitionPane root = new TransitionPane();
private final StackPane failedPane = new StackPane();
private final Label failedReasonLabel = new Label();
@SuppressWarnings("FieldCanBeLocal") // prevent from gc.
private final InvalidationListener observer;
Skin(SpinnerPane control) {
super(control);
topPane.getChildren().setAll(spinner);
topPane.getStyleClass().add("notice-pane");
failedPane.getStyleClass().add("notice-pane");
failedPane.getChildren().setAll(failedReasonLabel);
FXUtils.onClicked(failedPane, () -> {
EventHandler<Event> action = control.getOnFailedAction();
if (action != null)
action.handle(new Event(FAILED_ACTION));
});
updateContent();
this.getChildren().setAll(root);
}
FXUtils.onChangeAndOperate(getSkinnable().content, newValue -> {
if (newValue == null) {
private StackPane contentPane;
private StackPane spinnerPane;
private StackPane failedPane;
private Label failedReasonLabel;
void updateContent() {
SpinnerPane control = getSkinnable();
Node nextContent;
if (control.isLoading()) {
if (spinnerPane == null) {
spinnerPane = new StackPane(new JFXSpinner());
spinnerPane.getStyleClass().add("notice-pane");
}
nextContent = spinnerPane;
} else if (control.getFailedReason() != null) {
if (failedPane == null) {
failedReasonLabel = new Label();
failedPane = new StackPane(failedReasonLabel);
failedPane.getStyleClass().add("notice-pane");
FXUtils.onClicked(failedPane, () -> control.fireEvent(new Event(SpinnerPane.FAILED_ACTION)));
}
failedReasonLabel.setText(control.getFailedReason());
nextContent = failedPane;
} else {
if (contentPane == null) {
contentPane = new StackPane();
}
Node content = control.getContent();
if (content != null)
contentPane.getChildren().setAll(content);
else
contentPane.getChildren().clear();
} else {
contentPane.getChildren().setAll(newValue);
}
});
getChildren().setAll(root);
observer = FXUtils.observeWeak(() -> {
if (getSkinnable().getFailedReason() != null) {
root.setContent(failedPane, ContainerAnimations.FADE);
failedReasonLabel.setText(getSkinnable().getFailedReason());
} else if (getSkinnable().isLoading()) {
root.setContent(topPane, ContainerAnimations.FADE);
} else {
root.setContent(contentPane, ContainerAnimations.FADE);
}
}, getSkinnable().loadingProperty(), getSkinnable().failedReasonProperty());
nextContent = contentPane;
}
if (nextContent != failedPane && failedReasonLabel != null) {
failedReasonLabel.setText(null);
}
root.setContent(nextContent, ContainerAnimations.FADE);
}
}
@@ -41,6 +41,7 @@ import org.jackhuang.hmcl.download.forge.ForgeOldInstallTask;
import org.jackhuang.hmcl.download.game.GameAssetDownloadTask;
import org.jackhuang.hmcl.download.game.GameInstallTask;
import org.jackhuang.hmcl.download.java.mojang.MojangJavaDownloadTask;
import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricInstallTask;
import org.jackhuang.hmcl.download.liteloader.LiteLoaderInstallTask;
import org.jackhuang.hmcl.download.neoforge.NeoForgeInstallTask;
import org.jackhuang.hmcl.download.neoforge.NeoForgeOldInstallTask;
@@ -164,6 +165,8 @@ public final class TaskListPane extends StackPane {
task.setName(i18n("install.installer.install", i18n("install.installer.game")));
} else if (task instanceof CleanroomInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.cleanroom")));
} else if (task instanceof LegacyFabricInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.legacyfabric")));
} else if (task instanceof ForgeNewInstallTask || task instanceof ForgeOldInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.forge")));
} else if (task instanceof NeoForgeInstallTask || task instanceof NeoForgeOldInstallTask) {
@@ -323,8 +326,7 @@ public final class TaskListPane extends StackPane {
if (prevStageNodeRef != null && (prevStageNode = prevStageNodeRef.get()) != null)
prevStageNode.status.removeListener(statusChangeListener);
if (item instanceof ProgressListNode) {
var progressListNode = (ProgressListNode) item;
if (item instanceof ProgressListNode progressListNode) {
title.setText(progressListNode.title);
message.textProperty().bind(progressListNode.message);
bar.progressProperty().bind(progressListNode.progress);
@@ -333,8 +335,7 @@ public final class TaskListPane extends StackPane {
pane.setLeft(null);
pane.setRight(message);
pane.setBottom(bar);
} else if (item instanceof StageNode) {
var stageNode = (StageNode) item;
} else if (item instanceof StageNode stageNode) {
title.textProperty().bind(stageNode.title);
message.setText("");
bar.setProgress(ProgressIndicator.INDETERMINATE_PROGRESS);
@@ -427,23 +428,25 @@ public final class TaskListPane extends StackPane {
// CHECKSTYLE:OFF
// @formatter:off
switch (stageKey) {
case "hmcl.modpack": message = i18n("install.modpack"); break;
case "hmcl.modpack.download": message = i18n("launch.state.modpack"); break;
case "hmcl.install.assets": message = i18n("assets.download"); break;
case "hmcl.install.libraries": message = i18n("libraries.download"); break;
case "hmcl.install.game": message = i18n("install.installer.install", i18n("install.installer.game") + " " + stageValue); break;
case "hmcl.install.forge": message = i18n("install.installer.install", i18n("install.installer.forge") + " " + stageValue); break;
case "hmcl.install.cleanroom": message = i18n("install.installer.install", i18n("install.installer.cleanroom") + " " + stageValue); break;
case "hmcl.install.neoforge": message = i18n("install.installer.install", i18n("install.installer.neoforge") + " " + stageValue); break;
case "hmcl.install.liteloader": message = i18n("install.installer.install", i18n("install.installer.liteloader") + " " + stageValue); break;
case "hmcl.install.optifine": message = i18n("install.installer.install", i18n("install.installer.optifine") + " " + stageValue); break;
case "hmcl.install.fabric": message = i18n("install.installer.install", i18n("install.installer.fabric") + " " + stageValue); break;
case "hmcl.install.fabric-api": message = i18n("install.installer.install", i18n("install.installer.fabric-api") + " " + stageValue); break;
case "hmcl.install.quilt": message = i18n("install.installer.install", i18n("install.installer.quilt") + " " + stageValue); break;
case "hmcl.install.quilt-api": message = i18n("install.installer.install", i18n("install.installer.quilt-api") + " " + stageValue); break;
default: message = i18n(stageKey); break;
}
message = switch (stageKey) {
case "hmcl.modpack" -> i18n("install.modpack");
case "hmcl.modpack.download" -> i18n("launch.state.modpack");
case "hmcl.install.assets" -> i18n("assets.download");
case "hmcl.install.libraries" -> i18n("libraries.download");
case "hmcl.install.game" -> i18n("install.installer.install", i18n("install.installer.game") + " " + stageValue);
case "hmcl.install.forge" -> i18n("install.installer.install", i18n("install.installer.forge") + " " + stageValue);
case "hmcl.install.cleanroom" -> i18n("install.installer.install", i18n("install.installer.cleanroom") + " " + stageValue);
case "hmcl.install.neoforge" -> i18n("install.installer.install", i18n("install.installer.neoforge") + " " + stageValue);
case "hmcl.install.liteloader" -> i18n("install.installer.install", i18n("install.installer.liteloader") + " " + stageValue);
case "hmcl.install.optifine" -> i18n("install.installer.install", i18n("install.installer.optifine") + " " + stageValue);
case "hmcl.install.fabric" -> i18n("install.installer.install", i18n("install.installer.fabric") + " " + stageValue);
case "hmcl.install.fabric-api" -> i18n("install.installer.install", i18n("install.installer.fabric-api") + " " + stageValue);
case "hmcl.install.legacyfabric" -> i18n("install.installer.install", i18n("install.installer.legacyfabric") + " " + stageValue);
case "hmcl.install.legacyfabric-api" -> i18n("install.installer.install", i18n("install.installer.legacyfabric-api") + " " + stageValue);
case "hmcl.install.quilt" -> i18n("install.installer.install", i18n("install.installer.quilt") + " " + stageValue);
case "hmcl.install.quilt-api" -> i18n("install.installer.install", i18n("install.installer.quilt-api") + " " + stageValue);
default -> i18n(stageKey);
};
// @formatter:on
// CHECKSTYLE:ON
@@ -488,12 +491,10 @@ public final class TaskListPane extends StackPane {
private ProgressListNode(Task<?> task) {
this.title = task.getName();
message.bind(task.messageProperty());
progress.bind(task.progressProperty());
}
public void unbind() {
message.unbind();
progress.unbind();
}
@@ -18,115 +18,185 @@
package org.jackhuang.hmcl.ui.construct;
import javafx.beans.binding.Bindings;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.property.StringPropertyBase;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.css.PseudoClass;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.AggregatedObservableList;
public class TwoLineListItem extends VBox {
private static final String DEFAULT_STYLE_CLASS = "two-line-list-item";
private static Label createTagLabel(String tag) {
Label tagLabel = new Label();
tagLabel.setText(tag);
HBox.setMargin(tagLabel, new Insets(0, 8, 0, 0));
return tagLabel;
private final HBox firstLine;
private HBox secondLine;
private final Label lblTitle;
private Label lblSubtitle;
public TwoLineListItem() {
getStyleClass().add(DEFAULT_STYLE_CLASS);
setMouseTransparent(true);
lblTitle = new Label();
lblTitle.getStyleClass().add("title");
this.firstLine = new HBox(lblTitle);
firstLine.getStyleClass().add("first-line");
firstLine.setAlignment(Pos.CENTER_LEFT);
this.getChildren().setAll(firstLine);
}
private final StringProperty title = new SimpleStringProperty(this, "title");
private final ObservableList<Label> tags = FXCollections.observableArrayList();
private final StringProperty subtitle = new SimpleStringProperty(this, "subtitle");
private final AggregatedObservableList<Node> firstLineChildren;
public TwoLineListItem(String titleString, String subtitleString) {
this();
title.set(titleString);
subtitle.set(subtitleString);
setTitle(titleString);
setSubtitle(subtitleString);
}
public TwoLineListItem() {
setMouseTransparent(true);
private void initSecondLine() {
if (secondLine == null) {
lblSubtitle = new Label();
lblSubtitle.getStyleClass().add("subtitle");
HBox firstLine = new HBox();
firstLine.getStyleClass().add("first-line");
secondLine = new HBox(lblSubtitle);
}
}
Label lblTitle = new Label();
lblTitle.getStyleClass().add("title");
lblTitle.textProperty().bind(title);
private final StringProperty title = new StringPropertyBase() {
@Override
public Object getBean() {
return TwoLineListItem.this;
}
firstLineChildren = new AggregatedObservableList<>();
firstLineChildren.appendList(FXCollections.singletonObservableList(lblTitle));
firstLineChildren.appendList(tags);
Bindings.bindContent(firstLine.getChildren(), firstLineChildren.getAggregatedList());
@Override
public String getName() {
return "title";
}
Label lblSubtitle = new Label();
lblSubtitle.getStyleClass().add("subtitle");
lblSubtitle.textProperty().bind(subtitle);
@Override
protected void invalidated() {
lblTitle.setText(get());
}
};
HBox secondLine = new HBox();
secondLine.getChildren().setAll(lblSubtitle);
getChildren().setAll(firstLine, secondLine);
FXUtils.onChangeAndOperate(subtitle, subtitleString -> {
if (subtitleString == null) getChildren().setAll(firstLine);
else getChildren().setAll(firstLine, secondLine);
});
getStyleClass().add(DEFAULT_STYLE_CLASS);
public StringProperty titleProperty() {
return title;
}
public String getTitle() {
return title.get();
}
public StringProperty titleProperty() {
return title;
}
public void setTitle(String title) {
this.title.set(title);
}
public String getSubtitle() {
return subtitle.get();
}
private StringProperty subtitle;
public StringProperty subtitleProperty() {
if (subtitle == null) {
subtitle = new StringPropertyBase() {
@Override
public Object getBean() {
return TwoLineListItem.this;
}
@Override
public String getName() {
return "subtitle";
}
@Override
protected void invalidated() {
String subtitle = get();
if (subtitle != null) {
initSecondLine();
lblSubtitle.setText(subtitle);
if (getChildren().size() == 1)
getChildren().add(secondLine);
} else if (secondLine != null) {
lblSubtitle.setText(null);
if (getChildren().size() > 1)
getChildren().setAll(firstLine);
}
}
};
}
return subtitle;
}
public String getSubtitle() {
return subtitle != null ? subtitleProperty().get() : null;
}
public void setSubtitle(String subtitle) {
this.subtitle.set(subtitle);
if (this.subtitle == null && subtitle == null)
return;
subtitleProperty().set(subtitle);
}
public Label getTitleLabel() {
return lblTitle;
}
public Label getSubtitleLabel() {
initSecondLine();
return lblSubtitle;
}
private ObservableList<Label> tags;
public ObservableList<Label> getTags() {
if (tags == null) {
tags = FXCollections.observableArrayList();
var tagsBox = new HBox(8);
tagsBox.getStyleClass().add("tags");
tagsBox.setAlignment(Pos.CENTER_LEFT);
Bindings.bindContent(tagsBox.getChildren(), tags);
var scrollPane = new ScrollPane(tagsBox);
HBox.setHgrow(scrollPane, Priority.ALWAYS);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
FXUtils.onChangeAndOperate(tagsBox.heightProperty(), height -> FXUtils.setLimitHeight(scrollPane, height.doubleValue()));
scrollPane.setPrefWidth(50);
firstLine.getChildren().setAll(lblTitle, scrollPane);
}
return tags;
}
public void addTag(String tag, PseudoClass pseudoClass) {
var tagLabel = new Label(tag);
tagLabel.getStyleClass().add("tag");
if (pseudoClass != null)
tagLabel.pseudoClassStateChanged(pseudoClass, true);
getTags().add(tagLabel);
}
public void addTag(String tag) {
Label tagLabel = createTagLabel(tag);
tagLabel.getStyleClass().add("tag");
getTags().add(tagLabel);
addTag(tag, null);
}
private static final PseudoClass WARNING_PSEUDO_CLASS = PseudoClass.getPseudoClass("warning");
public void addTagWarning(String tag) {
Label tagLabel = createTagLabel(tag);
tagLabel.getStyleClass().add("tag-warning");
getTags().add(tagLabel);
}
public ObservableList<Label> getTags() {
return tags;
addTag(tag, WARNING_PSEUDO_CLASS);
}
@Override
public String toString() {
return getTitle();
return "TwoLineListItem[title=%s, subtitle=%s, tags=%s]".formatted(getTitle(), getSubtitle(), tags);
}
}
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.ui.decorator;
import com.jfoenix.controls.JFXSnackbar;
import com.jfoenix.controls.JFXSnackbarLayout;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
@@ -264,7 +265,7 @@ public class DecoratorController {
private Background createBackgroundWithOpacity(Image image, int opacity) {
if (opacity <= 0) {
return new Background(new BackgroundFill(new Color(1, 1, 1, 0), CornerRadii.EMPTY, Insets.EMPTY));
} else if (opacity >= 100) {
} else if (opacity >= 100 || image.getPixelReader() == null) {
return new Background(new BackgroundImage(
image,
BackgroundRepeat.NO_REPEAT,
@@ -454,7 +455,7 @@ public class DecoratorController {
// ==== Toast ====
public void showToast(String content) {
decorator.getSnackbar().fireEvent(new JFXSnackbar.SnackbarEvent(content, null, 2000L, false, null));
decorator.getSnackbar().fireEvent(new JFXSnackbar.SnackbarEvent(new JFXSnackbarLayout(content)));
}
// ==== Wizard ====
@@ -239,7 +239,7 @@ public class DecoratorSkin extends SkinBase<Decorator> {
JFXButton btnMin = new JFXButton();
btnMin.setFocusTraversable(false);
btnMin.setGraphic(SVG.MINIMIZE.createIcon(Themes.titleFillProperty()));
btnMin.setGraphic(SVG.MINIMIZE_CENTER.createIcon(Themes.titleFillProperty()));
btnMin.getStyleClass().add("jfx-decorator-button");
btnMin.setOnAction(e -> skinnable.minimize());
@@ -328,6 +328,18 @@ public class DecoratorSkin extends SkinBase<Decorator> {
}
if (onTitleBarDoubleClick != null)
center.setOnMouseClicked(onTitleBarDoubleClick);
center.setOnMouseDragged(mouseEvent -> {
if (!getSkinnable().isDragging() && primaryStage.isMaximized()) {
getSkinnable().setDragging(true);
mouseInitX = mouseEvent.getScreenX();
mouseInitY = mouseEvent.getScreenY();
primaryStage.setMaximized(false);
stageInitWidth = primaryStage.getWidth();
stageInitHeight = primaryStage.getHeight();
primaryStage.setY(stageInitY = 0);
primaryStage.setX(stageInitX = mouseInitX - stageInitWidth / 2);
}
});
navBar.setCenter(center);
if (canRefresh) {
@@ -24,12 +24,12 @@ import javafx.beans.property.SimpleBooleanProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.FlowPane;
import javafx.scene.layout.HBox;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.ui.Controllers;
import javafx.scene.layout.BorderPane;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.InstallerItem;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
@@ -38,9 +38,11 @@ import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.SettingsMap;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public abstract class AbstractInstallersPage extends Control implements WizardPage {
public static final String FABRIC_QUILT_API_TIP = "fabricQuiltApi";
protected final WizardController controller;
protected InstallerItem.InstallerItemGroup group;
@@ -56,8 +58,15 @@ public abstract class AbstractInstallersPage extends Control implements WizardPa
String libraryId = library.getLibraryId();
if (libraryId.equals(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId())) continue;
library.setOnInstall(() -> {
if (LibraryAnalyzer.LibraryType.FABRIC_API.getPatchId().equals(libraryId)) {
Controllers.dialog(i18n("install.installer.fabric-api.warning"), i18n("message.warning"), MessageDialogPane.MessageType.WARNING);
if (!Boolean.TRUE.equals(config().getShownTips().get(FABRIC_QUILT_API_TIP))
&& (LibraryAnalyzer.LibraryType.FABRIC_API.getPatchId().equals(libraryId)
|| LibraryAnalyzer.LibraryType.QUILT_API.getPatchId().equals(libraryId)
|| LibraryAnalyzer.LibraryType.LEGACY_FABRIC_API.getPatchId().equals(libraryId))) {
Controllers.dialog(new MessageDialogPane.Builder(
i18n("install.installer.fabric-quilt-api.warning", i18n("install.installer." + libraryId)),
i18n("message.warning"),
MessageDialogPane.MessageType.WARNING
).ok(null).addCancel(i18n("button.do_not_show_again"), () -> config().getShownTips().put(FABRIC_QUILT_API_TIP, true)).build());
}
if (!(library.resolvedStateProperty().get() instanceof InstallerItem.IncompatibleState))
@@ -134,7 +134,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
Path runDirectory = profile.getRepository().hasVersion(version) ? profile.getRepository().getRunDirectory(version) : profile.getRepository().getBaseDirectory();
Controllers.prompt(i18n("archive.file.name"), (result, resolve, reject) -> {
Controllers.prompt(i18n("archive.file.name"), (result, handler) -> {
Path dest = runDirectory.resolve(subdirectoryName).resolve(result);
Controllers.taskDialog(Task.composeAsync(() -> {
@@ -152,7 +152,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
Controllers.showToast(i18n("install.success"));
}
}), i18n("message.downloading"), TaskCancellationAction.NORMAL);
resolve.run();
handler.resolve();
}, file.getFile().getFilename(), new Validator(i18n("install.new_game.malformed"), FileUtils::isNameValid));
}
@@ -121,32 +121,20 @@ public class InstallersPage extends AbstractInstallersPage {
LibraryAnalyzer.LibraryType libraryType = LibraryAnalyzer.LibraryType.fromPatchId(libraryId);
if (libraryType != null) {
String loaderName;
switch (libraryType) {
case FORGE:
loaderName = i18n("install.installer.forge");
break;
case NEO_FORGE:
loaderName = i18n("install.installer.neoforge");
break;
case CLEANROOM:
loaderName = i18n("install.installer.cleanroom");
break;
case FABRIC:
loaderName = i18n("install.installer.fabric");
break;
case LITELOADER:
loaderName = i18n("install.installer.liteloader");
break;
case QUILT:
loaderName = i18n("install.installer.quilt");
break;
case OPTIFINE:
loaderName = i18n("install.installer.optifine");
break;
default:
continue;
}
String loaderName = switch (libraryType) {
case FORGE -> i18n("install.installer.forge");
case NEO_FORGE -> i18n("install.installer.neoforge");
case CLEANROOM -> i18n("install.installer.cleanroom");
case LEGACY_FABRIC -> i18n("install.installer.legacyfabric").replace(" ", "_");
case FABRIC -> i18n("install.installer.fabric");
case LITELOADER -> i18n("install.installer.liteloader");
case QUILT -> i18n("install.installer.quilt");
case OPTIFINE -> i18n("install.installer.optifine");
default -> null;
};
if (loaderName == null)
continue;
nameBuilder.append("-").append(loaderName);
}
@@ -107,7 +107,7 @@ public final class LocalModpackPage extends ModpackPage {
.whenComplete(Schedulers.javafx(), (manifest, exception) -> {
if (exception instanceof ManuallyCreatedModpackException) {
hideSpinner();
lblName.setText(FileUtils.getName(selectedFile));
nameProperty.set(FileUtils.getName(selectedFile));
installAsVersion.set(false);
if (name == null) {
@@ -127,9 +127,9 @@ public final class LocalModpackPage extends ModpackPage {
} else {
hideSpinner();
controller.getSettings().put(MODPACK_MANIFEST, manifest);
lblName.setText(manifest.getName());
lblVersion.setText(manifest.getVersion());
lblAuthor.setText(manifest.getAuthor());
nameProperty.set(manifest.getName());
versionProperty.set(manifest.getVersion());
authorProperty.set(manifest.getAuthor());
if (name == null) {
// trim: https://github.com/HMCL-dev/HMCL/issues/962
@@ -2,14 +2,15 @@ package org.jackhuang.hmcl.ui.download;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXTextField;
import javafx.geometry.Insets;
import javafx.beans.property.StringProperty;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.LinePane;
import org.jackhuang.hmcl.ui.construct.LineTextPane;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
@@ -23,9 +24,9 @@ public abstract class ModpackPage extends SpinnerPane implements WizardPage {
protected final WizardController controller;
protected final Label lblName;
protected final Label lblVersion;
protected final Label lblAuthor;
protected final StringProperty nameProperty;
protected final StringProperty versionProperty;
protected final StringProperty authorProperty;
protected final JFXTextField txtModpackName;
protected final JFXButton btnInstall;
protected final JFXButton btnDescription;
@@ -33,54 +34,43 @@ public abstract class ModpackPage extends SpinnerPane implements WizardPage {
protected ModpackPage(WizardController controller) {
this.controller = controller;
this.getStyleClass().add("large-spinner-pane");
VBox borderPane = new VBox();
borderPane.setAlignment(Pos.CENTER);
FXUtils.setLimitWidth(borderPane, 500);
ComponentList componentList = new ComponentList();
{
BorderPane archiveNamePane = new BorderPane();
var archiveNamePane = new LinePane();
{
Label label = new Label(i18n("archive.file.name"));
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
archiveNamePane.setLeft(label);
archiveNamePane.setTitle(i18n("archive.file.name"));
txtModpackName = new JFXTextField();
BorderPane.setMargin(txtModpackName, new Insets(0, 0, 8, 32));
txtModpackName.setPrefWidth(300);
// FIXME: Validator are not shown properly
// BorderPane.setMargin(txtModpackName, new Insets(0, 0, 8, 32));
BorderPane.setAlignment(txtModpackName, Pos.CENTER_RIGHT);
archiveNamePane.setCenter(txtModpackName);
archiveNamePane.setRight(txtModpackName);
}
BorderPane modpackNamePane = new BorderPane();
var modpackNamePane = new LineTextPane();
{
modpackNamePane.setLeft(new Label(i18n("modpack.name")));
lblName = new Label();
BorderPane.setAlignment(lblName, Pos.CENTER_RIGHT);
modpackNamePane.setCenter(lblName);
modpackNamePane.setTitle(i18n("modpack.name"));
nameProperty = modpackNamePane.textProperty();
}
BorderPane versionPane = new BorderPane();
var versionPane = new LineTextPane();
{
versionPane.setLeft(new Label(i18n("archive.version")));
lblVersion = new Label();
BorderPane.setAlignment(lblVersion, Pos.CENTER_RIGHT);
versionPane.setCenter(lblVersion);
versionPane.setTitle(i18n("archive.version"));
versionProperty = versionPane.textProperty();
}
BorderPane authorPane = new BorderPane();
var authorPane = new LineTextPane();
{
authorPane.setLeft(new Label(i18n("archive.author")));
lblAuthor = new Label();
BorderPane.setAlignment(lblAuthor, Pos.CENTER_RIGHT);
authorPane.setCenter(lblAuthor);
authorPane.setTitle(i18n("archive.author"));
authorProperty = authorPane.textProperty();
}
BorderPane descriptionPane = new BorderPane();
var descriptionPane = new BorderPane();
{
btnDescription = FXUtils.newBorderButton(i18n("modpack.description"));
btnDescription.setOnAction(e -> onDescribe());
@@ -124,45 +124,45 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
}
private void onChooseRemoteFile() {
Controllers.prompt(i18n("modpack.choose.remote.tooltip"), (url, resolve, reject) -> {
Controllers.prompt(i18n("modpack.choose.remote.tooltip"), (url, handler) -> {
try {
if (url.endsWith("server-manifest.json")) {
// if urlString ends with .json, we assume that the url is server-manifest.json
Controllers.taskDialog(new GetTask(url).whenComplete(Schedulers.javafx(), (result, e) -> {
ServerModpackManifest manifest = JsonUtils.fromMaybeMalformedJson(result, ServerModpackManifest.class);
if (manifest == null) {
reject.accept(i18n("modpack.type.server.malformed"));
handler.reject(i18n("modpack.type.server.malformed"));
} else if (e == null) {
resolve.run();
handler.resolve();
controller.getSettings().put(MODPACK_SERVER_MANIFEST, manifest);
controller.onNext();
} else {
reject.accept(e.getMessage());
handler.reject(e.getMessage());
}
}).executor(true), i18n("message.downloading"), TaskCancellationAction.NORMAL);
}), i18n("message.downloading"), TaskCancellationAction.NORMAL);
} else {
// otherwise we still consider the file as modpack zip file
// since casually the url may not ends with ".zip"
Path modpack = Files.createTempFile("modpack", ".zip");
resolve.run();
handler.resolve();
Controllers.taskDialog(
new FileDownloadTask(url, modpack)
.whenComplete(Schedulers.javafx(), e -> {
if (e == null) {
resolve.run();
handler.resolve();
controller.getSettings().put(MODPACK_FILE, modpack);
controller.onNext();
} else {
reject.accept(e.getMessage());
handler.reject(e.getMessage());
}
}).executor(true),
}),
i18n("message.downloading"),
TaskCancellationAction.NORMAL
);
}
} catch (IOException e) {
reject.accept(e.getMessage());
handler.reject(e.getMessage());
}
}, "", new URLValidator());
}
@@ -53,9 +53,9 @@ public final class RemoteModpackPage extends ModpackPage {
return;
}
lblName.setText(manifest.getName());
lblVersion.setText(manifest.getVersion());
lblAuthor.setText(manifest.getAuthor());
nameProperty.set(manifest.getName());
versionProperty.set(manifest.getVersion());
authorProperty.set(manifest.getAuthor());
Profile profile = controller.getSettings().get(ModpackPage.PROFILE);
String name = controller.getSettings().get(MODPACK_NAME);
@@ -38,6 +38,8 @@ import org.jackhuang.hmcl.download.fabric.FabricAPIRemoteVersion;
import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion;
import org.jackhuang.hmcl.download.forge.ForgeRemoteVersion;
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricAPIRemoteVersion;
import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricRemoteVersion;
import org.jackhuang.hmcl.download.liteloader.LiteLoaderRemoteVersion;
import org.jackhuang.hmcl.download.neoforge.NeoForgeRemoteVersion;
import org.jackhuang.hmcl.download.optifine.OptiFineRemoteVersion;
@@ -58,7 +60,6 @@ import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jackhuang.hmcl.ui.wizard.Refreshable;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.NativePatcher;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
@@ -129,11 +130,6 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
return title;
}
@Override
public void cleanup(SettingsMap settings) {
settings.remove(libraryId);
}
private void onRefresh() {
refresh();
}
@@ -166,6 +162,8 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
RemoteVersionListCell(VersionsPage control) {
this.control = control;
imageView.setMouseTransparent(true);
HBox hbox = new HBox(16);
HBox.setHgrow(twoLineListItem, Priority.ALWAYS);
hbox.setAlignment(Pos.CENTER);
@@ -270,6 +268,8 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
iconType = VersionIconType.CLEANROOM;
else if (remoteVersion instanceof NeoForgeRemoteVersion)
iconType = VersionIconType.NEO_FORGE;
else if (remoteVersion instanceof LegacyFabricRemoteVersion || remoteVersion instanceof LegacyFabricAPIRemoteVersion)
iconType = VersionIconType.LEGACY_FABRIC;
else if (remoteVersion instanceof FabricRemoteVersion || remoteVersion instanceof FabricAPIRemoteVersion)
iconType = VersionIconType.FABRIC;
else if (remoteVersion instanceof QuiltRemoteVersion || remoteVersion instanceof QuiltAPIRemoteVersion)
@@ -22,10 +22,10 @@ import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.mod.ModAdviser;
import org.jackhuang.hmcl.mod.ModpackExportInfo;
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackExportTask;
import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackExportTask;
import org.jackhuang.hmcl.mod.multimc.MultiMCInstanceConfiguration;
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackExportTask;
import org.jackhuang.hmcl.mod.server.ServerModpackExportTask;
import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackExportTask;
import org.jackhuang.hmcl.setting.Config;
import org.jackhuang.hmcl.setting.FontManager;
import org.jackhuang.hmcl.setting.Profile;
@@ -41,7 +41,9 @@ import org.jackhuang.hmcl.util.io.Zipper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
@@ -132,7 +134,9 @@ public final class ExportWizardProvider implements WizardProvider {
exported.getAuthlibInjectorServers().setAll(config().getAuthlibInjectorServers());
zip.putTextFile(exported.toJson(), ".hmcl/hmcl.json");
zip.putFile(tempModpack, "modpack.zip");
zip.putFile(tempModpack, ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH.equals(modpackType)
? "modpack.mrpack"
: "modpack.zip");
Path bg = Metadata.HMCL_CURRENT_DIRECTORY.resolve("background");
if (!Files.isDirectory(bg))
@@ -263,10 +267,10 @@ public final class ExportWizardProvider implements WizardProvider {
@Override
public void execute() {
dependency = new ModrinthModpackExportTask(
profile.getRepository(),
version,
exportInfo,
modpackFile
profile.getRepository(),
version,
exportInfo,
modpackFile
);
}
@@ -17,7 +17,11 @@
*/
package org.jackhuang.hmcl.ui.export;
import com.jfoenix.controls.*;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXSlider;
import com.jfoenix.controls.JFXTextArea;
import com.jfoenix.controls.JFXTextField;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
import javafx.collections.ObservableList;
@@ -53,8 +57,6 @@ import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.ui.FXUtils.jfxListCellFactory;
import static org.jackhuang.hmcl.ui.FXUtils.stringConverter;
import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.MODPACK_TYPE;
import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH;
import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.MODPACK_TYPE_SERVER;
@@ -109,7 +111,7 @@ public final class ModpackInfoPage extends Control implements WizardPage {
private void onNext() {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("modpack.wizard.step.initialization.save"));
if (controller.getSettings().get(MODPACK_TYPE) == MODPACK_TYPE_MODRINTH) {
if (!packWithLauncher.get() && controller.getSettings().get(MODPACK_TYPE) == MODPACK_TYPE_MODRINTH) {
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.mrpack"));
fileChooser.setInitialFileName(name.get() + (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS ? "" : ".mrpack"));
} else {
@@ -127,7 +129,6 @@ public final class ModpackInfoPage extends Control implements WizardPage {
exportInfo.setVersion(version.get());
exportInfo.setAuthor(author.get());
exportInfo.setDescription(description.get());
exportInfo.setPackWithLauncher(packWithLauncher.get());
exportInfo.setUrl(url.get());
exportInfo.setForceUpdate(forceUpdate.get());
exportInfo.setPackWithLauncher(packWithLauncher.get());
@@ -171,10 +172,11 @@ public final class ModpackInfoPage extends Control implements WizardPage {
public static class ModpackInfoPageSkin extends SkinBase<ModpackInfoPage> {
private ObservableList<Node> originList;
private final List<JFXTextField> validatingFields = new ArrayList<>();
public ModpackInfoPageSkin(ModpackInfoPage skinnable) {
super(skinnable);
Insets insets = new Insets(5, 0, 12, 0);
Insets componentListMargin = new Insets(16, 0, 16, 0);
ScrollPane scroll = new ScrollPane();
@@ -182,7 +184,6 @@ public final class ModpackInfoPage extends Control implements WizardPage {
scroll.setFitToHeight(true);
getChildren().setAll(scroll);
List<JFXTextField> validatingFields = new ArrayList<>();
{
BorderPane borderPane = new BorderPane();
@@ -209,83 +210,52 @@ public final class ModpackInfoPage extends Control implements WizardPage {
BorderPane.setMargin(list, componentListMargin);
borderPane.setCenter(list);
var instanceNamePane = new LineTextPane();
{
BorderPane borderPane1 = new BorderPane();
borderPane1.setLeft(new Label(i18n("modpack.wizard.step.initialization.exported_version")));
instanceNamePane.setTitle(i18n("modpack.wizard.step.initialization.exported_version"));
instanceNamePane.setText(skinnable.versionName);
Label versionNameLabel = new Label();
versionNameLabel.setText(skinnable.versionName);
borderPane1.setRight(versionNameLabel);
list.getContent().add(borderPane1);
list.getContent().add(instanceNamePane);
}
{
GridPane pane = new GridPane();
list.getContent().add(pane);
pane.setHgap(16);
pane.setVgap(8);
pane.getColumnConstraints().setAll(new ColumnConstraints(), FXUtils.getColumnHgrowing());
int rowIndex = 0;
list.getContent().addAll(
createTextFieldLinePane(i18n("modpack.name"), skinnable.name, new RequiredValidator()),
createTextFieldLinePane(i18n("archive.author"), skinnable.author, new RequiredValidator()),
createTextFieldLinePane(i18n("archive.version"), skinnable.version, new RequiredValidator())
);
JFXTextField txtModpackName = new JFXTextField();
txtModpackName.textProperty().bindBidirectional(skinnable.name);
txtModpackName.getValidators().add(new RequiredValidator());
validatingFields.add(txtModpackName);
pane.addRow(rowIndex++, new Label(i18n("modpack.name")), txtModpackName);
if (skinnable.options.isRequireFileApi()) {
list.getContent().add(createTextFieldLinePane(
i18n("modpack.file_api"), skinnable.fileApi,
skinnable.options.isValidateFileApi() ? new RequiredValidator() : null,
new URLValidator(true)
));
}
JFXTextField txtModpackAuthor = new JFXTextField();
txtModpackAuthor.textProperty().bindBidirectional(skinnable.author);
txtModpackAuthor.getValidators().add(new RequiredValidator());
validatingFields.add(txtModpackAuthor);
pane.addRow(rowIndex++, new Label(i18n("archive.author")), txtModpackAuthor);
if (skinnable.options.isRequireLaunchArguments()) {
list.getContent().add(createTextFieldLinePane(
i18n("settings.advanced.minecraft_arguments"), skinnable.launchArguments
));
}
JFXTextField txtModpackVersion = new JFXTextField();
txtModpackVersion.textProperty().bindBidirectional(skinnable.version);
txtModpackVersion.getValidators().add(new RequiredValidator());
validatingFields.add(txtModpackVersion);
pane.addRow(rowIndex++, new Label(i18n("archive.version")), txtModpackVersion);
if (skinnable.options.isRequireJavaArguments()) {
list.getContent().add(createTextFieldLinePane(
i18n("settings.advanced.jvm_args"), skinnable.javaArguments
));
}
if (skinnable.options.isRequireFileApi()) {
JFXTextField txtModpackFileApi = new JFXTextField();
txtModpackFileApi.textProperty().bindBidirectional(skinnable.fileApi);
validatingFields.add(txtModpackFileApi);
if (skinnable.options.isValidateFileApi()) {
txtModpackFileApi.getValidators().add(new RequiredValidator());
}
txtModpackFileApi.getValidators().add(new URLValidator(true));
pane.addRow(rowIndex++, new Label(i18n("modpack.file_api")), txtModpackFileApi);
}
if (skinnable.options.isRequireLaunchArguments()) {
JFXTextField txtLaunchArguments = new JFXTextField();
txtLaunchArguments.textProperty().bindBidirectional(skinnable.launchArguments);
pane.addRow(rowIndex++, new Label(i18n("settings.advanced.minecraft_arguments")), txtLaunchArguments);
}
if (skinnable.options.isRequireJavaArguments()) {
JFXTextField txtJavaArguments = new JFXTextField();
txtJavaArguments.textProperty().bindBidirectional(skinnable.javaArguments);
pane.addRow(rowIndex++, new Label(i18n("settings.advanced.jvm_args")), txtJavaArguments);
}
if (skinnable.options.isRequireUrl()) {
JFXTextField txtModpackUrl = new JFXTextField();
txtModpackUrl.textProperty().bindBidirectional(skinnable.url);
pane.addRow(rowIndex++, new Label(i18n("modpack.origin.url")), txtModpackUrl);
}
if (skinnable.options.isRequireOrigins()) {
JFXTextField txtMcbbs = new JFXTextField();
FXUtils.setValidateWhileTextChanged(txtMcbbs, true);
txtMcbbs.getValidators().add(new NumberValidator(i18n("input.number"), true));
txtMcbbs.textProperty().bindBidirectional(skinnable.mcbbsThreadId);
validatingFields.add(txtMcbbs);
pane.addRow(rowIndex++, new Label(i18n("modpack.origin.mcbbs")), txtMcbbs);
}
if (skinnable.options.isRequireUrl()) {
list.getContent().add(createTextFieldLinePane(
i18n("modpack.origin.url"), skinnable.url
));
}
if (skinnable.options.isRequireOrigins()) {
list.getContent().add(createTextFieldLinePane(
i18n("modpack.origin.mcbbs"), skinnable.mcbbsThreadId,
new NumberValidator(i18n("input.number"), true)
));
}
if (skinnable.options.isRequireMinMemory()) {
@@ -339,78 +309,51 @@ public final class ModpackInfoPage extends Control implements WizardPage {
}
if (skinnable.options.isRequireAuthlibInjectorServer()) {
JFXComboBox<AuthlibInjectorServer> cboServers = new JFXComboBox<>();
cboServers.setMaxWidth(Double.MAX_VALUE);
cboServers.setCellFactory(jfxListCellFactory(server -> new TwoLineListItem(server.getName(), server.getUrl())));
cboServers.setConverter(stringConverter(AuthlibInjectorServer::getName));
Bindings.bindContent(cboServers.getItems(), config().getAuthlibInjectorServers());
var serversSelectButton = new LineSelectButton<AuthlibInjectorServer>();
serversSelectButton.setTitle(i18n("account.injector.server"));
serversSelectButton.setConverter(AuthlibInjectorServer::getName);
serversSelectButton.setDescriptionConverter(AuthlibInjectorServer::getUrl);
serversSelectButton.itemsProperty().set(config().getAuthlibInjectorServers());
skinnable.authlibInjectorServer.bind(Bindings.createStringBinding(() ->
Optional.ofNullable(cboServers.getSelectionModel().getSelectedItem())
.map(AuthlibInjectorServer::getUrl)
.orElse(null)));
skinnable.authlibInjectorServer.bind(Bindings.createStringBinding(() -> {
AuthlibInjectorServer selected = serversSelectButton.getValue();
return selected != null ? selected.getUrl() : null;
}, serversSelectButton.valueProperty()));
BorderPane pane = new BorderPane();
Label left = new Label(i18n("account.injector.server"));
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
pane.setLeft(left);
pane.setRight(cboServers);
list.getContent().add(pane);
list.getContent().add(serversSelectButton);
}
if (skinnable.options.isRequireForceUpdate()) {
BorderPane pane = new BorderPane();
pane.setLeft(new Label(i18n("modpack.wizard.step.initialization.force_update")));
list.getContent().add(pane);
var requireForceUpdateButton = new LineToggleButton();
requireForceUpdateButton.setTitle(i18n("modpack.wizard.step.initialization.force_update"));
requireForceUpdateButton.selectedProperty().bindBidirectional(skinnable.forceUpdate);
JFXToggleButton button = new JFXToggleButton();
button.selectedProperty().bindBidirectional(skinnable.forceUpdate);
button.setSize(8);
button.setMinHeight(16);
button.setMaxHeight(16);
pane.setRight(button);
list.getContent().add(requireForceUpdateButton);
}
{
BorderPane pane = new BorderPane();
pane.setLeft(new Label(i18n("modpack.wizard.step.initialization.include_launcher")));
list.getContent().add(pane);
var canIncludeLauncherButton = new LineToggleButton();
canIncludeLauncherButton.setTitle(i18n("modpack.wizard.step.initialization.include_launcher"));
canIncludeLauncherButton.setDisable(!skinnable.canIncludeLauncher);
canIncludeLauncherButton.selectedProperty().bindBidirectional(skinnable.packWithLauncher);
JFXToggleButton button = new JFXToggleButton();
button.setDisable(!skinnable.canIncludeLauncher);
button.selectedProperty().bindBidirectional(skinnable.packWithLauncher);
button.setSize(8);
button.setMinHeight(16);
button.setMaxHeight(16);
pane.setRight(button);
list.getContent().add(canIncludeLauncherButton);
}
if (skinnable.options.isRequireNoCreateRemoteFiles()) {
BorderPane noCreateRemoteFiles = new BorderPane();
noCreateRemoteFiles.setLeft(new Label(i18n("modpack.wizard.step.initialization.no_create_remote_files")));
list.getContent().add(noCreateRemoteFiles);
var requireNoCreateRemoteFilesButton = new LineToggleButton();
requireNoCreateRemoteFilesButton.setTitle(i18n("modpack.wizard.step.initialization.no_create_remote_files"));
requireNoCreateRemoteFilesButton.selectedProperty().bindBidirectional(skinnable.noCreateRemoteFiles);
JFXToggleButton noCreateRemoteFilesButton = new JFXToggleButton();
noCreateRemoteFilesButton.selectedProperty().bindBidirectional(skinnable.noCreateRemoteFiles);
noCreateRemoteFilesButton.setSize(8);
noCreateRemoteFilesButton.setMinHeight(16);
noCreateRemoteFilesButton.setMaxHeight(16);
noCreateRemoteFiles.setRight(noCreateRemoteFilesButton);
list.getContent().add(requireNoCreateRemoteFilesButton);
}
if (skinnable.options.isRequireSkipCurseForgeRemoteFiles()) {
BorderPane skipCurseForgeRemoteFiles = new BorderPane();
skipCurseForgeRemoteFiles.setLeft(new Label(i18n("modpack.wizard.step.initialization.skip_curseforge_remote_files")));
list.getContent().add(skipCurseForgeRemoteFiles);
JFXToggleButton skipCurseForgeRemoteFilesButton = new JFXToggleButton();
var skipCurseForgeRemoteFilesButton = new LineToggleButton();
skipCurseForgeRemoteFilesButton.setTitle(i18n("modpack.wizard.step.initialization.skip_curseforge_remote_files"));
skipCurseForgeRemoteFilesButton.selectedProperty().bindBidirectional(skinnable.skipCurseForgeRemoteFiles);
skipCurseForgeRemoteFilesButton.setSize(8);
skipCurseForgeRemoteFilesButton.setMinHeight(16);
skipCurseForgeRemoteFilesButton.setMaxHeight(16);
skipCurseForgeRemoteFiles.setRight(skipCurseForgeRemoteFilesButton);
list.getContent().add(skipCurseForgeRemoteFilesButton);
}
}
@@ -436,5 +379,31 @@ public final class ModpackInfoPage extends Control implements WizardPage {
FXUtils.smoothScrolling(scroll);
}
private LinePane createTextFieldLinePane(String title, StringProperty property, ValidatorBase... validators) {
LinePane linePane = new LinePane();
JFXTextField textField = new JFXTextField();
textField.setMinWidth(500);
linePane.setTitle(title);
linePane.setRight(textField);
textField.textProperty().bindBidirectional(property);
boolean needValidation = false;
if (validators != null) {
for (ValidatorBase validator : validators) {
if (validator != null) {
needValidation = true;
textField.getValidators().add(validator);
}
}
}
if (needValidation) {
FXUtils.setValidateWhileTextChanged(textField, true);
validatingFields.add(textField);
}
return linePane;
}
}
}
@@ -17,11 +17,7 @@
*/
package org.jackhuang.hmcl.ui.main;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import javafx.beans.binding.Bindings;
import com.google.gson.*;
import javafx.geometry.Insets;
import javafx.scene.control.ScrollPane;
import javafx.scene.image.Image;
@@ -30,8 +26,10 @@ import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.IconedTwoLineListItem;
import org.jackhuang.hmcl.ui.construct.LineButton;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import java.io.IOException;
@@ -42,20 +40,22 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class AboutPage extends StackPane {
private final WeakListenerHolder holder = new WeakListenerHolder();
public AboutPage() {
ComponentList about = new ComponentList();
{
IconedTwoLineListItem launcher = new IconedTwoLineListItem();
launcher.setImage(FXUtils.newBuiltinImage("/assets/img/icon.png"));
var launcher = LineButton.createExternalLinkButton(Metadata.PUBLISH_URL);
launcher.setLargeTitle(true);
launcher.setLeading(FXUtils.newBuiltinImage("/assets/img/icon.png"));
launcher.setTitle("Hello Minecraft! Launcher");
launcher.setSubtitle(Metadata.VERSION);
launcher.setExternalLink(Metadata.PUBLISH_URL);
IconedTwoLineListItem author = new IconedTwoLineListItem();
author.setImage(FXUtils.newBuiltinImage("/assets/img/yellow_fish.png"));
var author = LineButton.createExternalLinkButton("https://space.bilibili.com/1445341");
author.setLargeTitle(true);
author.setLeading(FXUtils.newBuiltinImage("/assets/img/yellow_fish.png"));
author.setTitle("huanghongxun");
author.setSubtitle(i18n("about.author.statement"));
author.setExternalLink("https://space.bilibili.com/1445341");
about.getContent().setAll(launcher, author);
}
@@ -66,20 +66,20 @@ public final class AboutPage extends StackPane {
ComponentList legal = new ComponentList();
{
IconedTwoLineListItem copyright = new IconedTwoLineListItem();
var copyright = LineButton.createExternalLinkButton(Metadata.ABOUT_URL);
copyright.setLargeTitle(true);
copyright.setTitle(i18n("about.copyright"));
copyright.setSubtitle(i18n("about.copyright.statement"));
copyright.setExternalLink(Metadata.ABOUT_URL);
IconedTwoLineListItem claim = new IconedTwoLineListItem();
var claim = LineButton.createExternalLinkButton(Metadata.EULA_URL);
claim.setLargeTitle(true);
claim.setTitle(i18n("about.claim"));
claim.setSubtitle(i18n("about.claim.statement"));
claim.setExternalLink(Metadata.EULA_URL);
IconedTwoLineListItem openSource = new IconedTwoLineListItem();
var openSource = LineButton.createExternalLinkButton("https://github.com/HMCL-dev/HMCL");
openSource.setLargeTitle(true);
openSource.setTitle(i18n("about.open_source"));
openSource.setSubtitle(i18n("about.open_source.statement"));
openSource.setExternalLink("https://github.com/HMCL-dev/HMCL");
legal.getContent().setAll(copyright, claim, openSource);
}
@@ -113,7 +113,7 @@ public final class AboutPage extends StackPane {
: new Image(url);
}
private static ComponentList loadIconedTwoLineList(String path) {
private ComponentList loadIconedTwoLineList(String path) {
ComponentList componentList = new ComponentList();
InputStream input = FXUtils.class.getResourceAsStream(path);
@@ -127,36 +127,42 @@ public final class AboutPage extends StackPane {
for (JsonElement element : array) {
JsonObject obj = element.getAsJsonObject();
IconedTwoLineListItem item = new IconedTwoLineListItem();
var button = new LineButton();
button.setLargeTitle(true);
if (obj.get("externalLink") instanceof JsonPrimitive externalLink) {
button.setTrailingIcon(SVG.OPEN_IN_NEW);
String link = externalLink.getAsString();
button.setOnAction(event -> FXUtils.openLink(link));
}
if (obj.has("image")) {
JsonElement image = obj.get("image");
if (image.isJsonPrimitive()) {
item.setImage(loadImage(image.getAsString()));
button.setLeading(loadImage(image.getAsString()));
} else if (image.isJsonObject()) {
item.imageProperty().bind(Bindings.when(Themes.darkModeProperty())
.then(loadImage(image.getAsJsonObject().get("dark").getAsString()))
.otherwise(loadImage(image.getAsJsonObject().get("light").getAsString())));
holder.add(FXUtils.onWeakChangeAndOperate(Themes.darkModeProperty(), darkMode -> {
button.setLeading(darkMode
? loadImage(image.getAsJsonObject().get("dark").getAsString())
: loadImage(image.getAsJsonObject().get("light").getAsString())
);
}));
}
}
if (obj.has("title"))
item.setTitle(obj.get("title").getAsString());
else if (obj.has("titleLocalized"))
item.setTitle(i18n(obj.get("titleLocalized").getAsString()));
if (obj.get("title") instanceof JsonPrimitive title)
button.setTitle(title.getAsString());
else if (obj.get("titleLocalized") instanceof JsonPrimitive titleLocalized)
button.setTitle(i18n(titleLocalized.getAsString()));
if (obj.has("subtitle"))
item.setSubtitle(obj.get("subtitle").getAsString());
else if (obj.has("subtitleLocalized"))
item.setSubtitle(i18n(obj.get("subtitleLocalized").getAsString()));
if (obj.get("subtitle") instanceof JsonPrimitive subtitle)
button.setSubtitle(subtitle.getAsString());
else if (obj.get("subtitleLocalized") instanceof JsonPrimitive subtitleLocalized)
button.setSubtitle(i18n(subtitleLocalized.getAsString()));
if (obj.has("externalLink")) {
String link = obj.get("externalLink").getAsString();
item.setExternalLink(link);
FXUtils.installFastTooltip(item.getExternalLinkButton(), link);
}
componentList.getContent().add(item);
componentList.getContent().add(button);
}
} catch (IOException | JsonParseException e) {
LOG.warning("Failed to load list: " + path, e);
@@ -31,15 +31,15 @@ import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.javafx.SafeStringConverter;
import java.net.Proxy;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.ui.FXUtils.stringConverter;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.selectedItemPropertyFor;
public class DownloadSettingsPage extends StackPane {
@@ -55,51 +55,37 @@ public class DownloadSettingsPage extends StackPane {
getChildren().setAll(scrollPane);
{
VBox downloadSource = new VBox(8);
var downloadSource = new ComponentList();
downloadSource.getStyleClass().add("card-non-transparent");
{
VBox chooseWrapper = new VBox();
chooseWrapper.setPadding(new Insets(8, 0, 8, 0));
JFXCheckBox chkAutoChooseDownloadSource = new JFXCheckBox(i18n("settings.launcher.download_source.auto"));
chkAutoChooseDownloadSource.selectedProperty().bindBidirectional(config().autoChooseDownloadTypeProperty());
chooseWrapper.getChildren().setAll(chkAutoChooseDownloadSource);
var autoChooseDownloadSource = new LineToggleButton();
autoChooseDownloadSource.setTitle(i18n("settings.launcher.download_source.auto"));
autoChooseDownloadSource.selectedProperty().bindBidirectional(config().autoChooseDownloadTypeProperty());
BorderPane versionListSourcePane = new BorderPane();
versionListSourcePane.setPadding(new Insets(0, 0, 8, 30));
versionListSourcePane.disableProperty().bind(chkAutoChooseDownloadSource.selectedProperty().not());
{
Label label = new Label(i18n("settings.launcher.version_list_source"));
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
versionListSourcePane.setLeft(label);
Function<String, String> converter = key -> i18n("download.provider." + key);
Function<String, String> descriptionConverter = key -> {
String bundleKey = "download.provider." + key + ".desc";
return I18n.hasKey(bundleKey) ? i18n(bundleKey) : null;
};
JFXComboBox<String> cboVersionListSource = new JFXComboBox<>();
cboVersionListSource.setConverter(stringConverter(key -> i18n("download.provider." + key)));
versionListSourcePane.setRight(cboVersionListSource);
FXUtils.setLimitWidth(cboVersionListSource, 400);
var versionListSourcePane = new LineSelectButton<String>();
versionListSourcePane.disableProperty().bind(autoChooseDownloadSource.selectedProperty().not());
versionListSourcePane.setTitle(i18n("settings.launcher.version_list_source"));
versionListSourcePane.setConverter(converter);
versionListSourcePane.setDescriptionConverter(descriptionConverter);
versionListSourcePane.setItems(DownloadProviders.AUTO_PROVIDERS.keySet());
versionListSourcePane.valueProperty().bindBidirectional(config().versionListSourceProperty());
cboVersionListSource.getItems().setAll(DownloadProviders.AUTO_PROVIDERS.keySet());
selectedItemPropertyFor(cboVersionListSource).bindBidirectional(config().versionListSourceProperty());
}
var downloadSourcePane = new LineSelectButton<String>();
downloadSourcePane.disableProperty().bind(autoChooseDownloadSource.selectedProperty());
downloadSourcePane.setTitle(i18n("settings.launcher.download_source"));
downloadSourcePane.setConverter(converter);
downloadSourcePane.setDescriptionConverter(descriptionConverter);
downloadSourcePane.setItems(DownloadProviders.DIRECT_PROVIDERS.keySet());
downloadSourcePane.valueProperty().bindBidirectional(config().downloadTypeProperty());
BorderPane downloadSourcePane = new BorderPane();
downloadSourcePane.setPadding(new Insets(0, 0, 8, 30));
downloadSourcePane.disableProperty().bind(chkAutoChooseDownloadSource.selectedProperty());
{
Label label = new Label(i18n("settings.launcher.download_source"));
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
downloadSourcePane.setLeft(label);
JFXComboBox<String> cboDownloadSource = new JFXComboBox<>();
cboDownloadSource.setConverter(stringConverter(key -> i18n("download.provider." + key)));
downloadSourcePane.setRight(cboDownloadSource);
FXUtils.setLimitWidth(cboDownloadSource, 420);
cboDownloadSource.getItems().setAll(DownloadProviders.DIRECT_PROVIDERS.keySet());
selectedItemPropertyFor(cboDownloadSource).bindBidirectional(config().downloadTypeProperty());
}
downloadSource.getChildren().setAll(chooseWrapper, versionListSourcePane, downloadSourcePane);
downloadSource.getContent().setAll(autoChooseDownloadSource, versionListSourcePane, downloadSourcePane);
}
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.download_source")), downloadSource);
@@ -17,14 +17,14 @@
*/
package org.jackhuang.hmcl.ui.main;
import javafx.beans.binding.Bindings;
import javafx.geometry.Insets;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.IconedTwoLineListItem;
import org.jackhuang.hmcl.ui.construct.LineButton;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -33,6 +33,8 @@ import org.jackhuang.hmcl.Metadata;
public class FeedbackPage extends SpinnerPane {
private final WeakListenerHolder holder = new WeakListenerHolder();
public FeedbackPage() {
VBox content = new VBox();
content.setPadding(new Insets(10));
@@ -45,30 +47,33 @@ public class FeedbackPage extends SpinnerPane {
ComponentList groups = new ComponentList();
{
IconedTwoLineListItem users = new IconedTwoLineListItem();
users.setImage(FXUtils.newBuiltinImage("/assets/img/icon.png"));
var users = LineButton.createExternalLinkButton(Metadata.GROUPS_URL);
users.setLargeTitle(true);
users.setLeading(FXUtils.newBuiltinImage("/assets/img/icon.png"));
users.setTitle(i18n("contact.chat.qq_group"));
users.setSubtitle(i18n("contact.chat.qq_group.statement"));
users.setExternalLink(Metadata.GROUPS_URL);
IconedTwoLineListItem discord = new IconedTwoLineListItem();
discord.setImage(FXUtils.newBuiltinImage("/assets/img/discord.png"));
var discord = LineButton.createExternalLinkButton("https://discord.gg/jVvC7HfM6U");
discord.setLargeTitle(true);
discord.setLeading(FXUtils.newBuiltinImage("/assets/img/discord.png"));
discord.setTitle(i18n("contact.chat.discord"));
discord.setSubtitle(i18n("contact.chat.discord.statement"));
discord.setExternalLink("https://discord.gg/jVvC7HfM6U");
groups.getContent().setAll(users, discord);
}
ComponentList feedback = new ComponentList();
{
IconedTwoLineListItem github = new IconedTwoLineListItem();
github.imageProperty().bind(Bindings.when(Themes.darkModeProperty())
.then(FXUtils.newBuiltinImage("/assets/img/github-white.png"))
.otherwise(FXUtils.newBuiltinImage("/assets/img/github.png")));
var github = LineButton.createExternalLinkButton("https://github.com/HMCL-dev/HMCL/issues/new/choose");
github.setLargeTitle(true);
github.setTitle(i18n("contact.feedback.github"));
github.setSubtitle(i18n("contact.feedback.github.statement"));
github.setExternalLink("https://github.com/HMCL-dev/HMCL/issues/new/choose");
holder.add(FXUtils.onWeakChangeAndOperate(Themes.darkModeProperty(), darkMode -> {
github.setLeading(darkMode
? FXUtils.newBuiltinImage("/assets/img/github-white.png")
: FXUtils.newBuiltinImage("/assets/img/github.png"));
}));
feedback.getContent().setAll(github);
}
@@ -26,11 +26,11 @@ import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.IconedTwoLineListItem;
import org.jackhuang.hmcl.ui.construct.LineButton;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.io.HttpRequest;
import java.util.Collections;
import java.util.List;
import static org.jackhuang.hmcl.util.gson.JsonUtils.listTypeOf;
@@ -50,10 +50,11 @@ public class HelpPage extends SpinnerPane {
FXUtils.smoothScrolling(scrollPane);
setContent(scrollPane);
IconedTwoLineListItem docPane = new IconedTwoLineListItem();
var docPane = LineButton.createExternalLinkButton(Metadata.DOCS_URL);
docPane.setLargeTitle(true);
docPane.setTitle(i18n("help.doc"));
docPane.setSubtitle(i18n("help.detail"));
docPane.setExternalLink(Metadata.DOCS_URL);
ComponentList doc = new ComponentList();
doc.getContent().setAll(docPane);
content.getChildren().add(doc);
@@ -68,77 +69,32 @@ public class HelpPage extends SpinnerPane {
for (HelpCategory category : helpCategories) {
ComponentList categoryPane = new ComponentList();
for (Help help : category.getItems()) {
IconedTwoLineListItem item = new IconedTwoLineListItem();
item.setTitle(help.getTitle());
item.setSubtitle(help.getSubtitle());
item.setExternalLink(help.getUrl());
for (Help help : category.items()) {
var item = LineButton.createExternalLinkButton(help.url());
item.setLargeTitle(true);
item.setTitle(help.title());
item.setSubtitle(help.subtitle());
categoryPane.getContent().add(item);
}
content.getChildren().add(ComponentList.createComponentListTitle(category.title));
content.getChildren().add(ComponentList.createComponentListTitle(category.title()));
content.getChildren().add(categoryPane);
}
hideSpinner();
}).start();
}
private static class HelpCategory {
@SerializedName("title")
private final String title;
@SerializedName("items")
private final List<Help> items;
public HelpCategory() {
this("", Collections.emptyList());
}
public HelpCategory(String title, List<Help> items) {
this.title = title;
this.items = items;
}
public String getTitle() {
return title;
}
public List<Help> getItems() {
return items;
}
@JsonSerializable
private record HelpCategory(
@SerializedName("title") String title,
@SerializedName("items") List<Help> items) {
}
private static class Help {
@SerializedName("title")
private final String title;
@SerializedName("subtitle")
private final String subtitle;
@SerializedName("url")
private final String url;
public Help() {
this("", "", "");
}
public Help(String title, String subtitle, String url) {
this.title = title;
this.subtitle = subtitle;
this.url = url;
}
public String getTitle() {
return title;
}
public String getSubtitle() {
return subtitle;
}
public String getUrl() {
return url;
}
@JsonSerializable
private record Help(
@SerializedName("title") String title,
@SerializedName("subtitle") String subtitle,
@SerializedName("url") String url) {
}
}
@@ -18,13 +18,13 @@
package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.*;
import javafx.beans.InvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.control.Label;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.StackPane;
@@ -38,13 +38,18 @@ import org.jackhuang.hmcl.game.GameJavaVersion;
import org.jackhuang.hmcl.java.JavaInfo;
import org.jackhuang.hmcl.java.JavaManager;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.task.*;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.DialogPane;
import org.jackhuang.hmcl.ui.construct.JFXHyperlink;
import org.jackhuang.hmcl.ui.wizard.SinglePageWizardProvider;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Result;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.gson.JsonUtils;
@@ -105,23 +110,39 @@ public final class JavaDownloadDialog extends StackPane {
}
private final class DownloadMojangJava extends DialogPane {
private final ToggleGroup toggleGroup = new ToggleGroup();
private final List<JFXCheckBox> checkboxes = new ArrayList<>();
DownloadMojangJava() {
setTitle(i18n("java.download"));
validProperty().bind(toggleGroup.selectedToggleProperty().isNotNull());
setTitle(i18n("java.download.title"));
VBox vbox = new VBox(16);
Label prompt = new Label(i18n("java.download.prompt"));
vbox.getChildren().add(prompt);
setValid(false);
InvalidationListener updateValidStatus = e -> {
for (JFXCheckBox box : checkboxes) {
if (!box.isDisabled() && box.isSelected()) {
setValid(true);
return;
}
}
setValid(false);
};
for (GameJavaVersion version : supportedGameJavaVersions) {
JFXRadioButton button = new JFXRadioButton("Java " + version.getMajorVersion());
JFXCheckBox button = new JFXCheckBox("Java " + version.majorVersion());
button.setUserData(version);
vbox.getChildren().add(button);
toggleGroup.getToggles().add(button);
if (JavaManager.REPOSITORY.isInstalled(platform, version))
if (JavaManager.REPOSITORY.isInstalled(platform, version)) {
button.setDisable(true);
button.setSelected(true);
} else {
button.selectedProperty().addListener(updateValidStatus);
}
vbox.getChildren().add(button);
checkboxes.add(button);
}
setBody(vbox);
@@ -134,38 +155,52 @@ public final class JavaDownloadDialog extends StackPane {
setActions(warningLabel, acceptPane, cancelButton);
}
private Task<Void> downloadTask(GameJavaVersion javaVersion) {
return JavaManager.getDownloadJavaTask(downloadProvider, platform, javaVersion).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception != null) {
Throwable resolvedException = resolveException(exception);
LOG.warning("Failed to download java", exception);
if (!(resolvedException instanceof CancellationException)) {
Controllers.dialog(DownloadProviders.localizeErrorMessage(resolvedException), i18n("install.failed"));
}
}
});
}
@Override
protected void onAccept() {
fireEvent(new DialogCloseEvent());
GameJavaVersion javaVersion = (GameJavaVersion) toggleGroup.getSelectedToggle().getUserData();
if (javaVersion == null)
List<GameJavaVersion> selectedVersions = new ArrayList<>();
for (JFXCheckBox box : checkboxes) {
if (!box.isDisabled() && box.isSelected()) {
selectedVersions.add((GameJavaVersion) box.getUserData());
}
}
if (selectedVersions.isEmpty())
return;
if (JavaManager.REPOSITORY.isInstalled(platform, javaVersion))
Controllers.confirm(i18n("download.java.override"), null, () -> {
Controllers.taskDialog(Task.supplyAsync(() -> JavaManager.REPOSITORY.getJavaExecutable(platform, javaVersion))
.thenComposeAsync(Schedulers.javafx(), realPath -> {
if (realPath != null) {
JavaManager.removeJava(realPath);
Task<Void> task = Task.allOf(selectedVersions.stream().map(javaVersion -> JavaManager.getDownloadJavaTask(downloadProvider, platform, javaVersion).wrapResult()).toList())
.whenComplete(Schedulers.javafx(), (results, exception) -> {
Throwable exceptionToDisplay;
if (exception == null) {
List<Throwable> exceptions = results.stream()
.filter(Result::isFailure)
.map(Result::getException)
.map(Lang::resolveException)
.filter(it -> !(it instanceof CancellationException))
.toList();
if (!exceptions.isEmpty()) {
if (exceptions.size() == 1) {
exceptionToDisplay = exceptions.get(0);
} else {
exceptionToDisplay = new IOException("Failed to download Java");
for (Throwable e : exceptions)
exceptionToDisplay.addSuppressed(e);
}
return downloadTask(javaVersion);
}), i18n("download.java"), TaskCancellationAction.NORMAL);
}, null);
else
Controllers.taskDialog(downloadTask(javaVersion), i18n("download.java.process"), TaskCancellationAction.NORMAL);
} else {
exceptionToDisplay = null;
}
} else {
exceptionToDisplay = exception;
}
if (exceptionToDisplay != null) {
LOG.warning("Failed to download java", exceptionToDisplay);
Controllers.dialog(DownloadProviders.localizeErrorMessage(exceptionToDisplay), i18n("install.failed"));
}
});
Controllers.taskDialog(task, i18n("download.java.process"), TaskCancellationAction.NORMAL);
}
}
@@ -248,7 +283,7 @@ public final class JavaDownloadDialog extends StackPane {
for (int i = 0; i < versions.size(); i++) {
DiscoJavaRemoteVersion version = versions.get(i);
if (version.getJdkVersion() == GameJavaVersion.LATEST.getMajorVersion()) {
if (version.getJdkVersion() == GameJavaVersion.LATEST.majorVersion()) {
remoteVersionBox.getSelectionModel().select(i);
return;
}
@@ -308,7 +343,7 @@ public final class JavaDownloadDialog extends StackPane {
FXUtils.onChange(distributionBox.getSelectionModel().selectedItemProperty(),
it -> currentJavaVersionList.set(getJavaVersionList(it)));
setHeading(new Label(i18n("java.download")));
setHeading(new Label(i18n("java.download.title")));
setBody(body);
setActions(warningLabel, downloadButtonPane, cancelButton);
if (platform.getOperatingSystem() == OperatingSystem.LINUX && platform.getArchitecture() == Architecture.RISCV64) {
@@ -21,9 +21,7 @@ import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXTextField;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
@@ -110,17 +108,13 @@ public final class JavaInstallPage extends WizardSinglePage {
{
BorderPane namePane = new BorderPane();
var namePane = new LinePane();
{
Label label = new Label(i18n("java.install.name"));
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
namePane.setLeft(label);
namePane.setTitle(i18n("java.install.name"));
nameField = new JFXTextField();
nameField.textProperty().bindBidirectional(control.nameProperty);
FXUtils.setLimitWidth(nameField, 200);
BorderPane.setAlignment(nameField, Pos.CENTER_RIGHT);
BorderPane.setMargin(nameField, new Insets(0, 0, 12, 0));
namePane.setRight(nameField);
nameField.setValidators(
new RequiredValidator(),
@@ -176,16 +170,9 @@ public final class JavaInstallPage extends WizardSinglePage {
}
private void addInfo(String name, String value) {
BorderPane pane = new BorderPane();
pane.setLeft(new Label(name));
Label valueLabel = FXUtils.newSafeTruncatedLabel(value);
FXUtils.copyOnDoubleClick(valueLabel);
BorderPane.setMargin(valueLabel, new Insets(0, 0, 0, 16));
BorderPane.setAlignment(valueLabel, Pos.CENTER_RIGHT);
pane.setCenter(valueLabel);
LineTextPane pane = new LineTextPane();
pane.setTitle(name);
pane.setText(value);
this.componentList.getContent().add(pane);
}
}
@@ -30,6 +30,7 @@ import javafx.scene.control.Skin;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.java.JavaInfo;
@@ -210,7 +211,7 @@ public final class JavaManagementPage extends ListPageBase<JavaRuntime> {
@Override
protected ListCell<JavaRuntime> createListCell(JFXListView<JavaRuntime> listView) {
return new JavaItemCell();
return new JavaItemCell(listView);
}
}
@@ -222,7 +223,7 @@ public final class JavaManagementPage extends ListPageBase<JavaRuntime> {
private final StackPane removeIconPane;
private final Tooltip removeTooltip = new Tooltip();
JavaItemCell() {
JavaItemCell(JFXListView<JavaRuntime> listView) {
BorderPane root = new BorderPane();
HBox center = new HBox();
@@ -231,6 +232,7 @@ public final class JavaManagementPage extends ListPageBase<JavaRuntime> {
center.setAlignment(Pos.CENTER_LEFT);
this.content = new TwoLineListItem();
HBox.setHgrow(content, Priority.ALWAYS);
BorderPane.setAlignment(content, Pos.CENTER);
center.getChildren().setAll(content);
@@ -240,7 +242,7 @@ public final class JavaManagementPage extends ListPageBase<JavaRuntime> {
right.setAlignment(Pos.CENTER_RIGHT);
{
JFXButton revealButton = new JFXButton();
revealButton.setGraphic(FXUtils.limitingSize(SVG.FOLDER_OPEN.createIcon(24), 24, 24));
revealButton.setGraphic(SVG.FOLDER_OPEN.createIcon());
revealButton.getStyleClass().add("toggle-icon4");
revealButton.setOnAction(e -> {
JavaRuntime java = getItem();
@@ -272,6 +274,8 @@ public final class JavaManagementPage extends ListPageBase<JavaRuntime> {
root.setPadding(new Insets(8));
this.graphic = new RipplerContainer(root);
FXUtils.limitCellWidth(listView, this);
}
@Override
@@ -159,7 +159,7 @@ public final class JavaRestorePage extends ListPageBase<JavaRestorePage.Disabled
{
JFXButton revealButton = new JFXButton();
revealButton.getStyleClass().add("toggle-icon4");
revealButton.setGraphic(FXUtils.limitingSize(SVG.FOLDER_OPEN.createIcon(24), 24, 24));
revealButton.setGraphic(SVG.FOLDER_OPEN.createIcon());
revealButton.setOnAction(e -> skinnable.onReveal());
FXUtils.installFastTooltip(revealButton, i18n("reveal.in_file_manager"));
@@ -168,7 +168,7 @@ public final class JavaRestorePage extends ListPageBase<JavaRestorePage.Disabled
JFXButton removeButton = new JFXButton();
removeButton.getStyleClass().add("toggle-icon4");
removeButton.setGraphic(FXUtils.limitingSize(SVG.DELETE.createIcon(24), 24, 24));
removeButton.setGraphic(SVG.DELETE.createIcon());
removeButton.setOnAction(e -> skinnable.onRemove());
FXUtils.installFastTooltip(removeButton, i18n("java.disabled.management.remove"));
@@ -176,7 +176,7 @@ public final class JavaRestorePage extends ListPageBase<JavaRestorePage.Disabled
} else {
JFXButton restoreButton = new JFXButton();
restoreButton.getStyleClass().add("toggle-icon4");
restoreButton.setGraphic(FXUtils.limitingSize(SVG.RESTORE.createIcon(24), 24, 24));
restoreButton.setGraphic(SVG.RESTORE.createIcon());
restoreButton.setOnAction(e -> skinnable.onRestore());
FXUtils.installFastTooltip(restoreButton, i18n("java.disabled.management.restore"));
@@ -59,18 +59,17 @@ import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.versions.GameItem;
import org.jackhuang.hmcl.ui.versions.GameListPopupMenu;
import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.upgrade.RemoteVersion;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.Platform;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import java.io.IOException;
import java.util.List;
@@ -154,11 +153,13 @@ public final class MainPage extends StackPane implements DecoratorPage {
announcementCard.getStyleClass().addAll("card", "announcement");
VBox announcementBox = new VBox(16);
announcementBox.setPadding(new Insets(15));
announcementBox.getChildren().add(announcementCard);
announcementPane = new TransitionPane();
announcementPane.setContent(announcementBox, ContainerAnimations.NONE);
StackPane.setMargin(announcementPane, new Insets(-15));
getChildren().add(announcementPane);
}
@@ -246,7 +247,14 @@ public final class MainPage extends StackPane implements DecoratorPage {
menuButton = new JFXButton();
menuButton.getStyleClass().add("menu-button");
menuButton.setOnAction(e -> onMenu());
menuButton.setOnAction(e -> GameListPopupMenu.show(
menuButton,
JFXPopup.PopupVPosition.BOTTOM,
JFXPopup.PopupHPosition.RIGHT,
0,
-menuButton.getHeight(),
profile, versions
));
FXUtils.installFastTooltip(menuButton, i18n("version.switch"));
menuButton.setGraphic(SVG.ARROW_DROP_UP.createIcon(30));
@@ -312,6 +320,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
Task<?> task = versionList.refreshAsync("")
.thenSupplyAsync(() -> versionList.getVersions("").stream()
.filter(it -> it.getVersionType() == RELEASE)
.filter(it -> NativePatcher.checkSupportedStatus(GameVersionNumber.asGameVersion(it.getGameVersion()), Platform.SYSTEM_PLATFORM, OperatingSystem.SYSTEM_VERSION) != NativePatcher.SupportStatus.UNSUPPORTED)
.sorted()
.findFirst()
.orElseThrow(() -> new IOException("No versions found")))
@@ -342,19 +351,6 @@ public final class MainPage extends StackPane implements DecoratorPage {
Controllers.taskDialog(task, i18n("version.launch.empty.installing"), TaskCancellationAction.NORMAL);
}
private void onMenu() {
GameListPopupMenu menu = new GameListPopupMenu();
menu.getItems().setAll(versions.stream().map(it -> new GameItem(profile, it.getId())).toList());
JFXPopup popup = new JFXPopup(menu);
popup.show(
menuButton,
JFXPopup.PopupVPosition.BOTTOM,
JFXPopup.PopupHPosition.RIGHT,
0,
-menuButton.getHeight()
);
}
private void onUpgrade() {
RemoteVersion target = UpdateChecker.getLatestVersion();
if (target == null) {
@@ -25,7 +25,6 @@ import javafx.beans.binding.StringBinding;
import javafx.beans.binding.When;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.ColorPicker;
@@ -44,7 +43,6 @@ import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.javafx.SafeStringConverter;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
@@ -79,22 +77,13 @@ public class PersonalizationPage extends StackPane {
ComponentList themeList = new ComponentList();
{
BorderPane brightnessPane = new BorderPane();
var brightnessPane = new LineSelectButton<String>();
brightnessPane.setTitle(i18n("settings.launcher.brightness"));
brightnessPane.setConverter(name -> i18n("settings.launcher.brightness." + name));
brightnessPane.setItems("auto", "light", "dark");
brightnessPane.valueProperty().bindBidirectional(config().themeBrightnessProperty());
themeList.getContent().add(brightnessPane);
Label left = new Label(i18n("settings.launcher.brightness"));
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
brightnessPane.setLeft(left);
JFXComboBox<String> cboBrightness = new JFXComboBox<>(FXCollections.observableArrayList(
FXUtils.DARK_MODE != null
? List.of("auto", "light", "dark")
: List.of("light", "dark")
));
cboBrightness.setConverter(FXUtils.stringConverter(name -> i18n("settings.launcher.brightness." + name)));
cboBrightness.valueProperty().bindBidirectional(config().themeBrightnessProperty());
brightnessPane.setRight(cboBrightness);
}
{
@@ -116,13 +105,13 @@ public class PersonalizationPage extends StackPane {
Platform.runLater(() -> JFXDepthManager.setDepth(picker, 0));
}
{
OptionToggleButton titleTransparentButton = new OptionToggleButton();
LineToggleButton titleTransparentButton = new LineToggleButton();
themeList.getContent().add(titleTransparentButton);
titleTransparentButton.selectedProperty().bindBidirectional(config().titleTransparentProperty());
titleTransparentButton.setTitle(i18n("settings.launcher.title_transparent"));
}
{
OptionToggleButton animationButton = new OptionToggleButton();
LineToggleButton animationButton = new LineToggleButton();
themeList.getContent().add(animationButton);
animationButton.selectedProperty().bindBidirectional(config().animationDisabledProperty());
animationButton.setTitle(i18n("settings.launcher.turn_off_animations"));
@@ -191,7 +180,7 @@ public class PersonalizationPage extends StackPane {
}
Label textOpacity = new Label();
FXUtils.setLimitWidth(textOpacity, 35);
FXUtils.setLimitWidth(textOpacity, 50);
StringBinding valueBinding = Bindings.createStringBinding(() -> ((int) slider.getValue()) + "%", slider.valueProperty());
textOpacity.textProperty().bind(valueBinding);
@@ -208,8 +197,7 @@ public class PersonalizationPage extends StackPane {
}
{
ComponentList logPane = new ComponentSublist();
logPane.setTitle(i18n("settings.launcher.log"));
ComponentList logPane = new ComponentList();
{
VBox fontPane = new VBox();
@@ -265,8 +253,7 @@ public class PersonalizationPage extends StackPane {
}
{
ComponentSublist fontPane = new ComponentSublist();
fontPane.setTitle(i18n("settings.launcher.font"));
ComponentList fontPane = new ComponentList();
{
VBox vbox = new VBox();
@@ -308,45 +295,32 @@ public class PersonalizationPage extends StackPane {
}
{
BorderPane fontAntiAliasingPane = new BorderPane();
{
VBox left = new VBox();
Label title = new Label(i18n("settings.launcher.font.anti_aliasing"));
title.getStyleClass().add("title");
Label subtitle = new Label(i18n("settings.take_effect_after_restart"));
subtitle.getStyleClass().add("subtitle");
left.getChildren().setAll(title, subtitle);
fontAntiAliasingPane.setLeft(left);
var fontAntiAliasingPane = new LineSelectButton<Optional<FontSmoothingType>>();
fontAntiAliasingPane.setTitle(i18n("settings.launcher.font.anti_aliasing"));
fontAntiAliasingPane.setSubtitle(i18n("settings.take_effect_after_restart"));
fontAntiAliasingPane.setConverter(value ->
value.isPresent()
? i18n("settings.launcher.font.anti_aliasing." + value.get().name().toLowerCase(Locale.ROOT))
: i18n("settings.launcher.font.anti_aliasing.auto")
);
fontAntiAliasingPane.setItems(
Optional.empty(),
Optional.of(FontSmoothingType.LCD),
Optional.of(FontSmoothingType.GRAY)
);
String fontAntiAliasing = globalConfig().getFontAntiAliasing();
if ("lcd".equalsIgnoreCase(fontAntiAliasing)) {
fontAntiAliasingPane.setValue(Optional.of(FontSmoothingType.LCD));
} else if ("gray".equalsIgnoreCase(fontAntiAliasing)) {
fontAntiAliasingPane.setValue(Optional.of(FontSmoothingType.GRAY));
} else {
fontAntiAliasingPane.setValue(Optional.empty());
}
{
@SuppressWarnings("unchecked")
JFXComboBox<Optional<FontSmoothingType>> cboAntiAliasing = new JFXComboBox<>(FXCollections.observableArrayList(
Optional.empty(),
Optional.of(FontSmoothingType.LCD),
Optional.of(FontSmoothingType.GRAY)
));
String fontAntiAliasing = globalConfig().getFontAntiAliasing();
if ("lcd".equalsIgnoreCase(fontAntiAliasing)) {
cboAntiAliasing.setValue(Optional.of(FontSmoothingType.LCD));
} else if ("gray".equalsIgnoreCase(fontAntiAliasing)) {
cboAntiAliasing.setValue(Optional.of(FontSmoothingType.GRAY));
} else {
cboAntiAliasing.setValue(Optional.empty());
}
cboAntiAliasing.setConverter(FXUtils.stringConverter(value -> {
if (value.isPresent()) {
return i18n("settings.launcher.font.anti_aliasing." + value.get().name().toLowerCase(Locale.ROOT));
} else {
return i18n("settings.launcher.font.anti_aliasing.auto");
}
}));
FXUtils.onChange(cboAntiAliasing.valueProperty(), value ->
globalConfig().setFontAntiAliasing(value.map(it -> it.name().toLowerCase(Locale.ROOT))
.orElse(null)));
fontAntiAliasingPane.setRight(cboAntiAliasing);
}
FXUtils.onChange(fontAntiAliasingPane.valueProperty(), value ->
globalConfig().setFontAntiAliasing(value.map(it -> it.name().toLowerCase(Locale.ROOT))
.orElse(null)));
fontPane.getContent().add(fontAntiAliasingPane);
}
@@ -19,10 +19,8 @@ package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.JFXPopup;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.scene.control.Label;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.Region;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.event.EventBus;
import org.jackhuang.hmcl.event.RefreshedVersionsEvent;
import org.jackhuang.hmcl.game.HMCLGameRepository;
@@ -38,17 +36,18 @@ import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.account.AccountAdvancedListItem;
import org.jackhuang.hmcl.ui.account.AccountListPopupMenu;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.construct.AdvancedListBox;
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.PopupMenu;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.ui.nbt.NBTEditorPage;
import org.jackhuang.hmcl.ui.nbt.NBTFileType;
import org.jackhuang.hmcl.ui.versions.GameAdvancedListItem;
import org.jackhuang.hmcl.ui.versions.GameListPopupMenu;
import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.util.Lang;
@@ -67,7 +66,6 @@ import java.util.Locale;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.ui.versions.VersionPage.wrap;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -148,12 +146,7 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
// first item in left sidebar
AccountAdvancedListItem accountListItem = new AccountAdvancedListItem();
accountListItem.setOnAction(e -> Controllers.navigate(Controllers.getAccountListPage()));
accountListItem.setOnMouseClicked(e -> {
if (e.getButton() == MouseButton.SECONDARY) {
showAccountListPopupMenu(accountListItem);
e.consume();
}
});
FXUtils.onSecondaryButtonClicked(accountListItem, () -> AccountListPopupMenu.show(accountListItem, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.LEFT, accountListItem.getWidth(), 0));
accountListItem.accountProperty().bind(Accounts.selectedAccountProperty());
// second item in left sidebar
@@ -174,18 +167,18 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
if (AnimationUtils.isAnimationEnabled()) {
FXUtils.prepareOnMouseEnter(gameListItem, Controllers::prepareVersionPage);
}
FXUtils.onSecondaryButtonClicked(gameListItem, () -> showGameListPopupMenu(gameListItem));
// third item in left sidebar
AdvancedListItem gameItem = new AdvancedListItem();
gameItem.setLeftGraphic(wrap(SVG.FORMAT_LIST_BULLETED));
gameItem.setActionButtonVisible(false);
gameItem.setLeftIcon(SVG.FORMAT_LIST_BULLETED);
gameItem.setTitle(i18n("version.manage"));
gameItem.setOnAction(e -> Controllers.navigate(Controllers.getGameListPage()));
FXUtils.onSecondaryButtonClicked(gameItem, () -> showGameListPopupMenu(gameItem));
// forth item in left sidebar
AdvancedListItem downloadItem = new AdvancedListItem();
downloadItem.setLeftGraphic(wrap(SVG.DOWNLOAD));
downloadItem.setActionButtonVisible(false);
downloadItem.setLeftIcon(SVG.DOWNLOAD);
downloadItem.setTitle(i18n("download"));
downloadItem.setOnAction(e -> {
Controllers.getDownloadPage().showGameDownloads();
@@ -198,8 +191,7 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
// fifth item in left sidebar
AdvancedListItem launcherSettingsItem = new AdvancedListItem();
launcherSettingsItem.setLeftGraphic(wrap(SVG.SETTINGS));
launcherSettingsItem.setActionButtonVisible(false);
launcherSettingsItem.setLeftIcon(SVG.SETTINGS);
launcherSettingsItem.setTitle(i18n("settings"));
launcherSettingsItem.setOnAction(e -> {
Controllers.getSettingsPage().showGameSettings(Profiles.getSelectedProfile());
@@ -211,8 +203,7 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
// sixth item in left sidebar
AdvancedListItem terracottaItem = new AdvancedListItem();
terracottaItem.setLeftGraphic(wrap(SVG.GRAPH2));
terracottaItem.setActionButtonVisible(false);
terracottaItem.setLeftIcon(SVG.GRAPH2);
terracottaItem.setTitle(i18n("terracotta"));
terracottaItem.setOnAction(e -> {
if (TerracottaMetadata.PROVIDER != null) {
@@ -254,34 +245,14 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
setCenter(getSkinnable().getMainPage());
}
public void showAccountListPopupMenu(
AccountAdvancedListItem accountListItem
) {
PopupMenu popupMenu = new PopupMenu();
JFXPopup popup = new JFXPopup(popupMenu);
AdvancedListBox scrollPane = new AdvancedListBox();
scrollPane.getStyleClass().add("no-padding");
scrollPane.setPrefWidth(220);
scrollPane.setPrefHeight(-1);
scrollPane.setMaxHeight(260);
if (Accounts.getAccounts().isEmpty()) {
Label placeholder = new Label(i18n("account.empty"));
placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: -monet-on-surface-variant; -fx-font-style: italic;");
scrollPane.add(placeholder);
} else {
for (Account account : Accounts.getAccounts()) {
AccountAdvancedListItem item = new AccountAdvancedListItem(account);
item.setOnAction(e -> {
Accounts.setSelectedAccount(account);
popup.hide();
});
scrollPane.add(item);
}
}
popupMenu.getContent().add(scrollPane);
popup.show(accountListItem, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.LEFT, accountListItem.getWidth(), 0);
public void showGameListPopupMenu(Region gameListItem) {
GameListPopupMenu.show(gameListItem,
JFXPopup.PopupVPosition.TOP,
JFXPopup.PopupHPosition.LEFT,
gameListItem.getWidth(),
0,
getSkinnable().getMainPage().getProfile(),
getSkinnable().getMainPage().getVersions());
}
}
@@ -293,11 +264,21 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
checkedModpack = true;
if (repository.getVersionCount() == 0) {
Path modpackFile = Metadata.CURRENT_DIRECTORY.resolve("modpack.zip");
if (Files.exists(modpackFile)) {
Path zipModpack = Metadata.CURRENT_DIRECTORY.resolve("modpack.zip");
Path mrpackModpack = Metadata.CURRENT_DIRECTORY.resolve("modpack.mrpack");
Path modpackFile;
if (Files.exists(zipModpack)) {
modpackFile = zipModpack;
} else if (Files.exists(mrpackModpack)) {
modpackFile = mrpackModpack;
} else {
modpackFile = null;
}
if (modpackFile != null) {
Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(modpackFile))
.thenApplyAsync(
encoding -> ModpackHelper.readModpackManifest(modpackFile, encoding))
.thenApplyAsync(encoding -> ModpackHelper.readModpackManifest(modpackFile, encoding))
.thenApplyAsync(modpack -> ModpackHelper
.getInstallTask(repository.getProfile(), modpackFile, modpack.getName(), modpack, null)
.executor())
@@ -18,7 +18,6 @@
package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXComboBox;
import com.jfoenix.controls.JFXRadioButton;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
@@ -40,11 +39,8 @@ import org.jackhuang.hmcl.setting.Settings;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.ComponentSublist;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.construct.MultiFileItem;
import org.jackhuang.hmcl.ui.construct.OptionToggleButton;
import org.jackhuang.hmcl.upgrade.RemoteVersion;
import org.jackhuang.hmcl.upgrade.UpdateChannel;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
@@ -63,15 +59,12 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.*;
import java.util.zip.GZIPInputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.ui.FXUtils.stringConverter;
import static org.jackhuang.hmcl.util.Lang.thread;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.selectedItemPropertyFor;
@@ -185,20 +178,21 @@ public final class SettingsPage extends ScrollPane {
}
{
VBox content = new VBox();
content.setSpacing(8);
JFXRadioButton chkUpdateStable = new JFXRadioButton(i18n("update.channel.stable"));
JFXRadioButton chkUpdateDev = new JFXRadioButton(i18n("update.channel.dev"));
VBox content = new VBox(12);
content.setPadding(new Insets(8, 0, 0, 0));
updateChannelGroup = new ToggleGroup();
chkUpdateDev.setToggleGroup(updateChannelGroup);
chkUpdateDev.setUserData(UpdateChannel.DEVELOPMENT);
chkUpdateStable.setToggleGroup(updateChannelGroup);
JFXRadioButton chkUpdateStable = new JFXRadioButton(i18n("update.channel.stable"));
chkUpdateStable.setUserData(UpdateChannel.STABLE);
chkUpdateStable.setToggleGroup(updateChannelGroup);
JFXRadioButton chkUpdateDev = new JFXRadioButton(i18n("update.channel.dev"));
chkUpdateDev.setUserData(UpdateChannel.DEVELOPMENT);
chkUpdateDev.setToggleGroup(updateChannelGroup);
Label noteWrapper = new Label(i18n("update.note"));
VBox.setMargin(noteWrapper, new Insets(10, 0, 0, 0));
VBox.setMargin(noteWrapper, new Insets(8, 0, 0, 0));
content.getChildren().setAll(chkUpdateStable, chkUpdateDev, noteWrapper);
@@ -208,7 +202,7 @@ public final class SettingsPage extends ScrollPane {
}
{
OptionToggleButton previewPane = new OptionToggleButton();
LineToggleButton previewPane = new LineToggleButton();
previewPane.setTitle(i18n("update.preview"));
previewPane.setSubtitle(i18n("update.preview.subtitle"));
previewPane.selectedProperty().bindBidirectional(config().acceptPreviewUpdateProperty());
@@ -252,37 +246,27 @@ public final class SettingsPage extends ScrollPane {
}
{
BorderPane languagePane = new BorderPane();
VBox left = new VBox();
Label title = new Label(i18n("settings.launcher.language"));
title.getStyleClass().add("title");
Label subtitle = new Label(i18n("settings.take_effect_after_restart"));
subtitle.getStyleClass().add("subtitle");
left.getChildren().setAll(title, subtitle);
languagePane.setLeft(left);
var chooseLanguagePane = new LineSelectButton<SupportedLocale>();
chooseLanguagePane.setTitle(i18n("settings.launcher.language"));
chooseLanguagePane.setSubtitle(i18n("settings.take_effect_after_restart"));
SupportedLocale currentLocale = I18n.getLocale();
JFXComboBox<SupportedLocale> cboLanguage = new JFXComboBox<>();
cboLanguage.setConverter(stringConverter(locale -> {
chooseLanguagePane.setConverter(locale -> {
if (locale.isDefault())
return locale.getDisplayName(currentLocale);
else if (locale.isSameLanguage(currentLocale))
return locale.getDisplayName(locale);
else
return locale.getDisplayName(currentLocale) + " - " + locale.getDisplayName(locale);
}));
cboLanguage.getItems().setAll(SupportedLocale.getSupportedLocales());
selectedItemPropertyFor(cboLanguage).bindBidirectional(config().localizationProperty());
});
chooseLanguagePane.setItems(SupportedLocale.getSupportedLocales());
chooseLanguagePane.valueProperty().bindBidirectional(config().localizationProperty());
FXUtils.setLimitWidth(cboLanguage, 300);
languagePane.setRight(cboLanguage);
settingsPane.getContent().add(languagePane);
settingsPane.getContent().add(chooseLanguagePane);
}
{
OptionToggleButton disableAutoGameOptionsPane = new OptionToggleButton();
LineToggleButton disableAutoGameOptionsPane = new LineToggleButton();
disableAutoGameOptionsPane.setTitle(i18n("settings.launcher.disable_auto_game_options"));
disableAutoGameOptionsPane.selectedProperty().bindBidirectional(config().disableAutoGameOptionsProperty());
@@ -330,6 +314,19 @@ public final class SettingsPage extends ScrollPane {
UpdateHandler.updateFrom(target);
}
private static String getEntryName(Set<String> entryNames, String name) {
if (entryNames.add(name)) {
return name;
}
for (long i = 1; ; i++) {
String newName = name + "." + i;
if (entryNames.add(newName)) {
return newName;
}
}
}
/// This method guarantees to close both `input` and the current zip entry.
///
/// If no exception occurs, this method returns `true`;
@@ -389,6 +386,8 @@ public final class SettingsPage extends ScrollPane {
try (var os = Files.newOutputStream(outputFile);
var zos = new ZipOutputStream(os)) {
Set<String> entryNames = new HashSet<>();
for (Path path : recentLogFiles) {
String fileName = FileUtils.getName(path);
String extension = StringUtils.substringAfterLast(fileName, '.');
@@ -410,7 +409,7 @@ public final class SettingsPage extends ScrollPane {
input = null;
}
String entryName = StringUtils.substringBeforeLast(fileName, ".");
String entryName = getEntryName(entryNames, StringUtils.substringBeforeLast(fileName, "."));
if (input != null && exportLogFile(zos, path, entryName, input, buffer))
continue;
}
@@ -427,10 +426,10 @@ public final class SettingsPage extends ScrollPane {
continue;
}
exportLogFile(zos, path, fileName, input, buffer);
exportLogFile(zos, path, getEntryName(entryNames, fileName), input, buffer);
}
zos.putNextEntry(new ZipEntry("hmcl-latest.log"));
zos.putNextEntry(new ZipEntry(getEntryName(entryNames, "hmcl-latest.log")));
LOG.exportLogs(zos);
zos.closeEntry();
}
Loaded 100 of 229 files, more files were not shown because too many files have changed in this diff. Show more