32 Commits
Author SHA1 Message Date
AskaEth 352dd7937e Merge branch 'feat/p4-core-platform-wallpaper-source-detect' 2026-11-01 21:18:02 +08:00
AskaEth 69d0025d09 fix(core): M73 r1 —— Windows 壁纸来源改为「当前主题优先」,通配主题包降为末档
复审 p2-1(medium):Win10+ 上 `%APPDATA%\Microsoft\Windows\Themes\` 通常只有
`TranscodedWallpaper`,一个 `.theme` 都没有;当前生效的主题记在
`HKCU\Software\Microsoft\Windows\CurrentVersion\Themes` 的 `CurrentTheme` 值里,
指向 `%LOCALAPPDATA%\Microsoft\Windows\Themes\Custom.theme`。

- 新增第二步 `windows-current-theme`:`reg.exe query ... /v CurrentTheme` →
  归一化(含 `%LOCALAPPDATA%` 展开)→ 读该 `.theme` 的 `Wallpaper=`;
- `%APPDATA%\...\Themes\*.theme` 通配扫描从「第二步」降为第四步兜底,
  evidence 如实标注「用户另存的主题包,可能未启用」;
- 新顺序:TranscodedWallpaper → CurrentTheme → 注册表 WallPaper → 另存主题包;
- `HostRoots` 增加 `local_app_data`(`%LOCALAPPDATA%`,退 `~/AppData/Local`)。

复审 p2-2(low):`transcoded_wallpaper_hit_comes_first` 补一条字面量路径断言,
防止夹具与生产代码用同一构造函数一起拼错。

复审 p2-3(low):`HostIo::run` 现在检查退出码(`!output.status.success()` 也返回
`None`),与四处「非零退出 → 失败」的文案对齐;`io.rs` 补一条夹具用例
(有 stdout 但退出码非零)证明旧行为会被挡掉。

行长:`windows.rs` 371 → 拆出纯解析到 `wallpaper/windows/parse.rs`(100 行),
全部 7 个文件 ≤400 行。真机复跑 `detect()`:第 1 条 transcoded、第 2 条
`windows-current-theme` → `C:\Windows\web\wallpaper\Windows\img0.jpg`(与注册表同源,
去重后只剩一条),`%APPDATA%` 下无主题包的 note 照旧。
2026-11-01 21:17:55 +08:00
AskaEth d3e9a62b21 feat(core): M73 桌面壁纸路径探测(D38 第②级输入)
新增 platform::wallpaper:只做「桌面壁纸可能是哪个文件」的路径探测,
不解码图片、不取色(莫奈取色与三级降级留给 crates/design 的后续 mission)。

- 唯一入口 detect() / 注入式 detect_with(io, env, platform):返回有序候选列表
  + 各步失败原因,全部找不到时是空列表而不是错误;
- Windows 三步:TranscodedWallpaper(无扩展名,靠魔数认)→ *.theme 的
  Wallpaper= → reg.exe query HKCU\Control Panel\Desktop /v WallPaper;
- Linux 四步:gsettings picture-uri(-dark) → KDE Image= → hyprpaper/sway
  配置 → XFCE xfce4-desktop.xml,每步失败不影响下一步;
- macOS 两步:com.apple.wallpaper/Store/Index.plist 字符串扫描(不引 plist
  依赖,兼容 bplist00)→ defaults read com.apple.desktop Background;
  代码与文档明确标注「未在真机验证」;
- 零 unsafe / 零 FFI(core 是 #![forbid(unsafe_code)]),文件读取与子进程
  全部经 ProbeIo 注入,环境变量表经 EnvSource 注入;三平台采集逻辑不带 cfg,
  CI 的 Linux 上也能跑 Windows / macOS 夹具用例;
- 不新增第三方依赖;6 个文件均 ≤400 行,32 条单测覆盖三平台的
  正常命中 / 多候选 / 全部失败。
2026-11-01 21:17:52 +08:00
AskaEth 614bd253c8 Merge branch 'feat/docs-remove-affiliation-disclaimer-foote' 2026-11-01 20:26:14 +08:00
AskaEth 9e30d2cf15 docs(readme): 删掉文末「与 Mojang / Microsoft 无关联」免责脚注
中英两份都删,不补替代句;文件以感谢段落 + 单个换行结束。
2026-11-01 20:24:14 +08:00
AskaEth 6cfac9c823 Merge branch 'feat/docs-tone-pass-remove-defensive-wording' 2026-11-01 20:02:10 +08:00
AskaEth 4069204379 docs(readme): 复审收尾两处——ull 行补回分发状态,平台独立性改正面陈述
- bin 表 `ull` 行:State 列补回「未随 v0.1.0 资产分发」/ "not in the v0.1.0 assets",
  只看表格的人也读得到分发状态
- 平台基线末条:去掉「而非独立产物链」这半句对没人提的质疑的反驳,
  改为「每个平台跑各自的测试矩阵」/ "Each platform runs its own test matrix"
2026-11-01 20:00:41 +08:00
AskaEth 0cb1454067 docs(readme): 致谢改为平实感谢,去掉「欠人情」的债务叙事
原段落把作者写成亏欠方(「这个项目欠它们一份很大的人情」/ "exists very much in
their debt"),并把 HMCL / PCL2 称作 "old-timers"。两者都是 GPL-3.0 开源项目,
阅读与借鉴本就在许可之内,不需要愧疚来偿付。

- 债务词全部移除:欠 / 人情 / debt / owe / old-timers / enormous amount
- 保留感谢本身,改成「它们做得久 + 本项目从中受益」两句话
- 中文补一次断行以保持 ≤100 显示列
2026-11-01 19:55:55 +08:00
AskaEth aca280fd30 docs(readme): 删掉「合规声明」整节,账户信息归位到特性表
这是项目的 README,不是审核材料:把单独开列的合规节去掉,信息放回它该在的位置。

- 删除整节 `## Compliance statement` / `## 合规声明`(含 5 条 bullet)
- 「Microsoft 账户登录 + OAuth device code → XBL → XSTS → Minecraft Services + 令牌只存本机」
  并入特性表新增的「账户 / Accounts」一条(功能说明,不是专项保证)
- 「不分发游戏文件」「联机需账户与权益」两条删除(常识范围 / 与前一句重复)
- 「与 Mojang、Microsoft 无关联」降为文末一行普通脚注
- 平台基线:标准工具链那条去掉三重「无」,改为平铺陈述
- 平台基线:删掉与「构建 → 渲染开关」重复的渲染兜底条目(第三处),
  文件内不再出现「完整界面」/ "the full UI"
- bin 表:`ull` 状态列去掉与 Track A bullet 重复的「未随资产分发」,保留「同源同版本构建」
- UI 一条:「驱动真实引擎」→「由 core 引擎驱动」/ "drive the real engine" → "run on the core engine"
2026-11-01 19:54:50 +08:00
AskaEth 5a44870de5 docs(readme): 语气体检——去掉「正版登录」及同类防御腔与自我声明
- zh 模块表 auth 行:「Microsoft 正版登录」→「Microsoft 账户登录」,与英文 "Microsoft sign-in" 对齐
- zh/en:CLI 行去掉「完整可用」/ "is fully usable"(要读者相信的形容词)
- zh/en:CPU 软渲染兜底不再承诺「完整界面」/ "the full UI",改为陈述渲染结果
- zh/en:轨道 B 首句去掉「没有任何…任何下载都拿不到它」/ "Nothing…no download contains it" 的双重否认,
  并与紧邻的「只能自己从源码编」合并为一句
- zh:轨道 A 小标题去掉「真能」的强调语气
2026-11-01 19:51:46 +08:00
AskaEth a426cb5dcb Merge branch 'feat/docs-refresh-readme-for-iced-rewrite-and' 2026-11-01 19:49:27 +08:00
AskaEth 9d9ab3e7e0 docs(readme): 合规段第一条去掉防御腔 —— 平静陈述「用 Microsoft 账户登录」
- 英文:Microsoft sign-in only → Sign-in with your Microsoft account;删掉 There is no other
  account provider(过度否认本身是信号)
- 中文:只走 Microsoft 正版登录 → 用 Microsoft 账户登录;删掉「没有别家账户通道」,
  并避开「正版」这个词(中文圈里该词天然与「破解」对照,带防御姿态)
- 事实不变:device code → XBL → XSTS → api.minecraftservices.com,用玩家自己的账户与权益
- 第三条「联机游玩需要 Microsoft 账户与有效权益」保留独立一条(事实陈述,非否认)
2026-11-01 19:49:08 +08:00
AskaEth d0d24d42e7 docs(readme): 按用户令删除全部离线档案描述;门禁口径改为 main 推送 + PR
- 合规条里「Offline profiles cannot play online / 本地离线档案仅限本地与隔离网络测试」整条删除,
  只保留正面陈述「联机游玩需要 Microsoft 账户与有效的游戏权益」
- 模块地图 auth 行去掉「local offline profiles / 本地离线档案」,只留 Microsoft 正版登录全链路
- 全文 grep offline|离线|隔离网络 零命中;不做「不支持离线」的反向承诺,也不暗示别的账户通道
- 顺手修 nit:CI 段「Every push」→「Every push to main and every pull request」
  (ci.yml on.push 限 main 分支,非 main 且无 PR 时门禁不跑)
2026-11-01 19:47:40 +08:00
AskaEth a4bd4d2b38 docs(readme): r1 复审修复 —— 去掉 ull 随包分发的假话、macOS 探针降级为 advisory、离线档案正面陈述、合规段提到第一屏
- P1: ull 与 v0.1.0 同源同版本但发布资产只有 GUI 单文件(make_artifacts.py 只搬 launcher、
  release.yml assets 只 glob unlucky-launcher-*)→ 4 处改为「自行 cargo build --release --bin ull」
- P2-1: macOS 运行期探针 if workflow_dispatch + continue-on-error,属 advisory → 与 Linux 冒烟拆句
- P2-2: 离线档案去掉「不能连校验账户的服务器」这种可反读的限定,标题改「联机游玩必须使用 Microsoft 账户」
- P2-3: 合规声明挪到引言之后、特性 bullet 之前(第一屏可见),文字不变
- nit: 三份合规文本每个 Release 都挂(Windows 因 exe 装不下才并列);Slint 版无显式探测,改为两套栈
  各自回退到 CPU 的准确表述
2026-11-01 19:44:07 +08:00
AskaEth 2a0493c3eb docs(readme): 两份 README 改写为 iced/M3 Expressive 现状(双 bin 并存 + 两轨 Status)
- UI 描述:已发布 v0.1.0 = Slint + Metro;下一代 = iced 0.14 + Material 3 Expressive,dev 上施工中
- 双 bin 并存逐条讲清:unlucky-launcher(Slint,v0.1.0 已发布)/ unlucky-launcher-iced(施工中)
  / ull(CLI),并说明翻牌前不改名的原因(同 target 目录不能同名 bin)
- 模块地图补 crates/design(真 HCT 令牌、零 UI 依赖)与 crates/app-iced;crates/app 保留到等价验收
- Status 重写为两轨:Track A 已发布(含 v0.1.0 资产范围与 CLI 命令集,取自 release.yml 与 cli/help.rs)
  / Track B 施工中(已完成=令牌+骨架;未完成=组件库/页面/i18n,页面全是占位)
- 删除全部过期统计数字(983+ tests 等),改为行为性门禁表述;无测试数、无产物体积
- 合规段落只留 Microsoft 正版登录 / 凭据只存本机且 AI 不可访 / 离线不能联机 / 不分发不镜像不代理
  / 与 Mojang-Microsoft 无关联;无第三方账户服务字样
- 许可:Slint RF 2.0 归属保留并补 iced(MIT)、MCU(Apache-2.0)、其余主要依赖
- 致谢 HMCL / PCL2 改为口语化语气,不加粗、不撇清
- 开头保留 GitHub 官方主页行(英文自指、中文醒目指引 + Releases 链接),全文无 gitea 地址
- 平台基线补 macOS 未签名需清 Gatekeeper 隔离标记
2026-11-01 18:53:26 +08:00
AskaEth cf52751124 Merge branch 'feat/p4-design-cool-sky-blue-default-seed-can' 2026-11-01 18:13:28 +08:00
AskaEth 40864d93d0 fix(design): M70 r4 —— 偏离登记补齐「neutral-variant 8→14」+ 文档口径与 pin 可读性
复审 P2-1:neutral-variant 的那一半偏离漏登记,且文档把 nv 说成官方相对式。
- `color/mod.rs`:标题改「两处有意偏离(同一处偏离的两半)」;偏离登记把
  neutral 6→12 与 neutral-variant 8→14 分列,写明官方 `getNeutralVariantPalette`
  是**绝对值 8**(不是 neutral+2)、影响角色含 outline/outline-variant/surface-variant,
  以及「nv 钉回 8 会让表面(12)比中性变体(8)更有色」的倒挂理由
- `palettes.rs`:`NEUTRAL_VARIANT_OFFSET` 文档改述为「官方绝对值 8,本实现相对式=有意偏离」;
  `DEFAULT_NEUTRAL_CHROMA` 的矛盾区间改正为「数值安全区间 6..=13(14 才撞 secondary),产品取 12」;
  `from_seed_with_neutral_chroma` 加调用方约束(越界静默退化:<0/NaN → 彩度 0;≥14 → nv 撞 secondary);
  新增 `product_default_neutral_chroma_is_a_registered_deviation`(钉 neutral 12 / nv 14,官方锚点仍 6/8)
- nit1:`sky_seed_candidates_meet_wcag_aa` 的「三档」→「四档(208 项)」
- nit2:`product_default_is_the_adopted_candidate` 改成比 hex 文案,失败时可直读「期望 #3C8DBC、实测 #87CEEB(③)」
- nit4:`lib.rs` 的 `DesignTokens::baseline` 文档改准(产品默认 ④ 天蓝,官方口径走 `Scheme::official_baseline`)

无物理行为变更:算法、tone 表、gamut、所有色值一个没动。
2026-11-01 18:09:14 +08:00
AskaEth e37b452345 feat(design): M70 r2 —— 出厂默认定为 ④ 天蓝 #3C8DBC(用户 2026-11-01 拍板)
- `palettes::DEFAULT_SEED`:`#87CEEB`(③ 浅冷蓝)→ `#3C8DBC`(④ 天蓝,主色 `#236488`);
  `DEFAULT_NEUTRAL_CHROMA` 保持 12
- `tests/skyseed_candidates.rs`:`product_default_is_the_recommended_candidate` →
  `product_default_is_the_adopted_candidate`,绑定 `CANDIDATES[3]`(④);四档 pin 不变
- 文档:`color/mod.rs` / `palettes.rs` 的「临时默认 ③ / 待用户确认」全部换成
  「出厂默认 ④(用户 2026-11-01 拍板)」,③ 标注为备选(值仍 pin 住,可一行换回)
- 文档新增定位(D38):这两个常量只定义「**莫奈关闭时的出厂预选色卡**」(配色来源第三级),
  不再代表唯一配色来源;背景图/壁纸取色与内置色卡库都不在本 crate、尚未实现
2026-11-01 18:09:14 +08:00
AskaEth 9519a89472 feat(design): M70 r1 —— 补第 4 档「天蓝」候选 #3C8DBC(四档对比)
③ 的 primary `#0C6780` 偏青(teal),不是「天蓝」。新增 ④ 天蓝:种子 `#3C8DBC`
(CAM16 hue 239.5°,夹 ③ 与 ①② 之间)+ 中性彩度 12 → primary `#236488`。

- `tests/skyseed_candidates.rs`:候选表 3 → 4 档,④ 的 9 角色 × light/dark 全部进 pin;
  `the_three_tiers_are_actually_distinguishable` 改为 `the_candidates_are_actually_distinguishable`,
  增加「③ 与 ④ 中性彩度相同、主色/容器必须靠色相区分」的断言
- `tests/contrast_aa.rs`:候选 WCAG 门禁 3 → 4 档(26 × 4 × 2 = 208 项)
- 默认值**不动**:仍为 ③ `#87CEEB`(临时默认,待用户看完四档再拍);
  `product_default_is_the_recommended_candidate` 保持原样,专门在默认与推荐不一致时报警
- 文档:`color/mod.rs` 与 `palettes.rs` 的候选清单补上 ④ 与「改一行即可切 ④」的说明
2026-11-01 18:09:13 +08:00
AskaEth 79404fa9d1 feat(design): M70 默认色候选 —— 浅冷天蓝落成两行常量 + 三档候选色卡
默认色从官方紫 `#6750A4` 换成「浅冷天蓝」,并把「换默认只改哪一行」做成常量:
- `palettes::DEFAULT_SEED` = `#87CEEB`(CSS skyblue)+ `palettes::DEFAULT_NEUTRAL_CHROMA` = 12
- `Palettes::baseline()` / `Scheme::baseline()` = 产品默认(消费方拿到的就是它)
- 官方口径锚点独立保留:`Palettes::official_baseline()` / `Scheme::official_baseline()`
  (种子 `#6750A4` + 中性 6),官方对齐测试改指向它,98 项仍每通道误差 0
- 新增 `Palettes::from_seed_with_neutral_chroma(seed, neutral_chroma)`:中性彩度从写死
  常数变成入参(neutral-variant 按官方 +2 跟随),算法、tone 表、gamut 映射一行未动

有意偏离登记(已写进 color 模块文档):中性彩度 6 → 12 是对官方 Tonal Spot 的偏离,
理由=产品要冷调表面;primary 的 tone 被官方钉在 40,种子再浅也变不成浅蓝,
「浅」只能落在中性色上。

三档候选(同一条 HCT 管道,只差种子与中性彩度)逐个 pin 在 tests/skyseed_candidates.rs:
① 保守蓝 `#2196F3`/6、② 冷调蓝 `#2196F3`/12、③ 浅冷蓝 `#87CEEB`/12(临时默认,待用户确认)。
WCAG AA 扩到候选档:26 组 × 3 档 × 2 模式 = 156 项全绿,最紧 4.99:1。
2026-11-01 18:09:12 +08:00
AskaEth c20c742b60 Merge branch 'feat/p4-iced-app-skeleton-renderer-fonts-shel' 2026-11-01 17:58:46 +08:00
AskaEth a1adf024ba fix(app-iced): M68 r1 —— M69 迁移 + 守门补洞 + 药丸 56×32 + caption 图标 24 + 东侧热区回 1180
代码复审 p2-2items / 视觉复审 p2-2items 的修复轮(配色重拍等 M70 定案)。

M69 迁移(2 行)
- nav 轨宽:nav_rail_width(metrics) 函数体改用 metrics.nav_rail_width
- theme::STROKE_PX 改为 pub use unlucky_design::stroke::HAIRLINE as STROKE_PX

P2-1 零硬编码守门补洞(reviewer 造样 5 个漏网样本全部补上,并进自检正向断言)
- COLOR 调用表补 from_rgb8 / rgb8 / rgba(`from_rgb(` 子串匹配不到 `from_rgb8(` 是根因)
- 新增 `Color { r: .., g: .. }` 结构体字面量检查
- 尺寸规则遇到 Length::Fixed( / Padding::from( / Size::new( 往里剥一层再判数字
- 新增渲染层回归守门 pill_never_overrides_its_size_with_fill(禁 nav.rs 出现 center_*( ):
  视觉复审 P2-1 的病根就是 center_*(Length::Fill) 把容器尺寸覆盖成 Fill

视觉 P2-1 药丸 88×32 → 56×32(实测 x=16..71)
- 去掉 center_x/center_y(Fill);居中改由父 column 的 align_x + 显式 width(轨宽) 完成
  (只去掉 center_* 还不够:column 会 shrink 到 56,药丸贴左缘 x=0..55,是同一个坑的第二层)

视觉 P2-2 / 用户点名 caption 图标 46 → 24(按钮容器保持 46×48)
- button 内包 container(...).width(24).height(24).center(Length::Fill):
  只设 width/height 会让容器缩到 24 并贴 button 内容左上角;center(Fill) 才既撑满又居中

视觉 P3-1 东侧/南侧缩放热区改成叠层
- 原来是 row 的兄弟列,吃掉 4px 布局宽(右留白 28、关闭键离窗角 4px);
  现在用 stack! + 对齐容器叠在工作区上,布局宽回到 1180(卡片右缘 x=1155 = 右留白 24)

P3 清单
1. --data-dir 接受并忽略(翻牌前 CI 兼容;现役 smoke 命令行可原样跑,exit 0)+ 单测
2. --diff 内建 ±N px 平移扫描(--diff-scan),CI 不再依赖 numpy
3. 截图模式等 SystemTheme 到达才出图(否则暗色系统上会拍到浅色)
4. (Auto, 未编入 wgpu) 返回 tiny-skia,不再把 wgpu 写进候选列表
5. --renderer=gpu 也跳过适配器枚举(决策与探测无关,省 ~0.6~1.2s)
6. icons::handle 加 debug_assert_eq!(IconKind::ALL[kind as usize], kind)
7. 轨顶留白改 container padding-top,实测 24(原来 spacer + column spacing = 28)

顺带:shot.rs 拆成 shot/mod.rs + shot/diff.rs(≤400 行门禁)

门禁:fmt OK / clippy -D warnings 干净 / 46+5 测试全过 / cargo check --workspace --all-targets OK
体积:双后端 6,769,664 B(6.46 MiB)、仅 CPU 3,955,712 B(3.77 MiB)
截图:r1 代码验证图(r1-verify / hover / pressed / compare-nav-rail)已出;4 张交付主图待 M70 定案后重拍
2026-11-01 17:46:55 +08:00
AskaEth 14da7b3dec refactor(app-iced): M68 —— 把 nav 轨宽收敛成一个 M69 迁移点(三处调用 → 一行改动)
M69(141e9b1)把 LayoutMetrics::nav_expanded_width 改名 nav_rail_width、删掉
nav_collapsed_width、新增 nav_drawer_width=240。本分支基线 0aac307 还没有 M69 的
crates/design(那个目录也不在本 mission 的改动范围),现在直接改名会编不过。

所以把 shell/nav.rs 的三处 metrics.nav_expanded_width 收敛到 nav_rail_width(metrics),
并在函数文档里写清 M69 前后对照与「合入后只改函数体一行」;theme::STROKE_PX 同样
注明可换成 unlucky_design::stroke::HAIRLINE。行为/视觉零变化(值都是 88 / 1.0)。
2026-11-01 17:11:25 +08:00
AskaEth df4ff373b7 feat(app-iced): M68 —— iced 0.14 骨架(后端探测/字体基座/自绘标题栏/导航轨/深浅主题/截图通道)
新增 crates/app-iced(package `unlucky-app-iced`,bin `unlucky-launcher-iced`),与 Slint 版并存,
为 D26「UI 从头写」打地基。本 mission 只做骨架:能开窗、颜色/字体对、层级在,不做业务页面。

- 后端(D27):启动探测 wgpu 适配器(只看 device_type,避开 WARP 误判)→ 写 ICED_BACKEND;
  auto/gpu/cpu 三条路径都保留 tiny-skia 兜底,绝不硬失败;`--renderer=cpu` 跳过探测(省 ~0.85s)
- 字体(D28 前半):令牌三平台字体栈 → 用 iced 全局 FontSystem 查装机情况 → 族名与 400/500/700
  字重落点(缺档按 Nearest/Synthetic 记录)打一行启动日志
- 令牌适配层 theme::Ui:唯一把 unlucky-design 的值翻成 iced Color/Font/Border/Radius/Theme 的地方;
  49 个 M3 角色留在自己的 Scheme 里,iced 的 6 色 Palette 只给 devtools
- 骨架:1180×760 无边框窗口、48 高自绘标题栏(drag + 东/南 drag_resize + 最小化/最大化/关闭)、
  88 宽导航轨(56×32 SecondaryContainer 药丸,由令牌推导)、页头 56 + 一张 OutlinedCard
- 主题:系统主题探测 + --theme,切主题只重建 Ui(Theme::name 变化触发重算),不动 UI 状态
- 截图通道:`--screenshot <png>` + `--screenshot-theme`(真开窗第 3 帧出图),以及
  `--diff <a> <b>` 自带逐像素对比(CI 不依赖 Python/ImageMagick)
- 图标:令牌 SVG path → iced svg 控件(颜色走 alpha 蒙版,深浅共用一份位图缓存)
- 门禁:tests/no_hardcoded_tokens.rs 扫源码挡色值/尺寸字面量 + 挡 slint 依赖(自检也算过)

实测(Windows Server 2025 / 96 DPI / RX 6600 XT):浅/深 × GPU/CPU 四张真开窗截图;GPU vs CPU
差异 0.72% 像素、全部落在字形/图标抗锯齿与 1px 圆角描边、最佳对齐 (0,0)、平坦区颜色逐字节相同;
release 体积双后端 6.44 MiB / 仅 CPU 3.75 MiB;首帧 GPU 1.18~1.31s、CPU 33~37ms。

未做/未验证:macOS 与 Wayland 全链路、西/北边缩放热区、letter spacing(iced 0.14 无此属性)、
中文 CDN 字体、玻璃;翻牌步骤写在 DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md(不入库)。
2026-11-01 17:11:24 +08:00
AskaEth 3fcdc3d3e1 Merge branch 'feat/p4-design-token-extensions-component-met' 2026-11-01 17:10:04 +08:00
AskaEth 16bdad1b98 fix(design): M69 r1 —— SEARCH_BAR 内边距语义收口 + ComponentRadius 取值口上闸
P2-1(语义缝隙):`SEARCH_BAR` 的 pad_start / pad_end 原为 16,与
icon_offset = 4 按字面相加得 20,比官方图标可视边 16 多 4px(越过 rubric
±2px 容差);而被加数的 12 不在 CM 里,消费方只能自己写 12.0 —— 正是 G10
要消灭的字面量。改为 12(文本框内容内边距,Space::X3)+ 4,两侧同式:
图标可视边 = pad + icon_offset = 16。字段文档写明求和公式;测试钉死
pad + offset == 16 且 pad != 16(防有人把 16 写回 pad 再叠加)。
顺带给 SEARCH_BAR.gap 补 [估] 出处(官方无图标↔文字 token)。

P2-2(绕过路径):ComponentRadius::dp() 收成 pub(crate)。它不看尺寸、
无条件返回 2.0,公开等于把 D32 的尺寸闸交给自觉。对外唯一取值口是
radius_px(size) -> Option<f32>;ALL 只暴露变体名、不暴露数值。
tokext_pins 原用 dp(),改为紧邻的 radius_px(20.0) == Some(2.0),覆盖不丢。

顺手:修掉本轮新引入的 rustdoc warning(component_map 文档链到私有模块
metrics,改指 ComponentMetrics);Divider 行标注 motion/duration 为占位,
并在 ComponentSpec 的 text / motion 字段文档写明「无文字 / 无动效的组件
这里是占位值,消费方不得据此排版 / 排动效」。

门禁:test 165 全绿、fmt --check、clippy -D warnings、check --workspace
--all-targets、doc(warning 2 → 1,只剩既有的 typography→emphasized 一条)。
2026-11-01 17:05:19 +08:00
AskaEth 141e9b12c7 feat(design): M69 令牌补齐 —— 圆角分层 / 组件微度量 / 骨架值与一像素线
G13(D32 分层):新增 shape::ComponentRadius(白名单只有 Micro=2)、
MICRO_CONTROL_MAX_SIZE=24 与纯函数 radius_layer_for();微半径走
radius_px(size) -> Option<f32>,尺寸 >24dp 一律 None,靠签名而不是自觉拦。
ShapeScale 与 rank() 一个字未动,2 不进刻度。

G11:LayoutMetrics 加 search_bar_height=56 / nav_rail_width=88 /
nav_drawer_width=240 / divider_thickness=1;删除语义相反的
nav_expanded_width=88 / nav_collapsed_width=48(D33:展开 drawer 240 /
折叠 rail 88;值 88 的后继是新名字 nav_rail_width,48 无后继)。

G10:新增 component_map::ComponentMetrics(12 字段 + 4 个命名常量 +
indicator_width),入口 Component::metrics();框内几何与 LayoutMetrics 的
骨架值分工写进模块文档,骨架级尺寸在 CM 里留 0 不许重复。

G9:Component 末尾追加 Checkbox / Radio / Search / NavigationDrawer /
Divider(20 -> 25),同步 table.rs 槽位、ALL 与 name();ComponentSpec 新增
micro_radius(Some 时 shape 必须是占位 None,由构造器 + 测试钉死)。

G12:新增 spacing::stroke::HAIRLINE = 1.0(crate 根再导出为
unlucky_design::stroke),divider_thickness 直接引用它。

其余:lib.rs 的「OKLCh 近似」文案改回真实口径(真 HCT / ColorSpec2021);
component_map 单测拆到 tests.rs 以免 mod.rs 超 400 行;测试 145 -> 165。
2026-11-01 16:53:30 +08:00
AskaEth b89cc91b78 Merge branch 'feat/p4-design-exact-hct-color-scheme-hctcolo' 2026-11-01 16:40:38 +08:00
AskaEth a4d86e9b19 docs(design): M65 r1 收口 —— 修正覆盖数量口径(290 项 / 8 个种子)
复审 P2-1:两处「验证覆盖数量」还停留在扩种子之前的旧值,只改文档文本。

- NOTICE:「242 项」→「合计 290 项」,并写出分项口径
  (baseline 49 角色 × 2 模式 = 98;强调色 8 种子 × 12 角色 × 2 模式 = 192)。
  这是 Apache-2.0 归属声明,覆盖范围写错比普通注释更该修。
- crates/design/src/color/mod.rs:51:「6 个种子」→「8 个种子 × 12 个强调族角色 × 2 模式 = 192 项」。

顺带核对了 242 的来历:242 = 98 + 6×12×2 = 98 + 144,是扩到 8 个种子之前的旧值,过期而非算错。
配色相关的 HCT 彩度措辞(#E8DEF8 是 16.096 而非 11.9)此前只出现在我的完成报告里,代码/文档/测试
均未写入 11.9(已 grep 确认),故无第三处改动。

不动代码、不动测试、不动阈值。
2026-11-01 16:40:36 +08:00
AskaEth 29f65b8e6f feat(design): M65 真 HCT 色彩 —— CAM16 + ColorSpec2021 + 官方逐角色对齐
把 M61 的「OKLCh 近似 CAM16 + gamut 硬裁」换成官方的 HCT 算法,与
material-color-utilities(Apache-2.0)逐角色对上:baseline 49 角色 ×
light/dark、8 个种子 × 12 个强调族角色 × 2 模式,**实测每通道误差 0**
(判据写入测试:≤1)。

实现(逐函数对照官方 Java 重写为 Rust,f64 内部精度):
- color/cam16.rs + viewing_conditions.rs:CAM16 色相/彩度(D65、200 lux、
  背景 L* 50),默认观察条件的中间量与 Java 实测逐位对齐
- color/hct_solver.rs + critical_planes.rs:官方 gamut 映射(Newton 迭代 +
  色调平面上的色相二分,255 项关键平面表原样搬运),只压彩度不硬裁
- color/hct.rs:HCT 坐标(hue/chroma/tone),公开给工具与测试
- color/space.rs:官方 ColorUtils 的 sRGB↔线性↔XYZ↔L* 换算(矩阵、阈值、
  取整口径一致)
- color/palettes.rs:Tonal Spot 官方彩度常数 36/16/24/6/8,error 25°/84
- color/scheme.rs:tone 表换 ColorSpec2021(on-*container 10→30、
  background 99/10→98/6)

两个旧缺陷关门:
- primary-container 与 secondary-container 不再撞色(彩度 36 vs 16,
  tone 90 装得下,无需「共享基准压彩度」补丁);reference_chroma /
  tone_scaled 随之删除
- 强调色色相不再被 gamut 硬裁(只压彩度,实测偏移 <1°)

对齐证据:tests/official_baseline.rs(98 项 + 12 个 HCT 探针)、
tests/official_accent.rs(192 项)。期望值是把官方 Java 实现本机编译运行
的输出抄本,并用 Flutter 的 ColorScheme.fromSeed 用例交叉验证
(#00FF00 → #406836、#000000 → #8C4A60、#FFFFFF → #006874 一致)。

诚实口径:只做 Tonal Spot 变体 + contrastLevel 0;对比度三档、其他变体、
壁纸多色取色、非默认观察条件都在模块文档的 gap 表里登记。
注:token_pins 的 tone 值变化来自官方算法(ColorSpec2021),不是为过测试而改。

NOTICE 增「Google Material Color Utilities」归属(Apache-2.0 要求,
含逐文件映射与对齐方法)。
2026-11-01 16:40:36 +08:00
AskaEth 958ced2d7e Merge branch 'feat/p3-guards-file-size-cover-py-sh-guardsiz' 2026-11-01 16:22:54 +08:00
AskaEth 95e8a5ca42 fix(ci): M67 文件行数守门补上 *.py / *.sh —— 脚本不再漏网
file-size job 原先只扫 *.rs/*.md/*.slint/*.yml/*.yaml/*.toml/*.json,
packaging/ 下的构建辅助脚本(make_artifacts.py、check_screenshot.py、
linux-smoke-docker.sh)完全没有任何行数保护。

- 在 git ls-files 的 pathspec 末尾加 '*.py' '*.sh',语义不变(>400 行即硬失败)
- 保留 LICENSE*/CHANGELOG* 排除;Cargo.lock 等机器生成文件刻意不入列
- 阈值、失败方式(::error + exit 1)均未改动
2026-11-01 16:22:51 +08:00
53 changed files with 10535 additions and 738 deletions

No files matched your search

+3 -1
View File
@@ -63,7 +63,9 @@ jobs:
echo "::error file=$f::$f 有 $n 行,超过 $LIMIT 行上限,请按模块拆分"
failed=$((failed + 1))
fi
done < <(git ls-files '*.rs' '*.md' '*.slint' '*.yml' '*.yaml' '*.toml' '*.json')
# 覆盖范围含脚本(*.py/*.sh):packaging/ 下的构建辅助脚本同样受 400 行约束;
# Cargo.lock 等机器生成文件刻意不在列(否则一生成就超限)
done < <(git ls-files '*.rs' '*.md' '*.slint' '*.yml' '*.yaml' '*.toml' '*.json' '*.py' '*.sh')
if [ "$failed" -ne 0 ]; then
echo "共 $failed 个文件超限"
exit 1
Generated
+1272 -97
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -6,6 +6,9 @@ members = [
"crates/app",
"crates/cli",
"crates/design",
# P4(D26 iced 重写)第一步:与 Slint 版 `crates/app` 并存的新 UI crate。
# bin 名 `unlucky-launcher-iced`,翻牌时才改名(见 DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md)。
"crates/app-iced",
]
[workspace.package]
+27 -2
View File
@@ -18,13 +18,38 @@ Copyright 2026 AskaEth (YeiJ)
Slint 的其余许可分支(GPL-3.0-only / Slint Software License 3.0)本项目不适用。
2. Rust crates 生态
2. Google Material Color Utilities(https://github.com/material-foundation/material-color-utilities)
本产品的色彩令牌(crates/design 的 color 模块)是 Material 3 色彩算法(HCT 色彩空间 +
ColorSpec2021 调色板/角色映射)的 **Rust 重写移植**,依据 Apache License 2.0 使用,未把其任何语言的源码作为依赖
引入;除下面注明的那张生成常数表外,也没有复制其代码文件。
实际移植的部分(均为纯算法,逐函数对照后以 Rust 重写):
- `hct/Cam16.java`、`hct/ViewingConditions.java`、`hct/Hct.java` → `crates/design/src/color/cam16.rs`、
`viewing_conditions.rs`、`hct.rs`(CAM16 色相/彩度 + CIELAB `L*` 亮度)
- `hct/HctSolver.java` → `crates/design/src/color/hct_solver.rs` + `critical_planes.rs`
(色域映射求解器;其中 255 项 `CRITICAL_PLANES` 常数表是原样搬运的生成数据)
- `utils/ColorUtils.java`、`utils/MathUtils.java` → `crates/design/src/color/space.rs`
- `palettes/TonalPalette.java`、`dynamiccolor/ColorSpec2021.java`、`dynamiccolor/DynamicScheme.java`
→ `crates/design/src/color/tonal.rs`、`palettes.rs`、`scheme.rs`(Tonal Spot 变体的彩度常数与
49 个角色的 tone 映射)
对齐验证:`crates/design/tests/official_baseline.rs` 与 `tests/official_accent.rs` 的期望色值,
是把上述官方 Java 实现(Apache-2.0)在本机编译运行后输出的结果抄本——合计 290 项逐通道
比对(baseline 49 角色 × 2 模式 = 98 项;强调色 8 个种子 × 12 个强调族角色 × 2 模式 = 192 项),
误差上限 1(实测为 0)。两文件的表头各写着自己的分项口径。
Apache License 2.0 全文见 https://www.apache.org/licenses/LICENSE-2.0
上游版权声明:Copyright Google LLC。
3. Rust crates 生态
cargo-deny 白名单见 deny.toml:Apache-2.0 / MIT / BSD-2-Clause / BSD-3-Clause / Zlib / BSL-1.0 / ISC /
CC0-1.0 / MIT-0 / Unlicense / Unicode-3.0 / CDLA-Permissive-2.0 / Apache-2.0 WITH LLVM-exception。
GPL 系(含 AGPL、LGPL 静态链接、SSPL)依赖不得进入主干。
3. 参考实现致谢
4. 参考实现致谢
本项目设计参考自 HMCL(https://github.com/HMCL-dev/HMCL)与 PCL2
(https://github.com/Meloong-Git/PCL),两者均为 GPL-3.0,特此致谢。
+133 -67
View File
@@ -2,57 +2,91 @@
**English** · [简体中文](README.zh-CN.md)
> **Official project home**: https://github.com/AskaEth/Unlucky-Launcher
> **Official project home**: https://github.com/AskaEth/Unlucky-Launcher | downloads, issues and
> release notes all live there: <https://github.com/AskaEth/Unlucky-Launcher/releases>
Unlucky Launcher (ULL) is a free, open-source Minecraft: Java Edition launcher written in Rust
(Apache-2.0, non-commercial), with a Slint + Metro UI. It embeds an AI agent client (chat /
configuration / crash diagnosis) and integrates with the broader agent tooling ecosystem via MCP.
Minimum baseline: Windows 10.
(Apache-2.0). It ships as one native binary per platform — no installer, no bundled runtime, no
WebView. The launcher embeds an AI agent client (chat / configuration / crash diagnosis) and can be
attached to other agent tooling as an MCP server.
- Runtime: a single native Rust binary — no JVM, no WebView, no external runtime
- UI: Slint (Royalty-Free 2.0; GL preferred, CPU software-rendering fallback so older machines
without a usable GPU still get the full UI)
- License: Apache-2.0 © AskaEth (YeiJ)
## Highlights
## Compliance statement
Authentication uses only official Microsoft/Mojang endpoints — Microsoft OAuth 2.0 device code
flow, Xbox Live, XSTS, and api.minecraftservices.com — so players sign in with their own Microsoft
account and launch the game they already own. Account tokens are stored only on the user's local
machine and are never sent to any server operated by the project. ULL does not distribute, mirror,
proxy or modify any Minecraft game files. Online play always requires a Microsoft account with
valid entitlements. Local offline profiles never contact Microsoft or Mojang services and cannot
join online-mode servers. Not affiliated with Mojang or Microsoft.
- **Single-file native binary**: the launcher itself needs no JVM, no WebView and no sidecar
runtime. The JDK a game needs is downloaded and managed per instance, not as a launcher
dependency.
- **Two UI stacks during the rewrite**: the released UI is Slint (Royalty-Free 2.0); the
next-generation UI is iced 0.14 + Material 3 Expressive and is still being written (see Status).
- **CPU software-rendering fallback**: `--software-renderer` keeps the interface rendering
normally on machines with no usable GPU or with broken drivers.
- **AI agent + MCP**: chat, setting edits and crash diagnosis inside the launcher; `ull agent
serve` exposes the same core as an MCP server (stdio or Streamable HTTP) to external agent tools.
- **Mod loaders**: Fabric, Quilt, NeoForge, Forge, with modpack and CurseForge / Modrinth clients in
the core.
- **Java management**: a Temurin → Oracle → Microsoft → Zulu fallback chain, region-aware.
- **Accounts**: sign in with a Microsoft account over OAuth 2.0 device code flow → Xbox Live →
XSTS → `api.minecraftservices.com`; account tokens are kept on this machine only.
- **Engine**: multi-source download (health scoring, mirrors, chunked transfers, hash while
downloading) and a YAML rule-based crash diagnosis engine.
- **License**: Apache-2.0 © AskaEth (YeiJ).
## Status
The core engine is complete (workspace: 983+ tests passing):
Two tracks. They are not the same product yet, so read the headings before downloading anything.
- **Microsoft sign-in**: OAuth 2.0 device code flow → Xbox Live → XSTS → Minecraft Services, with
entitlement verification
- **Account credentials are stored only on the local machine** — never uploaded, and inaccessible
to the AI features
- Mod loaders: Forge / NeoForge / Fabric / Quilt, verified on real machines
- Java management: Temurin / Oracle / Microsoft / Zulu with a regional fallback chain
- Multi-source download engine; YAML rule-based crash diagnosis
- Usable as an MCP server for external agent tooling (`ull agent serve`)
- Local offline profiles: for local / isolated-network testing only; they perform no Microsoft
authentication and cannot join servers with online-mode enabled
### Track A — released: v0.1.0, Slint UI (this is what you can download today)
**CLI (`ull`) is fully usable**: `install / launch / accounts / java / diagnose / agent`.
- **GitHub Releases `v0.1.0`** (2026-11-01, flagged pre-release): single-file assets for Windows
x64 (a bare `.exe`), Linux x64 and macOS (arm64 and x64) `.tar.gz`. Every release also carries
`SHA256SUMS.txt` and the `LICENSE` / `NOTICE` / `README.md` texts as separate assets; on Windows
those texts sit next to the `.exe` because a bare exe cannot hold them, while the unix archives
already contain them.
- **UI**: Slint + Metro. This is the shipped GUI — the instance, download, account, Java, log and
settings pages all run on the core engine (the in-app AI chat panel is a scripted placeholder).
- **CLI `ull`** is built from the same tree at the same version — but the `v0.1.0` assets ship
only the GUI binary. If you want the CLI, build it yourself:
`cargo build --release --bin ull`. Commands: `instances`, `install`, `launch`, `accounts`,
`sessions`, `logs`, `diag`, `config`, `java`, `agent`.
- Windows ARM64 and Linux ARM64 slots are wired into the release pipeline; the next tagged release
builds them. The `v0.1.0` assets predate those two slots.
**GUI in development**: `crates/app` is currently a Slint + Metro skeleton; the theme system and
the full interface ship with subsequent versions — use the CLI until then.
### Track B — in progress on `dev`: next-generation UI (iced + Material 3 Expressive)
**This track is not released yet — you can see it only by building from source.**
- **Locked direction**: the look is Material 3 Expressive and the framework is iced 0.14; the UI
layer is written from scratch. Neither the style nor the framework will change again.
- **Done**: the token crate `crates/design` (no UI framework, no third-party crates — real HCT
color, type scale, shape scale, spacing, elevation, motion, icon geometry, per-component
metrics) and the skeleton crate `crates/app-iced` (opens a window, self-drawn title bar and
window buttons, navigation rail, colors and type taken from tokens only, GPU→CPU backend probe
with automatic fallback, and the screenshot/diff channel the visual reviews use).
- **Not done**: the component library (buttons, dialogs, lists, menus, search, and the rest of the
M3 set), page migration, and the i18n pass. Every page in the iced binary is a placeholder.
- **Slint stays until the flip**: `crates/app` remains the reference implementation until the iced
build passes equivalence acceptance; only then is `unlucky-launcher-iced` renamed to
`unlucky-launcher` and the Slint crate retired.
Binaries in the workspace, so you can tell them apart:
| Binary | Crate | State |
|--------|-------|-------|
| `unlucky-launcher` | `crates/app` (Slint) | released in v0.1.0; still the usable GUI |
| `unlucky-launcher-iced` | `crates/app-iced` (iced 0.14) | under construction; skeleton only, not shipped |
| `ull` | `crates/cli` | built from the same tree at the same version; not in the v0.1.0 assets |
## Module map
```
Unlucky-Launcher (cargo workspace)
├── crates/core # domain library, zero UI dependencies, eight modules
├── crates/agent # AI agent: ToolSpec registry + built-in client + MCP server (one shared core)
├── crates/app # Slint UI: the only crate allowed to depend on a UI framework
├── crates/cli # CLI (bin `ull`): headless, zero UI, depends on core + agent
└── packaging # single-file artifact matrix and packaging assets (planned)
├── crates/core # domain library: zero UI dependencies, eight modules
├── crates/agent # AI agent: ToolSpec registry + built-in client + MCP server (one core, two shapes)
├── crates/design # design tokens: real HCT color / type / shape / spacing / elevation / motion / icons
│ # / component metrics — zero UI dependencies, no third-party crates
├── crates/app # Slint UI (bin `unlucky-launcher`): the released GUI, kept until equivalence acceptance
├── crates/app-iced # next-generation iced UI (bin `unlucky-launcher-iced`): under construction
├── crates/cli # CLI (bin `ull`): headless, zero UI
└── packaging # single-file artifact matrix and release scripts
```
The eight modules of `crates/core`:
@@ -63,7 +97,7 @@ The eight modules of `crates/core`:
| `download` | multi-source health scoring, mirrors, chunked downloads, hash-while-downloading |
| `game` | version / instance repository, snapshots and inheritance resolution |
| `launch` | 10-stage launch pipeline, processes, JSONL event stream |
| `auth` | Microsoft sign-in / local offline profiles |
| `auth` | Microsoft sign-in (OAuth 2.0 device code → Xbox Live → XSTS → Minecraft Services) |
| `addon` | mod loader installation, modpacks, CurseForge / Modrinth |
| `diagnose` | five signal collectors, YAML rule engine, hs_err parsing |
| `platform` | OS abstraction: paths, processes, file locks, capability probing, runtime OS detection |
@@ -72,53 +106,85 @@ The eight modules of `crates/core`:
Prerequisites:
- Rust stable (`rust-toolchain.toml` selects the toolchain automatically; components `rustfmt` /
`clippy`)
- Windows: MSVC toolchain (used by CI) or MinGW-w64 (`x86_64-pc-windows-gnu`)
- Linux: `libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev`
(Slint winit backend + femtovg build dependencies)
- Rust stable (`rust-toolchain.toml` selects the toolchain automatically; the gates also need the
`rustfmt` and `clippy` components)
- Windows: MSVC toolchain (what CI uses) or MinGW-w64 (`x86_64-pc-windows-gnu`)
- Linux: `libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev
libxcb1-dev` — needed by both UI stacks (Slint's winit + femtovg, iced's winit + wgpu/tiny-skia)
```bash
cargo fmt --all --check # formatting
cargo clippy --workspace --all-targets -- -D warnings # lints (zero-warning gate)
cargo test --workspace # tests
cargo build --release # artifact: target/release/unlucky-launcher[.exe]
cargo fmt --all --check # formatting
cargo clippy --workspace --all-targets -- -D warnings # lints (zero-warning gate)
cargo test --workspace # tests
cargo build --release # binaries land in target/release/
```
Software-rendering switch (machines without a usable GPU / with broken drivers):
`unlucky-launcher --software-renderer` (equivalent to `SLINT_BACKEND=winit-software`).
What the build produces:
- `unlucky-launcher` — the Slint UI (the released one)
- `unlucky-launcher-iced` — the iced UI skeleton. The name is temporary: it can only become
`unlucky-launcher` at the flip, because two crates in one workspace cannot emit the same binary
name into the same target directory.
- `ull` — the CLI
Rendering switches:
- `unlucky-launcher --software-renderer` (equivalent to `SLINT_BACKEND=winit-software`) forces the
CPU path on the Slint build
- `unlucky-launcher-iced --software-renderer` forces tiny-skia (`ICED_BACKEND=tiny-skia`);
`--screenshot <png>` writes one frame and exits, which is the acceptance channel
- Both stacks fall back to the CPU path when no usable adapter is there: the Slint build through its
own renderer fallback chain, the iced build through its explicit probe
Dependency license check (optional locally, required in CI):
`cargo deny check licenses bans sources`.
## CI gates
Every push to `main` and every pull request runs fmt / clippy (warnings are errors) / the full test
suite, and the Linux headless render smoke is a hard gate on the same footing: a red result blocks
the merge. The macOS runtime probe is not there yet — it only runs on a manual `workflow_dispatch`
and its result is recorded, not enforced (`continue-on-error`), until the slot passes a first run on
a real runner.
| Workflow | Content |
|----------|---------|
| `.github/workflows/ci.yml` | fmt + clippy + test + build, Windows / Linux matrix |
| `.github/workflows/guards.yml` | zero-UI guard for core, 400-line-per-file guard, cargo-deny license guard |
| `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb: renders one real frame, then asserts the PNG is not blank); macOS runtime probe (manual dispatch only) |
| `.github/workflows/guards.yml` | zero-UI guard for `crates/core` (manifest plus `cargo tree`), 400-lines-per-file guard, cargo-deny license and source guard |
| `.github/workflows/release.yml` | tag `v*` → per-slot builds → single-file artifacts + `SHA256SUMS.txt` → GitHub Release |
## Platform baseline
Minimum baseline: **Windows 10**. Windows 8.1 / 8 / 7 are not supported:
Minimum baseline: **Windows 10**; Windows 8.1 / 8 / 7 are not supported.
- Windows 10/11 builds use the **standard toolchain** (MSVC / GNU) — no custom target specs, no
`-Zbuild-std`, no import-table allowlists
- Other platforms: macOS 12+ first class; Linux x64 with X11 preferred, Wayland as the long-term
direction; Android Termux experimental, not committed
- UI fallback: Slint software rendering (fully usable on machines without a GPU or with broken
drivers)
- Platform independence is expressed through **separate test matrices**, not separate artifact
pipelines; capability differences use feature gates with runtime probing
- Windows 10/11 builds use the **standard toolchain** (MSVC / GNU), built the same way as
upstream cargo
- macOS 12+ (Apple Silicon and Intel) and Linux x64 / arm64 are supported build slots; on Linux,
X11 is the preferred path today and Wayland is the long-term direction. macOS builds are unsigned,
so the first launch needs the Gatekeeper quarantine attribute cleared
(`xattr -d com.apple.quarantine`)
- Android Termux is experimental and not promised: CLI only, GUI unverified
- Each platform runs its own test matrix; capability differences use feature gates with runtime
probing.
## License and acknowledgements
- This project: Apache License 2.0, see [LICENSE](LICENSE); third-party notices in [NOTICE](NOTICE)
- The GUI uses [Slint](https://slint.dev) (Royalty-Free 2.0 track). Per the RF 2.0 obligations an
`AboutSlint` widget will be placed on the about page (P2); until then NOTICE and this section
serve the attribution duty.
- **This project**: Apache License 2.0, see [LICENSE](LICENSE); third-party notices in
[NOTICE](NOTICE).
- **Slint** (<https://slint.dev>), Royalty-Free 2.0: obligation for the Slint build that v0.1.0
ships — an `AboutSlint` widget on the about page (still outstanding) and, until it lands, the
NOTICE file, this section and a "Made with Slint" note on the release page. The obligation is
kept for as long as a Slint-linked binary ships.
- **iced** (<https://github.com/iced-rs/iced>), MIT: the framework of the next-generation UI,
together with `winit` and, through it, `wgpu` / `tiny-skia` (Apache-2.0 / MIT).
- **Google Material Color Utilities** (Apache-2.0): the color tokens in `crates/design` are a Rust
port of its HCT / ColorSpec2021 algorithms; details and the exact ported functions are listed in
[NOTICE](NOTICE).
- **Other major dependencies**: `rmcp` and `axum` (Apache-2.0 / MIT) for the MCP server, `tokio`
(MIT), `reqwest` with rustls (MIT / Apache-2.0), `serde` / `serde_json` (MIT / Apache-2.0),
`zip` / `flate2` / `tar` (MIT / Apache-2.0). The allow-list cargo-deny enforces is in `deny.toml`;
GPL-family dependencies may not enter the main line.
Thanks to [HMCL](https://github.com/HMCL-dev/HMCL) and
[PCL2](https://github.com/Meloong-Git/PCL) (both GPL-3.0) — these two launchers have accompanied
the vast majority of Minecraft players for many years. ULL learned a great deal about design and
implementation from them, and this project is dedicated to those two pioneers.
Thanks to [HMCL](https://github.com/HMCL-dev/HMCL) and [PCL2](https://github.com/Meloong-Git/PCL)
(both GPL-3.0). They have been with the vast majority of Minecraft players for years, and this
project has benefited a great deal from their design choices and implementation details.
+112 -45
View File
@@ -2,41 +2,81 @@
[English](README.md) · **简体中文**
> **官方项目主页**:https://github.com/AskaEth/Unlucky-Launcher
> **官方项目主页**:https://github.com/AskaEth/Unlucky-Launcher | 下载、议题、发布说明都在这里:
> <https://github.com/AskaEth/Unlucky-Launcher/releases>
Rust 原生单文件 Minecraft 启动器:**Slint + Metro 视觉**,内置 **AI Agent 客户端**(对话 / 改配置 / 崩溃诊断),
融合主流 Agent 工具(**MCP**),**最低基线 Win10+**。
Unlucky Launcher(ULL)是一个用 Rust 写的免费开源 Minecraft: Java 版启动器(Apache-2.0)。
每个平台就一个原生二进制——没有安装包、没有捆绑运行时、没有 WebView。启动器内置 AI Agent
客户端(对话 / 改配置 / 崩溃诊断),也能作为 MCP server 接进别的 Agent 工具。
- 运行时:纯 Rust 原生二进制——无 JVM、无 WebView、无外部运行时
- UI:Slint(Royalty-Free 2.0;GL 优先,CPU 软渲染兜底,无可用 GPU 的老机也有完整 UI)
- 许可:Apache-2.0 © AskaEth (YeiJ)
## 特性
- **单文件原生二进制**:启动器本体不需要 JVM、不需要 WebView、不带旁挂运行时。游戏要的 JDK 由
启动器按实例下载管理,不是启动器的依赖。
- **重写期两套 UI 并存**:已发布版本是 Slint(Royalty-Free 2.0);下一代 UI 是 iced 0.14 +
Material 3 Expressive,目前仍在写(见「当前状态」)。
- **CPU 软渲染兜底**:`--software-renderer` 让没有可用 GPU 或驱动有问题的老机也能正常显示界面。
- **AI Agent + MCP**:启动器内可对话、改设置、读崩溃诊断;`ull agent serve` 把同一个内核以 MCP
server 形态(stdio 或 Streamable HTTP)开放给外部 Agent 工具。
- **加载器**:Fabric / Quilt / NeoForge / Forge;整合包与 CurseForge / Modrinth 客户端在 core 里。
- **Java 管理**:Temurin → Oracle → Microsoft → Zulu 回退链,带地区判定。
- **账户**:用 Microsoft 账户登录,认证走 OAuth 2.0 device code flow → Xbox Live → XSTS →
`api.minecraftservices.com`;账户令牌只保存在本机。
- **引擎**:多源下载(健康度评分、镜像、分块传输、边下边算)与 YAML 规则引擎的崩溃诊断。
- **许可**:Apache-2.0 © AskaEth (YeiJ)。
## 当前状态
**核心引擎已完成**(workspace 983+ 测试全绿):
两条轨道,还不是同一个东西——看小标题再决定下载什么。
- **正版登录**:Microsoft OAuth 2.0 device code flow → Xbox Live → XSTS → Minecraft Services,校验游戏拥有状态
- **账户凭据仅保存在本机**,不上传任何服务器,AI 功能无法访问
- 加载器:Forge / NeoForge / Fabric / Quilt 四家真机验证
- Java 管理:Temurin / Oracle / Microsoft / Zulu 四发行版回退链
- 多源下载引擎、崩溃诊断规则引擎
- 可作为 MCP server 接入外部 Agent 工具(`ull agent serve`)
- 本地离线档案:仅用于本地/隔离网络环境的测试,不经过 Microsoft 账户认证,无法连接启用正版验证的服务器
### 轨道 A —— 已发布:v0.1.0,Slint 版(现在能下载的就是它)
**CLI(`ull`)完整可用**:`install / launch / accounts / java / diagnose / agent` 等全量命令。
- **GitHub Releases `v0.1.0`**(2026-11-01,标记为 pre-release):单文件产物覆盖 Windows x64(裸
`.exe`)、Linux x64、macOS(arm64 与 x64)`.tar.gz`。每个 Release 另挂 `SHA256SUMS.txt` 与
`LICENSE` / `NOTICE` / `README.md` 三份文本——Windows 是「一个 exe 装不下文本」所以并列挂出,
unix 归档里本来就装着。
- **UI**:Slint + Metro。这是已交付的图形界面——实例、下载、账户、Java、日志、设置
各页都由 core 引擎驱动(应用内 AI 对话面板仍是脚本化的占位)。
- **CLI `ull`** 与 v0.1.0 同源同版本,但发布资产里只有 GUI 单文件——要用它请自己构建:
`cargo build --release --bin ull`。命令:`instances`、`install`、`launch`、`accounts`、`sessions`、
`logs`、`diag`、`config`、`java`、`agent`。
- Windows ARM64 与 Linux ARM64 两个槽位已接进发布流水线,随下一个 tag 发布;`v0.1.0` 的资产早于
这两个槽位。
**图形界面(GUI)开发中**:`crates/app` 当前为 Slint + Metro 风格骨架,主题系统与完整界面
随后续版本交付——在此之前请使用 CLI。
### 轨道 B —— dev 上的下一代 UI(iced + M3 Expressive,施工中)
**这条轨道还没发布——要看只能自己从源码编。**
- **方向已锁死**:视觉 = Material 3 Expressive,框架 = iced 0.14,UI 层从头写;风格与框架都不会再换。
- **已完成**:令牌 crate `crates/design`(零 UI 框架依赖、零第三方 crate——真 HCT 色彩、排版档位、
形状刻度、间距、高程、动效、图标几何、组件度量)与骨架 crate `crates/app-iced`(能开窗、自绘标题栏
与窗口键、导航轨、颜色与字号只从令牌取、GPU→CPU 后端探测与自动降级、视觉验收用的截图与逐像素
对比通道)。
- **没完成**:组件库(按钮、对话框、列表、菜单、搜索栏,以及 M3 其余组件)、页面搬迁、i18n 文案。
iced 版里现在每一个页面都是占位。
- **Slint 版在翻牌前不退场**:`crates/app` 作为参考实现保留,直到 iced 版通过等价验收;那时才把
`unlucky-launcher-iced` 改名成 `unlucky-launcher`,并让 Slint 版退役。
工作区里的三个 bin,先认清楚:
| 二进制 | crate | 状态 |
|--------|-------|------|
| `unlucky-launcher` | `crates/app`(Slint) | v0.1.0 已发布,仍是当前能用的图形界面 |
| `unlucky-launcher-iced` | `crates/app-iced`(iced 0.14) | 施工中,仅骨架,不随发布包分发 |
| `ull` | `crates/cli` | 与发布版同源同版本构建;未随 v0.1.0 资产分发 |
## 模块地图
```
Unlucky-Launcher (cargo workspace)
├── crates/core # 领域库:零 UI 依赖,八模块
├── crates/agent # AI Agent:ToolSpec 注册表 + 内置客户端 + MCP server(双形态共用一个内核)
├── crates/app # Slint UI:唯一允许依赖 UI 框架的 crate
├── crates/cli # CLI(bin `ull`):headless,零 UI,只依赖 core + agent
└── packaging # 单文件产物矩阵与打包资源(规划中)
├── crates/core # 领域库:零 UI 依赖,八模块
├── crates/agent # AI Agent:ToolSpec 注册表 + 内置客户端 + MCP server(双形态共用一个内核)
├── crates/design # 设计令牌:真 HCT 色彩 / 排版 / 形状 / 间距 / 高程 / 动效 / 图标 / 组件度量
│ # —— 零 UI 依赖、零第三方 crate
├── crates/app # Slint UI(bin `unlucky-launcher`):已发布的图形界面,保留到等价验收通过
├── crates/app-iced # 下一代 iced UI(bin `unlucky-launcher-iced`):施工中
├── crates/cli # CLI(bin `ull`):headless,零 UI
└── packaging # 单文件产物矩阵与发布脚本
```
`crates/core` 的八个模块:
@@ -47,7 +87,7 @@ Unlucky-Launcher (cargo workspace)
| `download` | 多源健康度、镜像、分块下载、边下边算 |
| `game` | 版本/实例仓库、快照与继承解析 |
| `launch` | 10 阶段启动管线、进程、JSONL 事件流 |
| `auth` | Microsoft 正版登录 / 本地离线档案 |
| `auth` | Microsoft 账户登录(OAuth 2.0 device code → Xbox Live → XSTS → Minecraft Services) |
| `addon` | 加载器安装、整合包、CurseForge / Modrinth |
| `diagnose` | 五类信号采集、YAML 规则引擎、hs_err 解析 |
| `platform` | OS 抽象:路径、进程、文件锁、能力探测、运行期 OS 检测 |
@@ -56,44 +96,71 @@ Unlucky-Launcher (cargo workspace)
前置:
- Rust stable(`rust-toolchain.toml` 会自动选择工具链;组件 `rustfmt` / `clippy`)
- Windows:MSVC 工具链(CI 用)或 MinGW-w64(`x86_64-pc-windows-gnu`)
- Linux:`libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev`
(Slint winit 后端 + femtovg 编译依赖)
- Rust stable(`rust-toolchain.toml` 会自动选工具链;门禁还需要 `rustfmt` / `clippy` 组件)
- Windows:MSVC 工具链(CI 用这套)或 MinGW-w64(`x86_64-pc-windows-gnu`)
- Linux:`libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev
libxcb1-dev`——两套 UI 都要用(Slint 的 winit + femtovg,iced 的 winit + wgpu/tiny-skia)
```bash
cargo fmt --all --check # 格式
cargo clippy --workspace --all-targets -- -D warnings # 静态检查(零 warning 门禁)
cargo test --workspace # 测试
cargo build --release # 产物:target/release/unlucky-launcher[.exe]
cargo fmt --all --check # 格式
cargo clippy --workspace --all-targets -- -D warnings # 静态检查(零 warning 门禁)
cargo test --workspace # 测试
cargo build --release # 三个 bin 都落在 target/release/
```
软渲染开关(无可用 GPU / 烂驱动的老机):`unlucky-launcher --software-renderer`
(等价于 `SLINT_BACKEND=winit-software`)。
编出来的三个 bin:
- `unlucky-launcher`——Slint 版(已发布的那个)
- `unlucky-launcher-iced`——iced 版骨架。名字是临时的:只有翻牌时才能改成 `unlucky-launcher`,
因为同一个工作区里两个 crate 不能往同一个 target 目录里产出同名 bin。
- `ull`——CLI
渲染开关:
- `unlucky-launcher --software-renderer`(等价于 `SLINT_BACKEND=winit-software`)强制 Slint 版走 CPU
- `unlucky-launcher-iced --software-renderer` 强制 tiny-skia(`ICED_BACKEND=tiny-skia`);
`--screenshot <png>` 出一帧图后退出,这是验收通道
- 两套栈在没有可用适配器时都会退到 CPU:Slint 版走它自己的渲染器回退链,iced 版走显式探测
依赖许可检查(本地可选,CI 必跑):`cargo deny check licenses bans sources`。
## CI 门禁
每次推送到 `main` 与每个 PR 都跑 fmt / clippy(warnings 视为错误)/ 全量测试,Linux 无头渲染冒烟
与它们同级、是硬门禁:红了就不许合。macOS 运行期探针还不算——它只在手动 `workflow_dispatch` 时跑,
结果留证据但不作为流水线结论(`continue-on-error`),直到该槽位在真实 runner 上首跑通过。
| Workflow | 内容 |
|----------|------|
| `.github/workflows/ci.yml` | fmt + clippy + test + build,Windows / Linux 矩阵 |
| `.github/workflows/guards.yml` | core 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可守门 |
| `.github/workflows/ci.yml` | fmt + clippy + test + release build(Windows / Linux 矩阵);Linux runtime smoke(Xvfb 里真渲染一帧,再断言 PNG 非空白);macOS 运行期探针(仅手动 dispatch) |
| `.github/workflows/guards.yml` | `crates/core` 零 UI 守门(依赖表 + `cargo tree`)、单文件 ≤400 行守门、cargo-deny 许可与来源守门 |
| `.github/workflows/release.yml` | tag `v*` → 分槽位构建 → 单文件产物 + `SHA256SUMS.txt` → GitHub Release |
## 平台基线
最低基线 **Windows 10**。Windows 8.1 / 8 / 7 不在支持范围内:
最低基线 **Windows 10**;Windows 8.1 / 8 / 7 不在支持范围内。
- Windows 10/11 走**标准工具链**产物(MSVC / GNU),无自定义 target spec、无 `-Zbuild-std`、无导入表白名单
- 其他平台:macOS 12+ 一等;Linux x64 X11 优先、Wayland 为**大方向**;Android Termux 试验级不承诺
- UI 兜底:Slint 软渲染(无 GPU / 烂驱动的老机可完整使用)
- 平台独立性体现在**独立测试矩阵**,而非独立产物链;能力差异用 feature gate + 运行期探测表达
- Windows 10/11 的产物由**标准工具链**(MSVC / GNU)构建,构建方式与上游 cargo 一致
- macOS 12+(Apple Silicon 与 Intel)与 Linux x64 / arm64 都是支持槽位;Linux 上 X11 是当前首选路径,
Wayland 是大方向。macOS 产物未签名,首次启动要自己清掉 Gatekeeper 隔离标记
(`xattr -d com.apple.quarantine`)
- Android Termux 试验级、不承诺:只保证 CLI,GUI 未验证
- 每个平台跑各自的测试矩阵;能力差异用 feature gate + 运行期探测表达。
## 许可证与致谢
- 本项目:Apache License 2.0,见 [LICENSE](LICENSE);第三方声明见 [NOTICE](NOTICE)
- GUI 使用 [Slint](https://slint.dev)(Royalty-Free 2.0 轨道)。按 RF 2.0 义务,关于页将放置
`AboutSlint` 部件(P2),在此之前由 NOTICE 与本节承担告知义务
- **本项目**:Apache License 2.0,见 [LICENSE](LICENSE);第三方声明见 [NOTICE](NOTICE)。
- **Slint**(<https://slint.dev>),Royalty-Free 2.0:v0.1.0 发布的正是 Slint 版,这项义务继续有效
——关于页放 `AboutSlint` 部件(尚未落地),在此之前由 NOTICE、本节与发布页的「Made with Slint」
说明承担告知义务。只要还有链接 Slint 的产物在发,这条义务就保留。
- **iced**(<https://github.com/iced-rs/iced>),MIT:下一代 UI 的框架;与它同栈的 `winit`、以及经它
引入的 `wgpu` / `tiny-skia`(Apache-2.0 / MIT)。
- **Google Material Color Utilities**(Apache-2.0):`crates/design` 的色彩令牌是它 HCT /
ColorSpec2021 算法的 Rust 重写移植,移植清单与逐函数对照写在 [NOTICE](NOTICE)。
- **其余主要依赖**:MCP server 的 `rmcp` 与 `axum`(Apache-2.0 / MIT)、`tokio`(MIT)、`reqwest`
与 rustls(MIT / Apache-2.0)、`serde` / `serde_json`(MIT / Apache-2.0)、`zip` / `flate2` / `tar`
(MIT / Apache-2.0)。cargo-deny 强制执行的白名单在 `deny.toml`;GPL 系依赖不得进主干。
感谢 [HMCL](https://github.com/HMCL-dev/HMCL) 和 [PCL2](https://github.com/Meloong-Git/PCL)(均 GPL-3.0)这两个启动器陪伴了绝大多数 MC 玩家好多年。
ULL 的设计与实现从它们身上学到了太多,谨以此项目向两位前辈致敬。
感谢 [HMCL](https://github.com/HMCL-dev/HMCL) 和 [PCL2](https://github.com/Meloong-Git/PCL)
(均 GPL-3.0)。这两款启动器陪着绝大多数 MC 玩家走了很多年,
本项目在设计取舍与实现细节上都从中受益良多。
+51
View File
@@ -0,0 +1,51 @@
[package]
name = "unlucky-app-iced"
description = "Unlucky-Launcher 桌面壳(iced 0.14 重写版):M3 Expressive 骨架 / 令牌适配 / 截图通道"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
rust-version.workspace = true
publish.workspace = true
# 并存期(D26):Slint 版仍是 `crates/app` 的 `unlucky-launcher`,本 crate 出
# `unlucky-launcher-iced`。两个 package 在同一 target 目录产出同名 bin 会冲突,
# 所以**翻牌前不许改回 `unlucky-launcher`**(步骤见 SubAgentsTalk/app-iced-骨架与翻牌.md)。
[[bin]]
name = "unlucky-launcher-iced"
path = "src/main.rs"
[lib]
name = "unlucky_app_iced"
path = "src/lib.rs"
[dependencies]
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
unlucky-design = { path = "../design", version = "0.1" }
# UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。
# 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`),
# 这里给 `thread-pool`(iced 自己的默认执行器,不额外引 tokio 运行时)。
# `wgpu` 由本 crate 的 `gpu` feature 打开,于是「仅 CPU」体积基线用 `--no-default-features` 就能量到。
iced = { version = "=0.14.0", default-features = false, features = [
"tiny-skia",
"crisp",
"advanced",
"svg",
"thread-pool",
"x11",
"wayland",
"linux-theme-detection",
] }
# 截图落 PNG。只开 png 编码器,不要默认全套 codec(体积)。
image = { version = "0.25", default-features = false, features = ["png"] }
[features]
default = ["gpu"]
# 硬件渲染后端(wgpu→Vulkan/DX12)。关闭后只剩 tiny-skia(CPU),用于体积基线与无 GPU 机器。
gpu = ["iced/wgpu"]
[lints]
workspace = true
+400
View File
@@ -0,0 +1,400 @@
//! Elm 三件套:`State` / `Message` / `update` + `view` 接线。
//!
//! ## 启动序列(顺序有讲究)
//!
//! 1. **探测渲染后端** → 写 `ICED_BACKEND`(必须在 `run()` 之前,M60 Q1);
//! 2. **求值字体栈**(D28 前半)→ 一行启动日志;
//! 3. `iced::application(..).run()`:窗口 1180×760、无边框(自绘标题栏);
//! 4. 首个 `view()` 打「到首帧」耗时;截图模式下第 3 帧 `window::screenshot` 落盘后退出。
//!
//! ## 为什么截图要等 3 帧
//!
//! 无边框窗口启动时会先收到一次「显示器工作区尺寸」的 `Resized`,再回到设定尺寸(M60 坑 #5),
//! 第一帧可能是旧尺寸;再叠上字体系统首次上屏的排版抖动,留 2 帧余量最稳。
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::OnceLock;
use std::time::Instant;
use iced::{window, window::Direction, Element, Subscription, Task};
use unlucky_design::color::SchemeFlavor;
use crate::cli::Options;
use crate::fonts;
use crate::probe;
use crate::shell::{self, NavItem};
use crate::shot;
use crate::theme::{Families, Ui};
/// 验收窗口尺寸(1180×760,100% DPI —— D30 的硬要求)。
pub const WINDOW_SIZE: iced::Size = iced::Size {
width: 1180.0,
height: 760.0,
};
/// 产品名(自绘标题栏的标题;也是 `'static`,好让 view 借用)。
pub const APP_TITLE: &str = "Unlucky Launcher";
/// 窗口最小尺寸(低于这个值骨架会挤坏:88 导航 + 页面留白 + 卡片)。
const MIN_SIZE: iced::Size = iced::Size {
width: 880.0,
height: 560.0,
};
/// 截图落盘的帧号(见模块文档)。
const CAPTURE_AT_FRAME: u32 = 3;
/// 进程内「run() 开始」的时刻(首帧耗时基线)。
static STARTED: OnceLock<Instant> = OnceLock::new();
/// 首帧日志只打一次。
static FIRST_FRAME_LOGGED: AtomicBool = AtomicBool::new(false);
/// 应用状态(**只有 UI 状态**;业务状态等页面 mission 接 `host`/`state` 时再进)。
pub struct App {
options: Options,
families: Families,
ui: Ui,
nav: NavItem,
window: Option<window::Id>,
frames: u32,
/// 截图模式的「主题已定」闸门:`system::theme()` 是异步的,早拍会拍到错的深浅色。
theme_resolved: bool,
}
/// 消息(单一大枚举;页面接进来时按页拆子枚举)。
#[derive(Debug, Clone)]
pub enum Message {
/// 启动即发一次(`iced::application` 的 boot 闭包)。
Boot,
/// `window::latest()` 的结果。
WindowReady(Option<window::Id>),
/// 系统主题探测结果。
SystemTheme(iced::theme::Mode),
/// 一帧画完(只有截图模式才订阅)。
Frame,
/// 导航项切换。
Select(NavItem),
/// 按下标题栏 → 拖窗。
DragWindow,
/// 按下边缘热区 → 缩放窗口。
ResizeStart(Direction),
/// 最小化。
Minimize,
/// 最大化/还原。
ToggleMaximize,
/// 关闭。
Close,
/// 截图回来了。
Captured(window::Screenshot),
}
impl App {
fn new(options: Options, families: Families, flavor: SchemeFlavor) -> Self {
let options_theme_given = options.shot_flavor().is_some();
Self {
ui: Ui::new(flavor, families),
families,
options,
nav: NavItem::Instances,
window: None,
frames: 0,
// 显式给了主题就算已定;否则等 `SystemTheme` 到达
theme_resolved: options_theme_given,
}
}
fn has_screenshot(&self) -> bool {
self.options.screenshot.is_some()
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Boot => {
let mut tasks = vec![window::latest().map(Message::WindowReady)];
// 主题:参数里没指定就探系统(深浅两套都要能出图)
if self.options.shot_flavor().is_none() {
tasks.push(iced::system::theme().map(Message::SystemTheme));
}
Task::batch(tasks)
}
Message::WindowReady(id) => {
self.window = id;
Task::none()
}
Message::SystemTheme(mode) => {
let flavor = match mode {
iced::theme::Mode::Dark => SchemeFlavor::Dark,
iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light,
};
eprintln!("[theme] 系统主题 = {mode:?} -> {}", slug(flavor));
self.set_flavor(flavor);
self.theme_resolved = true;
Task::none()
}
Message::Frame => self.on_frame(),
Message::Select(nav) => {
self.nav = nav;
Task::none()
}
Message::DragWindow => match self.window {
Some(id) => window::drag(id),
None => Task::none(),
},
Message::ResizeStart(direction) => match self.window {
Some(id) => window::drag_resize(id, direction),
None => Task::none(),
},
Message::Minimize => match self.window {
Some(id) => window::minimize(id, true),
None => Task::none(),
},
Message::ToggleMaximize => match self.window {
Some(id) => window::toggle_maximize(id),
None => Task::none(),
},
Message::Close => iced::exit(),
Message::Captured(screenshot) => self.on_captured(&screenshot),
}
}
/// 切主题:只重建 [`Ui`](= 重建 Style),**不动任何 UI 状态**。
fn set_flavor(&mut self, flavor: SchemeFlavor) {
if self.ui.flavor() == flavor {
return;
}
let next = Ui::new(flavor, self.families);
eprintln!(
"[theme] {} -> {} (Theme::name 变化触发缓存重算)",
self.ui.name(),
next.name()
);
self.ui = next;
}
fn on_frame(&mut self) -> Task<Message> {
self.frames += 1;
// 主题没定就不出图:等 `SystemTheme`(否则暗色系统上会拍到浅色图)
if !self.has_screenshot() || !self.theme_resolved || self.frames < CAPTURE_AT_FRAME {
return Task::none();
}
match self.window {
Some(id) => window::screenshot(id).map(Message::Captured),
None => Task::none(),
}
}
fn on_captured(&mut self, screenshot: &window::Screenshot) -> Task<Message> {
let Some(path) = self.options.screenshot.clone() else {
return Task::none();
};
match shot::save(screenshot, &path) {
Ok(summary) => {
eprintln!("{}", summary.describe());
eprintln!(
"[shot] theme={} renderer={}",
shot::flavor_slug(&self.ui),
renderer_note(self.options.backend)
);
}
Err(error) => eprintln!("[shot] 失败:{error}"),
}
iced::exit()
}
fn subscription(&self) -> Subscription<Message> {
// 只有截图模式才订阅帧事件:否则「每帧一条消息 → 每帧重绘」会白烧 CPU。
if self.wants_frames() {
window::frames().map(|_| Message::Frame)
} else {
Subscription::none()
}
}
/// 是否订阅帧事件(= 截图模式)。
fn wants_frames(&self) -> bool {
self.has_screenshot()
}
fn theme(&self) -> iced::Theme {
self.ui.theme()
}
fn title(&self) -> String {
APP_TITLE.to_owned()
}
fn view(&self) -> Element<'_, Message> {
if !FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) {
if let Some(started) = STARTED.get() {
eprintln!(
"[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)",
started.elapsed().as_secs_f64() * 1000.0
);
}
}
shell::view(
&self.ui,
self.nav,
APP_TITLE,
self.nav.label(),
"Skeleton only: titlebar, navigation rail and one card. \
Business pages, the component library and the glass shader are follow-up missions.",
self.options.force_state,
)
}
}
fn renderer_note(backend: crate::Backend) -> &'static str {
if cfg!(feature = "gpu") {
match backend {
crate::Backend::Cpu => "cpu(forced)",
crate::Backend::Gpu => "gpu(preferred)",
crate::Backend::Auto => "auto",
}
} else {
"cpu(build without wgpu)"
}
}
/// 主题名(日志用,和 `Ui::name()` 同一口径)。
fn slug(flavor: SchemeFlavor) -> &'static str {
if flavor.is_dark() {
"ull-m3-dark"
} else {
"ull-m3-light"
}
}
/// 启动 iced(阻塞到退出)。`--diff` 不走这里,见 `main.rs`。
pub(crate) fn run(options: Options) -> iced::Result {
let _ = STARTED.set(Instant::now());
let probe_started = Instant::now();
// 只有 `auto` 需要探测:`cpu` 与 `gpu` 的决策与「有没有硬件 GPU」无关,
// 而枚举 13 个适配器在本机要 0.65~1.2 s(实测),纯属白花。
let needs_probe = options.backend == crate::Backend::Auto;
let probe = if needs_probe {
probe::Probe::run()
} else {
probe::Probe::default()
};
let probe_ms = probe_started.elapsed().as_secs_f64() * 1000.0;
probe::apply(options.backend, &probe);
if cfg!(feature = "gpu") {
if needs_probe {
eprintln!("[probe] 探测耗时 {probe_ms:.0} ms(wgpu 实例 + 全后端适配器枚举)");
} else {
eprintln!(
"[probe] 探测耗时 {probe_ms:.0} ms(--renderer={} 与硬件无关,跳过枚举)",
options.backend.name()
);
}
}
let font_started = Instant::now();
let resolved = fonts::resolve();
eprintln!(
"[font] 求值耗时 {:.0} ms",
font_started.elapsed().as_secs_f64() * 1000.0
);
eprintln!("{}", resolved.describe());
if resolved.has_synthetic() {
eprintln!(
"[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换族)要处理"
);
}
let families = resolved.families();
// 截图模式:`--screenshot-theme` > `--theme` > 浅色(后续系统探测会覆盖)
let flavor = options.shot_flavor().unwrap_or(SchemeFlavor::Light);
let boot_options = options.clone();
let application = iced::application(
move || {
(
App::new(boot_options.clone(), families, flavor),
Task::done(Message::Boot),
)
},
App::update,
App::view,
)
.title(App::title)
.theme(App::theme)
.subscription(App::subscription)
.default_font(iced::Font {
family: iced::font::Family::Name(families.text),
..iced::Font::DEFAULT
})
.window(window::Settings {
size: WINDOW_SIZE,
min_size: Some(MIN_SIZE),
decorations: false,
resizable: true,
..window::Settings::default()
});
match options.scale_factor {
Some(scale) => application.scale_factor(move |_| scale).run(),
None => application.run(),
}
}
#[cfg(test)]
mod tests {
use super::*;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
#[test]
fn switching_flavor_rebuilds_styles_but_keeps_ui_state() {
let mut app = App::new(Options::default(), FAMILIES, SchemeFlavor::Light);
app.nav = NavItem::Logs;
assert_eq!(app.ui.name(), "ull-m3-light");
app.set_flavor(SchemeFlavor::Dark);
assert_eq!(app.ui.name(), "ull-m3-dark", "切主题要换主题名");
assert_eq!(app.nav, NavItem::Logs, "切主题不许重置 UI 状态");
assert!(app.options.screenshot.is_none(), "切主题不许改启动参数");
}
#[test]
fn window_size_is_the_acceptance_size() {
assert_eq!((WINDOW_SIZE.width, WINDOW_SIZE.height), (1180.0, 760.0));
const {
assert!(MIN_SIZE.width < WINDOW_SIZE.width);
assert!(MIN_SIZE.height < WINDOW_SIZE.height);
}
}
#[test]
fn slug_matches_the_theme_name() {
assert_eq!(slug(SchemeFlavor::Light), "ull-m3-light");
assert_eq!(slug(SchemeFlavor::Dark), "ull-m3-dark");
}
#[test]
fn frames_are_only_requested_in_screenshot_mode() {
let mut app = App::new(Options::default(), FAMILIES, SchemeFlavor::Light);
assert!(!app.wants_frames(), "普通运行不订阅帧,避免每帧重绘");
app.options.screenshot = Some(std::path::PathBuf::from("x.png"));
assert!(
app.wants_frames(),
"截图模式必须订阅帧事件才能在第 3 帧出图"
);
}
}
+298
View File
@@ -0,0 +1,298 @@
//! 命令行参数(只做骨架期需要的那些;业务开关等页面 mission 再加)。
//!
//! 取值语法与现役 Slint 版一致:`--key value` 与 `--key=value` 两种都认
//! (`crates/app/src/screenshot.rs` 的 `value_of` 是同一口径),这样将来 CI 换 bin 不用改脚本。
use std::path::PathBuf;
use unlucky_design::color::SchemeFlavor;
/// 渲染后端请求(D27)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Backend {
/// 探测后决定:有硬件 GPU 就让 iced 自己走 wgpu→tiny-skia 回退;没有就强制 CPU 起步。
#[default]
Auto,
/// 优先 GPU(写 `ICED_BACKEND=wgpu,tiny-skia`:拿不到适配器仍会降级,不硬失败)。
Gpu,
/// 强制 CPU(`--software-renderer` 的等价物,写 `ICED_BACKEND=tiny-skia`)。
Cpu,
}
impl Backend {
/// 稳定名字(日志用)。
pub const fn name(self) -> &'static str {
match self {
Backend::Auto => "auto",
Backend::Gpu => "gpu",
Backend::Cpu => "cpu",
}
}
}
/// 解析后的启动参数。
#[derive(Debug, Clone, PartialEq)]
pub struct Options {
/// 渲染后端请求。
pub backend: Backend,
/// 主题:`None` = 探测系统主题。
pub theme: Option<SchemeFlavor>,
/// 截图通道:给了路径就出图后退出。
pub screenshot: Option<PathBuf>,
/// 截图时用的主题(`--screenshot-theme`,优先级高于 `--theme`)。
pub screenshot_theme: Option<SchemeFlavor>,
/// 强制窗口缩放因子(截图验收要 100% DPI;`None` = 用系统 DPI)。
pub scale_factor: Option<f32>,
/// `--diff <left> <right>`:只做两图逐像素对比,不开窗。
pub diff: Option<(PathBuf, PathBuf)>,
/// `--diff-scan <n>`:对比时额外做 ±n px 的平移扫描(找最佳对齐,默认 0 = 不扫)。
pub diff_scan: u32,
/// `--force-state=hover|pressed`:把按钮强制成某个状态便于截图取证(D30 的状态图)。
pub force_state: Option<ForcedState>,
/// `--data-dir <dir>`:当前**接受并忽略**。
///
/// 现役 Slint 版的 CI 冒烟用 `--software-renderer --screenshot <png> --data-dir <dir>`,
/// 翻牌后同一套脚本要能直接跑 iced 版;真正的数据目录消费由后续页面 mission 接 `host`/`state` 时做。
pub data_dir: Option<PathBuf>,
}
/// 截图取证用的强制交互状态(只影响观感,不改变任何状态机)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ForcedState {
/// 所有可交互控件按 hover 画。
Hover,
/// 所有可交互控件按 pressed 画。
Pressed,
}
impl ForcedState {
/// 稳定名字(日志用)。
pub const fn name(self) -> &'static str {
match self {
ForcedState::Hover => "hover",
ForcedState::Pressed => "pressed",
}
}
}
impl Default for Options {
fn default() -> Self {
Self {
backend: Backend::Auto,
theme: None,
screenshot: None,
screenshot_theme: None,
scale_factor: None,
diff: None,
diff_scan: 0,
force_state: None,
data_dir: None,
}
}
}
impl Options {
/// 从进程参数解析。
///
/// # Errors
/// 参数值不合法(未知的 `--renderer` / `--theme` / 非法 `--scale-factor`)时返回人话。
pub fn from_env() -> Result<Self, String> {
Self::parse(std::env::args().skip(1))
}
/// 从任意参数序列解析(`argv[1..]`,测试直接喂数组)。
///
/// # Errors
/// 同 [`Options::from_env`]。
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
let mut options = Self::default();
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let (key, inline) = match arg.split_once('=') {
Some((key, value)) => (key.to_owned(), Some(value.to_owned())),
None => (arg.clone(), None),
};
match key.as_str() {
"--software-renderer" => options.backend = Backend::Cpu,
"--renderer" => {
let value = take(&mut args, inline, "--renderer")?;
options.backend = match value.as_str() {
"auto" => Backend::Auto,
"gpu" => Backend::Gpu,
"cpu" => Backend::Cpu,
other => {
return Err(format!("--renderer 只认 auto|gpu|cpu,收到 {other:?}"));
}
};
}
"--theme" => {
options.theme = Some(flavor(&take(&mut args, inline, "--theme")?)?);
}
"--screenshot-theme" => {
options.screenshot_theme =
Some(flavor(&take(&mut args, inline, "--screenshot-theme")?)?);
}
"--screenshot" => {
options.screenshot =
Some(PathBuf::from(take(&mut args, inline, "--screenshot")?));
}
"--scale-factor" => {
let raw = take(&mut args, inline, "--scale-factor")?;
let value: f32 = raw
.parse()
.map_err(|_| format!("--scale-factor 需要数字,收到 {raw:?}"))?;
if !(value.is_finite() && value > 0.0) {
return Err(format!("--scale-factor 必须 > 0,收到 {raw:?}"));
}
options.scale_factor = Some(value);
}
"--diff" => {
let left = take(&mut args, inline, "--diff")?;
let right = args
.next()
.ok_or_else(|| "--diff 需要两张图的路径".to_owned())?;
options.diff = Some((PathBuf::from(left), PathBuf::from(right)));
}
"--diff-scan" => {
let raw = take(&mut args, inline, "--diff-scan")?;
options.diff_scan = raw
.parse()
.map_err(|_| format!("--diff-scan 需要非负整数,收到 {raw:?}"))?;
}
"--force-state" => {
let raw = take(&mut args, inline, "--force-state")?;
options.force_state = Some(match raw.as_str() {
"hover" => ForcedState::Hover,
"pressed" => ForcedState::Pressed,
other => {
return Err(format!(
"--force-state 只认 hover|pressed,收到 {other:?}"
));
}
});
}
// 翻牌前的兼容参数:接受并忽略(见字段文档)
"--data-dir" => {
options.data_dir = Some(PathBuf::from(take(&mut args, inline, "--data-dir")?));
}
other => return Err(format!("未知参数 {other:?}")),
}
}
Ok(options)
}
/// 截图时实际使用的主题(截图专用参数优先)。
pub fn shot_flavor(&self) -> Option<SchemeFlavor> {
self.screenshot_theme.or(self.theme)
}
}
fn take(
args: &mut impl Iterator<Item = String>,
inline: Option<String>,
flag: &str,
) -> Result<String, String> {
inline
.or_else(|| args.next())
.ok_or_else(|| format!("{flag} 需要一个值"))
}
fn flavor(raw: &str) -> Result<SchemeFlavor, String> {
match raw {
"light" => Ok(SchemeFlavor::Light),
"dark" => Ok(SchemeFlavor::Dark),
other => Err(format!("--theme 只认 light|dark,收到 {other:?}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Result<Options, String> {
Options::parse(args.iter().map(|arg| (*arg).to_owned()))
}
#[test]
fn defaults_are_auto_and_system_theme() {
let options = parse(&[]).expect("空参数要能解析");
assert_eq!(options.backend, Backend::Auto);
assert_eq!(options.theme, None);
assert_eq!(options.screenshot, None);
}
#[test]
fn software_renderer_is_an_alias_of_cpu() {
assert_eq!(
parse(&["--software-renderer"]).unwrap().backend,
Backend::Cpu
);
assert_eq!(parse(&["--renderer=cpu"]).unwrap().backend, Backend::Cpu);
assert_eq!(parse(&["--renderer", "gpu"]).unwrap().backend, Backend::Gpu);
}
#[test]
fn screenshot_accepts_both_argv_forms() {
let spaced = parse(&["--screenshot", "out.png"]).unwrap();
let inline = parse(&["--screenshot=out.png"]).unwrap();
assert_eq!(spaced.screenshot, Some(PathBuf::from("out.png")));
assert_eq!(spaced.screenshot, inline.screenshot);
}
#[test]
fn screenshot_theme_wins_over_theme() {
let options = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap();
assert_eq!(options.theme, Some(SchemeFlavor::Dark));
assert_eq!(options.shot_flavor(), Some(SchemeFlavor::Light));
}
#[test]
fn bad_values_are_reported_not_panicked() {
assert!(parse(&["--renderer=metal"]).is_err());
assert!(parse(&["--theme=blue"]).is_err());
assert!(parse(&["--scale-factor=0"]).is_err());
assert!(parse(&["--scale-factor=abc"]).is_err());
assert!(parse(&["--screenshot"]).is_err());
assert!(parse(&["--diff", "only-one.png"]).is_err());
assert!(parse(&["--nope"]).is_err());
}
#[test]
fn data_dir_is_accepted_and_ignored_for_the_flip() {
// 现役 CI 冒烟:--software-renderer --screenshot <png> --data-dir <dir>
let options = parse(&[
"--software-renderer",
"--screenshot",
"shot.png",
"--data-dir",
"/d/tmp/x",
])
.expect("翻牌前必须兼容 --data-dir");
assert_eq!(options.backend, Backend::Cpu);
assert_eq!(options.screenshot, Some(PathBuf::from("shot.png")));
assert_eq!(options.data_dir, Some(PathBuf::from("/d/tmp/x")));
}
#[test]
fn diff_scan_and_force_state_parse() {
assert_eq!(parse(&["--diff-scan=2"]).unwrap().diff_scan, 2);
assert_eq!(
parse(&["--force-state", "hover"]).unwrap().force_state,
Some(ForcedState::Hover)
);
assert!(parse(&["--force-state=focus"]).is_err());
assert!(parse(&["--diff-scan=-1"]).is_err());
}
#[test]
fn diff_takes_two_paths() {
let options = parse(&["--diff", "a.png", "b.png"]).unwrap();
assert_eq!(
options.diff,
Some((PathBuf::from("a.png"), PathBuf::from("b.png")))
);
}
}
+263
View File
@@ -0,0 +1,263 @@
//! 字体基座(D28 前半):把令牌的字体栈求值成「本机真的有的族名」,并打一行启动日志。
//!
//! ## 做什么
//!
//! 1. 取 `Platform::current()` 的三条栈(Display / Text / Mono,见 `unlucky_design::typography`);
//! 2. 用 iced 的**全局字体系统**(`iced_graphics::text::font_system()`,即 cosmic-text 的
//! `FontSystem`,首次调用会扫系统字体)核对每个族名是否装机 → 取栈里第一个装机可用的族;
//! 3. 给出 400/500/700 三档字重的落点(原生 / 最近档 / 合成加粗):
//! **先查系统里这个族真实提供的字面(fontdb 的 `FaceInfo.weight`)**,查不到才回落到令牌的
//! 纯函数 `Platform::resolve_weight`。缺档按降级记录。
//!
//! ## 为什么要真查系统
//!
//! 令牌栈写的是「Win11 有 Segoe UI Variable Text」,但 Win10 / Server 只有 `Segoe UI`(静态
//! 300/400/600/700,**没有 500**),干净安装的 Linux 可能一个都没有。只按栈头写死族名会把
//! 「实际使用哪个族、哪一档字重」变成猜测——本 mission 的开发机就是 Windows Server 2025,
//! 实测就回退到了经典 `Segoe UI`(见交接文档的启动日志)。
//! ## 不做什么
//!
//! **中文字体 CDN 下载不在这里**(后续 mission)。本模块只做「系统字体优先」这一半,
//! 也没有能力判断 CJK 字形覆盖(那是 `ttf-parser` 查 cmap 的活,M60 Q3 的 spike 做过)。
use unlucky_design::typography::{FontFamily, Platform, Weight, WeightMatch};
use crate::theme::Families;
/// 一条栈的求值结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FamilyReport {
/// 栈的用途(`text` / `display` / `mono`)。
pub stack: &'static str,
/// 实际选中的族名(栈里第一个装机可用的;都没装就是栈头)。
pub family: &'static str,
/// 选中的族是否真的在系统字体库里。
pub installed: bool,
}
/// 一档字重的求值结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WeightReport {
/// 请求的字重(400/500/700)。
pub requested: u16,
/// 实际可用的字重(降级后)。
pub available: u16,
/// 匹配质量(原生 / 最近档 / 合成加粗)。
pub quality: WeightMatch,
}
/// 字体求值的全部结论(启动日志与测试都用它)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolved {
/// 平台(决定栈与字重覆盖表)。
pub platform: Platform,
/// 三条栈的落点。
pub families: [FamilyReport; 3],
/// 三档字重的落点(对**正文**栈求值)。
pub weights: [WeightReport; 3],
}
impl Resolved {
/// 把结论塞回主题适配层要的形状。
pub fn families(&self) -> Families {
Families {
text: self.families[0].family,
display: self.families[1].family,
mono: self.families[2].family,
}
}
/// 是否有任何一档走了「合成加粗」(= 观感降级,要记日志)。
pub fn has_synthetic(&self) -> bool {
self.weights
.iter()
.any(|weight| weight.quality == WeightMatch::Synthetic)
}
/// 启动日志(两行:一行族,一行字重)。
pub fn describe(&self) -> String {
let families = self
.families
.iter()
.map(|report| {
format!(
"{}={:?}{}",
report.stack,
report.family,
if report.installed {
""
} else {
"(未装机→回落)"
}
)
})
.collect::<Vec<_>>()
.join(" ");
let weights = self
.weights
.iter()
.map(|report| {
let quality = match report.quality {
WeightMatch::Native => "native",
WeightMatch::Nearest => "nearest",
WeightMatch::Synthetic => "synthetic",
};
format!("w{}={}({quality})", report.requested, report.available)
})
.collect::<Vec<_>>()
.join(" ");
format!(
"[font] platform={} {families} | weights {weights}",
self.platform.name()
)
}
}
/// 求值本机字体(会触发 iced 全局字体系统的首次初始化,约 0.1~0.8s)。
pub fn resolve() -> Resolved {
let platform = Platform::current();
let faces = system_faces();
let is_installed = |family: &str| -> bool {
faces
.as_ref()
.is_none_or(|faces: &Vec<Face>| faces.iter().any(|face| face.family == family))
};
let families = [
FamilyReport {
stack: "text",
family: pick(platform, FontFamily::Text, &is_installed),
installed: is_installed(pick(platform, FontFamily::Text, &is_installed)),
},
FamilyReport {
stack: "display",
family: pick(platform, FontFamily::Display, &is_installed),
installed: is_installed(pick(platform, FontFamily::Display, &is_installed)),
},
FamilyReport {
stack: "mono",
family: pick(platform, FontFamily::Mono, &is_installed),
installed: is_installed(pick(platform, FontFamily::Mono, &is_installed)),
},
];
// 字重:**优先用系统里这个族真实提供的档位**(令牌表的 `WeightCoverage` 是「这个族通常有哪几档」,
// 装机版可能是静态子集:Win Server / 精简版只有经典 Segoe UI,没有 500)。查不到才回落到令牌纯函数。
let weights = [Weight::Regular, Weight::Medium, Weight::Bold]
.map(|weight| weight_report(platform, families[0].family, weight, faces.as_deref()));
Resolved {
platform,
families,
weights,
}
}
/// 一条系统字面(族名 + 字重)。
#[derive(Debug, Clone, PartialEq, Eq)]
struct Face {
family: String,
weight: u16,
}
/// 某档字重的落点:先问系统,再问令牌。
fn weight_report(
platform: Platform,
family: &str,
weight: Weight,
faces: Option<&[Face]>,
) -> WeightReport {
let requested = weight.value();
let available = faces.and_then(|faces| {
let mut weights = faces
.iter()
.filter(|face| face.family == family)
.map(|face| face.weight)
.collect::<Vec<_>>();
weights.sort_unstable();
weights.dedup();
if weights.is_empty() {
return None;
}
Some(
weights
.iter()
.copied()
.min_by_key(|candidate| candidate.abs_diff(requested))
.unwrap_or(requested),
)
});
match available {
Some(available) => WeightReport {
requested,
available,
quality: quality_of(requested, available),
},
None => {
let choice = platform.resolve_weight(FontFamily::Text, weight);
WeightReport {
requested: choice.requested,
available: choice.available,
quality: choice.quality,
}
}
}
}
/// 与令牌层同一判据:原生 / 最近档(差 <200)/ 合成加粗(差 ≥200)。
fn quality_of(requested: u16, available: u16) -> WeightMatch {
match available.abs_diff(requested) {
0 => WeightMatch::Native,
delta if delta >= 200 => WeightMatch::Synthetic,
_ => WeightMatch::Nearest,
}
}
/// 在栈里选第一个**装机可用**的族(跳过 `sans-serif` 这类通用族名)。
///
/// 一个都没装上时返回栈头:让 cosmic-text 自己去回退,比我们猜一个更靠谱。
pub fn pick(
platform: Platform,
stack: FontFamily,
is_installed: &dyn Fn(&str) -> bool,
) -> &'static str {
let faces = platform.font_stack(stack);
faces
.iter()
.find(|face| !is_generic(face.family) && is_installed(face.family))
.map_or(faces[0].family, |face| face.family)
}
/// 通用族名(CSS 关键字,不是真族名)。
fn is_generic(family: &str) -> bool {
matches!(family, "sans-serif" | "serif" | "monospace" | "cursive")
}
/// 系统字体库里的全部字面(`None` = 拿不到,视为「不知道」,全部当装机)。
fn system_faces() -> Option<Vec<Face>> {
let lock = iced::advanced::graphics::text::font_system();
let mut font_system = lock.write().ok()?;
let mut faces = Vec::new();
for face in font_system.raw().db().faces() {
for (family, _) in &face.families {
faces.push(Face {
family: family.clone(),
weight: face.weight.0,
});
}
}
Some(faces)
}
#[cfg(test)]
mod tests;
+163
View File
@@ -0,0 +1,163 @@
//! `fonts` 的单元测试(拆出来只为「src 文件 ≤400 行」的门禁)。
use super::*;
use unlucky_design::typography::WeightCoverage;
fn only(installed: &'static [&'static str]) -> impl Fn(&str) -> bool {
move |family| installed.contains(&family)
}
#[test]
fn windows_stack_falls_back_to_classic_segoe_when_variable_is_absent() {
// Win10 没有 Segoe UI Variable Text
let family = pick(
Platform::Windows,
FontFamily::Text,
&only(&["Segoe UI", "Microsoft YaHei UI"]),
);
assert_eq!(family, "Segoe UI");
}
#[test]
fn windows_stack_prefers_the_variable_face_on_win11() {
let family = pick(
Platform::Windows,
FontFamily::Text,
&only(&["Segoe UI Variable Text", "Segoe UI"]),
);
assert_eq!(family, "Segoe UI Variable Text");
}
#[test]
fn generic_family_names_are_never_reported_as_the_resolved_family() {
// 一个都没装:返回栈头,且栈头不是通用族名
let family = pick(Platform::Linux, FontFamily::Text, &only(&[]));
assert_eq!(family, "Roboto");
assert!(!is_generic(family));
}
#[test]
fn missing_cjk_fonts_do_not_change_the_latin_pick() {
// 装了苹方但没装 SF:macOS 栈应该选 Helvetica Neue 而不是中文族
let family = pick(
Platform::MacOs,
FontFamily::Text,
&only(&["PingFang SC", "Helvetica Neue"]),
);
assert_eq!(family, "Helvetica Neue");
}
#[test]
fn weight_reports_use_the_token_degradation_path() {
// 没有系统字面表时回落到令牌纯函数:Windows 栈里 Segoe UI Variable Text 是可变 300..700
let medium = weight_report(
Platform::Windows,
"Segoe UI Variable Text",
Weight::Medium,
None,
);
assert_eq!(medium.requested, 500);
assert_eq!(medium.quality, WeightMatch::Native);
// 手工构造一个「只有 400/700」的族(= 经典 Segoe UI),500 应该落到最近档
let faces = [
Face {
family: "Segoe UI".to_owned(),
weight: 400,
},
Face {
family: "Segoe UI".to_owned(),
weight: 700,
},
];
let medium = weight_report(Platform::Windows, "Segoe UI", Weight::Medium, Some(&faces));
assert_eq!(medium.available, 400, "400 比 700 更近");
assert_eq!(medium.quality, WeightMatch::Nearest);
// 只有 400 却要 700 → 合成加粗
let only_regular = [Face {
family: "Narrow".to_owned(),
weight: 400,
}];
let bold = weight_report(
Platform::Windows,
"Narrow",
Weight::Bold,
Some(&only_regular),
);
assert_eq!(bold.quality, WeightMatch::Synthetic);
// 原生有 500 就用 500
let has_medium = [Face {
family: "Noto".to_owned(),
weight: 500,
}];
assert_eq!(
weight_report(Platform::Linux, "Noto", Weight::Medium, Some(&has_medium)).quality,
WeightMatch::Native
);
let narrow = WeightCoverage::Static(&[400]);
assert_eq!(narrow.nearest(700), 400);
}
#[test]
fn describe_is_one_line_and_mentions_all_three_stacks() {
let resolved = Resolved {
platform: Platform::Windows,
families: [
FamilyReport {
stack: "text",
family: "Segoe UI",
installed: true,
},
FamilyReport {
stack: "display",
family: "Segoe UI Variable Display",
installed: false,
},
FamilyReport {
stack: "mono",
family: "Cascadia Mono",
installed: true,
},
],
weights: [
WeightReport {
requested: 400,
available: 400,
quality: WeightMatch::Native,
},
WeightReport {
requested: 500,
available: 400,
quality: WeightMatch::Nearest,
},
WeightReport {
requested: 700,
available: 400,
quality: WeightMatch::Synthetic,
},
],
};
let line = resolved.describe();
assert_eq!(line.lines().count(), 1);
assert!(line.contains("text=\"Segoe UI\""), "{line}");
assert!(line.contains("未装机"), "{line}");
assert!(line.contains("w700=400(synthetic)"), "{line}");
assert!(resolved.has_synthetic());
assert_eq!(resolved.families().text, "Segoe UI");
}
#[test]
fn type_roles_pick_the_display_stack_for_big_text() {
use unlucky_design::typography::TypeRole;
assert_eq!(
TypeRole::TitleLarge.style().family,
FontFamily::Display,
"22px 标题走 Display 栈"
);
assert_eq!(TypeRole::BodyMedium.style().family, FontFamily::Text);
}
+126
View File
@@ -0,0 +1,126 @@
//! 令牌图标(`unlucky_design::icons` 的 24 网格 SVG path)→ iced `svg` 控件。
//!
//! ## 为什么走 SVG 而不是自绘 canvas
//!
//! 令牌里存的是 **SVG path 字符串**(含 `A` 弧线命令),自绘要自己写 path parser + 弧线细分,
//! 那是一份将来只有自己在维护的代码;iced 的 `svg` 控件已经带 usvg 解析与按 id 缓存的位图。
//! 代价是二进制多一份 resvg/usvg(体积基线里会单列出来)。
//!
//! ## 描边语义(`design-令牌约定.md` §4)
//!
//! 令牌的 `Icon::stroke_width(render_px)` 已经把「视觉 1.5px」折算成 **viewBox 单位**
//! (`1.5 × 24 / render_px`),本模块**不重算也不手抄常数**。
//! 本骨架所有图标都按 24px 渲染(12b 2.2 标题栏 / 5.5 导航),所以每个图标只有一份位图缓存。
use std::sync::OnceLock;
use iced::widget::svg;
use iced::Color;
use unlucky_design::icons::{IconKind, IconPaint, IconSize};
/// 骨架期图标的统一渲染尺寸(12b:标题栏与导航轨都是 24)。
pub const RENDER_PX: f32 = IconSize::Toolbar.px();
/// 生成一个图标的 SVG 源码(`viewBox` 24×24,颜色用 `currentColor` 交给 iced 的色滤镜)。
///
/// 之所以用 `currentColor` 而不是把令牌色写进 SVG:色滤镜(`svg::Style::color`)会按
/// **alpha 蒙版**重涂 RGB,这样一份 SVG 位图能被浅/深两套主题复用,缓存不会翻倍。
pub fn source(kind: IconKind) -> String {
let icon = kind.icon();
let paint = match icon.paint {
IconPaint::Stroke => format!(
"fill=\"none\" stroke=\"currentColor\" stroke-width=\"{}\" \
stroke-linecap=\"round\" stroke-linejoin=\"round\"",
icon.stroke_width(RENDER_PX)
),
IconPaint::Fill => "fill=\"currentColor\" stroke=\"none\"".to_owned(),
};
format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{0}\" height=\"{0}\" \
viewBox=\"0 0 24 24\"><path d=\"{1}\" {paint}/></svg>",
RENDER_PX, icon.path
)
}
/// 图标的 SVG 句柄(按 `IconKind` 缓存;句柄 id 是内容哈希,所以 iced 侧也能命中缓存)。
fn handle(kind: IconKind) -> svg::Handle {
static HANDLES: OnceLock<Vec<svg::Handle>> = OnceLock::new();
let handles = HANDLES.get_or_init(|| {
IconKind::ALL
.iter()
.map(|kind| svg::Handle::from_memory(source(*kind).into_bytes()))
.collect()
});
// 依赖「枚举下标 == IconKind::ALL 下标」(令牌层用 `GLYPHS[self as usize]` 同一约定)。
// 这条断言把约定钉在测试与 debug 构建里,`IconKind` 一旦乱序插入就会立刻炸。
debug_assert_eq!(
IconKind::ALL[kind as usize],
kind,
"IconKind::ALL 必须与枚举声明顺序一致"
);
handles[kind as usize].clone()
}
/// 一个图标控件(尺寸走令牌,颜色走令牌角色)。
pub fn view<'a, Message: 'a>(kind: IconKind, color: Color) -> iced::Element<'a, Message> {
svg(handle(kind))
.width(RENDER_PX)
.height(RENDER_PX)
.style(move |_theme, _status| svg::Style { color: Some(color) })
.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_icon_produces_wellformed_svg() {
for kind in IconKind::ALL {
let source = source(kind);
assert!(source.starts_with("<svg"), "{} 的 SVG 头不对", kind.name());
assert!(source.ends_with("</svg>"), "{} 的 SVG 尾不对", kind.name());
assert!(source.contains("viewBox=\"0 0 24 24\""), "{}", kind.name());
assert!(
source.contains(kind.icon().path),
"{} 的 path 没写进去",
kind.name()
);
assert!(!source.contains('#'), "{} 里出现了色值字面量", kind.name());
}
}
#[test]
fn stroke_icons_carry_the_token_stroke_width() {
let stroke = source(IconKind::Settings);
let expected = IconKind::Settings.icon().stroke_width(RENDER_PX);
assert!(
stroke.contains(&format!("stroke-width=\"{expected}\"")),
"{stroke}"
);
assert!(stroke.contains("stroke-linecap=\"round\""));
// 24px 渲染时的视觉线宽必须是 1.5(M54 结论)
assert!((expected - 1.5).abs() < f32::EPSILON);
}
#[test]
fn filled_icons_do_not_get_a_stroke() {
// `more` 是三个填充圆点
let filled = source(IconKind::More);
assert!(filled.contains("fill=\"currentColor\""));
assert!(filled.contains("stroke=\"none\""));
}
#[test]
fn handles_are_cached_per_kind() {
let first = handle(IconKind::Close);
let second = handle(IconKind::Close);
assert_eq!(first.id(), second.id());
assert_ne!(first.id(), handle(IconKind::Minimize).id());
}
}
+55
View File
@@ -0,0 +1,55 @@
//! # unlucky-app-iced · iced 版桌面壳(P4 / D26)
//!
//! 本 crate 是「UI 从头写」的第一步:**只做骨架**(能开窗、颜色/字体对、层级在),
//! 业务页面、组件库、i18n 全文案、玻璃 shader 都是后续 mission。
//!
//! | 模块 | 职责 | 不负责 |
//! |---|---|---|
//! | [`theme`] | 令牌(`unlucky-design`)→ iced 类型:`Color` / `Font` / `Radius` / `Theme` | 发明令牌值 |
//! | `cli` | `--renderer` / `--software-renderer` / `--theme` / `--screenshot*` 参数 | 业务开关 |
//! | `probe` | 渲染后端探测(D27)+ 写 `ICED_BACKEND`,失败自动降级 CPU | 绘制 |
//! | `fonts` | 字体栈求值(D28 前半):平台族名 → 系统字体库校验 → 启动日志 | 中文字体下载 |
//! | `app` / `shell` | Elm 三件套 + 自绘标题栏 / 导航轨 / 内容骨架 | 业务逻辑 |
//! | `shot` | `window::screenshot` → PNG(验收通道) | 图像比对 |
//! | `icons` | 令牌里的 SVG path → iced `svg` 控件 | 画新图标 |
//!
//! ## 消费纪律
//!
//! **组件层零硬编码色值/字号/半径/间距**:一律走 [`theme::Ui`]。守门测试在
//! `tests/no_hardcoded_tokens.rs`(扫 `src/**`,出现 `#RRGGBB` / `Color::from_rgb(` 即红)。
//!
//! ## 与 Slint 版的关系
//!
//! 并存期:`crates/app` 仍出 `unlucky-launcher`,本 crate 出 `unlucky-launcher-iced`。
//! 等价验收后翻牌(bin 改名 + 旧 crate 降级),步骤写在
//! `DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md`。
pub mod shot;
pub mod theme;
mod app;
mod cli;
mod fonts;
mod icons;
mod probe;
mod shell;
pub use cli::{Backend, Options};
pub use probe::Probe;
/// 启动 UI(阻塞到窗口关闭)。渲染后端由 `probe` 在 `run()` 之前写进 `ICED_BACKEND`。
pub fn run(options: Options) -> iced::Result {
app::run(options)
}
/// 与 [`run`] 同义,但把命令行做成最终结论:`Options::from_env()` + 启动日志。
///
/// 二进制入口用 [`Options::from_env`] + [`run`] 自己拼(要额外处理 `--diff`),这个函数留给
/// 集成测试与将来的 CLI 复用。
///
/// # Errors
/// 参数不合法时返回人话(不 panic、不 `exit`)。
pub fn run_from_env() -> Result<iced::Result, String> {
let options = Options::from_env()?;
Ok(run(options))
}
+52
View File
@@ -0,0 +1,52 @@
//! 入口。所有实际逻辑在 lib(`unlucky_app_iced`)里:
//!
//! - `--diff <a> <b>`:只读两张 PNG 做逐像素对比(D30 的 GPU/CPU 几何一致性检查),不开窗;
//! - 其余参数:开窗跑骨架,`--screenshot` 出图后退出。
//!
//! 退出码:`0` 成功 / `1` 截图有差异(diff 模式)/ `2` 参数或运行错误。
use std::process::ExitCode;
use unlucky_app_iced::shot;
use unlucky_app_iced::Options;
fn main() -> ExitCode {
let options = match Options::from_env() {
Ok(options) => options,
Err(message) => {
eprintln!("[cli] {message}");
eprintln!(
"用法:unlucky-launcher-iced [--renderer=auto|gpu|cpu] [--software-renderer] \
[--theme=light|dark] [--screenshot <png>] [--screenshot-theme=light|dark] \
[--scale-factor <f32>] [--diff <a.png> <b.png>]"
);
return ExitCode::from(2);
}
};
if let Some((left, right)) = options.diff.clone() {
let scan = i32::try_from(options.diff_scan).unwrap_or(i32::MAX);
return match shot::diff_files_with_scan(&left, &right, scan) {
Ok(diff) => {
eprintln!("{}", diff.describe());
if diff.is_identical() {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
}
}
Err(message) => {
eprintln!("[diff] {message}");
ExitCode::from(2)
}
};
}
match unlucky_app_iced::run(options) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("[run] {error}");
ExitCode::FAILURE
}
}
}
+238
View File
@@ -0,0 +1,238 @@
//! 渲染后端探测 + 自动降级(D27)。
//!
//! ## 为什么需要探测
//!
//! iced 0.14 的 `Renderer` 本身就是「wgpu → tiny-skia」的运行时回退枚举(M60 Q1):
//! **不设** `ICED_BACKEND` 时,拿不到 GPU 适配器会自动降级到 CPU,零代码。
//! 需要自研的只有**判据**:
//!
//! - 本机会枚举出 `Microsoft Basic Render Driver [Cpu]`(= WARP 软光栅),
//! 所以「有没有适配器」不能当「有没有 GPU」(M60 实测),必须看 `device_type`。
//! - 没有硬件 GPU 时要**主动**把 CPU 放到候选首位(`tiny-skia,wgpu`),
//! 否则 wgpu 会先花时间尝试 WARP,观感与行为都难料。
//!
//! ## 语义订正(与 M60 备忘录的差异,以源码 + 实测为准)
//!
//! M60 记「`ICED_BACKEND=wgpu` 会**关闭**回退」。读 `iced_renderer-0.14.0/src/fallback.rs`
//! 得到的是:**候选列表**语义——列表里没有 `tiny-skia` 时才真的关闭回退。
//! 所以本 crate 的「优先 GPU」写 `wgpu,tiny-skia`(首选 GPU,拿不到仍降级,**绝不硬失败**),
//! 而不是 `wgpu`。
use crate::Backend;
/// 单个适配器的摘要(只留日志要用的字段)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdapterSummary {
/// 适配器名(如 `AMD Radeon RX 6600 XT`)。
pub name: String,
/// `device_type` 的字符串形式(`DiscreteGpu` / `IntegratedGpu` / `Cpu` / …)。
pub device_type: &'static str,
}
/// 探测结论。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Probe {
/// 枚举到的全部适配器(含 WARP 这类软光栅)。
pub adapters: Vec<AdapterSummary>,
/// 是否存在**真**硬件适配器(`device_type` 既不是 `Cpu` 也不是 `Other`)。
pub hardware: bool,
}
impl Probe {
/// 探测本机渲染能力(编入 `wgpu` 时真的枚举适配器)。
#[cfg(feature = "gpu")]
pub fn run() -> Self {
run_wgpu()
}
/// 探测本机渲染能力(`--no-default-features` 的纯 CPU 构建:没有 wgpu,直接判定无硬件)。
#[cfg(not(feature = "gpu"))]
pub fn run() -> Self {
Self::default()
}
/// 一行日志:适配器清单 + 判定。
pub fn describe(&self) -> String {
if self.adapters.is_empty() {
return format!("adapters=0 hardware={}", self.hardware);
}
let list = self
.adapters
.iter()
.map(|adapter| format!("{} [{}]", adapter.name, adapter.device_type))
.collect::<Vec<_>>()
.join(" | ");
format!(
"adapters={} [{list}] hardware={}",
self.adapters.len(),
self.hardware
)
}
}
/// 按请求与探测结果决定 `ICED_BACKEND` 的值(`None` = 不干预,交给 iced 自己回退)。
///
/// 纯函数,好让「降级矩阵」被测试钉死。
pub fn backend_env(request: Backend, probe: &Probe, gpu_compiled: bool) -> Option<&'static str> {
match (request, gpu_compiled) {
// 命令行 --software-renderer / --renderer=cpu:只留 CPU 候选。
(Backend::Cpu, _) => Some("tiny-skia"),
// 用户点名要 GPU,但没编进 wgpu:只能 CPU,日志里说明。
(Backend::Gpu, false) => Some("tiny-skia"),
// 优先 GPU,但保留 tiny-skia 兜底(候选列表语义:不会硬失败)。
(Backend::Gpu, true) => Some("wgpu,tiny-skia"),
// 自动:有硬件就让 iced 走默认顺序(wgpu 优先,自动回退)。
(Backend::Auto, true) if probe.hardware => None,
// 自动 + 没编进 wgpu:只有 CPU 候选,别把 wgpu 写进列表(会与「未编入 wgpu」的日志自相矛盾)。
(Backend::Auto, false) => Some("tiny-skia"),
// 自动 + 有 wgpu 但没硬件(含只有 WARP 的机器):CPU 起步,wgpu 留作兜底。
(Backend::Auto, true) => Some("tiny-skia,wgpu"),
}
}
/// 把决策落到进程环境变量,并把探测结果 + 生效值打进日志。
///
/// 返回**实际生效**的后端描述(`"auto (iced: wgpu -> tiny-skia)"` 之类)。
pub fn apply(request: Backend, probe: &Probe) -> String {
let gpu_compiled = cfg!(feature = "gpu");
let env = backend_env(request, probe, gpu_compiled);
// ICED_BACKEND 是进程级环境变量,必须在 `iced::application(..).run()` 之前写。
// 仓库是 edition 2021 → `set_var` 仍是安全 API(升 2024 后要改成子进程 exec 自身)。
if let Some(value) = env {
std::env::set_var("ICED_BACKEND", value);
}
let effective = if gpu_compiled {
match (request, env) {
(Backend::Cpu, _) => "tiny-skia (CPU,强制)".to_owned(),
(_, None) => "auto (wgpu -> tiny-skia)".to_owned(),
(_, Some("wgpu,tiny-skia")) => "wgpu.then(tiny-skia) (优先 GPU)".to_owned(),
(_, Some(value)) => format!("{value} (CPU 起步)"),
}
} else {
"tiny-skia (本构建未编入 wgpu,只有 CPU)".to_owned()
};
let note = if !gpu_compiled && request != Backend::Cpu {
";本构建未编入 wgpu(--no-default-features),只能 CPU"
} else {
""
};
eprintln!(
"[probe] {} :: request={} -> ICED_BACKEND={}{note}",
probe.describe(),
request.name(),
env.unwrap_or("<unset>")
);
eprintln!("[probe] renderer = {effective}");
effective
}
#[cfg(feature = "gpu")]
fn run_wgpu() -> Probe {
// `iced::wgpu` 就是 wgpu crate 本身(`pub use iced_renderer::wgpu::wgpu` 再导出),
// 所以不需要单独依赖 wgpu,版本也不会和 iced 打架。
use iced::wgpu::{Backends, DeviceType, Instance, InstanceDescriptor};
let instance = Instance::new(&InstanceDescriptor {
backends: Backends::all(),
..Default::default()
});
let adapters = instance
.enumerate_adapters(Backends::all())
.into_iter()
.map(|adapter| {
let info = adapter.get_info();
let device_type = match info.device_type {
DeviceType::DiscreteGpu => "DiscreteGpu",
DeviceType::IntegratedGpu => "IntegratedGpu",
DeviceType::VirtualGpu => "VirtualGpu",
DeviceType::Cpu => "Cpu",
DeviceType::Other => "Other",
};
AdapterSummary {
name: info.name,
device_type,
}
})
.collect::<Vec<_>>();
let hardware = adapters
.iter()
.any(|adapter| !matches!(adapter.device_type, "Cpu" | "Other"));
Probe { adapters, hardware }
}
#[cfg(test)]
mod tests {
use super::*;
fn probe(types: &[&'static str]) -> Probe {
let adapters = types
.iter()
.map(|device_type| AdapterSummary {
name: "adapter".to_owned(),
device_type,
})
.collect::<Vec<_>>();
Probe {
adapters,
hardware: types
.iter()
.any(|device_type| !matches!(*device_type, "Cpu" | "Other")),
}
}
#[test]
fn warp_only_machine_counts_as_no_gpu() {
// 本机实测会枚举出 WARP:`Microsoft Basic Render Driver [Cpu]`
let warp = probe(&["Cpu"]);
assert!(!warp.hardware, "只有 WARP 不能当「有 GPU」");
assert_eq!(
backend_env(Backend::Auto, &warp, true),
Some("tiny-skia,wgpu")
);
}
#[test]
fn auto_with_real_gpu_leaves_iced_alone() {
let gpu = probe(&["DiscreteGpu", "Cpu"]);
assert!(gpu.hardware);
assert_eq!(backend_env(Backend::Auto, &gpu, true), None);
}
#[test]
fn explicit_choices_always_keep_a_fallback() {
let gpu = probe(&["DiscreteGpu"]);
let none = probe(&[]);
// 优先 GPU 但候选列表里留 tiny-skia:拿不到适配器时降级而不是报错
assert_eq!(
backend_env(Backend::Gpu, &gpu, true),
Some("wgpu,tiny-skia")
);
assert_eq!(
backend_env(Backend::Gpu, &none, true),
Some("wgpu,tiny-skia")
);
assert_eq!(backend_env(Backend::Cpu, &gpu, true), Some("tiny-skia"));
// 没编进 wgpu 的构建绝不写 wgpu(连候选列表也不许出现,reviewer P3-4)
assert_eq!(backend_env(Backend::Gpu, &gpu, false), Some("tiny-skia"));
assert_eq!(backend_env(Backend::Auto, &gpu, false), Some("tiny-skia"));
assert_eq!(backend_env(Backend::Auto, &none, false), Some("tiny-skia"));
}
#[test]
fn describe_lists_every_adapter() {
let text = probe(&["DiscreteGpu", "Cpu"]).describe();
assert!(text.contains("DiscreteGpu"), "{text}");
assert!(text.contains("Cpu"), "{text}");
assert!(text.contains("hardware=true"), "{text}");
}
}
+349
View File
@@ -0,0 +1,349 @@
//! 首屏骨架(**不做业务**):自绘标题栏 + 左侧导航轨 + 内容区(页头 + 一张卡片)。
//!
//! ## 与参考判据的对应(`research/12b-component-fidelity.md`)
//!
//! | 部件 | 参考 | 令牌落点 |
//! |---|---|---|
//! | 标题栏高度 | 16.2 桌面标题栏 **40**(登记偏离) | `LM.titlebar_height = 48`(M63 §1.4/R13 桌面档,令牌优先) |
//! | 标题栏按钮 | 16.5 图标 **24** | `IconSize::Toolbar` + `LM.titlebar_button_width = 46` |
//! | 页头 | 16.1 官方 AppBar **64**;本项目 **56** | `LM.page_header_height = 56`(已有偏离登记) |
//! | 卡片 | 3.x Outlined:**Medium(12)**、L0、`OutlineVariant` 1px、`SurfaceContainerLow` | `Component::OutlinedCard.spec()` + `STROKE_PX` |
//! | 字级 | 16.4 标题 `TitleLarge`;卡片 `TitleMedium` / `BodySmall` | `Ui::style` / `Ui::emphasized` |
//!
//! 导航轨在 [`nav`] 子模块里(12b §5 的判据表也在那边)。
//!
//! 一屏只出现 4 档字号(22 / 16 / 14 / 12),且**没有实心 accent**(rubric K2:一屏最多一个实心
//! Primary,骨架期一个都不要)。阴影只给浮层,骨架里没有浮层,所以**全屏零阴影**(rubric K3)。
mod nav;
pub use nav::NavItem;
use iced::widget::text::LineHeight;
use iced::widget::{button, column, container, mouse_area, row, space, stack, text};
use iced::{mouse, Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow};
use iced::{window::Direction, Theme as IcedTheme};
use unlucky_design::color::{Role, StateLayer};
use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::app::Message;
use crate::cli::ForcedState;
use crate::icons;
use crate::theme::Ui;
/// 整棵树。
pub fn view<'a>(
ui: &'a Ui,
selected: NavItem,
app_title: &'a str,
page_title: &'a str,
body: &'a str,
forced: Option<ForcedState>,
) -> Element<'a, Message> {
let work_area = column![
titlebar(ui, app_title, forced),
row![
nav::rail(ui, selected, forced),
content(ui, page_title, body)
]
.width(Length::Fill)
.height(Length::Fill),
]
.width(Length::Fill)
.height(Length::Fill);
// 无边框窗口没有系统缩放边框 → 自己给两条热区(东/南)。
// **必须叠层而不是兄弟列**:当兄弟列会吃掉 4px 布局宽(右留白变 28、caption 按钮离窗角 4px,
// 视觉复审 P3-1)。叠层容器自己铺满,再用 align 把 4px 热区贴到边上,布局宽保持 1180。
// 东侧热区从标题栏下方开始,避免吃掉三个 caption 按钮最右 4px 的点击区。
let thickness = ui.space(Space::X1);
let east = container(
column![
space::vertical().height(ui.metrics().titlebar_height),
resize_edge(
Direction::East,
(Length::Fixed(thickness), Length::Fill),
mouse::Interaction::ResizingHorizontally,
),
]
.height(Length::Fill),
)
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::End);
let south = container(resize_edge(
Direction::South,
(Length::Fill, Length::Fixed(thickness)),
mouse::Interaction::ResizingVertically,
))
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::End);
let shell = stack![work_area, east, south];
container(shell)
.width(Length::Fill)
.height(Length::Fill)
.style(|_theme| filled(ui.layer(MaterialLayer::Canvas)))
.into()
}
/// 窗口缩放热区(透明;玻璃/底色由根容器提供)。
///
/// 拖动时调 `window::drag_resize(id, direction)`——Windows/X11 走 winit 原生实现,
/// **macOS 返回 `NotSupported`**(M60 Q4),如实登记在交接文档里。
fn resize_edge<'a>(
direction: Direction,
size: (Length, Length),
interaction: mouse::Interaction,
) -> Element<'a, Message> {
let (width, height) = size;
mouse_area(container(space::horizontal()).width(width).height(height))
.interaction(interaction)
.on_press(Message::ResizeStart(direction))
.into()
}
// ---------------------------------------------------------------- 标题栏
fn titlebar<'a>(ui: &'a Ui, title: &'a str, forced: Option<ForcedState>) -> Element<'a, Message> {
let metrics = ui.metrics();
// 拖动热区:从窗口左缘到第一个按钮(按钮自己不能被拖区吞掉,所以只包左侧一段)
let drag_area = mouse_area(
container(label(
ui,
TypeRole::LabelLarge,
true,
title,
ui.color(Role::OnSurface),
))
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::Center)
.padding(Padding::from([0.0, ui.space(Space::X4)])),
)
.on_press(Message::DragWindow);
let bar = row![
drag_area,
caption(ui, IconKind::Minimize, Message::Minimize, forced),
caption(ui, IconKind::Maximize, Message::ToggleMaximize, forced),
caption(ui, IconKind::Close, Message::Close, forced),
]
.width(Length::Fill)
.height(metrics.titlebar_height)
.align_y(Alignment::Center);
container(bar)
.width(Length::Fill)
.height(metrics.titlebar_height)
.style(|_theme| filled(ui.layer(MaterialLayer::Canvas)))
.into()
}
/// 标题栏按钮(自绘最小化/最大化/关闭)。
///
/// 图标必须**再包一层固定 24×24 的容器**:iced 的 `button` 在 layout 里给内容 Fill 约束,
/// 直接把 `svg(...).width(24)` 塞进 button,usvg 会按 Contain 撑满内区(实测渲染盒 46 → 视觉线宽
/// 从 1.5 变 2.9,视觉复审 P2-2)。Container 尊重子元素的 Length,所以这层是「锁尺寸」用的。
fn caption<'a>(
ui: &'a Ui,
kind: IconKind,
message: Message,
forced: Option<ForcedState>,
) -> Element<'a, Message> {
let metrics = ui.metrics();
// 用 `center(Length::Fill)` 而不是只设 `width(24).height(24)`:后者会让容器缩到 24×24 并贴到
// button 内容的左上角(button 不居中「收缩后」的内容,r1 实测图标跑到 cell 左上 (left+11.5, 11.5));
// `center(Fill)` = 撑满按钮内容区 + 内容居中,字形仍是 24 盒、命中区仍是 46×48。
let icon = container(icons::view::<Message>(
kind,
ui.color(Role::OnSurfaceVariant),
))
.width(icons::RENDER_PX)
.height(icons::RENDER_PX)
.center(Length::Fill);
button(icon)
.width(metrics.titlebar_button_width)
.height(metrics.titlebar_height)
.padding(0)
.style(move |_theme: &IcedTheme, status| {
let background = match overridden(forced, status) {
button::Status::Hovered => Some(Background::Color(ui.state_over(
StateLayer::Hover,
Role::OnSurface,
Role::Surface,
))),
button::Status::Pressed => Some(Background::Color(ui.state_over(
StateLayer::Pressed,
Role::OnSurface,
Role::Surface,
))),
button::Status::Active | button::Status::Disabled => None,
};
button::Style {
background,
text_color: ui.color(Role::OnSurfaceVariant),
border: Border::default(),
shadow: Shadow::default(),
snap: true,
}
})
.on_press(message)
.into()
}
// ---------------------------------------------------------------- 内容区
fn content<'a>(ui: &'a Ui, title: &'a str, body: &'a str) -> Element<'a, Message> {
let metrics = ui.metrics();
// 卡片档位由组件表决定(圆角/字级),组件层不自己挑
let card_spec = Component::OutlinedCard.spec();
let body_role = card_spec.support.unwrap_or(card_spec.text);
let header = container(label(
ui,
TypeRole::TitleLarge,
true,
title,
ui.color(Role::OnSurface),
))
.width(Length::Fill)
.height(metrics.page_header_height)
.align_y(Alignment::Center)
.padding(Padding::from([0.0, ui.space(Space::X6)]));
let card = container(
column![
label(
ui,
card_spec.text,
true,
"Shell ready",
ui.color(Role::OnSurface),
),
label(ui, body_role, false, body, ui.color(Role::OnSurfaceVariant)),
]
.spacing(ui.space(Space::X2)),
)
.width(Length::Fill)
.padding(metrics.card_padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
border: ui.outlined(card_spec.shape),
..container::Style::default()
});
let body_area = container(card)
.width(Length::Fill)
.height(Length::Fill)
.padding(ui.space(Space::X6))
.align_y(Alignment::Start);
column![header, body_area]
.width(Length::Fill)
.height(Length::Fill)
.into()
}
// ---------------------------------------------------------------- 小工具
/// 一个文字元素:字号/行高/字重全部来自令牌排版档。
pub(super) fn label<'a>(
ui: &'a Ui,
role: TypeRole,
emphasized: bool,
content: &'a str,
color: Color,
) -> Element<'a, Message> {
let style = if emphasized {
ui.emphasized(role)
} else {
ui.style(role)
};
text(content)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(ui.font_of(style))
.color(color)
.into()
}
/// 取证用的状态覆盖(`--force-state`):只改画出来的状态,不动任何状态机。
pub(super) fn overridden(forced: Option<ForcedState>, status: button::Status) -> button::Status {
match forced {
Some(ForcedState::Hover) => button::Status::Hovered,
Some(ForcedState::Pressed) => button::Status::Pressed,
None => status,
}
}
pub(super) fn filled(color: Color) -> container::Style {
container::Style {
background: Some(Background::Color(color)),
..container::Style::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::Families;
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(
flavor,
Platform::Windows,
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
)
}
#[test]
fn a_screen_only_uses_four_text_sizes_at_most() {
let ui = ui(SchemeFlavor::Dark);
let card = Component::OutlinedCard.spec();
let sizes = [
ui.emphasized(TypeRole::TitleLarge).size_px,
ui.emphasized(TypeRole::LabelLarge).size_px,
ui.emphasized(card.text).size_px,
ui.style(card.support.unwrap_or(card.text)).size_px,
ui.style(TypeRole::LabelMedium).size_px,
];
let mut unique = sizes.to_vec();
unique.sort_by(f32::total_cmp);
unique.dedup();
assert!(
unique.len() <= 4,
"rubric D4:一屏字号档 ≤4(实际 {} 档 {unique:?})",
unique.len()
);
assert!(unique[0] >= 12.0, "最小档不下 12px(caption ≥11)");
assert!(unique[unique.len() - 1] <= 28.0, "列表页标题不得 >28px");
}
#[test]
fn flavor_slug_is_stable_for_filenames() {
assert_eq!(crate::shot::flavor_slug(&ui(SchemeFlavor::Light)), "light");
assert_eq!(crate::shot::flavor_slug(&ui(SchemeFlavor::Dark)), "dark");
}
}
+258
View File
@@ -0,0 +1,258 @@
//! 左侧导航轨(M3 `NavigationRail`)。
//!
//! 判据来源 `research/12b-component-fidelity.md` §5:
//!
//! | 项 | 参考值 | 令牌落点 |
//! |---|---|---|
//! | 轨宽 | 5.1 官方 96(narrow 80);本项目 **88** | [`nav_rail_width`](`LayoutMetrics` 骨架值,详见该函数文档的 M69 迁移说明) |
//! | 轨容器 | 5.3 形状 None / L0 / `Surface` | `MaterialLayer::Canvas` |
//! | 选中指示器 | 5.4 **56×32**、圆角 full、`SecondaryContainer` | [`indicator_size`](令牌推导) |
//! | 图标 | 5.5 24px | `IconSize::Toolbar` |
//! | 标签 | 5.5 `LabelMedium 12/16` | `TypeRole::LabelMedium` |
//! | 颜色 | 5.6 选中图标 `OnSecondaryContainer` / 选中标签 `Secondary` / 未选中 `OnSurfaceVariant` | `Ui::color` |
//! | 项间距 | 5.7 **4** | `LayoutMetrics::nav_item_gap` |
use iced::widget::{button, column, container};
use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow, Theme as IcedTheme};
use unlucky_design::color::{Role, StateLayer};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::{LayoutMetrics, Space};
use unlucky_design::typography::TypeRole;
use crate::app::Message;
use crate::cli::ForcedState;
use crate::icons;
use crate::shell::{filled, label, overridden};
use crate::theme::Ui;
/// 导航占位项(任务要求 4~6 个;真正的页面由后续 mission 接管)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavItem {
/// 实例列表。
Instances,
/// 下载/版本。
Download,
/// 日志。
Logs,
/// 设置。
Settings,
/// AI 助手。
Ai,
}
impl NavItem {
/// 全部占位项(顺序 = 导航栏自上而下)。
pub const ALL: [NavItem; 5] = [
NavItem::Instances,
NavItem::Download,
NavItem::Logs,
NavItem::Settings,
NavItem::Ai,
];
/// 显示名(i18n 是后续 mission;本 mission 用英文基线文案,避免干净 CI 上缺中文变豆腐块)。
pub const fn label(self) -> &'static str {
match self {
NavItem::Instances => "Instances",
NavItem::Download => "Download",
NavItem::Logs => "Logs",
NavItem::Settings => "Settings",
NavItem::Ai => "Assistant",
}
}
/// 令牌图标。
pub const fn icon(self) -> IconKind {
match self {
NavItem::Instances => IconKind::Instances,
NavItem::Download => IconKind::Download,
NavItem::Logs => IconKind::Logs,
NavItem::Settings => IconKind::Settings,
NavItem::Ai => IconKind::Ai,
}
}
}
/// 整条导航轨。
pub(super) fn rail<'a>(
ui: &'a Ui,
selected: NavItem,
forced: Option<ForcedState>,
) -> Element<'a, Message> {
let metrics = ui.metrics();
let indicator = indicator_size(metrics);
let items = NavItem::ALL
.iter()
.map(|item| rail_item(ui, *item, *item == selected, indicator, forced))
.collect::<Vec<_>>();
// 轨顶留白走 container padding,而不是「先塞一个 spacer 元素」:后者会被 column 的
// spacing(4)再加一档,实测变 28(视觉复审 P3-5)。
container(column(items).spacing(metrics.nav_item_gap))
.width(nav_rail_width(metrics))
.height(Length::Fill)
.padding(Padding::from([ui.space(Space::X6), 0.0]))
.style(|_theme| filled(ui.layer(MaterialLayer::Canvas)))
.into()
}
fn rail_item<'a>(
ui: &'a Ui,
item: NavItem,
selected: bool,
indicator: (f32, f32),
forced: Option<ForcedState>,
) -> Element<'a, Message> {
let (indicator_width, indicator_height) = indicator;
let icon_color = if selected {
ui.color(Role::OnSecondaryContainer)
} else {
ui.color(Role::OnSurfaceVariant)
};
// 12b 5.6:选中标签用 `Secondary`,未选中 `OnSurfaceVariant`
let label_color = if selected {
ui.color(Role::Secondary)
} else {
ui.color(Role::OnSurfaceVariant)
};
// **不要用 `center_x/center_y(Fill)`**:那会把容器自己的 width/height 改成 Fill,
// 于是 56 被父级(button 宽 88)覆盖成 88、32 靠布局凑巧(视觉复审 P2-1)。
// 尺寸只认令牌推导值,居中交给父 column 的 `align_x`。
let pill = container(icons::view::<Message>(item.icon(), icon_color))
.width(indicator_width)
.height(indicator_height)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))),
border: Border {
radius: ui.radius(ShapeScale::Full, indicator_height),
..Border::default()
},
..container::Style::default()
});
let item_height = nav_item_height(ui, indicator_height);
// 列必须显式占满轨宽,否则它会 shrink 到 56,`align_x(Center)` 就变成空操作,
// 药丸会贴左缘(x=0..55 而不是 16..71)——r1 实测踩到的第二个坑。
let stack = column![
pill,
label(ui, TypeRole::LabelMedium, false, item.label(), label_color),
]
.spacing(ui.space(Space::X1))
.width(nav_rail_width(ui.metrics()))
.align_x(Alignment::Center)
.height(item_height);
button(stack)
.width(nav_rail_width(ui.metrics()))
.height(item_height)
.padding(0)
.style(move |_theme: &IcedTheme, status| {
let background = match overridden(forced, status) {
button::Status::Hovered if !selected => Some(Background::Color(ui.state_over(
StateLayer::Hover,
Role::OnSurface,
Role::Surface,
))),
button::Status::Pressed if !selected => Some(Background::Color(ui.state_over(
StateLayer::Pressed,
Role::OnSurface,
Role::Surface,
))),
_ => None,
};
button::Style {
background,
text_color: label_color,
border: Border::default(),
shadow: Shadow::default(),
snap: true,
}
})
.on_press(Message::Select(item))
.into()
}
/// 导航轨宽度(M69 起是令牌层的 `nav_rail_width`)。
///
/// M69(`141e9b1`)把 `LayoutMetrics` 的 nav 字段做了语义纠正:
///
/// | 旧 | 新 |
/// |---|---|
/// | `nav_expanded_width = 88` | **`nav_rail_width = 88`**(值不变,名字从「展开」改成「轨」) |
/// | `nav_collapsed_width = 48` | 删除(48 连 56 宽的选中指示器都放不下) |
/// | —(缺) | `nav_drawer_width = 240`(D33 的展开态抽屉,本 crate 未用) |
///
/// 三处调用点都走这里:将来令牌再改名,仍然只改这一行。
fn nav_rail_width(metrics: LayoutMetrics) -> f32 {
metrics.nav_rail_width
}
/// 选中指示器尺寸(12b 5.4 参考 56×32),全部由令牌推导、不写死:
///
/// - 宽 = [`nav_rail_width`] − 2 × `Space::X4` = 88 − 32 = **56**(轨的左右内边距 16)
/// - 高 = `control_height − Space::X2` = 40 − 8 = **32**(可见高度上下各让 4)
pub fn indicator_size(metrics: LayoutMetrics) -> (f32, f32) {
(
nav_rail_width(metrics) - Space::X4.dp() * 2.0,
metrics.control_height - Space::X2.dp(),
)
}
/// 导航项高度 = 指示器 + 4 + 标签行高 = 32 + 4 + 16 = 52(`LM.nav_item_height` 是列表行节奏,
/// 药丸 + 标签的轨项放不进 44,偏差登记在交接文档里)。
pub fn nav_item_height(ui: &Ui, indicator_height: f32) -> f32 {
indicator_height + ui.space(Space::X1) + ui.style(TypeRole::LabelMedium).line_height_px
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::Families;
fn ui() -> Ui {
Ui::with_platform(
SchemeFlavor::Light,
Platform::Windows,
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
)
}
#[test]
fn indicator_matches_the_reference_56x32() {
let (width, height) = indicator_size(LayoutMetrics::DESKTOP);
assert_eq!((width, height), (56.0, 32.0), "12b 5.4 的参考值");
}
#[test]
fn nav_item_height_is_indicator_plus_gap_plus_label() {
let ui = ui();
let (_, indicator_height) = indicator_size(ui.metrics());
// 32 + 4 + 16
assert_eq!(nav_item_height(&ui, indicator_height), 52.0);
}
#[test]
fn every_nav_item_has_a_label_and_an_icon() {
assert!((4..=6).contains(&NavItem::ALL.len()));
for item in NavItem::ALL {
assert!(!item.label().is_empty(), "{item:?} 没有文案");
assert!(!item.icon().icon().path.is_empty(), "{item:?} 没有图标");
}
}
}
+352
View File
@@ -0,0 +1,352 @@
//! 两图逐像素对比(GPU/CPU 几何一致性)与 ±N px 平移扫描。
//!
//! 拆成子模块只为「src 文件 ≤400 行」的门禁;对外通过 `shot::{compare, diff_files, Diff, Image}`
//! 使用,调用方不用感知这层。
use std::path::Path;
/// 一张已解码的 RGBA 图。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Image {
/// RGBA8 字节(逐像素 4 字节)。
pub rgba: Vec<u8>,
/// 宽(px)。
pub width: u32,
/// 高(px)。
pub height: u32,
}
impl Image {
/// 读 PNG(只支持 PNG:本 crate 的 `image` 只开了 png 编码/解码)。
///
/// # Errors
/// 读文件失败或解码失败时返回人话。
pub fn load(path: &Path) -> Result<Self, String> {
let reader = image::ImageReader::open(path)
.map_err(|error| format!("读图 {} 失败:{error}", path.display()))?
.with_guessed_format()
.map_err(|error| format!("判定 {} 的格式失败:{error}", path.display()))?;
let decoded = reader
.decode()
.map_err(|error| format!("解码 {} 失败:{error}", path.display()))?
.to_rgba8();
Ok(Self {
width: decoded.width(),
height: decoded.height(),
rgba: decoded.into_raw(),
})
}
}
/// 两图对比结果(GPU/CPU 一致性靠它给数字,不靠肉眼)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Diff {
/// 宽(px)。
pub width: u32,
/// 高(px)。
pub height: u32,
/// 有差异的像素数。
pub differing_pixels: usize,
/// 单通道最大差(0~255)。
pub max_channel_delta: u8,
/// 单通道平均差(0~255)。
pub mean_channel_delta: f64,
/// 平移扫描的最佳对齐(dx, dy);不扫描时为 (0, 0)。
pub best_shift: (i32, i32),
/// 最佳对齐下的差异像素数(不扫描时等于 `differing_pixels`)。
pub best_differing_pixels: usize,
}
/// 逐像素对比两张尺寸相同的图。
///
/// # Errors
/// 尺寸不同直接报错——**几何不一致不许静默通过**。
pub fn compare(left: &Image, right: &Image) -> Result<Diff, String> {
if (left.width, left.height) != (right.width, right.height) {
return Err(format!(
"尺寸不同:{}x{} vs {}x{}",
left.width, left.height, right.width, right.height
));
}
let pixel_count = left.rgba.len() / 4;
let mut differing = 0_usize;
let mut max_delta = 0_u8;
let mut sum_delta = 0_u64;
for index in 0..pixel_count {
let offset = index * 4;
let mut pixel_differs = false;
for channel in 0..4 {
let delta = left.rgba[offset + channel].abs_diff(right.rgba[offset + channel]);
if delta > 0 {
pixel_differs = true;
max_delta = max_delta.max(delta);
sum_delta += u64::from(delta);
}
}
if pixel_differs {
differing += 1;
}
}
Ok(Diff {
width: left.width,
height: left.height,
differing_pixels: differing,
max_channel_delta: max_delta,
mean_channel_delta: if pixel_count == 0 {
0.0
} else {
sum_delta as f64 / (pixel_count * 4) as f64
},
best_shift: (0, 0),
best_differing_pixels: differing,
})
}
/// ±`max_shift` px 的平移扫描:把 `right` 平移后再比,返回差异像素数最小的那一档。
///
/// 用途:D30 的「几何逐像素一致」除了看差异占比,还要证明**没有整体错位**——
/// 如果 (0,0) 就是最优,说明差异只来自抗锯齿而不是位移/缩放。CI 不必再依赖 numpy。
///
/// 平移后越界的像素按「无法比较」处理,不计入差异(否则边缘会造成假阳性)。
pub fn shift_scan(left: &Image, right: &Image, max_shift: i32) -> Result<Diff, String> {
let mut best = compare(left, right)?;
let max_shift = max_shift.unsigned_abs();
if (left.width, left.height) != (right.width, right.height) {
return Err("尺寸不同,无法做平移扫描".to_owned());
}
for dy in -(max_shift as i32)..=(max_shift as i32) {
for dx in -(max_shift as i32)..=(max_shift as i32) {
if (dx, dy) == (0, 0) {
continue;
}
let count = shifted_differing_pixels(left, right, dx, dy);
if count < best.best_differing_pixels {
best.best_differing_pixels = count;
best.best_shift = (dx, dy);
}
}
}
Ok(best)
}
/// 把 `right` 平移 `(dx, dy)` 后与 `left` 比较,返回差异像素数(越界不算)。
fn shifted_differing_pixels(left: &Image, right: &Image, dx: i32, dy: i32) -> usize {
let width = left.width as i32;
let height = left.height as i32;
let mut differing = 0;
for y in 0..height {
let source_y = y - dy;
if !(0..height).contains(&source_y) {
continue;
}
for x in 0..width {
let source_x = x - dx;
if !(0..width).contains(&source_x) {
continue;
}
let left_offset = ((y * width + x) * 4) as usize;
let right_offset = ((source_y * width + source_x) * 4) as usize;
if left.rgba[left_offset..left_offset + 4] != right.rgba[right_offset..right_offset + 4]
{
differing += 1;
}
}
}
differing
}
impl Diff {
/// 完全一致(几何 + 颜色都一致)。
pub fn is_identical(&self) -> bool {
self.differing_pixels == 0
}
/// 一行日志。
pub fn describe(&self) -> String {
let total = (self.width as usize) * (self.height as usize);
let percent = if total == 0 {
0.0
} else {
self.differing_pixels as f64 * 100.0 / total as f64
};
format!(
"[diff] {}x{} differing={}/{} ({percent:.2}%) max_delta={} mean_delta={:.4} best_shift=({},{}) best_differing={}",
self.width,
self.height,
self.differing_pixels,
total,
self.max_channel_delta,
self.mean_channel_delta,
self.best_shift.0,
self.best_shift.1,
self.best_differing_pixels,
)
}
}
/// `--diff <left> <right>` 的实现:读两张 PNG、对比、打日志。
///
/// # Errors
/// 任一图读不出来,或尺寸不同。
pub fn diff_files(left: &Path, right: &Path) -> Result<Diff, String> {
diff_files_with_scan(left, right, 0)
}
/// 同 [`diff_files`],但额外做 ±`max_shift` px 的平移扫描(`--diff-scan`)。
///
/// # Errors
/// 任一图读不出来,或尺寸不同。
pub fn diff_files_with_scan(left: &Path, right: &Path, max_shift: i32) -> Result<Diff, String> {
let left = Image::load(left)?;
let right = Image::load(right)?;
if max_shift == 0 {
compare(&left, &right)
} else {
shift_scan(&left, &right, max_shift)
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use iced::window;
use super::*;
use crate::shot::{save, write_rgba};
fn temp_dir() -> PathBuf {
let dir = std::env::temp_dir().join("ull-app-iced-shot-tests");
std::fs::create_dir_all(&dir).expect("建临时目录");
dir
}
#[test]
fn identical_images_have_zero_diff() {
let image = Image {
rgba: vec![7_u8; 4 * 4],
width: 2,
height: 2,
};
let diff = compare(&image, &image).unwrap();
assert!(diff.is_identical());
assert_eq!(diff.max_channel_delta, 0);
}
#[test]
fn geometry_mismatch_is_an_error_not_a_silent_pass() {
let square = Image {
rgba: vec![0; 16],
width: 2,
height: 2,
};
let strip = Image {
rgba: vec![0; 16],
width: 4,
height: 1,
};
assert!(compare(&square, &strip).is_err());
}
#[test]
fn color_only_differences_are_counted_per_pixel() {
let left = Image {
rgba: vec![0, 0, 0, 255, 0, 0, 0, 255],
width: 2,
height: 1,
};
let right = Image {
rgba: vec![10, 0, 0, 255, 0, 0, 0, 255],
width: 2,
height: 1,
};
let diff = compare(&left, &right).unwrap();
assert_eq!(diff.differing_pixels, 1);
assert_eq!(diff.max_channel_delta, 10);
assert!(diff.describe().contains("differing=1/2"));
}
#[test]
fn png_roundtrip_through_disk() {
let path = temp_dir().join("roundtrip.png");
let bytes = vec![200_u8; 4 * 3 * 2];
write_rgba(&bytes, 3, 2, &path).unwrap();
let loaded = Image::load(&path).unwrap();
assert_eq!((loaded.width, loaded.height), (3, 2));
assert_eq!(loaded.rgba, bytes);
let diff = diff_files(&path, &path).unwrap();
assert!(diff.is_identical());
}
#[test]
fn shift_scan_finds_the_best_alignment() {
// 5x5 图,R 通道存唯一值;构造 right[y][x] = left[y+1][x+1](即把左图向左上挪 1px),
// 于是「left 与 right 在 dy=1,dx=1 时逐像素相等」——扫描应该把 (1,1) 找出来。
let side = 5_i32;
let mut rgba = Vec::new();
for y in 0..side {
for x in 0..side {
rgba.extend([(y * side + x) as u8, 0, 0, 255]);
}
}
let left = Image {
rgba,
width: side as u32,
height: side as u32,
};
let mut moved = vec![255_u8; (side * side * 4) as usize];
for y in 0..side - 1 {
for x in 0..side - 1 {
let source = ((y + 1) * side + (x + 1)) as usize * 4;
let target = (y * side + x) as usize * 4;
moved[target..target + 4].copy_from_slice(&left.rgba[source..source + 4]);
}
}
let right = Image {
rgba: moved,
width: side as u32,
height: side as u32,
};
let diff = shift_scan(&left, &right, 2).unwrap();
assert_eq!(diff.best_shift, (1, 1), "应扫出 1px 的位移");
assert_eq!(diff.best_differing_pixels, 0, "对齐后应逐像素相等");
assert!(
diff.differing_pixels > 0,
"不对齐时应该有差异(否则这条测试没有信息量)"
);
// 同一张图:最佳对齐就是 (0,0)
let same = shift_scan(&left, &left, 2).unwrap();
assert_eq!(same.best_shift, (0, 0));
assert_eq!(same.best_differing_pixels, 0);
}
#[test]
fn wrong_byte_count_is_reported() {
let screenshot = window::Screenshot::new(vec![0_u8; 3], iced::Size::new(1, 1), 1.0);
let error = save(&screenshot, &temp_dir().join("unused.png")).unwrap_err();
assert!(error.contains("字节数对不上"), "{error}");
}
}
+100
View File
@@ -0,0 +1,100 @@
//! 截图通道(D30 验收用):`iced::window::screenshot` 的真窗口出图 → PNG,以及两图逐像素对比。
//!
//! ## 为什么用真窗口而不是 `iced_test::Emulator`
//!
//! 任务书首选路径①:真开窗截图覆盖了标题栏/DPI/窗口管理的真实行为,离屏通道不覆盖
//! (M60 §Q6 的边界说明)。本机与 Windows CI 都有交互会话,所以走路径①。
//!
//! ## 关键事实(M60 踩坑 Q3)
//!
//! `Screenshot` 的字节字段在 0.14 叫 **`rgba`**(0.13 是 `bytes`),`size` 是**物理像素**,
//! 另带 `scale_factor`(换算逻辑坐标用)。落盘用 `image::save_buffer` + `ExtendedColorType::Rgba8`。
//!
//! ## 为什么自己带一个 diff
//!
//! D30 的硬项是「GPU 档与 CPU 档**几何逐像素一致**,只有颜色/材质不同」。CI 里不该假设有
//! Python/PIL/ImageMagick,所以把「读两张 PNG → 数差异像素 → 给最大/平均通道差」做进本程序
//! (`--diff a.png b.png`),复用同一个二进制。
use std::path::{Path, PathBuf};
use iced::window;
use crate::theme;
/// 把一帧截图写到 `path`(自动建父目录)。
///
/// # Errors
/// 目录建不出来、PNG 编码失败、字节数与尺寸对不上时返回人话(截图通道要能一眼看出哪一步断)。
pub fn save(screenshot: &window::Screenshot, path: &Path) -> Result<Summary, String> {
let width = screenshot.size.width;
let height = screenshot.size.height;
let bytes = screenshot.rgba.as_ref();
let expected = width as usize * height as usize * 4;
if bytes.len() != expected {
return Err(format!(
"截图字节数对不上:{width}x{height} 应该 {expected} B,实际 {} B",
bytes.len()
));
}
write_rgba(bytes, width, height, path)?;
Ok(Summary {
width,
height,
scale_factor: screenshot.scale_factor,
path: path.to_path_buf(),
})
}
fn write_rgba(bytes: &[u8], width: u32, height: u32, path: &Path) -> Result<(), String> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("建目录 {} 失败:{error}", parent.display()))?;
}
}
image::save_buffer(path, bytes, width, height, image::ExtendedColorType::Rgba8)
.map_err(|error| format!("写 PNG {} 失败:{error}", path.display()))
}
/// 一次截图的结果摘要(打日志与报告用)。
#[derive(Debug, Clone, PartialEq)]
pub struct Summary {
/// 物理宽(px)。
pub width: u32,
/// 物理高(px)。
pub height: u32,
/// 窗口缩放因子(100% DPI = 1.0)。
pub scale_factor: f32,
/// 落盘路径。
pub path: PathBuf,
}
impl Summary {
/// 一行日志。
pub fn describe(&self) -> String {
format!(
"[shot] {}x{} scale={} -> {}",
self.width,
self.height,
self.scale_factor,
self.path.display()
)
}
}
mod diff;
pub use diff::{compare, diff_files, diff_files_with_scan, shift_scan, Diff, Image};
/// 主题名 → 归档文件名片段(`visual-acceptance-protocol.md` §7 的命名)。
pub fn flavor_slug(ui: &theme::Ui) -> &'static str {
match ui.flavor() {
unlucky_design::color::SchemeFlavor::Light => "light",
unlucky_design::color::SchemeFlavor::Dark => "dark",
}
}
+334
View File
@@ -0,0 +1,334 @@
//! 令牌 → iced 类型(`Color` / `Font` / `Border` / `Radius` / `Theme`)。
//!
//! ## 边界(M61 `design-令牌约定.md` §0 的落地)
//!
//! 本模块是**唯一**允许把 `unlucky-design` 的值翻成 iced 类型的地方。组件层(`shell`)
//! 只调 [`Ui`] 的方法,绝不出现色值 / 字号 / 半径 / 间距字面量——`tests/no_hardcoded_tokens.rs`
//! 会扫 `src/**` 做守门。
//!
//! ## 为什么不用 `iced::theme::Base` 自定义主题承载 49 个角色
//!
//! `iced` 的 `Palette` 只有 6 个颜色(`background/text/primary/success/warning/danger`),
//! M3 有 49 个角色,塞不进去(M60 Q5 + M61 §9-0 的同一结论)。所以:
//!
//! - **真源**是本模块持有的 [`Scheme`](49 角色);
//! - iced 的 `Theme` 只用来「换名字」——`Theme::name()` 变了 iced 才会重算受主题影响的缓存
//! (M61 §9 的最后一条)。`palette()` 只给 devtools 看,控件外观一律由各自的 `Style` 注入。
//!
//! `success` / `warning` 在 M3 里**没有对应角色**(`design-令牌约定.md` §2 明写),
//! 这里按「语义最近的现成角色」映射:`success → Secondary`(不是「成功」的承诺)、
//! `warning → Tertiary`(M3 把「提醒」放 Tertiary)。任何控件都不要去读这两个槽位。
use iced::border::Radius;
use iced::theme::Palette;
use iced::{Border, Color, Font, Theme};
use unlucky_design::color::{Role, Scheme, SchemeFlavor, StateLayer};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::{LayoutMetrics, Space};
use unlucky_design::typography::{FontFamily, Platform, TypeRole, TypeStyle, Weight};
/// 全站描边宽度(逻辑 px @100% DPI)。rubric G7 要求全站 1px —— 令牌层的「一像素线单一真源」。
///
/// M69 起令牌层提供 `unlucky_design::stroke::HAIRLINE`(容器描边/分隔线/聚焦环共用),
/// 这里**再导出**而不是自己写 `1.0`:全仓只有令牌层一个定义点。
pub use unlucky_design::stroke::HAIRLINE as STROKE_PX;
/// 已经解析到系统的字体族名(`&'static str` 才能塞进 `iced::Font`)。
///
/// 由 `fonts` 模块按令牌栈 + 系统字体库求值,本模块只消费。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Families {
/// 正文族(`FontFamily::Text` 栈里第一个装机可用的)。
pub text: &'static str,
/// 展示族(`FontFamily::Display`)。
pub display: &'static str,
/// 等宽族(`FontFamily::Mono`,日志/版本号用)。
pub mono: &'static str,
}
/// 令牌求值器:一个 `Ui` 对应一套主题(浅或深)。
///
/// 深浅热切 = 重新 `Ui::new(flavor, families)` 并整树重绘;控件里不要缓存颜色。
#[derive(Debug, Clone)]
pub struct Ui {
scheme: Scheme,
platform: Platform,
metrics: LayoutMetrics,
families: Families,
}
impl Ui {
/// 按平台(由 `cfg!` 决定)与令牌基线方案求值。
pub fn new(flavor: SchemeFlavor, families: Families) -> Self {
Self::with_platform(flavor, Platform::current(), families)
}
/// 显式指定平台(跨平台核对与测试用)。
pub fn with_platform(flavor: SchemeFlavor, platform: Platform, families: Families) -> Self {
Self {
scheme: Scheme::baseline(flavor),
platform,
metrics: LayoutMetrics::DESKTOP,
families,
}
}
/// 当前深浅模式。
pub fn flavor(&self) -> SchemeFlavor {
self.scheme.flavor()
}
/// 当前平台(决定字体族与字重降级)。
pub fn platform(&self) -> Platform {
self.platform
}
/// 骨架尺寸(标题栏/导航/行高…)。
pub fn metrics(&self) -> LayoutMetrics {
self.metrics
}
/// 已解析的字体族名。
pub fn families(&self) -> Families {
self.families
}
/// 间距档位(4px 栅格)。
pub fn space(&self, space: Space) -> f32 {
space.dp()
}
/// M3 角色 → iced 颜色。
pub fn color(&self, role: Role) -> Color {
to_iced(self.scheme.get(role))
}
/// 材质层(Canvas/Card/Overlay)→ 容器色。
pub fn layer(&self, layer: MaterialLayer) -> Color {
self.color(layer.surface_role())
}
/// 状态层叠在容器色上,返回**不透明**结果。
///
/// 为什么不用带 alpha 的叠加:D27 要求 CPU 档不出现半透明,而「不透明结果」让 GPU/CPU
/// 两档的颜色也逐像素一致(只有材质/玻璃才允许两档不同)。
pub fn state_over(&self, state: StateLayer, content: Role, container: Role) -> Color {
to_iced(state.over(self.scheme.get(content), self.scheme.get(container)))
}
/// 圆角(令牌刻度);`side` 是控件在该轴上的边长(`ShapeScale::Full` 要按边长折算)。
pub fn radius(&self, shape: ShapeScale, side: f32) -> Radius {
Radius::new(shape.radius_px(side))
}
/// 固定档圆角(不需要边长)。`ShapeScale::Full` 必须走 [`Ui::radius`](它没有绝对值)。
pub fn radius_dp(&self, shape: ShapeScale) -> Radius {
debug_assert!(
!matches!(shape, ShapeScale::Full),
"Full 需要边长,请用 Ui::radius(shape, side)"
);
Radius::new(if matches!(shape, ShapeScale::Full) {
0.0
} else {
shape.dp()
})
}
/// 描边:`OutlineVariant` + [`STROKE_PX`],圆角走刻度(固定档)。
pub fn outlined(&self, shape: ShapeScale) -> Border {
Border {
color: self.color(Role::OutlineVariant),
width: STROKE_PX,
radius: self.radius_dp(shape),
}
}
/// baseline 排版档。
pub fn style(&self, role: TypeRole) -> TypeStyle {
role.style()
}
/// Expressive 强调档(官方值:字重升到 500/700)。
pub fn emphasized(&self, role: TypeRole) -> TypeStyle {
role.emphasized()
}
/// 某个排版档对应的 iced 字体(族 + 字重)。
pub fn font_of(&self, style: TypeStyle) -> Font {
let family = match style.family {
FontFamily::Display => self.families.display,
FontFamily::Text => self.families.text,
FontFamily::Mono => self.families.mono,
};
Font {
family: iced::font::Family::Name(family),
weight: weight_of(style.weight),
..Font::DEFAULT
}
}
/// baseline 档的字体。
pub fn font(&self, role: TypeRole) -> Font {
self.font_of(self.style(role))
}
/// 强调档的字体。
pub fn emphasized_font(&self, role: TypeRole) -> Font {
self.font_of(self.emphasized(role))
}
/// iced 的 6 色 `Palette`(只给 devtools / 兜底控件用;M3 的 49 角色在 [`Ui::color`])。
pub fn palette(&self) -> Palette {
Palette {
background: self.color(Role::Surface),
text: self.color(Role::OnSurface),
primary: self.color(Role::Primary),
success: self.color(Role::Secondary),
warning: self.color(Role::Tertiary),
danger: self.color(Role::Error),
}
}
/// iced 主题对象:**名字带深浅**,切主题时 `Theme::name()` 变化会触发重算。
pub fn theme(&self) -> Theme {
Theme::custom(self.name(), self.palette())
}
/// `Theme::name()` 的取值(稳定字符串,改名要先在协作文档登记)。
pub fn name(&self) -> &'static str {
match self.flavor() {
SchemeFlavor::Light => "ull-m3-light",
SchemeFlavor::Dark => "ull-m3-dark",
}
}
}
/// 令牌色(8 bit sRGB)→ iced 的 sRGB 分量。
pub fn to_iced(color: unlucky_design::color::Color) -> Color {
Color::from_rgba8(
color.r,
color.g,
color.b,
f32::from(color.a) / f32::from(u8::MAX),
)
}
/// 令牌字重 → iced 字重。
pub fn weight_of(weight: Weight) -> iced::font::Weight {
match weight {
Weight::Regular => iced::font::Weight::Normal,
Weight::Medium => iced::font::Weight::Medium,
Weight::SemiBold => iced::font::Weight::Semibold,
Weight::Bold => iced::font::Weight::Bold,
}
}
#[cfg(test)]
mod tests {
use super::*;
const FAMILIES: Families = Families {
text: "Segoe UI Variable Text",
display: "Segoe UI Variable Display",
mono: "Cascadia Mono",
};
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
}
#[test]
fn theme_name_tracks_the_flavor() {
assert_eq!(ui(SchemeFlavor::Light).name(), "ull-m3-light");
assert_eq!(ui(SchemeFlavor::Dark).name(), "ull-m3-dark");
assert_ne!(
ui(SchemeFlavor::Light).theme().to_string(),
ui(SchemeFlavor::Dark).theme().to_string()
);
}
#[test]
fn canvas_card_overlay_follow_surface_containers() {
let ui = ui(SchemeFlavor::Light);
assert_eq!(ui.layer(MaterialLayer::Canvas), ui.color(Role::Surface));
assert_eq!(
ui.layer(MaterialLayer::Card),
ui.color(Role::SurfaceContainerLow)
);
assert_eq!(
ui.layer(MaterialLayer::Overlay),
ui.color(Role::SurfaceContainerHigh)
);
}
#[test]
fn radii_only_come_from_the_scale() {
let ui = ui(SchemeFlavor::Light);
// 卡片 Medium(12):边长够大时就是 12
assert_eq!(ui.radius_dp(ShapeScale::Medium).top_left, 12.0);
assert_eq!(ui.radius_dp(ShapeScale::ExtraSmall).top_left, 4.0);
// 药丸 Full:按边长一半折算
assert_eq!(ui.radius(ShapeScale::Full, 32.0).top_left, 16.0);
// 不许超过边长一半
assert_eq!(ui.radius(ShapeScale::ExtraLarge, 20.0).top_left, 10.0);
}
#[test]
fn outlines_are_one_pixel_and_use_outline_variant() {
let ui = ui(SchemeFlavor::Light);
let border = ui.outlined(ShapeScale::Medium);
assert_eq!(border.width, STROKE_PX);
assert_eq!(border.width, 1.0, "rubric G7:全站 1px");
assert_eq!(border.color, ui.color(Role::OutlineVariant));
assert_eq!(border.radius.top_left, 12.0);
}
#[test]
fn emphasized_weights_are_the_official_ones() {
let ui = ui(SchemeFlavor::Light);
// title-small 的强调档是 700(官方 Emphasized)
assert_eq!(
ui.emphasized_font(TypeRole::TitleSmall).weight,
iced::font::Weight::Bold
);
// baseline 正文是 400
assert_eq!(
ui.font(TypeRole::BodyMedium).weight,
iced::font::Weight::Normal
);
}
#[test]
fn body_text_keeps_wcag_contrast_in_both_flavors() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let scheme = Scheme::baseline(flavor);
assert!(
scheme.contrast(Role::OnSurface, Role::Surface) >= 4.5,
"{flavor:?} 的正文对比度不够"
);
assert!(
scheme.contrast(Role::OnSecondaryContainer, Role::SecondaryContainer) >= 4.5,
"{flavor:?} 的选中态药丸对比度不够"
);
}
}
#[test]
fn every_role_maps_to_an_iced_color() {
for role in Role::ALL {
let color = ui(SchemeFlavor::Dark).color(role);
assert!((0.0..=1.0).contains(&color.a), "{role:?} 的 alpha 越界");
}
}
#[test]
fn state_layers_are_opaque_so_cpu_and_gpu_match() {
let ui = ui(SchemeFlavor::Light);
let hovered = ui.state_over(StateLayer::Hover, Role::OnSurface, Role::Surface);
assert_eq!(hovered.a, 1.0, "状态层必须给出不透明结果");
assert_ne!(hovered, ui.color(Role::Surface));
}
}
@@ -0,0 +1,351 @@
//! 门禁:**组件层零硬编码**(色值 / 尺寸)与「不依赖 slint」。
//!
//! 这三条测试会扫本 crate 的 `src/**` 与 `Cargo.toml`,故意写得「宁可错杀」:
//! 将来 reviewer 复跑 `cargo test -p unlucky-app-iced` 就能挡住「顺手写个 #RRGGBB」的提交。
//!
//! 判定口径(与 `component-fidelity-rubric.md` T1/T2、`visual-acceptance-protocol.md` §1 对齐):
//!
//! - **色值字面量**:`#` + 6 位十六进制(且**至少含一个数字**,这样文档里的 `#RRGGBB` 占位不会被误杀)
//! 或 `Color::from_rgb(0.1, …)` / `color!(0x…)` 这类带数值参数的构造。
//! - **令牌优先**:唯一允许的是 `theme.rs`(适配层),且它也只做 `token → iced` 的搬运。
//! - **尺寸字面量**:`shell.rs` 里 `.width(12.0)` / `.height(34)` / `.size(22)` 一律不许出现
//! (必须走 `LayoutMetrics` / `TypeStyle` / `Space`)。
use std::path::{Path, PathBuf};
/// 本 crate 根目录(编译期注入,换机器也成立)。
fn manifest_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
/// 递归收集 `dir` 下的 `.rs` 文件。
fn rust_files(dir: &Path, collected: &mut Vec<PathBuf>) {
let entries =
std::fs::read_dir(dir).unwrap_or_else(|error| panic!("读不动 {}:{error}", dir.display()));
for entry in entries {
let path = entry.expect("目录项读失败").path();
if path.is_dir() {
rust_files(&path, collected);
} else if path.extension().is_some_and(|ext| ext == "rs") {
collected.push(path);
}
}
}
fn sources() -> Vec<(PathBuf, String)> {
let mut files = Vec::new();
rust_files(&manifest_dir().join("src"), &mut files);
assert!(
files.len() >= 8,
"只扫到 {} 个源文件:扫描逻辑坏了,这条门禁等于没跑",
files.len()
);
files
.into_iter()
.map(|path| {
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("读不动 {}:{error}", path.display()));
(path, text)
})
.collect()
}
/// `#RRGGBB` / `#AARRGGBB` 形式的色值字面量(至少含一个数字,避开 `#RRGGBB` 这种占位写法)。
fn hex_color_at(bytes: &[u8], start: usize) -> Option<&str> {
let digits = bytes.get(start + 1..start + 7)?;
let all_hex = digits
.iter()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_whitespace());
let has_number = digits.iter().any(u8::is_ascii_digit);
if all_hex && has_number {
// 后面不能再跟着十六进制字符(否则是更长的 token,不是 6 位色值)
let next = bytes.get(start + 7).copied();
if next.is_none_or(|byte| !byte.is_ascii_hexdigit()) {
return Some("hex 色值字面量(#RRGGBB)");
}
}
None
}
/// 带数值参数的取色构造(`Color::from_rgb(0.1, …)`、`Color::from_rgb8(103, 80, 164)`、`color!(0x12, …)`)。
///
/// **子串匹配要注意前后缀**:`from_rgb(` 匹配不到 `from_rgb8(`,所以 8 bit 那个必须单独列出来
/// (reviewer 造样就是这么绕过去的)。
const NUMERIC_COLOR_CALLS: [&str; 8] = [
"Color::from_rgb(",
"Color::from_rgb8(",
"Color::from_rgba(",
"Color::from_rgba8(",
"Color::from_hex(",
"Color::rgba(",
"Color::rgb8(",
"color!(",
];
/// 取色构造后面是不是跟着数值字面量(允许 `Color::from_rgba8(color.r, …)` 这类搬运)。
fn numeric_color_call(text: &str) -> Option<&'static str> {
for pattern in NUMERIC_COLOR_CALLS {
let mut from = 0;
while let Some(found) = text[from..].find(pattern) {
let after = from + found + pattern.len();
let next = text[after..].trim_start().chars().next();
// 参数以数字或 `0x`/`#` 开头才算字面量;`Color::from_rgba8(color.r, …)` 是搬运,放行。
if next.is_some_and(|ch| ch.is_ascii_digit() || ch == '#') {
return Some(pattern);
}
from = after;
}
}
None
}
/// 结构体字面量取色:`Color { r: 0.4, g: 0.3, b: 0.6, a: 1.0 }`。
///
/// 判据:`Color {` 之后 64 个字符内同时出现 `r:` 与 `g:` 且有数字(`Color { a: 0.0, .. }` 这种
/// 只改 alpha 的写法不会命中,因为它没有 `r:`)。
fn color_struct_literal(text: &str) -> bool {
let mut from = 0;
while let Some(found) = text[from..].find("Color {") {
let after = from + found + "Color {".len();
let window: String = text[after..].chars().take(64).collect();
let has_channels = window.contains("r:") && window.contains("g:");
let has_number = window.chars().any(|ch| ch.is_ascii_digit());
if has_channels && has_number {
return true;
}
from = after;
}
false
}
#[test]
fn no_hardcoded_colors_in_sources() {
let mut offenders = Vec::new();
for (path, text) in sources() {
for (index, line) in text.lines().enumerate() {
let bytes = line.as_bytes();
let mut position = 0;
while let Some(found) = line[position..].find('#') {
let at = position + found;
if let Some(kind) = hex_color_at(bytes, at) {
offenders.push(format!(
"{}:{} {line:?} -> {kind}",
path.display(),
index + 1
));
}
position = at + 1;
}
if let Some(pattern) = numeric_color_call(line) {
offenders.push(format!(
"{}:{} {line:?} -> {pattern}",
path.display(),
index + 1
));
}
if color_struct_literal(line) {
offenders.push(format!(
"{}:{} {line:?} -> Color {{ r: … }} 结构体字面量",
path.display(),
index + 1
));
}
}
}
assert!(
offenders.is_empty(),
"组件层不许出现色值字面量(一律走 unlucky_design 的 Role):\n{}",
offenders.join("\n")
);
}
/// 判断某个布局调用后面是不是直接跟了尺寸字面量;`0`(= 无内边距)放行。
///
/// iced 里尺寸经常被包一层:`.width(Length::Fixed(123.0))`、`.padding(Padding::from([12.0, 8.0]))`、
/// `.size(Size::new(10.0, 10.0))` —— 所以遇到这些包装要**往里剥一层**再判。
fn size_literal(argument: &str) -> Option<String> {
let mut rest = argument.trim_start();
for wrapper in ["Length::Fixed(", "Padding::from(", "Size::new("] {
if let Some(after) = rest.strip_prefix(wrapper) {
rest = after.trim_start().trim_start_matches(['[', '(']);
break;
}
}
let number: String = rest
.trim_start()
.chars()
.take_while(|ch| ch.is_ascii_digit() || *ch == '.')
.collect();
if number.is_empty() || number.trim_end_matches('.').parse::<f64>() == Ok(0.0) {
return None;
}
Some(number)
}
#[test]
fn shell_takes_every_size_from_tokens() {
let mut files = Vec::new();
rust_files(&manifest_dir().join("src").join("shell"), &mut files);
assert!(
files.len() >= 2,
"shell 模块至少有两个文件(mod.rs + nav.rs),实际扫到 {}",
files.len()
);
let mut offenders = Vec::new();
for path in files {
let text = std::fs::read_to_string(&path).expect("读 shell 源文件");
for (index, line) in text.lines().enumerate() {
// 文档注释是散文不是代码:里面写 `.width(24)` 是在解释坑,不该判违规
if line.trim_start().starts_with("//") {
continue;
}
for pattern in [".width(", ".height(", ".size(", ".spacing(", ".padding("] {
let mut from = 0;
while let Some(found) = line[from..].find(pattern) {
let after = from + found + pattern.len();
if let Some(number) = size_literal(&line[after..]) {
offenders.push(format!(
"{}:{} {line:?} -> {pattern} 后面直接写了数字 {number}",
path.display(),
index + 1
));
}
from = after;
}
}
}
}
assert!(
offenders.is_empty(),
"骨架尺寸必须来自 LayoutMetrics / TypeStyle / Space:\n{}",
offenders.join("\n")
);
}
/// 依赖行里出现 `slint` 就算违规(注释行放过:说明并存期要提到 slint)。
fn manifest_has_slint_dependency(manifest: &str) -> Vec<String> {
manifest
.lines()
.enumerate()
.filter(|(_, line)| {
let trimmed = line.trim_start();
!trimmed.starts_with('#') && trimmed.contains("slint")
})
.map(|(index, line)| format!("Cargo.toml:{} {line}", index + 1))
.collect()
}
#[test]
fn this_crate_never_depends_on_slint() {
let manifest =
std::fs::read_to_string(manifest_dir().join("Cargo.toml")).expect("读 Cargo.toml");
let offenders = manifest_has_slint_dependency(&manifest);
assert!(
offenders.is_empty(),
"P4 的 iced 版 crate 不许依赖 slint(D26):\n{}",
offenders.join("\n")
);
for (path, text) in sources() {
for (index, line) in text.lines().enumerate() {
// 只查真代码里的 use/路径,文档里提 slint 是允许的(要解释并存期)
let code = line.trim_start();
let is_import = code.starts_with("use ") || code.starts_with("extern crate");
if is_import && code.contains("slint") {
panic!("{}:{} 引入了 slint:{line}", path.display(), index + 1);
}
}
}
}
#[test]
fn the_gate_itself_catches_a_planted_violation() {
// 自检:把违规样本喂给同一套判定,必须被抓住(否则门禁是空转)。
// **reviewer r1 造样验证过的 5 个漏网样本逐条钉在这里**,退化成「放行」立刻红。
// 1. `Color::from_rgb8(103, 80, 164)` —— 8bit 版曾因 `from_rgb(` 匹配不到而漏
assert!(numeric_color_call("Color::from_rgb8(103, 80, 164)").is_some());
// 2. `iced::Color::from_rgb8(...)` —— 带路径前缀的写法同样要拦
assert!(numeric_color_call("iced::Color::from_rgb8(103, 80, 164)").is_some());
// 3. `Color { r: 0.4, g: 0.3, b: 0.6, a: 1.0 }` —— 结构体字面量
assert!(color_struct_literal(
"Color { r: 0.4, g: 0.3, b: 0.6, a: 1.0 }"
));
// 4. `.width(Length::Fixed(123.0))` —— 尺寸被 iced 包装一层
assert_eq!(
size_literal("Length::Fixed(123.0))").as_deref(),
Some("123.0")
);
// 5. `.padding(Padding::from([12.0, 8.0]))` —— 数组字面量
assert_eq!(
size_literal("Padding::from([12.0, 8.0]))").as_deref(),
Some("12.0")
);
// 原有规则不能退化
assert!(hex_color_at(b" color: #6750A4,", 11).is_some());
assert!(hex_color_at(b"// placeholder #RRGGBB here", 15).is_none());
assert!(numeric_color_call("Color::from_rgb(0.1, 0.2, 0.3)").is_some());
assert!(numeric_color_call("Color::from_rgba8(color.r, color.g, color.b, color.a)").is_none());
assert!(numeric_color_call("color!(0x5865F2)").is_some());
assert!(size_literal("40.0)").is_some());
assert!(size_literal("0)").is_none());
assert!(size_literal("metrics.titlebar_height)").is_none());
assert!(size_literal("Length::Fixed(thickness))").is_none());
assert!(size_literal("Padding::from([0.0, ui.space(Space::X4)]))").is_none());
assert!(!color_struct_literal(
"Color::from_rgba8(color.r, color.g, color.b, 1.0)"
));
assert!(!color_struct_literal("let color = Color { a: 0.5 };"));
assert!(!manifest_has_slint_dependency("slint = \"1.18\"").is_empty());
assert!(manifest_has_slint_dependency("# 不得依赖 slint").is_empty());
}
/// 渲染层回归守门(视觉复审 P2-1 的教训):`indicator_matches_the_reference_56x32` 只测纯函数,
/// 门禁全绿但图上的药丸实际是 88×32 —— 因为 `.center_x(Length::Fill)` 会把容器 width 覆盖成 Fill。
/// 这里直接扫 `nav.rs`:药丸容器**不许**出现 `center_x(` / `center_y(`。
#[test]
fn pill_never_overrides_its_size_with_fill() {
let nav = manifest_dir().join("src").join("shell").join("nav.rs");
let text = std::fs::read_to_string(&nav).expect("读 nav.rs");
for (index, line) in text.lines().enumerate() {
let code = line.trim_start();
if code.starts_with("//") {
continue;
}
for pattern in ["center_x(", "center_y("] {
assert!(
!code.contains(pattern),
"nav.rs:{} 用了 {pattern}——`center_*(Length::Fill)` 会把容器尺寸改成 Fill, 药丸会被父级宽度覆盖(视觉复审 P2-1)。居中请用父级 `align_x`:{line}",
index + 1
);
}
}
}
+4
View File
@@ -21,6 +21,9 @@
//! - [`linux_caps`]:发行版(仅显示)+ 内核 release(喂 `os.version`)+ 会话/GL 能力探测;
//! - [`vk_probe`]:Vulkan 能力的安全层探测(loader 文件 / ICD 注册 / 环境变量,无 `unsafe`);
//! - [`theme_probe`]:系统主题(浅色/深色)探测,供主题三态的 `auto` 初始化对齐与兜底跟随。
//! - [`wallpaper`]:桌面壁纸**路径探测**(D38 配色三级优先里第②级的输入)。本模块只做路径
//! 探测——不解码图片、不取色、不生成调色板,莫奈取色与三级降级在 `crates/design` 的
//! 后续 mission。
//!
//! 尚未落地(后续 mission):文件锁、进程工具、真实 GL/Vulkan 能力探测(钩子见
//! [`linux_caps::probe_gl`] 与 [`vk_probe::probe_vk`],FFI 受限,实际探测归 app 层渲染后端选择)。
@@ -39,4 +42,5 @@ pub mod receipt;
pub mod redirect;
pub mod theme_probe;
pub mod vk_probe;
pub mod wallpaper;
pub mod windows_version;
+358
View File
@@ -0,0 +1,358 @@
//! 桌面壁纸**路径探测**(D38 配色三级优先里的第②级输入)。
//!
//! 用户原话(D38,2026-11-01):「如果用户设置了启动器背景图,则使用莫奈取色从背景图取,
//! 没有背景图时用户也可以开启莫奈取色,此时从桌面壁纸取色。」本模块只承担中间那一级的
//! **输入**:按可信度给出「这台机器的桌面壁纸可能是哪个文件」的有序候选列表。
//!
//! # 边界(只做路径探测)
//!
//! - **不解码图片、不取色、不生成调色板**。莫奈取色本身、D38 三级降级、
//! 「取色失败回落到色卡当前选择 + 设置页可读提示」都在 `crates/design` 的后续 mission。
//! - 全部找不到时返回**空列表**([`WallpaperProbe::is_empty`])而不是错误:没有桌面壁纸是
//! 正常状态(服务器、无桌面环境、壁纸是纯色),调用方按 D38 直接落到第③级色卡。
//! - **零 `unsafe`、零 FFI**(core 是 `#![forbid(unsafe_code)]`):不用
//! `SystemParametersInfo(SPI_GETDESKWALLPAPER)`、不碰 win32 注册表 API,全部走文件读取与
//! `reg.exe` / `gsettings` / `defaults` 子进程——与 [`super::theme_probe`]、
//! [`super::windows_version`] 同一套路子。
//!
//! # 分层与可测性
//!
//! - 采集逻辑按平台分文件(`windows` / `linux` / `macos` 子模块),**无条件编译**、不按
//! `cfg` 裁剪:CI 是 Linux,Windows / macOS 的夹具用例同样要能在那里跑;
//! - 平台分流只发生在 [`detect_with`] 一处;
//! - 外部 IO 全部经 [`ProbeIo`](文件内容 / 文件头 / 目录列表 / 子进程)与
//! [`EnvSource`](环境变量表)注入,[`detect`] 只是把它们换成真实实现。
//!
//! # 什么算「候选壁纸」
//!
//! 只收「归一化后是绝对路径 + 文件头认得出是图片」的条目(魔数集合见 `parse` 子模块的
//! `looks_like_image`)。这条校验既挡掉过期配置里指向已删除文件的路径,也挡掉 macOS
//! 二进制 plist 字符串扫描出的碎片串。
use std::path::PathBuf;
use crate::platform::paths::{EnvSource, Platform, ProcessEnv};
use self::io::{HostIo, HostRoots, ProbeIo};
use self::parse::{looks_like_image, normalize_path};
mod io;
mod linux;
mod macos;
mod parse;
mod windows;
/// 读文件头做魔数嗅探时的字节数(够 `ftyp` 分支与文本 SVG 头)。
const IMAGE_HEAD_LEN: usize = 512;
/// 一个候选壁纸是**怎么被找到的**:平台 + 桌面环境标识。
///
/// 标识只表示「这条路径从哪个通道读出来」,不承诺该通道一定存在(读不到就走下一个)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceKind {
/// Windows:`%APPDATA%\Microsoft\Windows\Themes\TranscodedWallpaper`(系统转码后的当前壁纸)。
WindowsTranscoded,
/// Windows:`HKCU\Software\Microsoft\Windows\CurrentVersion\Themes` 的 `CurrentTheme`
/// 指向的 `.theme`(Win10+ 落在 `%LOCALAPPDATA%\Microsoft\Windows\Themes\Custom.theme`)。
///
/// 这是**当前生效**的那份主题,比 `%APPDATA%` 下的主题包可靠得多。
WindowsCurrentTheme,
/// Windows:`%APPDATA%\Microsoft\Windows\Themes\*.theme` 里的 `Wallpaper=` 行。
///
/// 用户另存的主题包(不一定被启用),只作最后一档兜底。
WindowsThemeFile,
/// Windows:注册表 `HKCU\Control Panel\Desktop` 的 `WallPaper` 值(`reg.exe` 子进程)。
WindowsRegistry,
/// Linux · GNOME:`gsettings get org.gnome.desktop.background picture-uri`(`-dark`)。
Gnome,
/// Linux · KDE Plasma:`plasma-org.kde.plasma.desktop-appletsrc` 的 `Image=` 行。
Kde,
/// Linux · swaybg / hyprpaper 配置里的 wallpaper 行。
WlrootsCompositor,
/// Linux · XFCE:`xfce4-desktop.xml` 的 `last-image` / `image-path` 属性。
Xfce,
/// macOS:`com.apple.wallpaper/Store/Index.plist` 里的 file URL / 绝对路径串。
MacOsStore,
/// macOS:`defaults read com.apple.desktop Background`(老式每屏壁纸设置)。
MacOsDesktopDefaults,
}
impl SourceKind {
/// 稳定标识(日志 / 设置页提示用)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::WindowsTranscoded => "windows-transcoded",
Self::WindowsCurrentTheme => "windows-current-theme",
Self::WindowsThemeFile => "windows-theme-file",
Self::WindowsRegistry => "windows-registry",
Self::Gnome => "linux-gnome",
Self::Kde => "linux-kde",
Self::WlrootsCompositor => "linux-wlroots",
Self::Xfce => "linux-xfce",
Self::MacOsStore => "macos-store",
Self::MacOsDesktopDefaults => "macos-desktop-defaults",
}
}
/// 这条通道属于哪个平台族。
#[must_use]
pub const fn platform(self) -> Platform {
match self {
Self::WindowsTranscoded
| Self::WindowsCurrentTheme
| Self::WindowsThemeFile
| Self::WindowsRegistry => Platform::Windows,
Self::MacOsStore | Self::MacOsDesktopDefaults => Platform::MacOs,
Self::Gnome | Self::Kde | Self::WlrootsCompositor | Self::Xfce => Platform::Linux,
}
}
}
/// 一个候选壁纸来源。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WallpaperSource {
/// 通道标识(平台 + 桌面环境)。
pub kind: SourceKind,
/// 归一化后的绝对路径。
pub path: PathBuf,
/// 原始证据:配置文件 / 注册表键 / 命令行,用于日志与设置页的「从哪读到的」。
pub evidence: String,
}
/// 探测结果:有序候选列表 + 各步失败原因。
///
/// 列表**不是** `Result`:找不到壁纸不是错误,见模块文档的边界一节。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WallpaperProbe {
sources: Vec<WallpaperSource>,
notes: Vec<String>,
}
impl WallpaperProbe {
/// 候选列表,按可信度从高到低(第一个就是首选)。
#[must_use]
pub fn sources(&self) -> &[WallpaperSource] {
&self.sources
}
/// 首选候选(等价于 `sources().first()`)。
#[must_use]
pub fn best(&self) -> Option<&WallpaperSource> {
self.sources.first()
}
/// 候选迭代器(按可信度顺序)。
pub fn iter(&self) -> impl Iterator<Item = &WallpaperSource> {
self.sources.iter()
}
/// 候选个数。
#[must_use]
pub fn len(&self) -> usize {
self.sources.len()
}
/// 一个候选都没有(调用方应落到 D38 的第③级色卡)。
#[must_use]
pub fn is_empty(&self) -> bool {
self.sources.is_empty()
}
/// 各步的失败原因(`通道: 原因`),可直接展示在设置页的提示里。
#[must_use]
pub fn notes(&self) -> &[String] {
&self.notes
}
/// 取走候选列表。
#[must_use]
pub fn into_sources(self) -> Vec<WallpaperSource> {
self.sources
}
}
/// 真实探测宿主平台([`HostIo`] + 进程环境 + 编译目标平台)。
#[must_use]
pub fn detect() -> WallpaperProbe {
detect_with(&HostIo, &ProcessEnv, Platform::current())
}
/// 注入式探测:IO、环境变量表、平台族全部由调用方给。
///
/// 三平台采集逻辑都不带 `cfg`,所以任意宿主都能用固定夹具跑某一平台的用例。
#[must_use]
pub fn detect_with(io: &dyn ProbeIo, env: &dyn EnvSource, platform: Platform) -> WallpaperProbe {
let Some(roots) = HostRoots::from_env(env) else {
return WallpaperProbe {
sources: Vec::new(),
notes: vec!["host: HOME / USERPROFILE 都读不到,无法定位用户目录".to_owned()],
};
};
let mut collect = Collect::new(io, roots, platform);
match platform {
Platform::Windows => windows::collect(&mut collect),
Platform::MacOs => macos::collect(&mut collect),
// 其它 Unix 按 Linux 的桌面习惯兜底(与 `paths` 模块的处理一致)。
Platform::Linux | Platform::Other => linux::collect(&mut collect),
}
collect.finish()
}
/// 采集过程的累加器:负责归一化、去重、图片校验与失败原因记录。
pub(crate) struct Collect<'io> {
io: &'io dyn ProbeIo,
roots: HostRoots,
platform: Platform,
sources: Vec<WallpaperSource>,
notes: Vec<String>,
seen: Vec<String>,
}
impl<'io> Collect<'io> {
/// 构造空累加器。
pub(crate) fn new(io: &'io dyn ProbeIo, roots: HostRoots, platform: Platform) -> Self {
Self {
io,
roots,
platform,
sources: Vec::new(),
notes: Vec::new(),
seen: Vec::new(),
}
}
/// 注入的 IO(文件 / 目录 / 子进程)。
pub(crate) fn io(&self) -> &'io dyn ProbeIo {
self.io
}
/// 宿主根目录(由环境变量表推出)。
pub(crate) fn roots(&self) -> &HostRoots {
&self.roots
}
/// 记一条失败原因,探测继续往下走。
pub(crate) fn note(&mut self, step: &str, detail: impl std::fmt::Display) {
self.notes.push(format!("{step}: {detail}"));
}
/// 收一个候选:归一化 → 去重 → 图片校验,任何一步不过都只记原因、不中断。
///
/// 返回是否真的收下(调用方据此决定要不要继续试别名)。
pub(crate) fn add(&mut self, kind: SourceKind, evidence: &str, raw: &str) -> bool {
match normalize_path(raw, &self.roots) {
Some(path) => self.add_path(kind, evidence, path),
None => {
self.note(evidence, format!("不是可用的绝对路径({raw})"));
false
}
}
}
/// 收一个已经算好的路径(调用方自己拼的,不再归一化)。
pub(crate) fn add_path(&mut self, kind: SourceKind, evidence: &str, path: PathBuf) -> bool {
let key = dedupe_key(self.platform, &path);
if self.seen.contains(&key) {
return false;
}
let head = self.io.read_head(&path, IMAGE_HEAD_LEN);
if !head.as_deref().is_some_and(looks_like_image) {
self.note(
evidence,
format!("读不到或认不出图片格式({})", path.display()),
);
return false;
}
self.seen.push(key);
self.sources.push(WallpaperSource {
kind,
path,
evidence: evidence.to_owned(),
});
true
}
/// 收尾。
pub(crate) fn finish(self) -> WallpaperProbe {
WallpaperProbe {
sources: self.sources,
notes: self.notes,
}
}
}
/// 去重键:Windows 路径大小写不敏感(同一文件可能被写出两种大小写),其它平台按原样。
fn dedupe_key(platform: Platform, path: &std::path::Path) -> String {
let text = path.to_string_lossy().into_owned();
if platform == Platform::Windows {
text.to_ascii_lowercase()
} else {
text
}
}
#[cfg(test)]
mod tests {
use super::io::{HostIo, HostRoots};
use super::*;
use crate::platform::paths::MapEnv;
#[test]
fn empty_probe_is_empty_not_error() {
let probe = WallpaperProbe::default();
assert!(probe.is_empty());
assert_eq!(probe.len(), 0);
assert!(probe.best().is_none());
assert_eq!(probe.iter().count(), 0);
assert!(probe.notes().is_empty());
assert!(probe.into_sources().is_empty());
}
#[test]
fn missing_home_yields_note_and_no_candidates() {
let env = MapEnv::new();
for platform in [Platform::Windows, Platform::MacOs, Platform::Linux] {
let probe = detect_with(&HostIo, &env, platform);
assert!(probe.is_empty(), "{platform:?} 没有 HOME 时不该有候选");
assert_eq!(probe.notes().len(), 1);
assert!(probe.notes()[0].contains("HOME"));
}
}
#[test]
fn source_kind_exposes_platform_and_stable_name() {
assert_eq!(SourceKind::Gnome.platform(), Platform::Linux);
assert_eq!(SourceKind::WindowsRegistry.platform(), Platform::Windows);
assert_eq!(SourceKind::MacOsStore.platform(), Platform::MacOs);
assert_eq!(SourceKind::WindowsTranscoded.as_str(), "windows-transcoded");
assert!(SourceKind::Kde.as_str().starts_with("linux-"));
}
#[test]
fn collect_dedupes_and_keeps_priority_order() {
let env = MapEnv::new().with("HOME", "/home/u");
let roots = HostRoots::from_env(&env).expect("HOME 已给");
let io = io::fake::FakeIo::new()
.with_image("/home/u/a.png")
.with_image("/home/u/b.jpg");
let mut collect = Collect::new(&io, roots, Platform::Linux);
collect.add(SourceKind::Gnome, "gsettings", "/home/u/a.png");
collect.add(SourceKind::Kde, "kde", "file:///home/u/b.jpg");
// 同一条路径再来一次(换了个通道)不该重复计入。
collect.add(SourceKind::Xfce, "xfce", "/home/u/a.png");
collect.add(SourceKind::Xfce, "xfce", "/home/u/missing.png");
let probe = collect.finish();
assert_eq!(probe.len(), 2);
assert_eq!(probe.best().expect("有候选").kind, SourceKind::Gnome);
assert_eq!(probe.sources()[1].path, PathBuf::from("/home/u/b.jpg"));
assert!(
probe
.notes()
.iter()
.any(|note| note.contains("missing.png")),
"落选的候选要留原因:{:?}",
probe.notes()
);
}
}
+297
View File
@@ -0,0 +1,297 @@
//! 壁纸探测要用的外部 IO:文件读取、文件头、目录列表、子进程,以及宿主根目录。
//!
//! 生产实现是 [`HostIo`],测试实现是 `fake::FakeIo`(固定夹具,不碰真机壁纸)。
//! 这一层的存在只为把「外部世界」变成注入参数,不含任何平台判断。
use std::path::{Path, PathBuf};
use crate::platform::paths::EnvSource;
/// 探测需要的外部世界:四个方法覆盖三平台的全部读法。
pub trait ProbeIo {
/// 读整个文件并按 UTF-8 lossy 转文本。
///
/// 二进制 plist 也走这里:路径串本身是可读 ASCII,坏字节会被替换成 U+FFFD 而不影响
/// 路径串的完整性(macOS 的 `Index.plist` 就是 bplist00)。
fn read_text(&self, path: &Path) -> Option<String>;
/// 读文件头最多 `limit` 字节(图片魔数嗅探用;不存在或读不了返回 `None`)。
fn read_head(&self, path: &Path, limit: usize) -> Option<Vec<u8>>;
/// 列出目录里的**文件**(非递归、不排序;目录不存在返回空列表)。排序由调用方做。
fn list_dir(&self, dir: &Path) -> Vec<PathBuf>;
/// 跑一个外部命令取 stdout(命令缺失 / 非零退出 / 空输出都返回 `None`)。
fn run(&self, program: &str, args: &[&str]) -> Option<String>;
}
/// 真实宿主 IO。
#[derive(Debug, Clone, Copy, Default)]
pub struct HostIo;
impl ProbeIo for HostIo {
fn read_text(&self, path: &Path) -> Option<String> {
let bytes = std::fs::read(path).ok()?;
Some(String::from_utf8_lossy(&bytes).into_owned())
}
fn read_head(&self, path: &Path, limit: usize) -> Option<Vec<u8>> {
use std::io::Read;
let file = std::fs::File::open(path).ok()?;
let mut buffer = vec![0_u8; limit];
let mut handle = file.take(limit as u64);
let read = handle.read(&mut buffer).ok()?;
buffer.truncate(read);
Some(buffer)
}
fn list_dir(&self, dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
entries
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_type()
.map(|kind| kind.is_file())
.unwrap_or(false)
})
.map(|entry| entry.path())
.collect()
}
fn run(&self, program: &str, args: &[&str]) -> Option<String> {
let output = std::process::Command::new(program)
.args(args)
.output()
.ok()?;
// `Command::output` 对非零退出同样返回 `Ok`,所以退出码要单独判:三条命令
// (`reg.exe` / `gsettings` / `defaults`)出错时都走 stderr,只看 stdout 会把
// 「查不到」和「命令失败」混成一种,调用方的原因文案就名不副实了。
if !output.status.success() || output.stdout.is_empty() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
}
/// 探测要用的宿主根目录(全部由环境变量表推出,因此测试可以整体替换)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostRoots {
/// 用户主目录(`HOME`,退 `USERPROFILE`)。
pub home: PathBuf,
/// Windows `%APPDATA%`(退 `~/AppData/Roaming`);其它平台用不到,但仍填上以免分支取空。
pub app_data: PathBuf,
/// Windows `%LOCALAPPDATA%`(退 `~/AppData/Local`):Win10+ 的「当前主题」
/// `Custom.theme` 落在这一侧。
pub local_app_data: PathBuf,
/// `$XDG_CONFIG_HOME`(仅接受绝对路径),退 `~/.config`。
pub config_home: PathBuf,
/// Windows `%SystemRoot%`(`.theme` 里 `Wallpaper=%SystemRoot%\...` 展开用),无则不展开。
pub system_root: Option<PathBuf>,
}
impl HostRoots {
/// 从环境变量表推出根目录;连主目录都没有时返回 `None`(调用方记一条原因)。
///
/// 顺序固定(`HOME` 优先于 `USERPROFILE`),**不按编译目标分支**:否则在 Windows 上
/// 跑 Linux 夹具时会被 `USERPROFILE` 抢走主目录,夹具就不再确定。
#[must_use]
pub fn from_env(env: &dyn EnvSource) -> Option<Self> {
let home = env
.var("HOME")
.or_else(|| env.var("USERPROFILE"))
.map(PathBuf::from)?;
let app_data = env
.var("APPDATA")
.map(PathBuf::from)
.unwrap_or_else(|| home.join("AppData/Roaming"));
let local_app_data = env
.var("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_else(|| home.join("AppData/Local"));
let config_home = env
.var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|dir| dir.is_absolute())
.unwrap_or_else(|| home.join(".config"));
let system_root = env
.var("SYSTEMROOT")
.or_else(|| env.var("WINDIR"))
.map(PathBuf::from);
Some(Self {
home,
app_data,
local_app_data,
config_home,
system_root,
})
}
}
#[cfg(test)]
pub(crate) mod fake {
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use super::ProbeIo;
/// PNG 魔数(夹具用它冒充「像图片」的文件)。
pub(crate) const PNG_HEAD: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR";
/// 固定夹具 IO:文件内容、图片、目录列表、命令输出全部预先给定。
///
/// 未登记的路径一律「不存在 / 读不出 / 目录为空」,命令也未登记则报失败。
#[derive(Debug, Default, Clone)]
pub struct FakeIo {
texts: BTreeMap<PathBuf, String>,
images: Vec<PathBuf>,
dirs: BTreeMap<PathBuf, Vec<PathBuf>>,
commands: BTreeMap<String, String>,
}
impl FakeIo {
/// 空世界(一切探测都会失败)。
pub(crate) fn new() -> Self {
Self::default()
}
/// 登记一个文本文件(配置文件、`.theme`、plist、xml……)。
pub(crate) fn with_file(mut self, path: impl AsRef<Path>, text: &str) -> Self {
self.texts
.insert(path.as_ref().to_path_buf(), text.to_owned());
self
}
/// 登记一个「图片」文件(`read_head` 返回 PNG 魔数)。
pub(crate) fn with_image(mut self, path: impl AsRef<Path>) -> Self {
self.images.push(path.as_ref().to_path_buf());
self
}
/// 登记目录内容(按文件名给,路径拼在 `dir` 上)。
pub(crate) fn with_dir(mut self, dir: impl AsRef<Path>, names: &[&str]) -> Self {
let dir = dir.as_ref();
let entries = names.iter().map(|name| dir.join(name)).collect();
self.dirs.insert(dir.to_path_buf(), entries);
self
}
/// 登记一条命令的 stdout;`key` 是「程序名 + 空格分隔的参数」整串。
pub(crate) fn with_command(mut self, key: &str, stdout: &str) -> Self {
self.commands.insert(key.to_owned(), stdout.to_owned());
self
}
}
fn command_key(program: &str, args: &[&str]) -> String {
let mut key = program.to_owned();
for arg in args {
key.push(' ');
key.push_str(arg);
}
key
}
impl ProbeIo for FakeIo {
fn read_text(&self, path: &Path) -> Option<String> {
self.texts.get(path).cloned()
}
fn read_head(&self, path: &Path, limit: usize) -> Option<Vec<u8>> {
if self.images.iter().any(|image| image.as_path() == path) {
return Some(PNG_HEAD[..limit.min(PNG_HEAD.len())].to_vec());
}
let text = self.texts.get(path)?;
let bytes = text.as_bytes();
Some(bytes[..limit.min(bytes.len())].to_vec())
}
fn list_dir(&self, dir: &Path) -> Vec<PathBuf> {
self.dirs.get(dir).cloned().unwrap_or_default()
}
fn run(&self, program: &str, args: &[&str]) -> Option<String> {
let key = command_key(program, args);
let stdout = self.commands.get(&key)?;
if stdout.is_empty() {
return None;
}
Some(stdout.clone())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::platform::paths::MapEnv;
#[test]
fn roots_come_from_env_in_fixed_order() {
let env = MapEnv::new()
.with("HOME", "/home/u")
.with("USERPROFILE", "C:/Users/ignored")
.with("SYSTEMROOT", "C:/Windows");
let roots = HostRoots::from_env(&env).expect("HOME 已给");
assert_eq!(roots.home, PathBuf::from("/home/u"));
assert_eq!(roots.app_data, PathBuf::from("/home/u/AppData/Roaming"));
assert_eq!(roots.local_app_data, PathBuf::from("/home/u/AppData/Local"));
assert_eq!(roots.config_home, PathBuf::from("/home/u/.config"));
assert_eq!(roots.system_root, Some(PathBuf::from("C:/Windows")));
}
#[test]
fn roots_prefer_explicit_appdata_and_absolute_xdg() {
let env = MapEnv::new()
.with("HOME", "/home/u")
.with("APPDATA", "C:/Users/u/AppData/Roaming")
.with("LOCALAPPDATA", "C:/Users/u/AppData/Local")
// 相对路径按 XDG 规范忽略。
.with("XDG_CONFIG_HOME", "relative/dir");
let roots = HostRoots::from_env(&env).expect("HOME 已给");
assert_eq!(roots.app_data, PathBuf::from("C:/Users/u/AppData/Roaming"));
assert_eq!(
roots.local_app_data,
PathBuf::from("C:/Users/u/AppData/Local")
);
assert_eq!(roots.config_home, PathBuf::from("/home/u/.config"));
assert_eq!(roots.system_root, None);
}
#[test]
fn host_io_treats_non_zero_exit_as_failure() {
// 有 stdout 但退出码非零:只看 stdout 的旧实现会把这种输出当成功。
#[cfg(windows)]
let (program, args) = ("cmd", ["/c", "echo hi & exit 3"]);
#[cfg(not(windows))]
let (program, args) = ("sh", ["-c", "echo hi; exit 3"]);
assert!(HostIo.run(program, &args).is_none());
}
#[test]
fn fake_io_serves_only_registered_world() {
let io = fake::FakeIo::new()
.with_file("/home/u/note.txt", "hello")
.with_image("/home/u/a.png")
.with_dir("/home/u/dir", &["a.png", "b.theme"])
.with_command("gsettings get x y", "'file:///home/u/a.png'");
assert_eq!(
io.read_text(Path::new("/home/u/note.txt")).as_deref(),
Some("hello")
);
assert_eq!(
io.read_head(Path::new("/home/u/note.txt"), 4).as_deref(),
Some(&b"hell"[..])
);
assert_eq!(
io.read_head(Path::new("/home/u/a.png"), 8).as_deref(),
Some(&fake::PNG_HEAD[..8])
);
assert_eq!(io.list_dir(Path::new("/home/u/dir")).len(), 2);
assert!(io.run("gsettings", &["get", "x", "y"]).is_some());
assert!(io.run("reg.exe", &["query"]).is_none());
assert!(io.read_text(Path::new("/nope")).is_none());
}
}
+396
View File
@@ -0,0 +1,396 @@
//! Linux 壁纸路径探测:GNOME → KDE Plasma → swaybg / hyprpaper → XFCE。
//!
//! 四步互相独立:`gsettings` 缺失、配置文件不存在、解析不到值都只记一条原因,探测继续往下
//! 走(Wayland 上一条配置都读不到是常态,此时应直接落到 D38 的第③级色卡)。
//!
//! 探测路径全部来自「桌面环境自己记在磁盘上的东西」,不需要 D-Bus 与 xdg-desktop-portal:
//!
//! 1. GNOME / Ubuntu 系:`gsettings get org.gnome.desktop.background picture-uri`,再看
//! `picture-uri-dark`(深色壁纸是独立键);
//! 2. KDE Plasma:`~/.config/plasma-org.kde.plasma.desktop-appletsrc` 的 `Image=` 行;
//! 3. swaybg / hyprpaper:`~/.config/hypr/hyprpaper.conf`(`wallpaper =` / `preload =`)与
//! `~/.config/sway/config`(`output <name> bg <path>`);
//! 4. XFCE:`~/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-desktop.xml` 的
//! `last-image` / `image-path` 属性。
//!
//! 本文件不带 `cfg`:平台分流只在 [`super::detect_with`] 一处,任何宿主都能跑夹具用例。
use super::{Collect, SourceKind};
/// GNOME 壁纸 schema。
pub(super) const GNOME_SCHEMA: &str = "org.gnome.desktop.background";
/// 探测的键,顺序即优先级(浅色在前,深色壁纸在后)。
pub(super) const GNOME_KEYS: &[&str] = &["picture-uri", "picture-uri-dark"];
const KDE_CONFIG: &str = "plasma-org.kde.plasma.desktop-appletsrc";
const HYPRPAPER_CONFIG: &str = "hypr/hyprpaper.conf";
const SWAY_CONFIG: &str = "sway/config";
const XFCE_DESKTOP_XML: &str = "xfce4/xfconf/xfce-perchannel-xml/xfce4-desktop.xml";
/// 四步依次探测。
pub(super) fn collect(collect: &mut Collect<'_>) {
gnome(collect);
kde(collect);
compositors(collect);
xfce(collect);
}
/// 第一步:GNOME 的 `picture-uri` / `picture-uri-dark`。
fn gnome(collect: &mut Collect<'_>) {
for key in GNOME_KEYS {
let evidence = format!("gsettings get {GNOME_SCHEMA} {key}");
match collect.io().run("gsettings", &["get", GNOME_SCHEMA, key]) {
Some(text) => match parse_gsettings_value(&text) {
Some(value) => {
collect.add(SourceKind::Gnome, &evidence, &value);
}
None => collect.note(
SourceKind::Gnome.as_str(),
format!("{evidence} = {}(没有设壁纸)", text.trim()),
),
},
None => collect.note(
SourceKind::Gnome.as_str(),
format!("{evidence} 失败(命令缺失 / 非零退出 / 空输出)"),
),
}
}
}
/// 第二步:KDE Plasma 的壁纸插件配置。
fn kde(collect: &mut Collect<'_>) {
let Some(text) = read_config(collect, SourceKind::Kde, KDE_CONFIG) else {
return;
};
let values = parse_kde_images(&text);
if values.is_empty() {
collect.note(
SourceKind::Kde.as_str(),
format!("{KDE_CONFIG} 里没有 Image= 行"),
);
}
let evidence = format!("{KDE_CONFIG} 的 Image=");
for value in values {
collect.add(SourceKind::Kde, &evidence, &value);
}
}
/// 第三步:swaybg(sway 配置)/ hyprpaper。
fn compositors(collect: &mut Collect<'_>) {
for (relative, what) in [
(HYPRPAPER_CONFIG, "wallpaper/preload 行"),
(SWAY_CONFIG, "output ... bg 行"),
] {
let Some(text) = read_config(collect, SourceKind::WlrootsCompositor, relative) else {
continue;
};
let values = match relative {
HYPRPAPER_CONFIG => parse_hyprpaper_wallpapers(&text),
_ => parse_sway_backgrounds(&text),
};
if values.is_empty() {
collect.note(
SourceKind::WlrootsCompositor.as_str(),
format!("{relative} 里没有 {what}"),
);
}
let evidence = format!("{relative} 的 {what}");
for value in values {
collect.add(SourceKind::WlrootsCompositor, &evidence, &value);
}
}
}
/// 第四步:XFCE 的 xfconf XML。
fn xfce(collect: &mut Collect<'_>) {
let Some(text) = read_config(collect, SourceKind::Xfce, XFCE_DESKTOP_XML) else {
return;
};
let values = parse_xfce_last_image(&text);
if values.is_empty() {
collect.note(
SourceKind::Xfce.as_str(),
format!("{XFCE_DESKTOP_XML} 里没有 last-image/image-path 属性"),
);
}
let evidence = format!("{XFCE_DESKTOP_XML} 的 last-image/image-path");
for value in values {
collect.add(SourceKind::Xfce, &evidence, &value);
}
}
/// 读 `$XDG_CONFIG_HOME` 下的一个配置文件;读不到只记原因并返回 `None`。
fn read_config(collect: &mut Collect<'_>, kind: SourceKind, relative: &str) -> Option<String> {
let path = collect.roots().config_home.join(relative);
match collect.io().read_text(&path) {
Some(text) => Some(text),
None => {
collect.note(kind.as_str(), format!("读不到 {}", path.display()));
None
}
}
}
/// 解析 `gsettings get` 的输出:`'file:///home/u/a.png'`;`'none'` / 空串返回 `None`。
pub(super) fn parse_gsettings_value(text: &str) -> Option<String> {
let trimmed = text.trim();
let value = trimmed
.strip_prefix('\'')
.and_then(|rest| rest.strip_suffix('\''))
.unwrap_or(trimmed)
.trim();
if value.is_empty() || value.eq_ignore_ascii_case("none") {
return None;
}
Some(value.to_owned())
}
/// 取 Plasma 配置里的 `Image=` 值(壁纸插件是 `[org.kde.image][General]` 段)。
///
/// 只收绝对路径与 `file://` URI:Plasma 的其它插件也有 `Image=` 键,相对值当成非壁纸。
pub(super) fn parse_kde_images(text: &str) -> Vec<String> {
text.lines()
.filter_map(|line| {
let (key, value) = line.trim().split_once('=')?;
if !key.trim().eq_ignore_ascii_case("image") {
return None;
}
let value = value.trim();
let usable = value.starts_with('/')
|| value.starts_with("file://")
|| value
.get(..2)
.is_some_and(|head| head.eq_ignore_ascii_case("~/"));
usable.then(|| value.to_owned())
})
.collect()
}
/// 取 hyprpaper 的 `wallpaper = <监视器>,<路径>` 与 `preload = <路径>` 行。
pub(super) fn parse_hyprpaper_wallpapers(text: &str) -> Vec<String> {
text.lines()
.filter_map(|line| {
let line = line.trim();
if line.starts_with('#') {
return None;
}
let (key, value) = line.split_once('=')?;
let value = value.trim();
match key.trim().to_ascii_lowercase().as_str() {
"preload" => non_empty(value),
// `wallpaper = eDP-1,/path/a.png`(监视器可留空);没写逗号时整段就是路径。
"wallpaper" => match value.split_once(',') {
Some((_, path)) => non_empty(path),
None => non_empty(value),
},
_ => None,
}
})
.collect()
}
/// 取 sway 配置里 `output <名字|*> bg <路径> [填充方式]` 的路径。
///
/// sway 允许用引号包住含空格的对象名/路径,这里把引号拼回来;解析不出来的行直接跳过。
pub(super) fn parse_sway_backgrounds(text: &str) -> Vec<String> {
text.lines()
.filter_map(|line| {
let line = line.trim();
if line.starts_with('#') {
return None;
}
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.len() < 4 || tokens[0] != "output" || tokens[2] != "bg" {
return None;
}
let raw = tokens[3];
let mut value = raw.trim_start_matches('"').to_owned();
if raw.starts_with('"') && !raw.ends_with('"') {
for token in &tokens[4..] {
value.push(' ');
match token.strip_suffix('"') {
Some(tail) => {
value.push_str(tail);
break;
}
None => value.push_str(token),
}
}
}
non_empty(value.trim_end_matches('"'))
})
.collect()
}
/// 取 XFCE `xfce4-desktop.xml` 里 `last-image` / `image-path` 属性的值。
pub(super) fn parse_xfce_last_image(text: &str) -> Vec<String> {
text.lines()
.filter(|line| line.contains("last-image") || line.contains("image-path"))
.filter_map(|line| non_empty(&attribute(line, "value")?))
.collect()
}
/// 取 XML 一行里 `name="..."` 属性的值。
fn attribute(line: &str, name: &str) -> Option<String> {
let needle = format!("{name}=\"");
let start = line.find(&needle)? + needle.len();
let rest = &line[start..];
let end = rest.find('"')?;
Some(rest[..end].to_owned())
}
fn non_empty(value: &str) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
#[cfg(test)]
mod tests {
use super::super::io::fake::FakeIo;
use super::super::{detect_with, SourceKind};
use super::*;
use crate::platform::paths::{MapEnv, Platform};
const CONFIG: &str = "/home/u/.config";
fn env() -> MapEnv {
MapEnv::new().with("HOME", "/home/u")
}
fn gsettings(key: &str) -> String {
format!("gsettings get {GNOME_SCHEMA} {key}")
}
fn config(name: &str) -> String {
format!("{CONFIG}/{name}")
}
#[test]
fn gnome_picture_uri_hit_decodes_percent_escapes() {
let io = FakeIo::new()
.with_command(
&gsettings("picture-uri"),
"'file:///home/u/my%20wall.png'\n",
)
.with_image("/home/u/my wall.png");
let probe = detect_with(&io, &env(), Platform::Linux);
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
let best = probe.best().expect("有候选");
assert_eq!(best.kind, SourceKind::Gnome);
assert_eq!(best.path.display().to_string(), "/home/u/my wall.png");
}
#[test]
fn four_steps_yield_ordered_candidates() {
let io = ["a", "b", "k", "h", "h2", "s", "x"]
.into_iter()
.map(|name| format!("/home/u/{name}.png"))
.fold(
FakeIo::new()
.with_command(&gsettings("picture-uri"), "'file:///home/u/a.png'\n")
.with_command(&gsettings("picture-uri-dark"), "'file:///home/u/b.png'\n")
.with_file(config(KDE_CONFIG), "Image=file:///home/u/k.png\n")
.with_file(
config(HYPRPAPER_CONFIG),
"preload = /home/u/h.png\nwallpaper = eDP-1, /home/u/h2.png\n",
)
.with_file(config(SWAY_CONFIG), "output * bg /home/u/s.png fill\n")
.with_file(
config(XFCE_DESKTOP_XML),
"<property name=\"last-image\" value=\"/home/u/x.png\"/>\n",
),
FakeIo::with_image,
);
let probe = detect_with(&io, &env(), Platform::Linux);
let seen: Vec<(SourceKind, String)> = probe
.iter()
.map(|source| (source.kind, source.path.display().to_string()))
.collect();
assert_eq!(
seen,
[
(SourceKind::Gnome, "/home/u/a.png"),
(SourceKind::Gnome, "/home/u/b.png"),
(SourceKind::Kde, "/home/u/k.png"),
(SourceKind::WlrootsCompositor, "/home/u/h.png"),
(SourceKind::WlrootsCompositor, "/home/u/h2.png"),
(SourceKind::WlrootsCompositor, "/home/u/s.png"),
(SourceKind::Xfce, "/home/u/x.png"),
]
.map(|(kind, path)| (kind, path.to_owned()))
);
}
#[test]
fn failing_step_does_not_stop_later_steps() {
// gsettings 说「没设壁纸」,KDE 指向一个不存在的文件,XFCE 才是真的。
let io = FakeIo::new()
.with_command(&gsettings("picture-uri"), "'none'\n")
.with_file(config(KDE_CONFIG), "Image=file:///home/u/gone.png\n")
.with_file(
config(XFCE_DESKTOP_XML),
"<property name=\"image-path\" value=\"/home/u/x.png\"/>\n",
)
.with_image("/home/u/x.png");
let probe = detect_with(&io, &env(), Platform::Linux);
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
assert_eq!(probe.best().expect("有候选").kind, SourceKind::Xfce);
assert!(
probe.notes().iter().any(|note| note.contains("gone.png")),
"落选原因要留:{:?}",
probe.notes()
);
}
#[test]
fn everything_missing_returns_empty_with_reasons() {
let probe = detect_with(&FakeIo::new(), &env(), Platform::Linux);
assert!(probe.is_empty(), "全部失败要返回空列表");
let notes = probe.notes().join("\n");
assert!(notes.contains("gsettings"), "{notes}");
assert!(notes.contains("xfce4-desktop.xml"), "{notes}");
assert!(notes.contains("hyprpaper.conf"), "{notes}");
}
#[test]
fn gsettings_output_parsing() {
assert_eq!(
parse_gsettings_value("'file:///home/u/a.png'\n"),
Some("file:///home/u/a.png".to_owned())
);
assert_eq!(parse_gsettings_value("'none'\n"), None);
assert_eq!(parse_gsettings_value("''"), None);
assert_eq!(parse_gsettings_value(""), None);
}
#[test]
fn config_line_parsing() {
assert_eq!(
parse_kde_images("Image=file:///home/u/a.png\nImage=relative/b.png\n"),
vec!["file:///home/u/a.png".to_owned()]
);
assert_eq!(
parse_hyprpaper_wallpapers(
"# 注释行\npreload = /home/u/a.png\nwallpaper = eDP-1, /home/u/b.png\nwallpaper = ,/home/u/c.png\nsplash = false\n"
),
vec![
"/home/u/a.png".to_owned(),
"/home/u/b.png".to_owned(),
"/home/u/c.png".to_owned(),
]
);
assert_eq!(
parse_sway_backgrounds(
"output * bg /home/u/a.png fill\noutput eDP-1 bg \"/home/u/b c.png\" stretch\n"
),
vec!["/home/u/a.png".to_owned(), "/home/u/b c.png".to_owned()]
);
assert_eq!(
parse_xfce_last_image(
"<property name=\"last-image\" value=\"/home/u/a.png\"/>\n<property name=\"image-style\" value=\"3\"/>\n"
),
vec!["/home/u/a.png".to_owned()]
);
}
}
+304
View File
@@ -0,0 +1,304 @@
//! macOS 壁纸路径探测:`com.apple.wallpaper` 的 `Index.plist` → 老式 `com.apple.desktop` 域。
//!
//! 两条通道:
//!
//! 1. `~/Library/Application Support/com.apple.wallpaper/Store/Index.plist`——Big Sur 之后
//! 壁纸设置的真实落点,**二进制 plist(`bplist00`)**,里面记着
//! `file:///…/xxx.heic` 与裸绝对路径;
//! 2. `defaults read com.apple.desktop Background`——Catalina 及更早的每屏壁纸配置
//! (`ImageFilePath = "/Library/Desktop Pictures/…"`),新系统上该域通常已不存在。
//!
//! # 未在真机验证
//!
//! 本机(Windows)与 CI(Linux)都没有 macOS 通道:**下列代码没有在 macOS 真机上跑过**。
//! 因此这里刻意不引入 plist 解析依赖(`plist` crate 会为「读几个路径串」多背一个依赖),
//! 改成手写的字符串扫描:plist 里的路径串本身是可读 ASCII,按「可打印 ASCII 连续段」切出来
//! 再按「行首/分隔符后的 `/`」认路径开头即可,二进制 plist 的二进制部分天然成为分隔符。
//! 扫出的碎片串由 [`super::Collect`] 的图片头校验兜掉,不会喂给取色器。
//! 真机行为待后续 mission 在 macOS 上补充验证(`Index.plist` 的真实结构、`.heic` 壁纸路径)。
use super::{Collect, SourceKind};
/// Big Sur 之后的壁纸设置存储(相对用户主目录)。
pub(super) const STORE_INDEX: &str =
"Library/Application Support/com.apple.wallpaper/Store/Index.plist";
/// 老式壁纸配置域。
pub(super) const DESKTOP_DOMAIN: &str = "com.apple.desktop";
/// 一次扫描最多收多少个候选串(防止异常文件把内存打满;正常情况下只有个位数)。
const MAX_SCANNED: usize = 64;
/// 两步依次探测。
pub(super) fn collect(collect: &mut Collect<'_>) {
store_index(collect);
desktop_defaults(collect);
}
/// 第一步:壁纸 Store 的 `Index.plist`。
fn store_index(collect: &mut Collect<'_>) {
let path = collect.roots().home.join(STORE_INDEX);
let Some(text) = collect.io().read_text(&path) else {
collect.note(
SourceKind::MacOsStore.as_str(),
format!("读不到 {}(Catalina 及更早没有这个文件)", path.display()),
);
return;
};
let values = scan_string_paths(&text);
if values.is_empty() {
collect.note(
SourceKind::MacOsStore.as_str(),
format!("{} 里没有路径串", path.display()),
);
return;
}
let evidence = "Library/Application Support/com.apple.wallpaper/Store/Index.plist 的路径串";
for value in values {
collect.add(SourceKind::MacOsStore, evidence, &value);
}
}
/// 第二步:`defaults read com.apple.desktop Background`(老系统)。
fn desktop_defaults(collect: &mut Collect<'_>) {
let evidence = format!("defaults read {DESKTOP_DOMAIN} Background");
match collect
.io()
.run("defaults", &["read", DESKTOP_DOMAIN, "Background"])
{
Some(text) => {
let values = scan_defaults_background(&text);
if values.is_empty() {
collect.note(
SourceKind::MacOsDesktopDefaults.as_str(),
format!("{evidence} 里没有 ImageFilePath"),
);
return;
}
for value in values {
collect.add(SourceKind::MacOsDesktopDefaults, &evidence, &value);
}
}
None => collect.note(
SourceKind::MacOsDesktopDefaults.as_str(),
format!("{evidence} 失败(命令缺失 / 非零退出 / 该域不存在)"),
),
}
}
/// 从 plist 原文里扫出候选串:`file://` URL 与裸绝对路径。
///
/// 输入既可能是 XML plist(`<string>file:///…</string>`)也可能是二进制 plist 的 lossy 文本
/// (可读 ASCII 串之间夹着不可打印字节)。因此先按「可打印 ASCII 连续段」切,再在每段里认:
///
/// - 任意位置的 `file://` 开头;
/// - 「段首 / 分隔符之后 / `~` 之后」的 `/` 开头(路径中间的 `/` 不算新起点,避免同一路径
/// 被切成好几段)。
#[must_use]
pub(super) fn scan_string_paths(text: &str) -> Vec<String> {
let mut found = Vec::new();
for run in printable_runs(text) {
let mut cursor = 0;
while let Some(offset) = run[cursor..].find("file://") {
let start = cursor + offset;
push_candidate(&mut found, &run[start..]);
cursor = start + "file://".len();
}
for (position, _) in run.match_indices('/') {
let Some(start) = path_start(run, position) else {
continue;
};
push_candidate(&mut found, &run[start..]);
}
if found.len() >= MAX_SCANNED {
break;
}
}
found.truncate(MAX_SCANNED);
found
}
/// 解析 `defaults read com.apple.desktop Background` 的文本:只认 `ImageFilePath` / `NewPath`。
#[must_use]
pub(super) fn scan_defaults_background(text: &str) -> Vec<String> {
text.lines()
.filter_map(|line| {
let (key, value) = line.split_once('=')?;
let key = key.trim().trim_matches('"');
if !key.eq_ignore_ascii_case("ImageFilePath") && !key.eq_ignore_ascii_case("NewPath") {
return None;
}
let value = value.trim().trim_end_matches(';').trim().trim_matches('"');
(!value.is_empty()).then(|| value.to_owned())
})
.collect()
}
/// 可打印 ASCII 连续段(二进制 plist 的二进制字节在这里变成分隔符)。
fn printable_runs(text: &str) -> Vec<&str> {
text.split(|ch: char| !(ch.is_ascii_graphic() || ch == ' '))
.map(str::trim)
.filter(|run| !run.is_empty())
.collect()
}
/// 判断 `run[position]` 的 `/` 是不是一个路径起点;是则返回该路径在 `run` 里的起点。
fn path_start(run: &str, position: usize) -> Option<usize> {
let next = run[position + 1..].chars().next()?;
if !(next.is_ascii_alphanumeric() || next == '_') {
return None;
}
match run[..position].chars().next_back() {
// 段首:路径直接跟在二进制分隔符后面。
None => Some(position),
// `~/Library/...`:连 `~` 一起收,交给路径归一化展开。
Some('~') => Some(position - 1),
// 闭合标签 `</string>` 里的那个斜杠不是路径。
Some('<') => None,
// 路径中间的 `/`(前面还是路径字符)不当新起点。
Some(prev) if is_path_char(prev) => None,
Some(_) => Some(position),
}
}
fn is_path_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | '%' | '+' | '@' | '~' | ' ')
}
/// plist/XML 序列化里的终止字符(`<string>` 的尖括号、引号、实体)切掉。
fn push_candidate(found: &mut Vec<String>, raw: &str) {
let end = raw.find(['<', '>', '"', '&', '\'']).unwrap_or(raw.len());
let candidate = raw[..end].trim_end();
if !candidate.is_empty() && !found.iter().any(|seen| seen.as_str() == candidate) {
found.push(candidate.to_owned());
}
}
#[cfg(test)]
mod tests {
use super::super::io::fake::FakeIo;
use super::super::{detect_with, SourceKind};
use super::*;
use crate::platform::paths::{MapEnv, Platform};
const STORE_PATH: &str =
"/Users/me/Library/Application Support/com.apple.wallpaper/Store/Index.plist";
const DEFAULTS_KEY: &str = "defaults read com.apple.desktop Background";
fn env() -> MapEnv {
MapEnv::new().with("HOME", "/Users/me")
}
fn xml_plist(body: &str) -> String {
format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<plist version=\"1.0\">\n{body}\n</plist>\n")
}
#[test]
fn store_index_hit_decodes_file_url() {
let io = FakeIo::new()
.with_file(
STORE_PATH,
&xml_plist("<string>file:///Users/me/Pictures/my%20wall.heic</string>"),
)
.with_image("/Users/me/Pictures/my wall.heic");
let probe = detect_with(&io, &env(), Platform::MacOs);
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
let best = probe.best().expect("有候选");
assert_eq!(best.kind, SourceKind::MacOsStore);
assert_eq!(
best.path,
std::path::PathBuf::from("/Users/me/Pictures/my wall.heic")
);
}
#[test]
fn multiple_candidates_from_both_channels_keep_order() {
let io = FakeIo::new()
.with_file(
STORE_PATH,
&xml_plist(
"<array>\n\
<string>file:///System/Library/Desktop Pictures/a.heic</string>\n\
<string>/Users/me/Library/Application Support/com.apple.wallpaper/Store/b.heic</string>\n\
</array>",
),
)
.with_image("/System/Library/Desktop Pictures/a.heic")
.with_image(
"/Users/me/Library/Application Support/com.apple.wallpaper/Store/b.heic",
)
.with_image("/Library/Desktop Pictures/legacy.jpg")
.with_command(
DEFAULTS_KEY,
"{\n \"Display Identifier\" = \"Main\";\n \
ImageFilePath = \"/Library/Desktop Pictures/legacy.jpg\";\n}\n",
);
let probe = detect_with(&io, &env(), Platform::MacOs);
let kinds: Vec<SourceKind> = probe.iter().map(|source| source.kind).collect();
assert_eq!(
kinds,
vec![
SourceKind::MacOsStore,
SourceKind::MacOsStore,
SourceKind::MacOsDesktopDefaults,
]
);
assert_eq!(
probe.best().expect("有候选").path,
std::path::PathBuf::from("/System/Library/Desktop Pictures/a.heic")
);
}
#[test]
fn everything_missing_returns_empty_with_reasons() {
let probe = detect_with(&FakeIo::new(), &env(), Platform::MacOs);
assert!(probe.is_empty(), "全部失败要返回空列表");
let notes = probe.notes().join("\n");
assert!(notes.contains("Index.plist"), "{notes}");
assert!(notes.contains("defaults"), "{notes}");
}
#[test]
fn binary_plist_strings_are_scanned_between_binary_bytes() {
// bplist00 的形态:控制字符/长度字节把可读 ASCII 串隔开。
let raw = "\u{0}\u{1}\u{2}/Users/me/Pictures/a.heic\u{0}\u{3}file:///Users/me/b.jpg\u{0}";
let found = scan_string_paths(raw);
assert_eq!(
found,
vec![
"/Users/me/Pictures/a.heic".to_owned(),
"file:///Users/me/b.jpg".to_owned(),
]
);
}
#[test]
fn xml_tags_are_not_part_of_the_candidate() {
let found = scan_string_paths(&xml_plist(
"<key>Content</key>\n<string>/Users/me/a.png</string>",
));
assert_eq!(found, vec!["/Users/me/a.png".to_owned()]);
}
#[test]
fn defaults_output_parsing() {
assert_eq!(
scan_defaults_background(
"{\n \"Display Identifier\" = \"Main\";\n \
ImageFilePath = \"/Library/Desktop Pictures/a.jpg\";\n \
NewPath = \"/Users/me/b.png\";\n}\n"
),
vec![
"/Library/Desktop Pictures/a.jpg".to_owned(),
"/Users/me/b.png".to_owned(),
]
);
assert!(scan_defaults_background("{\n BackgroundColor = \"0 0 0\";\n}\n").is_empty());
}
#[test]
fn tilde_paths_keep_their_prefix() {
let found = scan_string_paths("<string>~/Library/Desktop Pictures/a.heic</string>");
assert_eq!(found, vec!["~/Library/Desktop Pictures/a.heic".to_owned()]);
}
}
+320
View File
@@ -0,0 +1,320 @@
//! 路径归一化与图片头嗅探:把各平台读出的「原始串」变成可用的绝对路径候选。
//!
//! 三平台读出来的东西形状都不一样(gsettings 的 `'file:///x'`、KDE 的 `file:///x`、
//! Windows `.theme` 的 `%SystemRoot%\x`、plist 里裸的 `/x`),全部在这里收口,
//! 上层子模块只管「从哪读」。
use std::path::PathBuf;
use super::io::HostRoots;
/// 图片文件头识别(不用解码库,只看魔数与文本 SVG 头)。
///
/// 认得的:PNG / JPEG / GIF / BMP / WebP / HEIF-AVIF / TIFF,外加头部出现 `<svg` 的文本。
/// 认不出的一律不收——壁纸探测宁可少一个候选,也不要把随便一个文件喂给取色器。
#[must_use]
pub(super) fn looks_like_image(head: &[u8]) -> bool {
const PNG: &[u8] = b"\x89PNG\r\n\x1a\n";
if head.starts_with(PNG) {
return true;
}
if head.starts_with(&[0xFF, 0xD8, 0xFF]) {
return true; // JPEG
}
if head.starts_with(b"GIF87a") || head.starts_with(b"GIF89a") {
return true;
}
if head.len() >= 14 && head.starts_with(b"BM") {
return true; // BMP
}
if head.len() >= 12 && &head[0..4] == b"RIFF" && &head[8..12] == b"WEBP" {
return true;
}
if head.len() >= 12 && &head[4..8] == b"ftyp" {
// ISO-BMFF 家族:HEIC / HEIF 是 macOS 的默认壁纸容器(`.heic`),AVIF 是新格式。
let brand = &head[8..12];
return matches!(
brand,
b"avif" | b"avis" | b"heic" | b"heix" | b"mif1" | b"msf1"
);
}
if head.starts_with(b"II*\0") || head.starts_with(b"MM\0*") {
return true; // TIFF
}
contains_ignore_ascii_case(head, b"<svg")
}
/// 把一条原始串归一化成绝对路径;不像壁纸路径的返回 `None`(具体原因由调用方记)。
///
/// 处理顺序:去引号 → 认 `file://` URI(含百分号解码与 `/C:/` 盘符还原)→ 展开
/// `%USERPROFILE%` 之类的 Windows 变量 → 展开 `~/` → 要求绝对路径。
#[must_use]
pub(super) fn normalize_path(raw: &str, roots: &HostRoots) -> Option<PathBuf> {
let unquoted = unquote(raw).trim();
if unquoted.is_empty() {
return None;
}
// GNOME 的「无壁纸」与 macOS `defaults` 的「未设置」占位值。
if unquoted.eq_ignore_ascii_case("none") || unquoted.eq_ignore_ascii_case("default") {
return None;
}
let text = match strip_prefix_ignore_ascii_case(unquoted, "file://") {
Some(rest) => strip_uri_drive_prefix(&percent_decode(rest)),
None => expand_vars(unquoted, roots),
};
let text = text.trim();
if let Some(rest) = text.strip_prefix("~/") {
return Some(roots.home.join(rest));
}
if !looks_absolute(text) {
return None;
}
Some(PathBuf::from(text))
}
/// 去掉成对的单/双引号(`gsettings get` 的输出是 `'file:///x'`)。
fn unquote(raw: &str) -> &str {
let trimmed = raw.trim();
let bytes = trimmed.as_bytes();
if bytes.len() >= 2 {
let first = bytes[0];
if (first == b'\'' || first == b'"') && bytes[bytes.len() - 1] == first {
return &trimmed[1..trimmed.len() - 1];
}
}
trimmed
}
fn strip_prefix_ignore_ascii_case<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
let head = text.get(..prefix.len())?;
head.eq_ignore_ascii_case(prefix)
.then(|| &text[prefix.len()..])
}
/// `file://` URI 的路径部分:`/C:/Users/me/a.jpg` 在 Windows 上是 `C:/Users/me/a.jpg`。
///
/// 非 Windows 上这条分支不会命中(Unix 路径不会以 `/C:` 开头),统一处理不影响正确性。
fn strip_uri_drive_prefix(text: &str) -> String {
let bytes = text.as_bytes();
if bytes.len() >= 3 && bytes[0] == b'/' && bytes[1].is_ascii_alphabetic() && bytes[2] == b':' {
text[1..].to_owned()
} else {
text.to_owned()
}
}
/// 百分号解码(GNOME 会把空格编码成 `%20`;非法转义原样保留)。
fn percent_decode(text: &str) -> String {
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%'
&& index + 2 < bytes.len()
&& bytes[index + 1].is_ascii_hexdigit()
&& bytes[index + 2].is_ascii_hexdigit()
{
let hex = &text[index + 1..index + 3];
if let Ok(byte) = u8::from_str_radix(hex, 16) {
out.push(byte);
index += 3;
continue;
}
}
out.push(bytes[index]);
index += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// 展开 Windows 路径里的 `%VAR%`(`.theme` 的 `Wallpaper=%SystemRoot%\web\...`)。
///
/// 认不出的变量原样保留,让后面的图片校验把它判掉——这里不猜路径。
fn expand_vars(text: &str, roots: &HostRoots) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(start) = rest.find('%') {
let Some(offset) = rest[start + 1..].find('%') else {
break;
};
let name = &rest[start + 1..start + 1 + offset];
let value = match name.to_ascii_lowercase().as_str() {
"userprofile" | "home" => Some(roots.home.to_string_lossy()),
"appdata" => Some(roots.app_data.to_string_lossy()),
// Win10+ 的「当前主题」`Custom.theme` 在 `%LOCALAPPDATA%` 一侧。
"localappdata" => Some(roots.local_app_data.to_string_lossy()),
"systemroot" | "windir" => roots
.system_root
.as_deref()
.map(std::path::Path::to_string_lossy),
_ => None,
};
match value {
Some(value) => {
out.push_str(&rest[..start]);
out.push_str(&value);
rest = &rest[start + offset + 2..];
}
None => {
out.push_str(&rest[..=start]);
rest = &rest[start + 1..];
}
}
}
out.push_str(rest);
out
}
/// 字符串层面判绝对路径:`/x`、`\\server\share`、`C:/x`、`C:\x`。
///
/// 不用 `Path::is_absolute` 是因为它按**编译目标**判,Windows 夹具在 Linux CI 上会被判成
/// 相对路径,夹具就不跨宿主了。
fn looks_absolute(text: &str) -> bool {
let bytes = text.as_bytes();
if text.starts_with('/') || text.starts_with('\\') {
return true;
}
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'/' | b'\\')
}
fn contains_ignore_ascii_case(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || haystack.len() < needle.len() {
return false;
}
haystack
.windows(needle.len())
.any(|window| window.eq_ignore_ascii_case(needle))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::platform::paths::MapEnv;
fn roots() -> HostRoots {
let env = MapEnv::new()
.with("HOME", "/home/u")
.with("APPDATA", "C:/Users/u/AppData/Roaming")
.with("SYSTEMROOT", "C:/Windows");
HostRoots::from_env(&env).expect("HOME 已给")
}
#[test]
fn image_sniff_accepts_common_wallpaper_containers() {
assert!(looks_like_image(b"\x89PNG\r\n\x1a\n\x00\x00"));
assert!(looks_like_image(b"\xFF\xD8\xFF\xE0\x00\x10JFIF"));
assert!(looks_like_image(b"GIF89a---"));
assert!(looks_like_image(
b"BM\x36\x00\x00\x00\x00\x00\x00\x00\x36\x00\x00\x00"
));
assert!(looks_like_image(b"RIFF\x24\x00\x00\x00WEBPVP8 "));
assert!(looks_like_image(
b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00heicmif1"
));
assert!(looks_like_image(b"II*\x00\x08\x00\x00\x00"));
assert!(looks_like_image(
b"<?xml version=\"1.0\"?><svg xmlns=\"...\""
));
}
#[test]
fn image_sniff_rejects_non_images_and_short_heads() {
assert!(!looks_like_image(b""));
assert!(!looks_like_image(b"BM"));
assert!(!looks_like_image(
b"[Control Panel\\Desktop]\r\nWallpaper=x\r\n"
));
assert!(!looks_like_image(b"RIFF\x24\x00\x00\x00AVI LIST"));
assert!(!looks_like_image(b"\x00\x00\x00\x18ftypmp42"));
}
#[test]
fn normalize_handles_file_uris_quotes_and_percent_encoding() {
let roots = roots();
assert_eq!(
normalize_path("'file:///home/u/Pictures/my%20wall.png'", &roots),
Some(PathBuf::from("/home/u/Pictures/my wall.png"))
);
// Windows 的 URI 会多一个前导斜杠,盘符要还原。
assert_eq!(
normalize_path("file:///C:/Users/u/a.jpg", &roots),
Some(PathBuf::from("C:/Users/u/a.jpg"))
);
assert_eq!(
normalize_path("\"C:\\Users\\u\\a.jpg\"", &roots),
Some(PathBuf::from("C:\\Users\\u\\a.jpg"))
);
// 非法百分号转义原样保留,不 panic(多字节字符也要安全)。
assert_eq!(
normalize_path("file:///home/u/100%zz%日.png", &roots),
Some(PathBuf::from("/home/u/100%zz%日.png"))
);
}
#[test]
fn normalize_expands_windows_vars_and_home() {
let roots = roots();
assert_eq!(
normalize_path(r"%SystemRoot%\web\wallpaper\a.jpg", &roots),
Some(PathBuf::from("C:/Windows\\web\\wallpaper\\a.jpg"))
);
assert_eq!(
normalize_path(
"%APPDATA%\\Microsoft\\Windows\\Themes\\TranscodedWallpaper",
&roots
),
Some(PathBuf::from(
"C:/Users/u/AppData/Roaming\\Microsoft\\Windows\\Themes\\TranscodedWallpaper"
))
);
// Win10+ 的当前主题在 %LOCALAPPDATA%,展开失败就会白丢最可靠的候选。
assert_eq!(
normalize_path(
"%LOCALAPPDATA%\\Microsoft\\Windows\\Themes\\Custom.theme",
&roots
),
Some(PathBuf::from(
"/home/u/AppData/Local\\Microsoft\\Windows\\Themes\\Custom.theme"
))
);
assert_eq!(
normalize_path("~/Library/Desktop Pictures/a.heic", &roots),
Some(PathBuf::from("/home/u/Library/Desktop Pictures/a.heic"))
);
// 认不出的变量不猜:整串保留,随后被「必须绝对路径」这条校验判掉。
assert_eq!(normalize_path("%NOPE%\\a.jpg", &roots), None);
}
#[test]
fn normalize_rejects_placeholders_relative_and_empty() {
let roots = roots();
for raw in [
"",
" ",
"none",
"'none'",
"default",
"relative/a.png",
"./a.png",
] {
assert_eq!(
normalize_path(raw, &roots),
None,
"{raw:?} 不该被当成壁纸路径"
);
}
}
#[test]
fn absolute_detection_is_host_independent() {
assert!(looks_absolute("/home/u/a.png"));
assert!(looks_absolute(r"\\server\share\a.png"));
assert!(looks_absolute("C:/Users/u/a.png"));
assert!(looks_absolute(r"C:\Users\u\a.png"));
assert!(!looks_absolute("a.png"));
assert!(!looks_absolute("C:"));
}
}
@@ -0,0 +1,371 @@
//! Windows 壁纸路径探测:四步,可信度从高到低。
//!
//! Win10+ 的真机事实(Windows 11 24H2 实测):`%APPDATA%\Microsoft\Windows\Themes\` 里
//! **通常只有** `TranscodedWallpaper`,一个 `.theme` 都没有;当前生效的主题记在
//! `HKCU\Software\Microsoft\Windows\CurrentVersion\Themes` 的 `CurrentTheme` 值里,指向
//! `%LOCALAPPDATA%\Microsoft\Windows\Themes\Custom.theme`。所以「扫 `%APPDATA%` 下的
//! `*.theme`」只能当最后一档兜底:那里的主题包是用户另存的,**可能从未启用**,拿它取色会
//! 取到与当前桌面无关的图(宁可少给一个候选,也不给错路径)。
//!
//! 1. `%APPDATA%\Microsoft\Windows\Themes\TranscodedWallpaper`——系统**当前**壁纸转码后的
//! 副本,没有扩展名(部分版本还有 `TranscodedWallpaper.jpg` 别名),所以「像不像图片」
//! 只看文件头魔数,不看后缀;
//! 2. `CurrentTheme` 指向的 `.theme`——当前生效主题里的 `Wallpaper=` 行(Win10+ 在
//! `%LOCALAPPDATA%` 一侧,支持 `%LOCALAPPDATA%` / `%SystemRoot%` 之类变量展开);
//! 3. `HKCU\Control Panel\Desktop` 的 `WallPaper` 值——注册表权威来源;
//! 4. `%APPDATA%\...\Themes\*.theme`——用户另存的主题包,`evidence` 里如实标「可能未启用」。
//!
//! 第 2、3 步走 `reg.exe` 子进程,不用 win32 API(core 是 `#![forbid(unsafe_code)]`,
//! 见 [`super`] 模块文档);命令缺失 / 非零退出 / 空输出都只记一条原因,探测继续。
//!
//! 本文件不带 `cfg`:Linux CI 上同样要能跑夹具用例,平台分流只在
//! [`super::detect_with`] 一处。
use std::path::{Path, PathBuf};
use self::parse::{parse_reg_sz_value, parse_theme_wallpaper};
use super::io::HostRoots;
use super::parse::normalize_path;
use super::{Collect, SourceKind};
mod parse;
/// `reg query` 用的桌面键(`HKCU\Control Panel\Desktop`,壁纸值名 `WallPaper`)。
pub(super) const DESKTOP_KEY: &str = r"HKCU\Control Panel\Desktop";
/// `reg query` 用的主题键(Win10+ 当前主题记录在这里)。
pub(super) const THEMES_KEY: &str = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes";
/// 壁纸值名(注册表里是 `WallPaper`,`.theme` 里是 `Wallpaper=`,比对时都忽略大小写)。
pub(super) const WALLPAPER_VALUE: &str = "WallPaper";
/// 当前主题文件名(值是指向 `.theme` 的绝对路径)。
pub(super) const CURRENT_THEME_VALUE: &str = "CurrentTheme";
/// `%APPDATA%` 下的主题目录(分段拼,避免 Windows 上出现混用的分隔符)。
const THEMES_PARTS: &[&str] = &["Microsoft", "Windows", "Themes"];
/// 转码副本的两个已知文件名(无扩展名的是主力)。
const TRANSCODED_NAMES: &[&str] = &["TranscodedWallpaper", "TranscodedWallpaper.jpg"];
/// 主题文件后缀。
const THEME_EXTENSION: &str = "theme";
/// 四步依次探测。
pub(super) fn collect(collect: &mut Collect<'_>) {
transcoded(collect);
current_theme(collect);
registry(collect);
saved_theme_packages(collect);
}
fn themes_dir(roots: &HostRoots) -> PathBuf {
let mut dir = roots.app_data.clone();
for part in THEMES_PARTS {
dir.push(part);
}
dir
}
/// 第一步:系统转码后的当前壁纸副本(第一个名字命中后就不再试别名)。
fn transcoded(collect: &mut Collect<'_>) {
let dir = themes_dir(collect.roots());
for name in TRANSCODED_NAMES {
let path = dir.join(name);
let evidence = format!("%APPDATA%\\Microsoft\\Windows\\Themes\\{name}");
if collect.add_path(SourceKind::WindowsTranscoded, &evidence, path) {
return;
}
}
}
/// 第二步:当前生效主题(`CurrentTheme` 指向的 `.theme`)里的 `Wallpaper=`。
fn current_theme(collect: &mut Collect<'_>) {
let Some(raw) = query_reg(
collect,
SourceKind::WindowsCurrentTheme,
THEMES_KEY,
CURRENT_THEME_VALUE,
) else {
return;
};
let Some(theme) = normalize_path(&raw, collect.roots()) else {
collect.note(
SourceKind::WindowsCurrentTheme.as_str(),
format!("{CURRENT_THEME_VALUE} 不是可用的绝对路径({raw})"),
);
return;
};
let label = format!(
"reg query {THEMES_KEY} /v {CURRENT_THEME_VALUE} → {}",
theme.display()
);
collect_theme_file(collect, SourceKind::WindowsCurrentTheme, &theme, &label);
}
/// 第三步:注册表里的权威壁纸值。
fn registry(collect: &mut Collect<'_>) {
let Some(value) = query_reg(
collect,
SourceKind::WindowsRegistry,
DESKTOP_KEY,
WALLPAPER_VALUE,
) else {
return;
};
let label = format!("reg query {DESKTOP_KEY} /v {WALLPAPER_VALUE}");
collect.add(SourceKind::WindowsRegistry, &label, &value);
}
/// 第四步(兜底):`%APPDATA%\...\Themes\*.theme`——用户另存的主题包,**可能未启用**。
fn saved_theme_packages(collect: &mut Collect<'_>) {
let dir = themes_dir(collect.roots());
let mut themes: Vec<PathBuf> = collect
.io()
.list_dir(&dir)
.into_iter()
.filter(|path| {
path.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case(THEME_EXTENSION))
})
.collect();
themes.sort();
if themes.is_empty() {
collect.note(
SourceKind::WindowsThemeFile.as_str(),
format!("{} 下没有 .theme 文件", dir.display()),
);
return;
}
for theme in themes {
let label = format!("{}(用户另存的主题包,可能未启用)", theme.display());
collect_theme_file(collect, SourceKind::WindowsThemeFile, &theme, &label);
}
}
/// 读一个 `.theme` 文件,把里面的 `Wallpaper=` 收成候选;读不到 / 没值只记原因。
fn collect_theme_file(collect: &mut Collect<'_>, kind: SourceKind, file: &Path, label: &str) {
let Some(text) = collect.io().read_text(file) else {
collect.note(kind.as_str(), format!("读不到 {}", file.display()));
return;
};
let values = parse_theme_wallpaper(&text);
if values.is_empty() {
collect.note(
kind.as_str(),
format!("{} 里没有 Wallpaper= 行", file.display()),
);
return;
}
let evidence = format!("{label} 的 Wallpaper=");
for value in values {
collect.add(kind, &evidence, &value);
}
}
/// 跑一次 `reg query <键> /v <值名>` 并取出值;失败或缺值都只记原因、返回 `None`。
fn query_reg(
collect: &mut Collect<'_>,
kind: SourceKind,
key: &str,
value_name: &str,
) -> Option<String> {
let query = format!("reg query {key} /v {value_name}");
match collect
.io()
.run("reg.exe", &["query", key, "/v", value_name])
{
Some(text) => match parse_reg_sz_value(&text, value_name) {
Some(value) => Some(value),
None => {
collect.note(
kind.as_str(),
format!("{query} 输出里没有 {value_name} 值(可能被策略禁用或壁纸为纯色)"),
);
None
}
},
None => {
collect.note(
kind.as_str(),
format!("{query} 失败(命令缺失 / 非零退出 / 空输出)"),
);
None
}
}
}
#[cfg(test)]
mod tests {
use super::super::io::fake::FakeIo;
use super::super::{detect_with, SourceKind};
use super::*;
use crate::platform::paths::{MapEnv, Platform};
const APP_DATA: &str = "C:/Users/u/AppData/Roaming";
const LOCAL_APP_DATA: &str = "C:/Users/u/AppData/Local";
const DESKTOP_QUERY: &str = "reg.exe query HKCU\\Control Panel\\Desktop /v WallPaper";
const THEMES_QUERY: &str =
"reg.exe query HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes /v CurrentTheme";
const CUSTOM_THEME: &str = r"C:\Users\u\AppData\Local\Microsoft\Windows\Themes\Custom.theme";
fn env() -> MapEnv {
MapEnv::new()
.with("HOME", "C:/Users/u")
.with("APPDATA", APP_DATA)
.with("LOCALAPPDATA", LOCAL_APP_DATA)
.with("SYSTEMROOT", "C:/Windows")
}
fn roots() -> HostRoots {
HostRoots::from_env(&env()).expect("HOME 已给")
}
/// 夹具用**生产代码同一个拼法**造路径,所以 Windows / Linux 宿主上都能对上
/// (另有一条字面量断言盯着这个拼法本身,见 `transcoded_wallpaper_hit_comes_first`)。
fn themes() -> PathBuf {
themes_dir(&roots())
}
#[test]
fn transcoded_wallpaper_hit_comes_first() {
let io = FakeIo::new().with_image(themes().join("TranscodedWallpaper"));
let probe = detect_with(&io, &env(), Platform::Windows);
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
let best = probe.best().expect("有候选");
assert_eq!(best.kind, SourceKind::WindowsTranscoded);
assert_eq!(best.path, themes().join("TranscodedWallpaper"));
// 字面量:夹具路径是拿生产代码的构造函数造出来的,这条断言防止两边一起拼错。
assert_eq!(
best.path,
PathBuf::from(
"C:/Users/u/AppData/Roaming/Microsoft/Windows/Themes/TranscodedWallpaper"
)
);
assert!(best.evidence.contains("TranscodedWallpaper"));
}
#[test]
fn current_theme_is_read_from_local_app_data() {
// Win10+ 的真机形态:%APPDATA% 下没有 .theme,当前主题在 %LOCALAPPDATA%。
let io = FakeIo::new()
.with_command(
THEMES_QUERY,
&format!(" CurrentTheme REG_SZ {CUSTOM_THEME}\r\n"),
)
.with_file(
CUSTOM_THEME,
"[Control Panel\\Desktop]\r\nWallpaper=%SystemRoot%\\web\\wallpaper\\Windows\\img0.jpg\r\n",
)
.with_image("C:/Windows\\web\\wallpaper\\Windows\\img0.jpg");
let probe = detect_with(&io, &env(), Platform::Windows);
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
let best = probe.best().expect("有候选");
assert_eq!(best.kind, SourceKind::WindowsCurrentTheme);
assert_eq!(
best.path,
PathBuf::from("C:/Windows\\web\\wallpaper\\Windows\\img0.jpg")
);
assert!(best.evidence.contains("CurrentTheme"), "{}", best.evidence);
assert!(best.evidence.contains("Windows\\Themes\\Custom.theme"));
}
#[test]
fn current_theme_expands_local_app_data_env_var() {
let io = FakeIo::new()
.with_command(
THEMES_QUERY,
" CurrentTheme REG_SZ %LOCALAPPDATA%\\Microsoft\\Windows\\Themes\\Custom.theme\r\n",
)
.with_file(
CUSTOM_THEME,
"Wallpaper=C:\\Users\\u\\Pictures\\a.jpg\r\n",
)
.with_image(r"C:\Users\u\Pictures\a.jpg");
let probe = detect_with(&io, &env(), Platform::Windows);
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
assert_eq!(
probe.best().expect("有候选").kind,
SourceKind::WindowsCurrentTheme
);
}
#[test]
fn authority_order_puts_saved_packages_last() {
// 四步全在场,路径互不相同:顺序必须是 转码副本 > 当前主题 > 注册表 > 另存包。
let package = themes().join("a.theme");
let io = FakeIo::new()
.with_image(themes().join("TranscodedWallpaper"))
.with_command(
THEMES_QUERY,
&format!(" CurrentTheme REG_SZ {CUSTOM_THEME}\r\n"),
)
.with_file(
CUSTOM_THEME,
"Wallpaper=C:\\Users\\u\\Pictures\\one.jpg\r\n",
)
.with_command(
DESKTOP_QUERY,
" WallPaper REG_SZ C:\\Users\\u\\Pictures\\two.jpg\r\n",
)
.with_dir(themes(), &["a.theme", "b.theme", "notes.txt"])
.with_file(
package.clone(),
"Wallpaper=%SystemRoot%\\web\\wallpaper\\three.jpg\r\n",
)
.with_image(r"C:\Users\u\Pictures\one.jpg")
.with_image(r"C:\Users\u\Pictures\two.jpg")
.with_image("C:/Windows\\web\\wallpaper\\three.jpg");
let probe = detect_with(&io, &env(), Platform::Windows);
let seen: Vec<(SourceKind, String)> = probe
.iter()
.map(|source| (source.kind, source.path.display().to_string()))
.collect();
assert_eq!(seen.len(), 4, "{:?}", probe.notes());
assert_eq!(seen[0].0, SourceKind::WindowsTranscoded);
assert_eq!(seen[1].0, SourceKind::WindowsCurrentTheme);
assert_eq!(seen[2].0, SourceKind::WindowsRegistry);
assert_eq!(seen[3].0, SourceKind::WindowsThemeFile);
// 兜底档在 evidence 里如实标明「可能未启用」,调用方据此可降权。
assert!(
probe.sources()[3].evidence.contains("可能未启用"),
"{}",
probe.sources()[3].evidence
);
}
#[test]
fn everything_missing_returns_empty_with_reasons() {
let probe = detect_with(&FakeIo::new(), &env(), Platform::Windows);
assert!(probe.is_empty(), "全部失败要返回空列表而不是错误");
let notes = probe.notes().join("\n");
assert!(notes.contains("reg query"), "注册表失败要留原因:{notes}");
assert!(
notes.contains("CurrentTheme"),
"当前主题失败要留原因:{notes}"
);
assert!(
notes.contains("TranscodedWallpaper"),
"转码副本缺失要留原因:{notes}"
);
}
#[test]
fn same_path_from_two_steps_is_deduped() {
let transcoded = themes().join("TranscodedWallpaper");
let registry_value = transcoded.display().to_string().to_ascii_uppercase();
let io = FakeIo::new()
.with_image(&transcoded)
// 注册表指向同一个文件,大小写还不同(Windows 路径大小写不敏感)。
.with_command(
DESKTOP_QUERY,
&format!(" WallPaper REG_SZ {registry_value}\r\n"),
);
let probe = detect_with(&io, &env(), Platform::Windows);
assert_eq!(probe.len(), 1, "{:?}", probe.sources());
assert_eq!(
probe.best().expect("有候选").kind,
SourceKind::WindowsTranscoded
);
}
}
@@ -0,0 +1,100 @@
//! Windows 侧的纯解析函数:`.theme` 的 `Wallpaper=` 行与 `reg query` 输出的取值。
//!
//! 单独一个文件只为守住「单文件 ≤400 行」(`guards.yml` 的 file-size 闸):这里没有任何
//! IO,全部是纯函数,跨平台编译,夹具用例覆盖在文件尾部。
/// 取 `.theme` 文本里所有 `Wallpaper=` 行的值(键名大小写不敏感,值去首尾空白)。
///
/// `.theme` 是 INI:`[Control Panel\Desktop]` 段下的 `Wallpaper=...`。`Wallpaper` 这个键名
/// 本身不随系统语言变(Windows 只本地化少数键),所以直接按 ASCII 比对。
pub(super) fn parse_theme_wallpaper(text: &str) -> Vec<String> {
text.lines()
.filter_map(|line| {
let (key, value) = line.split_once('=')?;
if !key.trim().eq_ignore_ascii_case("wallpaper") {
return None;
}
let value = value.trim();
(!value.is_empty()).then(|| value.to_owned())
})
.collect()
}
/// 取 `reg query <键> /v <值名>` 输出里某个值的原文。
///
/// 形如 ` WallPaper REG_SZ C:\Users\me\Pictures\a.jpg`;值里的空格要保留,所以
/// 「值名 + 类型」之后的部分整段取原文(不能 `split_whitespace` 再拼,会吃掉连续空格)。
/// 值名为 `WallPaper` 时是壁纸路径,`CurrentTheme` 时是 `.theme` 文件路径。
pub(super) fn parse_reg_sz_value(text: &str, value_name: &str) -> Option<String> {
text.lines().find_map(|line| {
let trimmed = line.trim_start();
let name_end = trimmed.find(char::is_whitespace)?;
if !trimmed[..name_end].eq_ignore_ascii_case(value_name) {
return None;
}
let after_name = trimmed[name_end..].trim_start();
let kind_end = after_name.find(char::is_whitespace)?;
let value = after_name[kind_end..].trim();
(!value.is_empty()).then(|| value.to_owned())
})
}
#[cfg(test)]
mod tests {
use super::super::{CURRENT_THEME_VALUE, WALLPAPER_VALUE};
use super::*;
const CUSTOM_THEME: &str = r"C:\Users\u\AppData\Local\Microsoft\Windows\Themes\Custom.theme";
#[test]
fn theme_text_yields_only_wallpaper_values() {
let text = "[Control Panel\\Desktop]\r\n\
Wallpaper=C:\\Users\\u\\a.jpg\r\n\
wallpaper = %SystemRoot%\\web\\wallpaper\\b.jpg\r\n\
[Slideshow]\r\n\
ImagesRootPath=C:\\Users\\u\\Pictures\r\n\
Wallpaper=\r\n";
assert_eq!(
parse_theme_wallpaper(text),
vec![
r"C:\Users\u\a.jpg".to_owned(),
r"%SystemRoot%\web\wallpaper\b.jpg".to_owned(),
]
);
assert!(parse_theme_wallpaper("").is_empty());
}
#[test]
fn reg_output_parses_value_and_keeps_spaces() {
assert_eq!(
parse_reg_sz_value(
"HKEY_CURRENT_USER\\Control Panel\\Desktop\r\n \
WallPaper REG_SZ C:\\Users\\Jak e\\Desktop Pictures\\a.jpg\r\n",
WALLPAPER_VALUE
),
Some(r"C:\Users\Jak e\Desktop Pictures\a.jpg".to_owned())
);
// 纯色壁纸 = 空值。
assert_eq!(
parse_reg_sz_value(" WallPaper REG_SZ \r\n", WALLPAPER_VALUE),
None
);
assert_eq!(parse_reg_sz_value("", WALLPAPER_VALUE), None);
assert_eq!(
parse_reg_sz_value(" TileWallpaper REG_SZ 0\r\n", WALLPAPER_VALUE),
None
);
// 当前主题:值名换成 CurrentTheme,取到的是 `.theme` 的绝对路径。
assert_eq!(
parse_reg_sz_value(
&format!(" CurrentTheme REG_SZ {CUSTOM_THEME}\r\n"),
CURRENT_THEME_VALUE
),
Some(CUSTOM_THEME.to_owned())
);
assert_eq!(
parse_reg_sz_value(" CurrentTheme REG_SZ \r\n", CURRENT_THEME_VALUE),
None
);
}
}
+120
View File
@@ -0,0 +1,120 @@
//! CAM16 正向换算:sRGB 色值 → CAM16 的 **hue**(色相角)与 **chroma**(彩度)。
//!
//! HCT = **H**ue / **C**hroma from CAM16 + **T**one from `L*`。这里只实现 M3 用到的方向
//! (色值 → hue/chroma);反向(hue/chroma/tone → 色值)在 [`super::hct_solver`] 里按官方
//! 求解器内联实现——两边如果各写一份,数值会对不上。
//!
//! 照搬 `java/hct/Cam16.java`(Apache-2.0,见 `NOTICE`),逐行对应,包括 `Math.signum` 的
//! 「零返回 0」这种 Java 语义差别(见 [`space::signum`])。
use super::space::{self, signum};
use super::viewing_conditions::{default_conditions, ViewingConditions};
use super::Color;
/// XYZ → CAM16 的「锥体响应」矩阵。
pub(crate) const XYZ_TO_CAM16_RGB: [[f64; 3]; 3] = [
[0.401_288, 0.650_173, -0.051_461],
[-0.250_268, 1.204_414, 0.045_854],
[-0.002_079, 0.048_952, 0.953_127],
];
/// CAM16 里 M3 真正用到的两个坐标(`J` 明度不参与 HCT,HCT 的 tone 走 `L*`)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Cam16 {
/// 色相角(度,`0..360`)。
pub(crate) hue: f64,
/// 彩度(无上限;sRGB 立方体内最大值约 130)。
pub(crate) chroma: f64,
}
impl Cam16 {
/// 从色值取 CAM16 色相/彩度(默认 sRGB 观察条件)。
pub(crate) fn from_color(color: Color) -> Self {
Self::from_xyz(space::xyz_from_color(color), default_conditions())
}
/// 从 XYZ 取 CAM16 色相/彩度。
pub(crate) fn from_xyz(xyz: [f64; 3], vc: &ViewingConditions) -> Self {
let [r_t, g_t, b_t] = space::matrix_multiply(xyz, XYZ_TO_CAM16_RGB);
// 折扣光源(色适应)
let r_d = vc.rgb_d[0] * r_t;
let g_d = vc.rgb_d[1] * g_t;
let b_d = vc.rgb_d[2] * b_t;
// 非线性压缩(视觉响应)
let r_af = (vc.fl * r_d.abs() / 100.0).powf(0.42);
let g_af = (vc.fl * g_d.abs() / 100.0).powf(0.42);
let b_af = (vc.fl * b_d.abs() / 100.0).powf(0.42);
let r_a = signum(r_d) * 400.0 * r_af / (r_af + 27.13);
let g_a = signum(g_d) * 400.0 * g_af / (g_af + 27.13);
let b_a = signum(b_d) * 400.0 * b_af / (b_af + 27.13);
// 红绿 / 黄蓝对立通道
let a = (11.0 * r_a - 12.0 * g_a + b_a) / 11.0;
let b = (r_a + g_a - 2.0 * b_a) / 9.0;
// 辅助量
let u = (20.0 * r_a + 20.0 * g_a + 21.0 * b_a) / 20.0;
let p2 = (40.0 * r_a + 20.0 * g_a + b_a) / 20.0;
let hue = space::sanitize_degrees(b.atan2(a).to_degrees());
let ac = p2 * vc.nbb;
let j = 100.0 * (ac / vc.aw).powf(vc.c * vc.z);
// 彩度:eHue 的 20.14° 断点是官方原样(把橙红段挪到 0 附近做插值)
let hue_prime = if hue < 20.14 { hue + 360.0 } else { hue };
let e_hue = 0.25 * ((hue_prime.to_radians() + 2.0).cos() + 3.8);
let p1 = 50_000.0 / 13.0 * e_hue * vc.nc * vc.ncb;
let t = p1 * a.hypot(b) / (u + 0.305);
let alpha = (1.64 - 0.29_f64.powf(vc.n)).powf(0.73) * t.powf(0.9);
let chroma = alpha * (j / 100.0).sqrt();
Self { hue, chroma }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn primaries_have_the_official_hue_angles() {
// 官方 material-color-utilities 对纯红/绿/蓝解出的 CAM16 色相(Java 实测)。
let cases = [
(Color::rgb(255, 0, 0), 27.408_225, 113.357_887),
(Color::rgb(0, 255, 0), 142.139_894, 108.410_061),
(Color::rgb(0, 0, 255), 282.788_18, 87.230_694),
];
for (color, hue, chroma) in cases {
let cam = Cam16::from_color(color);
assert!(
(cam.hue - hue).abs() < 1.0e-4,
"{} 的 hue 是 {},官方 {hue}",
color.to_hex(),
cam.hue
);
assert!(
(cam.chroma - chroma).abs() < 1.0e-4,
"{} 的 chroma 是 {},官方 {chroma}",
color.to_hex(),
cam.chroma
);
}
}
#[test]
fn gray_has_near_zero_chroma() {
for value in [16_u8, 64, 128, 200] {
let cam = Cam16::from_color(Color::rgb(value, value, value));
assert!(cam.chroma < 2.5, "灰 {value} 的彩度 {}", cam.chroma);
}
}
#[test]
fn black_is_finite() {
let cam = Cam16::from_color(Color::BLACK);
assert!(cam.hue.is_finite() && cam.chroma.is_finite());
assert_eq!(cam.chroma, 0.0);
}
}
@@ -0,0 +1,77 @@
//! 官方 `HctSolver.CRITICAL_PLANES` 表(255 项,**原样搬运**)。
//!
//! 这不是可以推导的常数集:它是 HCT 求解器在 sRGB 立方体上按 `Y` 找边界、再做色相二分时的
//! 「关键平面」索引表,由 Google 的脚本一次性生成后冻结。逐值复制是唯一忠实的做法——
//! 表错了,极端 tone(深色/浅色端)的颜色会偏一两个通道。
//!
//! 来源:`material-color-utilities/java/hct/HctSolver.java`(Apache-2.0),见仓库根 `NOTICE`。
/// sRGB 立方体上按 `Y` 递增排序的关键平面(线性 RGB 0..=100 标度)。
///
/// 手写密度由 `#[rustfmt::skip]` 保住:这张表是数据,不是代码,一列一个数会撑到 266 行。
#[rustfmt::skip]
pub(crate) const CRITICAL_PLANES: [f64; 255] = [
0.015176349177441876, 0.045529047532325624, 0.07588174588720938, 0.10623444424209313,
0.13658714259697685, 0.16693984095186062, 0.19729253930674434, 0.2276452376616281,
0.2579979360165119, 0.28835063437139563, 0.3188300904430532, 0.350925934958123,
0.3848314933096426, 0.42057480301049466, 0.458183274052838, 0.4976837250274023,
0.5391024159806381, 0.5824650784040898, 0.6277969426914107, 0.6751227633498623,
0.7244668422128921, 0.775853049866786, 0.829304845476233, 0.8848452951698498,
0.942497089126609, 1.0022825574869039, 1.0642236851973577, 1.1283421258858297,
1.1946592148522128, 1.2631959812511864, 1.3339731595349034, 1.407011200216447,
1.4823302800086415, 1.5599503113873272, 1.6398909516233677, 1.7221716113234105,
1.8068114625156377, 1.8938294463134073, 1.9832442801866852, 2.075074464868551,
2.1693382909216234, 2.2660538449872063, 2.36523901573795, 2.4669114995532007,
2.5710888059345764, 2.6777882626779785, 2.7870270208169257, 2.898822059350997,
3.0131901897720907, 3.1301480604002863, 3.2497121605402226, 3.3718988244681087,
3.4967242352587946, 3.624204428461639, 3.754355295633311, 3.887192587735158,
4.022731918402185, 4.160988767090289, 4.301978482107941, 4.445716283538092,
4.592217266055746, 4.741496401646282, 4.893568542229298, 5.048448422192488,
5.20615066083972, 5.3666897647573375, 5.5300801301023865, 5.696336044816294,
5.865471690767354, 6.037501145825082, 6.212438385869475, 6.390297286737924,
6.571091626112461, 6.7548350853498045, 6.941541251256611, 7.131223617812143,
7.323895587840543, 7.5195704746346665, 7.7182615035334345, 7.919981813454504,
8.124744458384042, 8.332562408825165, 8.543448553206703, 8.757415699253682,
8.974476575321063, 9.194643831691977, 9.417930041841839, 9.644347703669503,
9.873909240696694, 10.106627003236781, 10.342513269534024, 10.58158024687427,
10.8238400726681, 11.069304815507364, 11.317986476196008, 11.569896988756009,
11.825048221409341, 12.083451977536606, 12.345119996613247, 12.610063955123938,
12.878295467455942, 13.149826086772048, 13.42466730586372, 13.702830557985108,
13.984327217668513, 14.269168601521828, 14.55736596900856, 14.848930523210871,
15.143873411576273, 15.44220572664832, 15.743938506781891, 16.04908273684337,
16.35764934889634, 16.66964922287304, 16.985093187232053, 17.30399201960269,
17.62635644741625, 17.95219714852476, 18.281524751807332, 18.614349837764564,
18.95068293910138, 19.290534541298456, 19.633915083172692, 19.98083495742689,
20.331304511189067, 20.685334046541502, 21.042933821039977, 21.404114048223256,
21.76888489811322, 22.137256497705877, 22.50923893145328, 22.884842241736916,
23.264076429332462, 23.6469514538663, 24.033477234264016, 24.42366364919083,
24.817520537484558, 25.21505769858089, 25.61628489293138, 26.021211842414342,
26.429848230738664, 26.842203703840827, 27.258287870275353, 27.678110301598522,
28.10168053274597, 28.529008062403893, 28.96010235337422, 29.39497283293396,
29.83362889318845, 30.276079891419332, 30.722335150426627, 31.172403958865512,
31.62629557157785, 32.08401920991837, 32.54558406207592, 33.010999283389665,
33.4802739966603, 33.953417292456834, 34.430438229418264, 34.911345834551085,
35.39614910352207, 35.88485700094671, 36.37747846067349, 36.87402238606382,
37.37449765026789, 37.87891309649659, 38.38727753828926, 38.89959975977785,
39.41588851594697, 39.93615253289054, 40.460400508064545, 40.98864111053629,
41.520882981230194, 42.05713473317016, 42.597404951718396, 43.141702194811224,
43.6900349931913, 44.24241185063697, 44.798841244188324, 45.35933162437017,
45.92389141541209, 46.49252901546552, 47.065252796817916, 47.64207110610409,
48.22299226451468, 48.808024568002054, 49.3971762874833, 49.9904556690408,
50.587870934119984, 51.189430279724725, 51.79514187861014, 52.40501387947288,
53.0190544071392, 53.637271562750364, 54.259673423945976, 54.88626804504493,
55.517063457223934, 56.15206766869424, 56.79128866487574, 57.43473440856916,
58.08241284012621, 58.734331877617365, 59.39049941699807, 60.05092333227251,
60.715611475655585, 61.38457167773311, 62.057811747619894, 62.7353394731159,
63.417162620860914, 64.10328893648692, 64.79372614476921, 65.48848194977529,
66.18756403501224, 66.89098006357258, 67.59873767827808, 68.31084450182222,
69.02730813691093, 69.74813616640164, 70.47333615344107, 71.20291564160104,
71.93688215501312, 72.67524319850172, 73.41800625771542, 74.16517879925733,
74.9167682708136, 75.67278210128072, 76.43322770089146, 77.1981124613393, 77.96744375590167,
78.74122893956174, 79.51947534912904, 80.30219030335869, 81.08938110306934,
81.88105503125999, 82.67721935322541, 83.4778813166706, 84.28304815182372,
85.09272707154808, 85.90692527145302, 86.72564993000343, 87.54890820862819,
88.3767072518277, 89.2090541872801, 90.04595612594655, 90.88742016217518, 91.73345337380438,
92.58406282226491, 93.43925555268066, 94.29903859396902, 95.16341895893969,
96.03240364439274, 96.9059996312159, 97.78421388448044, 98.6670533535366, 99.55452497210776,
];
+160
View File
@@ -0,0 +1,160 @@
//! **HCT** 色彩空间:色相/彩度取自 CAM16,亮度取自 CIELAB 的 `L*`(即 M3 的 tone)。
//!
//! 为什么不是 OKLab/HSL:`L*` 与人眼感知线性的**对比度**直接挂钩(`Y` 是它的单调函数),
//! 所以「tone 差 40 ⇒ 对比度 ≥ 3:1」「tone 差 50 ⇒ ≥ 4.5:1」这类性质在 HCT 里是恒等式,
//! 换任何种子色都成立。CAM16 的色相/彩度则保证「同一条调色板只有一种色相」在感知上也是真的。
//!
//! 语义与官方 `java/hct/Hct.java` 一致:`Hct` 的真身是那个 **8 bit 色值**,`hue/chroma/tone`
//! 是它的测量值。所以 [`Hct::from_components`] 解出来的 `tone` 可能比请求值差一点点
//! (例如请求 tone 40、实测 40.08),官方也是这样——请求值只是求解目标。
//!
//! 来源:`material-color-utilities`(Apache-2.0),见仓库根 `NOTICE`。
use super::space;
use super::{cam16::Cam16, hct_solver, Color};
/// 一个色值的 HCT 坐标。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Hct {
/// CAM16 色相角(度,`0..360`)。
pub hue: f64,
/// CAM16 彩度(无上限;sRGB 立方体内约 0..=130)。
pub chroma: f64,
/// CIELAB `L*`(0 = 黑 / 100 = 白)——M3 里叫 **tone**。
pub tone: f64,
/// 这个坐标对应的 8 bit 色值(HCT 的真身)。
color: Color,
}
impl Hct {
/// 量一个色值的 HCT 坐标。
pub fn from_color(color: Color) -> Self {
let cam = Cam16::from_color(color);
Self {
hue: cam.hue,
chroma: cam.chroma,
tone: space::lstar_from_color(color),
color,
}
}
/// 解 HCT 方程:给定色相/彩度/tone,求最接近的 sRGB 色值。
///
/// 彩度装不下时**只压彩度**(色相与 tone 保住),这就是 M3 的 gamut 映射;
/// 返回值的 `tone` 是实测值,可能与 `tone` 参数相差千分之几。
pub fn from_components(hue: f64, chroma: f64, tone: f64) -> Self {
Self::from_color(hct_solver::solve_to_color(hue, chroma, tone))
}
/// 该坐标对应的色值。
pub fn to_color(self) -> Color {
self.color
}
/// 色相是否落在「黄」段(官方 `TonalPalette` 的 tone 99 特例用它)。
pub fn is_yellow(hue: f64) -> bool {
(105.0..125.0).contains(&hue)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 官方 Java 实测的种子色 HCT 坐标(`hct.Count` 打印,APCA/CAM16 D65 默认条件)。
const SEED_HCT: [(&str, f64, f64, f64); 6] = [
("#6750A4", 298.980_997, 47.856_526, 40.083_244),
("#FF0000", 27.408_225, 113.357_887, 53.232_882),
("#00FF00", 142.139_894, 108.410_061, 87.737_033),
("#0000FF", 282.788_18, 87.230_694, 32.302_587),
("#6E7C8A", 245.036_867, 14.675_86, 51.342_101),
("#D000FF", 324.929_26, 99.544_477, 52.542_037),
];
#[test]
fn seed_coordinates_match_official() {
for (hex, hue, chroma, tone) in SEED_HCT {
let hct = Hct::from_color(Color::from_hex(hex).expect("合法 hex"));
assert!((hct.hue - hue).abs() < 1.0e-4, "{hex} hue {}", hct.hue);
assert!(
(hct.chroma - chroma).abs() < 1.0e-4,
"{hex} chroma {}",
hct.chroma
);
assert!((hct.tone - tone).abs() < 1.0e-4, "{hex} tone {}", hct.tone);
}
}
#[test]
fn solver_reproduces_official_hexes() {
// 官方 Java 实测(`hct.Probe`):同一组 (h, c, t) 必须解出同一个 hex。
let cases = [
(95.0, 36.0, 40.0, "#705D0D"),
(298.98, 36.0, 90.0, "#E9DDFF"),
(25.0, 84.0, 80.0, "#FFB4AB"),
(245.0, 16.0, 80.0, "#B8C8D9"),
(245.0, 42.0, 30.0, "#004B72"),
(10.0, 120.0, 5.0, "#2C000A"),
(245.0, 4.0, 30.0, "#45474A"),
];
for (hue, chroma, tone, hex) in cases {
assert_eq!(
hct_solver::solve_to_color(hue, chroma, tone).to_hex(),
hex,
"HCT({hue}, {chroma}, {tone}) 与官方不一致"
);
}
}
#[test]
fn solver_keeps_hue_and_tone_when_chroma_is_available() {
for (hue, chroma, tone) in [
(95.0, 36.0, 40.0),
(298.98, 36.0, 90.0),
(245.0, 42.0, 30.0),
(25.0, 84.0, 80.0),
(245.0, 16.0, 80.0),
] {
let solved = Hct::from_components(hue, chroma, tone);
assert_eq!(
solved.to_color(),
hct_solver::solve_to_color(hue, chroma, tone)
);
assert!(
(solved.tone - tone).abs() < 1.0,
"tone {} vs {tone}",
solved.tone
);
let delta = (solved.hue - hue)
.abs()
.min(360.0 - (solved.hue - hue).abs());
assert!(delta < 1.0, "色相 {} vs {hue}", solved.hue);
assert!(
solved.chroma <= chroma + 1.0,
"彩度反被拉高:{}",
solved.chroma
);
}
}
#[test]
fn low_chroma_hue_is_ill_conditioned_even_officially() {
// 彩度 8 在 tone 30 上,8 bit 量化会让 CAM16 色相跳 6°——官方同样如此
// (Java 实测 HCT(245,8,30) → #42474E / hue 251.5965)。这条钉的是「别去修它」。
let solved = Hct::from_components(245.0, 8.0, 30.0);
assert_eq!(solved.to_color().to_hex(), "#42474E");
assert!(
(solved.hue - 251.596_498).abs() < 1.0e-4,
"hue {}",
solved.hue
);
}
#[test]
fn yellow_band_matches_official_definition() {
assert!(Hct::is_yellow(105.0));
assert!(Hct::is_yellow(124.9));
assert!(!Hct::is_yellow(125.0));
assert!(!Hct::is_yellow(104.9));
}
}
+311
View File
@@ -0,0 +1,311 @@
//! HCT 求解器:给定 `(hue, chroma, tone)` 求 sRGB 色值——**这就是 M3 的 gamut 映射**。
//!
//! M3 不做「越界就硬裁」:它先假设色域足够,用 Newton 迭代解出线性 RGB;只有当解落进 sRGB
//! 立方体之外时,才退到「在色调平面(`Y` 恒定)上沿色相二分」,把彩度压到该色相能取到的最大值,
//! **色相与 tone 都保持不变**。所以深色/浅色端的颜色是「变灰」而不是「变色」。
//!
//! 照搬 `java/hct/HctSolver.java`(Apache-2.0,见 `NOTICE`):两段(Newton + 二分)、
//! 迭代次数、`100.01` 这类边界容差都一致。关键平面表见 [`super::critical_planes`]。
use super::space::{self, signum};
use super::viewing_conditions::default_conditions;
use super::Color;
/// 线性 RGB → 「折扣光源」标度的矩阵(求解器内联版,与 [`super::cam16`] 等价但省一次除法)。
const SCALED_DISCOUNT_FROM_LINRGB: [[f64; 3]; 3] = [
[
0.001_200_833_568_784_504,
0.002_389_694_492_170_889,
0.000_279_574_288_586_112_4,
],
[
0.000_589_108_665_137_599_9,
0.002_978_550_257_343_875_8,
0.000_327_066_610_400_839_8,
],
[
0.000_101_466_924_916_405_72,
0.000_536_421_435_918_669_4,
0.003_297_940_177_071_207_6,
],
];
/// 「折扣光源」标度 → 线性 RGB([`SCALED_DISCOUNT_FROM_LINRGB`] 的逆)。
const LINRGB_FROM_SCALED_DISCOUNT: [[f64; 3]; 3] = [
[
1_373.219_870_959_423_1,
-1_100.425_119_075_482_1,
-7.278_681_089_101_213,
],
[
-271.815_969_077_903,
559.658_046_594_073_3,
-32.460_474_827_911_94,
],
[
1.962_289_959_966_566_6,
-57.173_814_538_844_006,
308.723_319_781_238_5,
],
];
/// 线性 RGB 的 `Y` 权重(Rec.709 亮度系数)。
const Y_FROM_LINRGB: [f64; 3] = [0.2126, 0.7152, 0.0722];
/// `(hue, chroma, tone)` → 色值。彩度取不到时压到该色相/色调下的最大值(色相与 tone 不变)。
pub(crate) fn solve_to_color(hue_degrees: f64, chroma: f64, lstar: f64) -> Color {
// 端点与极低彩度直接走灰:官方用同一断点,保证 tone 0/100 是纯黑/纯白。
if chroma < 0.0001 || lstar < 0.0001 || lstar > 99.9999 {
return space::color_from_lstar(lstar);
}
let hue_radians = space::sanitize_degrees(hue_degrees).to_radians();
let y = space::y_from_lstar(lstar);
if let Some(exact) = find_result_by_j(hue_radians, chroma, y) {
return exact;
}
space::color_from_linrgb(bisect_to_limit(y, hue_radians))
}
/// 牛顿迭代直接解(色域内能解出来时,这条路径是精确解)。
fn find_result_by_j(hue_radians: f64, chroma: f64, y: f64) -> Option<Color> {
let vc = default_conditions();
let mut j = y.sqrt() * 11.0;
let t_inner_coeff = 1.0 / (1.64 - 0.29_f64.powf(vc.n)).powf(0.73);
let e_hue = 0.25 * ((hue_radians + 2.0).cos() + 3.8);
let p1 = e_hue * (50_000.0 / 13.0) * vc.nc * vc.ncb;
let h_sin = hue_radians.sin();
let h_cos = hue_radians.cos();
for round in 0..5 {
let j_normalized = j / 100.0;
let alpha = if chroma == 0.0 || j == 0.0 {
0.0
} else {
chroma / j_normalized.sqrt()
};
let t = (alpha * t_inner_coeff).powf(1.0 / 0.9);
let ac = vc.aw * j_normalized.powf(1.0 / vc.c / vc.z);
let p2 = ac / vc.nbb;
let gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11.0 * t * h_cos + 108.0 * t * h_sin);
let a = gamma * h_cos;
let b = gamma * h_sin;
let r_a = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0;
let g_a = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0;
let b_a = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0;
let linrgb = space::matrix_multiply(
[
inverse_chromatic_adaptation(r_a),
inverse_chromatic_adaptation(g_a),
inverse_chromatic_adaptation(b_a),
],
LINRGB_FROM_SCALED_DISCOUNT,
);
if linrgb[0] < 0.0 || linrgb[1] < 0.0 || linrgb[2] < 0.0 {
return None;
}
let f_nj = Y_FROM_LINRGB[0] * linrgb[0]
+ Y_FROM_LINRGB[1] * linrgb[1]
+ Y_FROM_LINRGB[2] * linrgb[2];
if f_nj <= 0.0 {
return None;
}
if round == 4 || (f_nj - y).abs() < 0.002 {
if linrgb[0] > 100.01 || linrgb[1] > 100.01 || linrgb[2] > 100.01 {
return None;
}
return Some(space::color_from_linrgb(linrgb));
}
// Newton:用 2 * fn(j) / j 近似 fn'(j)
j -= (f_nj - y) * j / (2.0 * f_nj);
}
None
}
/// 色域外时的兜底:在 `Y` 恒定的平面上按色相二分,取边界上的颜色。
fn bisect_to_limit(y: f64, target_hue: f64) -> [f64; 3] {
let (mut left, mut right) = bisect_to_segment(y, target_hue);
let mut left_hue = hue_of(left);
for axis in 0..3 {
if left[axis] == right[axis] {
continue;
}
let (mut l_plane, mut r_plane) = if left[axis] < right[axis] {
(
critical_plane_below(space::true_delinearized(left[axis])),
critical_plane_above(space::true_delinearized(right[axis])),
)
} else {
(
critical_plane_above(space::true_delinearized(left[axis])),
critical_plane_below(space::true_delinearized(right[axis])),
)
};
for _ in 0..8 {
if (r_plane - l_plane).abs() <= 1 {
break;
}
let m_plane = ((f64::from(l_plane) + f64::from(r_plane)) / 2.0).floor() as i32;
let coordinate = super::critical_planes::CRITICAL_PLANES[m_plane as usize];
let mid = set_coordinate(left, coordinate, right, axis);
let mid_hue = hue_of(mid);
if are_in_cyclic_order(left_hue, target_hue, mid_hue) {
right = mid;
r_plane = m_plane;
} else {
left = mid;
left_hue = mid_hue;
l_plane = m_plane;
}
}
}
midpoint(left, right)
}
/// 找到包含目标色相的边界线段(sRGB 立方体与 `Y` 平面的交是多边形)。
fn bisect_to_segment(y: f64, target_hue: f64) -> ([f64; 3], [f64; 3]) {
let mut left = [-1.0_f64; 3];
let mut right = left;
let mut left_hue = 0.0;
let mut initialized = false;
let mut uncut = true;
for n in 0..12 {
let Some(mid) = nth_vertex(y, n) else {
continue;
};
let mid_hue = hue_of(mid);
if !initialized {
initialized = true;
left = mid;
right = mid;
left_hue = mid_hue;
continue;
}
if uncut || are_in_cyclic_order(left_hue, mid_hue, hue_of(right)) {
uncut = false;
if are_in_cyclic_order(left_hue, target_hue, mid_hue) {
right = mid;
} else {
left = mid;
left_hue = mid_hue;
}
}
}
(left, right)
}
/// `Y` 平面与立方体的第 `n` 个顶点(不在立方体内返回 `None`)。
fn nth_vertex(y: f64, n: usize) -> Option<[f64; 3]> {
let [k_r, k_g, k_b] = Y_FROM_LINRGB;
let coord_a = if n % 4 <= 1 { 0.0 } else { 100.0 };
let coord_b = if n.is_multiple_of(2) { 0.0 } else { 100.0 };
let vertex = if n < 4 {
let (g, b) = (coord_a, coord_b);
[((y - g * k_g - b * k_b) / k_r), g, b]
} else if n < 8 {
let (b, r) = (coord_a, coord_b);
[r, ((y - r * k_r - b * k_b) / k_g), b]
} else {
let (r, g) = (coord_a, coord_b);
[r, g, ((y - r * k_r - g * k_g) / k_b)]
};
if vertex.iter().all(|c| (0.0..=100.0).contains(c)) {
Some(vertex)
} else {
None
}
}
/// 线性 RGB 的色相(弧度,未归一)。
fn hue_of(linrgb: [f64; 3]) -> f64 {
let scaled = space::matrix_multiply(linrgb, SCALED_DISCOUNT_FROM_LINRGB);
let r_a = chromatic_adaptation(scaled[0]);
let g_a = chromatic_adaptation(scaled[1]);
let b_a = chromatic_adaptation(scaled[2]);
let a = (11.0 * r_a - 12.0 * g_a + b_a) / 11.0;
let b = (r_a + g_a - 2.0 * b_a) / 9.0;
b.atan2(a)
}
/// 角度上 `b` 是否落在 `a → c` 的前进区间内。
fn are_in_cyclic_order(a: f64, b: f64, c: f64) -> bool {
space::sanitize_radians(b - a) < space::sanitize_radians(c - a)
}
/// 把线段 `source → target` 在 `axis` 上截到 `coordinate`。
fn set_coordinate(source: [f64; 3], coordinate: f64, target: [f64; 3], axis: usize) -> [f64; 3] {
let t = (coordinate - source[axis]) / (target[axis] - source[axis]);
[
source[0] + (target[0] - source[0]) * t,
source[1] + (target[1] - source[1]) * t,
source[2] + (target[2] - source[2]) * t,
]
}
/// 线段中点。
fn midpoint(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[
(a[0] + b[0]) / 2.0,
(a[1] + b[1]) / 2.0,
(a[2] + b[2]) / 2.0,
]
}
/// 非线性压缩的正向(与 CAM16 里那段同公式)。
fn chromatic_adaptation(component: f64) -> f64 {
let af = component.abs().powf(0.42);
signum(component) * 400.0 * af / (af + 27.13)
}
/// 非线性压缩的逆。
fn inverse_chromatic_adaptation(adapted: f64) -> f64 {
let adapted_abs = adapted.abs();
let base = (27.13 * adapted_abs / (400.0 - adapted_abs)).max(0.0);
signum(adapted) * base.powf(1.0 / 0.42)
}
/// 关键平面索引:向下取(官方 `(int) Math.floor(x - 0.5)`)。
fn critical_plane_below(x: f64) -> i32 {
(x - 0.5).floor() as i32
}
/// 关键平面索引:向上取(官方 `(int) Math.ceil(x - 0.5)`)。
fn critical_plane_above(x: f64) -> i32 {
(x - 0.5).ceil() as i32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn in_gamut_requests_are_met_exactly() {
// 官方 Java 实测:HCT(95, 36, 40) → #705D0D。
assert_eq!(solve_to_color(95.0, 36.0, 40.0).to_hex(), "#705D0D");
assert_eq!(solve_to_color(25.0, 84.0, 40.0).to_hex(), "#BA1A1A");
assert_eq!(solve_to_color(95.0, 36.0, 90.0).to_hex(), "#FCE186");
}
#[test]
fn out_of_gamut_requests_keep_hue_and_tone() {
// 请求 chroma 200(sRGB 装不下):官方只在彩度上退让,色相与 tone 必须保住。
let color = solve_to_color(200.0, 200.0, 70.0);
assert_eq!(color.to_hex(), "#00BEC3");
let hct = super::super::Hct::from_color(color);
assert!((hct.tone - 70.0).abs() < 1.0, "tone 掉了:{}", hct.tone);
assert!(
(hct.hue - 200.0).abs() < 1.0,
"色相偏了:{}(应 ≈200)",
hct.hue
);
assert!(hct.chroma < 200.0);
}
#[test]
fn neutral_requests_return_exact_grays() {
for lstar in [0.0_f64, 4.0, 10.0, 50.0, 87.0, 98.0, 100.0] {
let color = solve_to_color(120.0, 0.0, lstar);
assert!(color.r == color.g && color.g == color.b, "{:?}", color);
}
assert_eq!(solve_to_color(0.0, 0.0, 0.0), Color::BLACK);
assert_eq!(solve_to_color(0.0, 0.0, 100.0), Color::WHITE);
}
}
+108 -30
View File
@@ -16,43 +16,109 @@
//! assert_eq!(custom.get(Role::Surface), surface);
//! ```
//!
//! ## 口径声明:这是 M3 **近似**,不是标准 HCT 动态取色
//! ## 口径声明:真 HCT,不是近似
//!
//! 标准 M3 的调色板建在 **HCT**(CAM16 色相/彩度 + CIELAB `L*`)上。本 crate 不实现 CAM16,
//! 用的是 **OKLCh**(色相/彩度)+ **CIELAB `L*`**(tone 轴)。具体差异:
//! 标准 M3 的调色板建在 **HCT**(CAM16 色相/彩度 + CIELAB 的 `L*`)上。本模块**就是**那套算法:
//! CAM16 正换算(`cam16` 模块)取色相/彩度,`L*` 取 tone,色域外走官方的 `HctSolver`
//! (`hct_solver` 模块:Newton 迭代 + 色调平面上的色相二分,**只压彩度、不硬裁**)。
//!
//! | 轴 | 标准 M3 | 本实现 | 影响 |
//! | 轴 | 标准 M3 | 本实现 | 一致性 |
//! |---|---|---|---|
//! | tone | CIELAB `L*` | CIELAB `L*`(二分反解 OKLab 明度,误差 ≤1 tone) | **无偏差**:tone 定义严格对齐 |
//! | 色相 | CAM16 hue | OKLCh hue(种子色相原样保留) | 绝对色相角不同,但「同族色相恒定」这个性质成立;派生色(tertiary = +60°)偏移量与 M3 不完全相同 |
//! | 彩度 | CAM16 chroma(如 primary 36 / secondary 16 / neutral 6) | OKLCh chroma(标定值见 [`palettes`]) | 数值量纲不同,语义等价;饱和度观感与官方色板会有可见差别(不是抄错,是量纲换算不通用) |
//! | gamut | CAM16 gamut mapping(按色相压缩彩度) | 按 tone 二分压 OKLCh 彩度进 sRGB | 极端 tone(5/95+)的饱和度可能比官方色板略低 |
//! | 动态色 | Material You 从壁纸取 5 组种子 | 只用**一个**种子 + 固定派生规则 | 没有「多色壁纸取色」能力;本启动器用不到(壁纸取色是安卓端特性) |
//! | 对比度模式 | Dynamic Contrast 三档(standard/medium/high) | 只有 standard | 不提供高对比方案;WCAG AA 在 standard 下已满足(见 [`super::color::contrast`] 与测试) |
//! | tone | CIELAB `L*` | CIELAB `L*`(同一个 `Y` 轴) | 逐值一致 |
//! | 色相 / 彩度 | CAM16 | CAM16(D65 默认观察条件) | 逐值一致(误差 < 1e-4) |
//! | gamut 映射 | `HctSolver` | 同一套两段求解器,关键平面表原样搬运 | 逐通道一致 |
//! | 调色板彩度 | Tonal Spot 变体:36 / 16 / 24 / 6 / 8,error 25°/84 | 同(primary/secondary/tertiary/neutral-variant/error 逐值一致) | 逐值一致 |
//! | 中性彩度 | 6(几乎无色) | 官方口径 6;**产品默认用 12**(有意偏离,见下节「出厂默认色」) | 仅此处偏离 |
//! | 角色 tone 表 | ColorSpec2021 | 同(49 角色 × light/dark) | 逐值一致 |
//!
//! 结论:**不许对外宣称「标准 M3 动态取色」**。要对比官方色板时按上表解释差异,
//! 不要试图逐值对齐 material-color-utilities 的输出(那需要 CAM16 + HCT,是另一个量级的实现)。
//! 对齐证据在 `tests/official_baseline.rs` 与 `tests/official_accent.rs`:期望值全部由
//! `material-color-utilities`(官方 Java 实现,Apache-2.0)跑出来,**每通道误差 ≤1** 才通过。
//! 所以本 crate 可以对外说「M3 动态取色(Tonal Spot 变体 / standard 对比度)」。
//!
//! ## 未实现的部分(gap 登记,别以为已经有)
//!
//! | 能力 | 官方叫什么 | 状态 |
//! |---|---|---|
//! | 对比度三档(default/medium/high) | `ContrastCurve` + `contrastLevel` | **未实现**:只做 `contrastLevel = 0`。在 0 档下官方那层「tone 差对 + 对比度修正」机制的输入全是常数,等价于本模块的静态 tone 表(已由对齐测试反证) |
//! | 其他配色变体 | `Vibrant` / `Expressive` / `Fidelity` / `Content` / `Monochrome` / `Neutral` / `Rainbow` / `FruitSalad` / `CMF` | **未实现**:只有 `TonalSpot`(M3 默认、也是 Material Theme Builder 默认) |
//! | 壁纸多色取色 | `QuantizerCelebi` / `Score` / 多 source 色 | **未实现**:桌面启动器用不到(安卓端特性),也避免引入图像依赖 |
//! | 换观察条件 | `Hct.inViewingConditions` | **未实现**:只有 sRGB 屏的默认条件(D65 / 200 lux / 背景 `L* 50`) |
//!
//! ## 「自定义强调色」与官方 `from_seed` 的关系
//!
//! 官方 `SchemeTonalSpot(accent)` 会把**中性表面也染上**种子的色相;本项目的产品语义是
//! 「换强调色不该让整个窗口底色变脏」,所以 [`Scheme::from_accent`] 只换 primary/secondary/
//! tertiary 三族,中性色与 error 保持基线。强调族的色值与官方 `from_seed(accent)` 逐个对得上
//! (`tests/official_accent.rs`,8 个种子 × 12 个强调族角色 × 2 模式 = 192 项)。
//!
//! ## 出厂默认色(M70:浅冷天蓝)——含**两处**有意偏离(同一处偏离的两半)
//!
//! 产品默认不走官方紫,而是「浅冷天蓝」。默认值收在两个常量里,**换默认只改这两行**
//! (用户 2026-11-01 拍板采用候选 **④ 天蓝**;③ 浅冷天蓝 `#87CEEB` 留作备选,值仍 pin 在
//! `tests/skyseed_candidates.rs`):
//!
//! | 项 | 当前值 | 官方值 | 说明 |
//! |---|---|---|---|
//! | [`palettes::DEFAULT_SEED`] | `#3C8DBC`(CAM16 hue ≈ 240°) | `#6750A4`([`palettes::BASELINE_SEED`]) | 种子只贡献**色相**(CAM16 240°);种子彩度会被 Tonal Spot 丢掉 |
//! | [`palettes::DEFAULT_NEUTRAL_CHROMA`](neutral) | `12` | `6` | 让 surface / surface-container-* 带淡蓝调 |
//! | 联动的 neutral-variant | `14` | `8`(**绝对值**;官方不是「neutral + 2」) | `outline` / `outline-variant` / `surface-variant` 一起偏蓝(light `outline` = `#6A7985` 就是 14 彩度的口径) |
//!
//! **定位(D38)**:这套默认只是「**莫奈关闭时的出厂预选色卡**」(首次启动、用户还没选过色卡时
//! 用的那一套),**不是唯一的配色来源**。用户的配色来源有三级优先:① 启动器背景图取色(Monet)
//! → ② 桌面壁纸取色 → ③ 莫奈关闭(默认)时从内置多套色卡里选。取色与「内置色卡库」都不在本 crate,
//! 也还没实现;本模块只负责「给定一个种子 + 中性彩度,派生出 49 个角色」。
//!
//! > ⚠️ **有意偏离登记(两处,同一处偏离的两半)**:
//! >
//! > 1. **neutral 彩度 `6 → 12`**。理由=产品要「浅冷」的冷调表面:官方 6 的表面几乎无色,单靠换
//! > 种子做不到「浅」——primary 的 tone 被官方钉在 40,种子再浅主色也还是中蓝,所以「浅」只能
//! > 落在中性色上。
//! > 2. **neutral-variant 彩度 `8 → 14`**。这是第 1 条的连带结果:官方
//! > `getNeutralVariantPalette` 写死的是**绝对值 8**(不是「neutral + 2」这种相对式),
//! > 本实现为了让层次不倒挂,让它跟着 neutral 联动(`nv = neutral + 2`)。若把 nv 钉回 8,
//! > 表面(12)会比中性变体(8)更「有色」,破坏官方的「neutral < neutral-variant」层次。
//! > 影响面不只表面:`outline` / `outline-variant` / `surface-variant` 三个角色一起偏蓝。
//! >
//! > 除这两处外全部走官方常数(primary 36 / secondary 16 / tertiary 24 且色相 +60° /
//! > error 25°·84),tone 表与 gamut 映射一行未动;[`Scheme::official_baseline`] 仍是官方的
//! > 6(neutral)/ 8(neutral-variant)口径。
//!
//! 官方口径的锚点仍然完整保留:[`Scheme::official_baseline`](种子 `#6750A4` + 中性 6)
//! 是 `tests/official_baseline.rs` 的对账入口,98 项仍是每通道 ≤1;
//! `tests/skyseed_candidates.rs` 把四档候选(保守蓝 / 冷调蓝 / 浅冷蓝 / 天蓝)的色值逐个 pin 住,
//! 换默认时它会指着要改的行。
//!
//! ✅ 默认色已由用户 2026-11-01 拍板:**④ 天蓝 `#3C8DBC`**。备选 ③ 浅冷蓝 `#87CEEB`
//! (主色偏青 `#0C6780`)仍在候选表里,换回去只需改 [`palettes::DEFAULT_SEED`] 一行。
//!
//! ## 对比度纪律(WCAG AA)
//!
//! - 所有 `on-*` 与其承载色(`*-container`、`primary`、`surface`)都满足 **≥ 4.5:1**;
//! 这一条由 `tests/contrast_aa.rs` 全表验证,且因为 tone 轴固定,**换任何种子色都成立**
//! (WCAG 对比度只取决于相对亮度,而 tone 直接钉住 L*)。
//! (WCAG 对比度只取决于相对亮度,而 tone 直接钉住 `L*`;中性彩度同理,
//! `sky_seed_candidates_meet_wcag_aa` 逐档验证了 M70 的四档候选:4 档 × 2 模式 × 26 对 = 208 项)。
//! - 例外(显式登记,非缺陷):
//! - [`Role::Outline`] 对 [`Role::Surface`]:M3 定义的**非文字**元素(输入框描边),
//! 目标 3:1,实测 light 4.29:1 / dark 6.5:1;
//! 目标 3:1,实测 light 4.26:1 / dark 5.89:1;
//! - [`Role::OutlineVariant`] 对 [`Role::Surface`]:纯装饰分隔线,M3 不要求对比度下限;
//! - [`Role::SurfaceVariant`]:它是**容器**色(不是前景),不参与对比度门禁。
//! - 禁用态按 M3 的 alpha 叠加([`state::DISABLED_CONTENT_ALPHA`]),刻意低于 AA。
pub mod hct;
pub mod palettes;
pub mod roles;
pub mod scheme;
pub mod space;
pub mod state;
pub mod tonal;
pub use palettes::{PaletteSlot, Palettes, BASELINE_SEED};
mod cam16;
mod critical_planes;
mod hct_solver;
mod space;
mod viewing_conditions;
pub use hct::Hct;
pub use palettes::{PaletteSlot, Palettes, BASELINE_SEED, DEFAULT_NEUTRAL_CHROMA, DEFAULT_SEED};
pub use roles::Role;
pub use scheme::{Scheme, SchemeFlavor, SCRIM_ALPHA, SHADOW_ALPHA};
pub use state::StateLayer;
@@ -143,17 +209,19 @@ impl Color {
self.a == 255
}
/// WCAG 相对亮度(0..1,按 sRGB→XYZ 的 Y 行计算)。
/// WCAG 相对亮度(0..1,即 XYZ 的 `Y` 除以 100)。
///
/// 与 WCAG 2.x 的定义同一条 `Y` 轴(Rec.709 系数 0.2126/0.7152/0.0722),
/// 所以 [`Color::contrast_ratio`] 就是标准对比度公式。
pub fn relative_luminance(self) -> f32 {
let lin = |c: u8| space::srgb_to_linear(f32::from(c) / 255.0);
space::luminance([lin(self.r), lin(self.g), lin(self.b)])
(space::xyz_from_color(self)[1] / 100.0) as f32
}
/// CIELAB `L*`(0..100)——这就是 M3 的 **tone**。
///
/// 调色板按这条轴生成,验收也按这条轴断言;想知道某个色值「是几号 tone」就用它。
pub fn lstar(self) -> f32 {
space::lstar_from_luminance(self.relative_luminance())
space::lstar_from_color(self) as f32
}
/// WCAG 对比度(1..21)。`self` 与 `other` 都不透明时才严格;带 alpha 时先算「不计 alpha」的值。
@@ -200,16 +268,6 @@ impl Color {
a: mix(self.a, other.a),
}
}
/// 从线性 RGB(0..1,可越界)构造:先夹取再 sRGB 编码。
fn from_linear_f32(rgb: [f32; 3]) -> Self {
let enc = |c: f32| {
(space::linear_to_srgb(c.clamp(0.0, 1.0)) * 255.0)
.round()
.clamp(0.0, 255.0) as u8
};
Self::rgb(enc(rgb[0]), enc(rgb[1]), enc(rgb[2]))
}
}
#[cfg(test)]
@@ -250,4 +308,24 @@ mod tests {
assert_eq!(a.lerp(b, 1.0), b);
assert_eq!(a.lerp(b, 0.5).r, 128);
}
#[test]
fn lstar_anchors_at_the_ends() {
assert!(Color::BLACK.lstar().abs() < 1.0e-3);
assert!((Color::WHITE.lstar() - 100.0).abs() < 1.0e-3);
// 中灰(sRGB 128)的 L* 是 53.6 左右,不是 50——这正是「L* 不是 sRGB 编码值」的证据。
let mid = Color::rgb(128, 128, 128).lstar();
assert!((mid - 53.585).abs() < 0.01, "中灰 L* = {mid}");
}
#[test]
fn tone_delta_implies_contrast_floor() {
// M3 的招牌性质:tone 差 50 ⇒ 对比度 ≥ 4.5:1(与色相/彩度无关)。
for (low, high) in [(10.0_f64, 60.0), (30.0, 80.0), (40.0, 90.0), (20.0, 70.0)] {
let a = space::color_from_lstar(low);
let b = space::color_from_lstar(high);
let ratio = a.contrast_ratio(b);
assert!(ratio >= 4.5, "tone {low}→{high} 只有 {ratio:.2}");
}
}
}
+218 -79
View File
@@ -1,46 +1,101 @@
//! 六条 tone 调色板的集合(M3 scheme 的原料)。
//!
//! 角色 → 调色板槽位 → tone 的三段映射在 [`super::scheme`];这里只负责「种子 → 六条调色板」。
//! 角色 → 调色板槽位 → tone 的三段映射在 [`super::scheme`];这里只负责「种子 → 六条调色板」,
//! 以及**官方 Tonal Spot 变体**的彩度常数。
//!
//! 彩度值来自 `material-color-utilities` 的 `ColorSpec2021.get*Palette(Variant.TONAL_SPOT)`
//! (Apache-2.0,见 `NOTICE`):primary 恒定 **36**、secondary **16**、tertiary **24**
//! (色相 +60°)、neutral **6**、neutral-variant **8**、error 固定色相 **25** 彩度 **84**。
//! 注意 primary 的 36 与种子自身的彩度无关——这是官方口径:Tonal Spot 是「镇静」变体,
//! 再鲜艳的种子也会被收到 36,再灰的种子也会被提到 36。
//!
//! ## 两个入口:官方口径 vs 产品默认(M70)
//!
//! | 入口 | 种子 | 中性彩度 | 用途 |
//! |---|---|---|---|
//! | [`Palettes::official_baseline`] / [`super::Scheme::official_baseline`] | [`BASELINE_SEED`](`#6750A4`) | 6(官方) | 与官方 Java 实现逐角色对齐的**锚点**,`tests/official_baseline.rs` 用它 |
//! | [`Palettes::baseline`] / [`super::Scheme::baseline`] | [`DEFAULT_SEED`] | [`DEFAULT_NEUTRAL_CHROMA`] | **产品出厂默认**(消费方拿到的就是它) |
//!
//! 换默认色只改 [`DEFAULT_SEED`] 与 [`DEFAULT_NEUTRAL_CHROMA`] 两行常量(中性彩度不动就只改一行),
//! 然后重跑 `tests/skyseed_candidates.rs` 的 pin(它会打印新值并指出哪几行要同步)。
//! 官方锚点不受影响——**算法没动,动的只是入参**。
//!
//! ⚠️ 中性彩度 6 → 12 是**有意偏离**官方 Tonal Spot(理由:产品要「浅冷」的冷调表面),
//! 偏离登记见 [`super`] 模块文档的「出厂默认色」小节。
use super::tonal::TonalPalette;
use super::Color;
/// M3 baseline 种子色(与 material-color-utilities 的默认基线同值)。
///
/// **不是**产品默认色——产品默认见 [`DEFAULT_SEED`]。
pub const BASELINE_SEED: Color = Color::rgb(0x67, 0x50, 0xA4);
/// 产品出厂默认种子色 —— M70 候选④「天蓝」,**用户 2026-11-01 拍板采用**。
///
/// `#3C8DBC`(CAM16 hue ≈ 239.5°,主色 `#236488`)。注意种子的彩度(42.6)会被 Tonal Spot
/// 丢掉——主色板的彩度恒为 36,种子只贡献**色相**。
///
/// ## 定位(D38):这是「莫奈关闭时的出厂预选色卡」,不是唯一的配色来源
///
/// 用户定了三级优先的配色来源:① 启动器背景图取色(Monet)→ ② 桌面壁纸取色 → ③ 莫奈关闭
/// (默认)时用户从内置多套色卡里选。本常量 + [`DEFAULT_NEUTRAL_CHROMA`] 只定义**第三级里
/// 出厂预选的那一套**(首次启动、用户没选过色卡时的那一套)。取色(Monet / 壁纸 /
/// `QuantizerCelebi`)与「内置多色卡库」都不在本 crate,也还没实现。
pub const DEFAULT_SEED: Color = Color::rgb(0x3C, 0x8D, 0xBC);
/// 产品出厂默认的中性调色板彩度(官方 Tonal Spot 是 6,这里 12 = **有意偏离**)。
///
/// 官方 6 让表面几乎无色;12 让 surface / surface-container-* 带一层淡蓝调,
/// 这是「浅冷天蓝」唯一能落在表面上的手段(primary 的 tone 被官方钉在 40,
/// 种子再浅也变不成浅蓝)。
///
/// **数值安全区间 `6..=13`,产品取 12**:neutral-variant 跟 neutral 联动(`nv = neutral + 2`),
/// 13 时 nv = 15 仍低于 secondary 的 16;**14 才会撞上**(nv = 16 = secondary),届时要一并改
/// `NEUTRAL_VARIANT_OFFSET`(私有常量)的规则。nv 的那一半偏离见 `color` 模块文档的偏离登记。
///
/// 同样只服务于「出厂预选色卡」(D38 第三级),不约束将来的取色结果。将来若做「内置多套色卡」,
/// 每套色卡各带自己的中性彩度更合适——本常量到时候会退化成「默认那套的取值」。
pub const DEFAULT_NEUTRAL_CHROMA: f64 = 12.0;
/// 调色板槽位(M3 的六条 tonal palette)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaletteSlot {
/// 主调色板(种子色相)。
/// 主调色板(种子色相,彩度 36)。
Primary,
/// 次调色板(同色相、低彩度)。
/// 次调色板(同色相,彩度 16)。
Secondary,
/// 第三调色板(种子色相 +60°,中彩度)。
/// 第三调色板(种子色相 +60°,彩度 24)。
Tertiary,
/// 中性调色板(近零彩度的表面色)。
/// 中性调色板(同色相;官方彩度 6,产品默认 12——见 [`DEFAULT_NEUTRAL_CHROMA`])。
Neutral,
/// 中性变体调色板(比中性略彩色,做分隔与次级容器)。
/// 中性变体调色板(同色相,彩度 = neutral + 2;官方 8)。
NeutralVariant,
/// 错误调色板(固定红相)。
/// 错误调色板(固定红相 25°,彩度 84)。
Error,
}
// 下面几个常数是**我们的标定值**(OKLCh 彩度单位),不是 M3 的 CAM16 chroma:
// CAM16 的 chroma 36/16/24/6/8 量纲与本实现不同,直接照抄会得到过饱和的色板。
/// 次色的彩度上限。
const SECONDARY_CHROMA: f32 = 0.055;
/// 第三色的彩度上限。
const TERTIARY_CHROMA: f32 = 0.085;
/// 中性的彩度上限(几乎是灰)。
const NEUTRAL_CHROMA: f32 = 0.008;
/// 中性变体的彩度上限。
const NEUTRAL_VARIANT_CHROMA: f32 = 0.016;
/// 错误色的色相(OKLCh 红相)。
const ERROR_HUE_DEG: f32 = 25.0;
/// 错误色的彩度上限。
const ERROR_CHROMA: f32 = 0.18;
/// 第三色调色板的色相偏移。
const TERTIARY_HUE_SHIFT_DEG: f32 = 60.0;
/// Tonal Spot 的 primary 彩度(官方常数,与种子彩度无关)。
const PRIMARY_CHROMA: f64 = 36.0;
/// secondary 彩度。
const SECONDARY_CHROMA: f64 = 16.0;
/// tertiary 彩度。
const TERTIARY_CHROMA: f64 = 24.0;
/// 官方 Tonal Spot 的 neutral 彩度(几乎是灰)。产品默认用 [`DEFAULT_NEUTRAL_CHROMA`]。
const TONAL_SPOT_NEUTRAL_CHROMA: f64 = 6.0;
/// neutral-variant 相对 neutral 的彩度偏移。
///
/// ⚠️ 官方 `getNeutralVariantPalette` 写死的是**绝对值 8**,**不是**「neutral + 2」这种相对式;
/// 本实现选择让 neutral-variant 随 neutral 联动,于是产品默认(neutral 12)取到 **14**。
/// 这是**有意偏离**的另一半(见 `color` 模块文档的偏离登记),不是官方口径——
/// 官方的 6 / 8 由 [`Palettes::official_baseline`] 原样保留。
const NEUTRAL_VARIANT_OFFSET: f64 = 2.0;
/// error 的色相(HCT 红相)。
const ERROR_HUE_DEG: f64 = 25.0;
/// error 的彩度。
const ERROR_CHROMA: f64 = 84.0;
/// tertiary 相对种子的色相偏移。
const TERTIARY_HUE_SHIFT_DEG: f64 = 60.0;
/// 一套完整的六条调色板。
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -57,50 +112,71 @@ pub struct Palettes {
pub neutral_variant: TonalPalette,
/// 错误调色板。
pub error: TonalPalette,
/// 彩度压缩的**共享基准**(取建方案时的 primary 彩度,强调色覆盖时**不变**)。
///
/// 为什么单独存一份:`from_accent` 换掉 primary 后,中性表面不该跟着变色调
/// (否则「换强调色不动底色」这条语义就破了),而彩度压缩又必须用同一个基准才能
/// 保证 primary/secondary 之间的相对灰度——两者靠这个字段同时成立。
reference_chroma: f32,
}
impl Palettes {
/// 出厂基线(种子 `#6750A4`,等价 M3 baseline)。
/// **产品出厂默认**:种子 [`DEFAULT_SEED`] + 中性彩度 [`DEFAULT_NEUTRAL_CHROMA`]。
///
/// 消费方(`DesignTokens::baseline`、iced 适配层)拿到的就是这套,所以产品默认色
/// 只需要改那两个常量。
pub fn baseline() -> Self {
Self::from_seed_with_neutral_chroma(DEFAULT_SEED, DEFAULT_NEUTRAL_CHROMA)
}
/// **官方基线的锚点**:种子 [`BASELINE_SEED`] + 官方中性彩度 6(等价官方
/// `SchemeTonalSpot(0xFF6750A4)`)。只给对齐测试与文档表格用。
pub fn official_baseline() -> Self {
Self::from_seed(BASELINE_SEED)
}
/// 从一个种子色派生全套调色板。
/// 从一个种子色派生全套调色板(官方 Tonal Spot 变体,中性彩度取官方 6)。
pub fn from_seed(seed: Color) -> Self {
let primary = TonalPalette::from_color(seed);
Self::from_seed_with_neutral_chroma(seed, TONAL_SPOT_NEUTRAL_CHROMA)
}
/// 从一个种子色派生,并显式指定中性调色板彩度(`6.0` 即官方 Tonal Spot)。
///
/// ⚠️ **调用方负责传 `6.0..=13.0`(产品取 12)**。本函数**不做范围校验**,越界的表现是
/// 静默退化而不是 panic:
/// - `neutral_chroma < 0` 或 `NaN` → 彩度被夹到 0(`TonalPalette::new` 里 `.max(0.0)`),
/// 得到一套纯灰的表面(surface ≈ `#F9F9F9`),**不报错**;
/// - `neutral_chroma >= 14` → neutral-variant(= neutral + 2)够到甚至超过 secondary 的 16,
/// 「中性 < 中性变体 < 次色」的层次被破坏(描边会明显发蓝)。
///
/// 除 neutral / neutral-variant 外**全部走官方常数**:primary 恒定 36、secondary 16、
/// tertiary 24(色相 +60°)、error 固定 25°/84。所以这条入口不绕算法,只是把「官方写死在
/// `getNeutralPalette` / `getNeutralVariantPalette` 里的那两个数」变成可调入参
/// (neutral-variant 这里取相对式 `neutral + 2`,属**有意偏离**,见 `NEUTRAL_VARIANT_OFFSET`)。
pub fn from_seed_with_neutral_chroma(seed: Color, neutral_chroma: f64) -> Self {
let hue = super::Hct::from_color(seed).hue;
Self::tonal_spot(hue, neutral_chroma)
}
/// 只换强调色:primary/secondary/tertiary 跟着新强调色的色相走,
/// **表面中性色与错误色保持不变**。
///
/// 这是本项目的产品语义(换强调色不该让整个窗口底色变脏,见 `DesignTokens::with_accent`):
/// 强调族的色值与官方 `from_seed(accent)` 逐个对得上,中性表面则仍走基线——
/// 官方 `from_seed` 会把中性色也染上种子的色相,那是「整窗染色」,不是我们要的。
pub fn with_accent(self, accent: Color) -> Self {
let hue = super::Hct::from_color(accent).hue;
Self {
primary,
secondary: primary.limited_chroma(SECONDARY_CHROMA),
tertiary: primary
.rotated(TERTIARY_HUE_SHIFT_DEG)
.limited_chroma(TERTIARY_CHROMA),
neutral: primary.with_chroma(NEUTRAL_CHROMA),
neutral_variant: primary.with_chroma(NEUTRAL_VARIANT_CHROMA),
error: TonalPalette::new(ERROR_HUE_DEG, ERROR_CHROMA),
reference_chroma: primary.chroma(),
primary: TonalPalette::new(hue, PRIMARY_CHROMA),
secondary: TonalPalette::new(hue, SECONDARY_CHROMA),
tertiary: TonalPalette::new(hue + TERTIARY_HUE_SHIFT_DEG, TERTIARY_CHROMA),
..self
}
}
/// 只换强调色:primary/secondary/tertiary 跟着新强调色走,**表面中性色保持不变**。
///
/// 这是「用户自定义强调色」的正确语义——换强调色不该让整个界面底色变脏;
/// 也因为 tone 轴固定,任何色相都能自动满足 WCAG AA(不需要再算一遍对比度)。
/// `reference_chroma` 也保持原值,所以中性色与 baseline 逐像素一致(有测试钉住)。
pub fn with_accent(self, accent: Color) -> Self {
let accent_primary = TonalPalette::from_color(accent);
/// 官方 Tonal Spot 的六条调色板(色相来自种子,彩度取官方常数,neutral 可调)。
fn tonal_spot(hue: f64, neutral_chroma: f64) -> Self {
Self {
primary: accent_primary,
secondary: accent_primary.limited_chroma(SECONDARY_CHROMA),
tertiary: accent_primary
.rotated(TERTIARY_HUE_SHIFT_DEG)
.limited_chroma(TERTIARY_CHROMA),
..self
primary: TonalPalette::new(hue, PRIMARY_CHROMA),
secondary: TonalPalette::new(hue, SECONDARY_CHROMA),
tertiary: TonalPalette::new(hue + TERTIARY_HUE_SHIFT_DEG, TERTIARY_CHROMA),
neutral: TonalPalette::new(hue, neutral_chroma),
neutral_variant: TonalPalette::new(hue, neutral_chroma + NEUTRAL_VARIANT_OFFSET),
error: TonalPalette::new(ERROR_HUE_DEG, ERROR_CHROMA),
}
}
@@ -116,16 +192,9 @@ impl Palettes {
}
}
/// 方案级取色:以 [`Palettes::reference_chroma`] 为**共享基准**压所有调色板(见
/// [`TonalPalette::tone_scaled`]),保证同 tone 下 primary/secondary/tertiary 不会撞色,
/// 且换强调色时中性表面逐像素不变。
pub fn tone(&self, slot: PaletteSlot, tone: f32) -> Color {
self.slot(slot).tone_scaled(tone, self.reference_chroma)
}
/// 彩度压缩的共享基准(调试/测试用)。
pub fn reference_chroma(&self) -> f32 {
self.reference_chroma
/// 方案级取色:槽位 + tone → 色值。
pub fn tone(&self, slot: PaletteSlot, tone: f64) -> Color {
self.slot(slot).tone(tone)
}
}
@@ -134,28 +203,98 @@ mod tests {
use super::*;
#[test]
fn baseline_seed_is_the_primary_palette_hue() {
let palettes = Palettes::baseline();
let seed_lch = super::super::space::Oklch::from_color(BASELINE_SEED);
let delta = (palettes.primary.hue_deg() - seed_lch.hue_deg).abs();
assert!(delta < 0.01, "primary 调色板应保持种子色相,偏差 {delta}°");
fn official_baseline_uses_the_official_tonal_spot_chromas() {
let palettes = Palettes::official_baseline();
assert_eq!(palettes.primary.chroma(), 36.0);
assert_eq!(palettes.secondary.chroma(), 16.0);
assert_eq!(palettes.tertiary.chroma(), 24.0);
assert_eq!(palettes.neutral.chroma(), 6.0);
assert_eq!(palettes.neutral_variant.chroma(), 8.0);
assert_eq!(palettes.error.hue_deg(), 25.0);
assert_eq!(palettes.error.chroma(), 84.0);
}
#[test]
fn accent_override_keeps_surfaces() {
fn product_default_only_moves_the_named_knobs() {
// 产品默认与官方锚点的差别必须**只**在种子色相与中性彩度两处:
// primary/secondary/tertiary/error 的取法一模一样(算法没动)。
let official = Palettes::official_baseline();
let product = Palettes::baseline();
assert_eq!(product.primary.chroma(), official.primary.chroma());
assert_eq!(product.secondary.chroma(), official.secondary.chroma());
assert_eq!(product.tertiary.chroma(), official.tertiary.chroma());
assert_eq!(product.error, official.error);
assert_eq!(product.neutral.chroma(), DEFAULT_NEUTRAL_CHROMA);
assert_eq!(
product.neutral_variant.chroma(),
DEFAULT_NEUTRAL_CHROMA + NEUTRAL_VARIANT_OFFSET
);
let seed = super::super::Hct::from_color(DEFAULT_SEED);
assert!((product.primary.hue_deg() - seed.hue).abs() < 1.0e-9);
}
#[test]
fn neutral_chroma_knob_keeps_the_official_ordering() {
// 官方关系 neutral(6) < neutral-variant(8) < secondary(16) 在安全区间里必须一直成立,
// 否则「表面比次色还艳」或两族撞色。
for neutral in [6.0, 8.0, 10.0, 12.0] {
let p = Palettes::from_seed_with_neutral_chroma(DEFAULT_SEED, neutral);
assert!(p.neutral.chroma() < p.neutral_variant.chroma());
assert!(p.neutral_variant.chroma() < p.secondary.chroma());
// 中性彩度不影响 tone 轴:tone 98 的 surface 仍是最亮的那个。
let surface = p.tone(PaletteSlot::Neutral, 98.0);
assert!(surface.relative_luminance() > 0.9);
}
}
#[test]
fn product_default_neutral_chroma_is_a_registered_deviation() {
// 官方 neutral / neutral-variant 是 6 / 8;产品默认是 12 / 14。
// 这**不是官方口径**,是「有意偏离」(见 `color` 模块文档的偏离登记)。
// 想把 nv 拉回官方绝对值 8 的人,请先改登记再改这里:直接钉成 8 会让表面(12)
// 比中性变体(8)更「有色」,破坏官方的 neutral < neutral-variant 层次。
let product = Palettes::baseline();
assert_eq!(product.neutral.chroma(), 12.0, "产品默认 neutral 彩度");
assert_eq!(
product.neutral_variant.chroma(),
14.0,
"产品默认的 neutral-variant 彩度 14 是**有意偏离**(官方绝对值 8),改前先更新偏离登记"
);
let official = Palettes::official_baseline();
assert_eq!(official.neutral.chroma(), 6.0, "官方锚点仍是 6");
assert_eq!(
official.neutral_variant.chroma(),
8.0,
"官方锚点仍是绝对值 8(不是 neutral + 2 的相对式)"
);
}
#[test]
fn baseline_hue_comes_from_the_seed() {
let palettes = Palettes::official_baseline();
let seed = super::super::Hct::from_color(BASELINE_SEED);
assert!((palettes.primary.hue_deg() - seed.hue).abs() < 1.0e-9);
assert!((palettes.neutral.hue_deg() - seed.hue).abs() < 1.0e-9);
assert!((palettes.tertiary.hue_deg() - (seed.hue + 60.0)).abs() < 1.0e-9);
}
#[test]
fn seed_chroma_never_leaks_into_the_primary_ramp() {
// 官方口径:Tonal Spot 的彩度是常数,纯灰种子也会得到彩度 36 的主色板。
let gray = Palettes::from_seed(Color::rgb(0x6E, 0x6E, 0x6E));
assert_eq!(gray.primary.chroma(), 36.0);
let hot = Palettes::from_seed(Color::rgb(0xFF, 0x00, 0x00));
assert_eq!(hot.primary.chroma(), 36.0);
}
#[test]
fn accent_override_keeps_surfaces_and_error() {
let base = Palettes::baseline();
let accent = Color::rgb(0x00, 0x6A, 0x5C);
let swapped = base.with_accent(accent);
let swapped = base.with_accent(Color::rgb(0x00, 0x6A, 0x5C));
assert_eq!(swapped.neutral, base.neutral);
assert_eq!(swapped.neutral_variant, base.neutral_variant);
assert_eq!(swapped.error, base.error);
assert_ne!(swapped.primary.hue_deg(), base.primary.hue_deg());
}
#[test]
fn neutral_variant_is_less_chromatic_than_primary() {
let palettes = Palettes::baseline();
assert!(palettes.neutral.chroma() < palettes.neutral_variant.chroma());
assert!(palettes.neutral_variant.chroma() < palettes.secondary.chroma());
assert_eq!(swapped.primary.chroma(), 36.0);
}
}
+36 -19
View File
@@ -1,7 +1,17 @@
//! 一套完整的 M3 语义色方案(`Scheme`):角色 → 调色板槽位 → tone 的三段映射。
//!
//! tone 表取自 M3 基础方案的官方映射(light/dark 各一列)。固定角色
//! (`*-fixed*`)深浅同值,因为它们的用途就是「跨模式稳定」。
//! tone 表取自 **ColorSpec2021** 的官方映射(`MaterialDynamicColors` 的 light/dark 两列,
//! 见 `NOTICE`)。固定角色(`*-fixed*`)深浅同值,因为它们的用途就是「跨模式稳定」。
//!
//! ## 只实现了 contrast level = 0(standard)
//!
//! 官方的 `ColorSpec2021.getTone()` 还有一层机制:`toneDeltaPair`(成对角色保持 tone 差)
//! 与 `ContrastCurve`(按对比度等级修正 tone)。在 **contrastLevel = 0** 下这层机制的输入
//! 全部是常数:`ContrastCurve.get(0)` 恒等于 normal 档的 tone,而「背景 tone」也都是本表里的
//! 常数,所以它对 49 个角色的调整结果**就是这张表本身**。测试
//! `tests/official_baseline.rs` 用官方 Java 实现的逐角色输出反证了这一点(每通道误差 ≤1):
//! 表错一格,对齐测试就会红。三档对比度(medium/high)不在本 crate 范围内,见
//! [`super`] 的 gap 登记。
use super::palettes::{PaletteSlot, Palettes};
use super::roles::Role;
@@ -48,33 +58,33 @@ pub const SCRIM_ALPHA: f32 = 0.32;
struct ToneRow {
role: Role,
slot: PaletteSlot,
light: f32,
dark: f32,
light: f64,
dark: f64,
}
/// 49 个角色的 tone 映射表,**下标必须等于 `Role as usize`**(由测试钉死)。
/// 49 个角色的 tone 映射表(ColorSpec2021),**下标必须等于 `Role as usize`**(由测试钉死)。
const TONE_TABLE: [ToneRow; 49] = [
row(Role::Primary, PaletteSlot::Primary, 40.0, 80.0),
row(Role::OnPrimary, PaletteSlot::Primary, 100.0, 20.0),
row(Role::PrimaryContainer, PaletteSlot::Primary, 90.0, 30.0),
row(Role::OnPrimaryContainer, PaletteSlot::Primary, 10.0, 90.0),
row(Role::OnPrimaryContainer, PaletteSlot::Primary, 30.0, 90.0),
row(Role::Secondary, PaletteSlot::Secondary, 40.0, 80.0),
row(Role::OnSecondary, PaletteSlot::Secondary, 100.0, 20.0),
row(Role::SecondaryContainer, PaletteSlot::Secondary, 90.0, 30.0),
row(
Role::OnSecondaryContainer,
PaletteSlot::Secondary,
10.0,
30.0,
90.0,
),
row(Role::Tertiary, PaletteSlot::Tertiary, 40.0, 80.0),
row(Role::OnTertiary, PaletteSlot::Tertiary, 100.0, 20.0),
row(Role::TertiaryContainer, PaletteSlot::Tertiary, 90.0, 30.0),
row(Role::OnTertiaryContainer, PaletteSlot::Tertiary, 10.0, 90.0),
row(Role::OnTertiaryContainer, PaletteSlot::Tertiary, 30.0, 90.0),
row(Role::Error, PaletteSlot::Error, 40.0, 80.0),
row(Role::OnError, PaletteSlot::Error, 100.0, 20.0),
row(Role::ErrorContainer, PaletteSlot::Error, 90.0, 30.0),
row(Role::OnErrorContainer, PaletteSlot::Error, 10.0, 90.0),
row(Role::OnErrorContainer, PaletteSlot::Error, 30.0, 90.0),
row(Role::Surface, PaletteSlot::Neutral, 98.0, 6.0),
row(Role::OnSurface, PaletteSlot::Neutral, 10.0, 90.0),
row(
@@ -89,7 +99,7 @@ const TONE_TABLE: [ToneRow; 49] = [
30.0,
80.0,
),
row(Role::Background, PaletteSlot::Neutral, 99.0, 10.0),
row(Role::Background, PaletteSlot::Neutral, 98.0, 6.0),
row(Role::OnBackground, PaletteSlot::Neutral, 10.0, 90.0),
row(Role::SurfaceDim, PaletteSlot::Neutral, 87.0, 6.0),
row(Role::SurfaceBright, PaletteSlot::Neutral, 98.0, 24.0),
@@ -151,7 +161,7 @@ const TONE_TABLE: [ToneRow; 49] = [
];
/// `const` 友好的行构造器(数组初始化里不能调用非 const fn 之外的东西)。
const fn row(role: Role, slot: PaletteSlot, light: f32, dark: f32) -> ToneRow {
const fn row(role: Role, slot: PaletteSlot, light: f64, dark: f64) -> ToneRow {
ToneRow {
role,
slot,
@@ -168,12 +178,22 @@ pub struct Scheme {
}
impl Scheme {
/// 出厂基线方案(种子 `#6750A4`)。
/// **产品出厂默认方案**(种子 [`super::palettes::DEFAULT_SEED`] + 中性彩度
/// [`super::palettes::DEFAULT_NEUTRAL_CHROMA`],见 [`Palettes::baseline`])。
pub fn baseline(flavor: SchemeFlavor) -> Self {
Self::from_palettes(&Palettes::baseline(), flavor)
}
/// 指定种子色的方案(表面色与强调色同源)。
/// **官方基线锚点**(种子 `#6750A4` + 官方中性彩度 6,等价官方
/// `SchemeTonalSpot(0xFF6750A4)`)。
///
/// 与 [`Scheme::baseline`] 的区别只有入参:算法、tone 表、gamut 映射完全同一条路径,
/// 所以 `tests/official_baseline.rs` 拿它跟官方 Java 输出逐角色对账。
pub fn official_baseline(flavor: SchemeFlavor) -> Self {
Self::from_palettes(&Palettes::official_baseline(), flavor)
}
/// 指定种子色的方案(表面色与强调色同源,等价官方 `SchemeTonalSpot`)。
pub fn from_seed(flavor: SchemeFlavor, seed: Color) -> Self {
Self::from_palettes(&Palettes::from_seed(seed), flavor)
}
@@ -187,17 +207,13 @@ impl Scheme {
pub fn from_palettes(palettes: &Palettes, flavor: SchemeFlavor) -> Self {
let colors = std::array::from_fn(|i| {
let entry = &TONE_TABLE[i];
let tone = match flavor {
SchemeFlavor::Light => entry.light,
SchemeFlavor::Dark => entry.dark,
};
palettes.tone(entry.slot, tone)
palettes.tone(entry.slot, Scheme::tone_of(entry.role, flavor))
});
Self { flavor, colors }
}
/// 该角色在指定模式下的 tone 值(M3 语义;文档表格、调试与测试用)。
pub fn tone_of(role: Role, flavor: SchemeFlavor) -> f32 {
pub fn tone_of(role: Role, flavor: SchemeFlavor) -> f64 {
let entry = &TONE_TABLE[role as usize];
debug_assert_eq!(entry.role, role, "TONE_TABLE 下标必须等于角色序号");
match flavor {
@@ -309,5 +325,6 @@ mod tests {
teal.get(Role::Outline),
"强调色覆盖不该动中性描边"
);
assert_eq!(base.get(Role::Error), teal.get(Role::Error));
}
}
+153 -148
View File
@@ -1,198 +1,203 @@
//! 色彩空间转换:sRGB ↔ 线性 RGB ↔ CIELAB(tone 轴)+ OKLab/OKLCh(色相/彩度轴)。
//! 色彩空间底层换算:sRGB ↔ 线性 RGB ↔ CIE XYZ ↔ `L*`(tone 轴)。
//!
//! 这里只放**纯数学**,不认识任何语义色角色。口径说明见 [`super`] 的模块文档。
//! 只放**纯数学**,不认识任何语义色角色。数值口径逐函数照搬 Google
//! `material-color-utilities` 的 `java/utils/ColorUtils.java`(Apache-2.0,见 `NOTICE`):
//! 矩阵、阈值、取整方式都一致——差一位小数,HCT 求解器就会与官方差一个通道。
//!
//! 内部一律用 `f64`(官方实现是 Java `double`)。公开 API 仍以 `u8` 通道与 `f32` 标量对外,
//! 因为令牌消费方(绘制层)不需要比 8 bit 更高的精度。
use super::Color;
/// gamut 判定容差:线性 RGB 允许露出这么点负值/溢出(浮点误差),别因此误判「越界」。
const GAMUT_EPS: f32 = 1.0e-4;
/// sRGB → XYZ(D65)。官方用的是 IEC 6-1 的舍入值,不是更精确的 sRGB 矩阵。
const SRGB_TO_XYZ: [[f64; 3]; 3] = [
[0.412_338_95, 0.357_620_64, 0.180_510_42],
[0.2126, 0.7152, 0.0722],
[0.019_321_41, 0.119_163_82, 0.950_344_78],
];
/// sRGB 里能取到的 OKLCh 彩度上限(超过它没有意义,二分搜索的初始上界)。
pub(crate) const GAMUT_CHROMA_CAP: f32 = 0.5;
/// D65 白点(XYZ,Y 归一化到 100)。
pub(crate) const WHITE_POINT_D65: [f64; 3] = [95.047, 100.0, 108.883];
/// sRGB 编码值(0..1)→ 线性光强。
pub(crate) fn srgb_to_linear(c: f32) -> f32 {
if c <= 0.040_45 {
c / 12.92
/// 行向量 × 3×3 矩阵。
pub(crate) fn matrix_multiply(row: [f64; 3], matrix: [[f64; 3]; 3]) -> [f64; 3] {
let mul = |m: [f64; 3]| row[0] * m[0] + row[1] * m[1] + row[2] * m[2];
[mul(matrix[0]), mul(matrix[1]), mul(matrix[2])]
}
/// sRGB 编码值(8 bit 通道)→ 线性光强(0..=100,官方标度)。
pub(crate) fn linearized(component: u8) -> f64 {
let normalized = f64::from(component) / 255.0;
if normalized <= 0.040_449_936 {
normalized / 12.92 * 100.0
} else {
((c + 0.055) / 1.055).powf(2.4)
((normalized + 0.055) / 1.055).powf(2.4) * 100.0
}
}
/// 线性光强 → sRGB 编码值(0..1),结果不夹取,越界由调用方处理。
pub(crate) fn linear_to_srgb(c: f32) -> f32 {
if c <= 0.003_130_8 {
c * 12.92
/// 线性光强(0..=100)→ sRGB 编码值,四舍五入并夹取到 0..=255。
pub(crate) fn delinearized(component: f64) -> u8 {
let value = true_delinearized(component).round().clamp(0.0, 255.0);
value as u8
}
/// 同 [`delinearized`],但不取整也不夹取(求解器二分时需要连续值)。
pub(crate) fn true_delinearized(component: f64) -> f64 {
let normalized = component / 100.0;
let encoded = if normalized <= 0.003_130_8 {
normalized * 12.92
} else {
1.055 * c.powf(1.0 / 2.4) - 0.055
}
1.055 * normalized.powf(1.0 / 2.4) - 0.055
};
encoded * 255.0
}
/// 线性 RGB → CIE XYZ(D65 白点,sRGB 原色)。
pub(crate) fn linear_to_xyz(rgb: [f32; 3]) -> [f32; 3] {
let [r, g, b] = rgb;
[
0.412_456_4 * r + 0.357_576_1 * g + 0.180_437_5 * b,
0.212_672_9 * r + 0.715_152_2 * g + 0.072_175_0 * b,
0.019_333_9 * r + 0.119_192 * g + 0.950_304_1 * b,
]
/// 线性 RGB(0..=100)→ 不透明颜色。
pub(crate) fn color_from_linrgb(linrgb: [f64; 3]) -> Color {
Color::rgb(
delinearized(linrgb[0]),
delinearized(linrgb[1]),
delinearized(linrgb[2]),
)
}
/// 相对亮度 Y(WCAG 与 CIELAB 共用这条 Y,白点 1.0)。
pub(crate) fn luminance(rgb: [f32; 3]) -> f32 {
linear_to_xyz(rgb)[1]
/// 色值 → XYZ(Y 在 0..=100 标度)。
pub(crate) fn xyz_from_color(color: Color) -> [f64; 3] {
let linear = [
linearized(color.r),
linearized(color.g),
linearized(color.b),
];
matrix_multiply(linear, SRGB_TO_XYZ)
}
/// CIELAB `L*`(M3 的 **tone** 就是这个轴,0 = 黑 / 100 = 白)。
pub(crate) fn lstar_from_luminance(y: f32) -> f32 {
let t = y.max(0.0);
if t > 0.008_856 {
116.0 * t.cbrt() - 16.0
/// `L*` 的比较函数(CIE 分段线性/立方根)。
fn lab_f(t: f64) -> f64 {
const EPSILON: f64 = 216.0 / 24389.0;
const KAPPA: f64 = 24389.0 / 27.0;
if t > EPSILON {
t.powf(1.0 / 3.0)
} else {
903.296_3 * t
(KAPPA * t + 16.0) / 116.0
}
}
/// `L*` → 相对亮度 Y([`lstar_from_luminance`] 的逆)。
pub(crate) fn luminance_from_lstar(lstar: f32) -> f32 {
let l = lstar.clamp(0.0, 100.0);
if l > 8.0 {
((l + 16.0) / 116.0).powi(3)
/// [`lab_f`] 的逆。
fn lab_invf(ft: f64) -> f64 {
const EPSILON: f64 = 216.0 / 24389.0;
const KAPPA: f64 = 24389.0 / 27.0;
let cubed = ft * ft * ft;
if cubed > EPSILON {
cubed
} else {
l / 903.296_3
(116.0 * ft - 16.0) / KAPPA
}
}
/// 线性 RGB → OKLab。
pub(crate) fn linear_to_oklab(rgb: [f32; 3]) -> [f32; 3] {
let [r, g, b] = rgb;
let l = 0.412_221_47 * r + 0.536_332_55 * g + 0.051_445_99 * b;
let m = 0.211_903_5 * r + 0.680_699_5 * g + 0.107_396_96 * b;
let s = 0.088_302_46 * r + 0.281_718_84 * g + 0.629_978_7 * b;
let (l_, m_, s_) = (l.cbrt(), m.cbrt(), s.cbrt());
[
0.210_454_26 * l_ + 0.793_617_8 * m_ - 0.004_072_047 * s_,
1.977_998_5 * l_ - 2.428_592_2 * m_ + 0.450_593_7 * s_,
0.025_904_037 * l_ + 0.782_771_77 * m_ - 0.808_675_77 * s_,
]
/// `L*` → 相对亮度 Y(0..=100)。
pub(crate) fn y_from_lstar(lstar: f64) -> f64 {
100.0 * lab_invf((lstar + 16.0) / 116.0)
}
/// OKLab → 线性 RGB(可能越界,负值表示超出 sRGB gamut)。
pub(crate) fn oklab_to_linear(lab: [f32; 3]) -> [f32; 3] {
let [l, a, b] = lab;
let l_ = l + 0.396_337_78 * a + 0.215_803_76 * b;
let m_ = l - 0.105_561_346 * a - 0.063_854_17 * b;
let s_ = l - 0.089_484_18 * a - 1.291_485_5 * b;
let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
[
4.076_741_7 * l3 - 3.307_711_6 * m3 + 0.230_969_94 * s3,
-1.268_438 * l3 + 2.609_757_4 * m3 - 0.341_319_38 * s3,
-0.004_196_086_3 * l3 - 0.703_418_6 * m3 + 1.707_614_7 * s3,
]
/// 相对亮度 Y(0..=100)→ `L*`。
pub(crate) fn lstar_from_y(y: f64) -> f64 {
lab_f(y / 100.0) * 116.0 - 16.0
}
/// OKLCh → 线性 RGB。
pub(crate) fn oklch_to_linear(l: f32, chroma: f32, hue_deg: f32) -> [f32; 3] {
let h = hue_deg.to_radians();
oklab_to_linear([l, chroma * h.cos(), chroma * h.sin()])
/// 色值 → `L*`(即 M3 的 **tone**)。
pub(crate) fn lstar_from_color(color: Color) -> f64 {
lstar_from_y(xyz_from_color(color)[1])
}
/// 线性 RGB 是否落在 sRGB 立方体内([`GAMUT_EPS`] 容差)。
pub(crate) fn in_gamut(rgb: [f32; 3]) -> bool {
rgb.iter()
.all(|c| *c >= -GAMUT_EPS && *c <= 1.0 + GAMUT_EPS)
/// `L*` → 中性灰色值(`L*` 与彩度无关,纯灰)。
pub(crate) fn color_from_lstar(lstar: f64) -> Color {
let component = delinearized(y_from_lstar(lstar));
Color::rgb(component, component, component)
}
/// 求「在保证 `L* = lstar` 的前提下,OKLab 明度该取多少」。
///
/// OKLab 的 L 不是 Y 的纯函数(彩色会偏离),所以给定 tone 与色相/彩度时用二分反解,
/// 让最终颜色的 **CIELAB L\* 精确等于 tone**——M3 的 tone 定义在 L\* 上,这一点必须对齐。
pub(crate) fn oklab_lightness_for_tone(chroma: f32, hue_deg: f32, lstar: f32) -> f32 {
let target = luminance_from_lstar(lstar);
let mut lo = 0.0_f32;
let mut hi = 1.0_f32;
for _ in 0..32 {
let mid = 0.5 * (lo + hi);
if luminance(oklch_to_linear(mid, chroma, hue_deg)) < target {
lo = mid;
} else {
hi = mid;
}
/// Java `Math.signum` 的口径:负数 −1、零 0、正数 1(**Rust 的 `f64::signum(0.0)` 是 1**,
/// 直接换会改变中性色的求解结果)。
pub(crate) fn signum(value: f64) -> f64 {
if value < 0.0 {
-1.0
} else if value == 0.0 {
0.0
} else {
1.0
}
0.5 * (lo + hi)
}
/// 在该 tone / 色相下,把彩度压进 sRGB gamut 后能取到的最大值(上限 `chroma_max`)。
pub(crate) fn chroma_in_gamut(lstar: f32, hue_deg: f32, chroma_max: f32) -> f32 {
let hi0 = chroma_max.max(0.0);
if in_gamut(oklch_to_linear(
oklab_lightness_for_tone(hi0, hue_deg, lstar),
hi0,
hue_deg,
)) {
return hi0;
/// 角度归一到 `0.0..360.0`。
pub(crate) fn sanitize_degrees(degrees: f64) -> f64 {
let wrapped = degrees % 360.0;
if wrapped < 0.0 {
wrapped + 360.0
} else {
wrapped
}
let mut lo = 0.0_f32;
let mut hi = hi0;
for _ in 0..24 {
let mid = 0.5 * (lo + hi);
let ok = in_gamut(oklch_to_linear(
oklab_lightness_for_tone(mid, hue_deg, lstar),
mid,
hue_deg,
));
if ok {
lo = mid;
} else {
hi = mid;
}
}
lo
}
/// 该 tone / 色相下 sRGB 能表达的最大彩度(P3.2 的「按比例压彩度」用它做基准)。
pub(crate) fn max_chroma(lstar: f32, hue_deg: f32) -> f32 {
chroma_in_gamut(lstar, hue_deg, GAMUT_CHROMA_CAP)
/// 弧度归一到 `0.0..2π`(官方 `sanitizeRadians`,多加了 8π 保证非负)。
pub(crate) fn sanitize_radians(angle: f64) -> f64 {
(angle + std::f64::consts::PI * 8.0) % (std::f64::consts::PI * 2.0)
}
/// OKLCh 坐标(色相用度,0..360)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Oklch {
/// 明度 0..1(中性灰时 ≈ 相对亮度的立方根)。
pub lightness: f32,
/// 彩度(0 = 中性灰)。
pub chroma: f32,
/// 色相角,单位度,0..360。
pub hue_deg: f32,
/// 线性插值(官方 `lerp`)。
pub(crate) fn lerp(start: f64, stop: f64, amount: f64) -> f64 {
(1.0 - amount) * start + amount * stop
}
impl Oklch {
/// 从一个 sRGB 颜色取色相/彩度(用作调色板的种子)。
pub fn from_color(color: Color) -> Self {
let lin = color.to_linear_f32();
let [l, a, b] = linear_to_oklab(lin);
let mut hue = b.atan2(a).to_degrees();
if hue < 0.0 {
hue += 360.0;
}
Self {
lightness: l,
chroma: (a * a + b * b).sqrt(),
hue_deg: hue,
/// 夹取到 `min..=max`。
pub(crate) fn clamp(min: f64, max: f64, input: f64) -> f64 {
input.clamp(min, max)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lstar_and_luminance_are_inverse() {
for lstar in [0.0_f64, 8.0, 20.0, 50.0, 87.0, 98.0, 100.0] {
let back = lstar_from_y(y_from_lstar(lstar));
assert!((back - lstar).abs() < 1.0e-9, "L* {lstar} 往返成 {back}");
}
}
/// 在色相环上旋转(用于派生 tertiary 这类同族但不同色的调色板)。
pub fn rotated(mut self, deg: f32) -> Self {
self.hue_deg = (self.hue_deg + deg).rem_euclid(360.0);
self
#[test]
fn white_and_black_anchor_the_scale() {
assert_eq!(delinearized(100.0), 255);
assert_eq!(delinearized(0.0), 0);
assert_eq!(color_from_lstar(100.0), Color::WHITE);
assert_eq!(color_from_lstar(0.0), Color::BLACK);
assert!((lstar_from_color(Color::WHITE) - 100.0).abs() < 1.0e-6);
assert!(lstar_from_color(Color::BLACK).abs() < 1.0e-6);
}
}
impl Color {
/// 线性光强三元组(0..1,越界不夹取)。
fn to_linear_f32(self) -> [f32; 3] {
let f = |c: u8| srgb_to_linear(f32::from(c) / 255.0);
[f(self.r), f(self.g), f(self.b)]
#[test]
fn white_point_matches_d65() {
// 用官方那组舍入矩阵,纯白必须落在 D65 白点上(容差 1e-3 对应矩阵末位舍入)。
let xyz = xyz_from_color(Color::WHITE);
for (got, want) in xyz.iter().zip(WHITE_POINT_D65) {
assert!(
(got - want).abs() < 1.0e-3,
"{xyz:?} vs D65 {WHITE_POINT_D65:?}"
);
}
assert!(xyz_from_color(Color::BLACK).iter().all(|v| *v == 0.0));
}
#[test]
fn signum_matches_java_semantics() {
assert_eq!(signum(-1.5), -1.0);
assert_eq!(signum(0.0), 0.0);
assert_eq!(signum(-0.0), 0.0);
assert_eq!(signum(2.5), 1.0);
}
#[test]
fn degrees_sanitize_wraps_negatives() {
assert!((sanitize_degrees(-30.0) - 330.0).abs() < 1.0e-9);
assert!(sanitize_degrees(360.0).abs() < 1.0e-9);
}
}
+77 -90
View File
@@ -1,115 +1,102 @@
//! tone 调色板:一个种子色 → 一条「固定色相/彩度、tone 任取」的调色板。
//! tone 调色板:**固定 HCT 色相 + 固定 HCT 彩度**,沿 tone 轴取色。
//!
//! 这是 M3 tonal palette 的**近似实现**:色相恒定、按 tone 取色、按 tone 压彩度进 sRGB gamut。
//! 口径与已知偏差见 [`super`] 模块文档(OKLCh 而非 CAM16/HCT)。
//! 这就是 M3 的 `TonalPalette`:一条调色板只回答一个问题——「我的第 N 号 tone 是什么色」。
//! 因为 HCT 的 tone 轴是 `L*`,同一条调色板上「tone 差 40 ⇒ 对比度 ≥ 3:1」天然成立;
//! 又因为 hue 固定,整条调色板在感知上是同一个色相(这正是 OKLCh 近似做不到的那一点)。
//!
//! 语义对齐 `java/palettes/TonalPalette.java`(Apache-2.0,见 `NOTICE`)。
use super::space::{self, Oklch};
use super::Color;
use super::{Color, Hct};
/// 一条 tone 调色板(色相固定 + 彩度上限)。
/// 一条 tone 调色板(HCT 色相 + 彩度上限)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TonalPalette {
hue_deg: f32,
chroma: f32,
hue_deg: f64,
chroma: f64,
}
impl TonalPalette {
/// 直接给色相(度)与彩度上限(OKLCh 单位,0 = 中性灰)建调色板。
pub fn new(hue_deg: f32, chroma: f32) -> Self {
/// 直接给 HCT 色相(度)与 HCT 彩度建调色板(彩度 0 = 中性灰)。
pub fn new(hue_deg: f64, chroma: f64) -> Self {
Self {
hue_deg: hue_deg.rem_euclid(360.0),
chroma: chroma.max(0.0),
}
}
/// 从种子色取色相与彩度(等价 M3 的 `palette from seed`)。
/// 从种子色取 HCT 色相与彩度(等价官方 `TonalPalette.fromInt`)。
pub fn from_color(seed: Color) -> Self {
let lch = Oklch::from_color(seed);
Self::new(lch.hue_deg, lch.chroma)
let hct = Hct::from_color(seed);
Self::new(hct.hue, hct.chroma)
}
/// 固定色相,改彩度上限(用于把种子彩度压到 secondary/neutral 档)。
pub fn with_chroma(self, chroma: f32) -> Self {
/// 固定色相,换彩度(M3 用它把种子的彩度压到 secondary/neutral 档)。
pub fn with_chroma(self, chroma: f64) -> Self {
Self::new(self.hue_deg, chroma)
}
/// 彩度不超过 `limit`(种子本身更灰时保持更灰,不硬拉饱和)。
pub fn limited_chroma(self, limit: f32) -> Self {
self.with_chroma(self.chroma.min(limit))
}
/// 旋转色相(derived hue)。
pub fn rotated(self, deg: f32) -> Self {
/// 旋转色相(derived hue,例如 tertiary = 种子色相 +60°)。
pub fn rotated(self, deg: f64) -> Self {
Self::new(self.hue_deg + deg, self.chroma)
}
/// 该调色板的色相(度)。
pub fn hue_deg(self) -> f32 {
/// 该调色板的 HCT 色相(度)。
pub fn hue_deg(self) -> f64 {
self.hue_deg
}
/// 该调色板的彩度上限(OKLCh 单位)。
pub fn chroma(self) -> f32 {
/// 该调色板的 HCT 彩度。
pub fn chroma(self) -> f64 {
self.chroma
}
/// 取某个 tone 的颜色(tone 0..100,超出夹取)。
/// 取某个 tone 的颜色(tone 超出 0..=100 时自然夹到纯黑/纯白)。
///
/// 实现口径:CIELAB `L*` 精确等于 tone(M3 的 tone 定义),彩度按该 tone 的 gamut 上限
/// 二分裁剪——所以 tone 0 恒为纯黑、tone 100 恒为纯白,且同 tone 的 WCAG 对比度
/// 与色相无关(这是对比度门禁能跨种子稳定的原因)。
pub fn tone(self, tone: f32) -> Color {
self.tone_scaled(tone, self.chroma)
/// 彩度装不下时按官方的 gamut 映射**只压彩度**,色相与 tone 不变。
pub fn tone(self, tone: f64) -> Color {
self.hct(tone).to_color()
}
/// 带「共享彩度基准」的取色:**所有调色板按同一比例压彩度进 gamut**。
/// 取某个 tone 的 HCT 坐标(`TonalPalette.hct` / 官方 `getHct`)。
///
/// `reference_chroma` 一般是方案里最饱和那条调色板(primary)的彩度。压缩系数
/// `f = min(1, C_max(tone,hue) / reference)`,每条调色板取 `C_req × f`——
/// 这样 `primary` 与 `secondary` 在高 tone(tone 90/96 这类浅容器)上不会被**一起裁到**
/// 同一个 gamut 上限而撞色,`secondary` 永远比 `primary` 更灰(M3 的语义就靠这个)。
/// 单独用 [`TonalPalette::tone`] 时没有基准,等价于 `reference = 自身彩度`(直接裁剪)。
pub fn tone_scaled(self, tone: f32, reference_chroma: f32) -> Color {
let lstar = tone.clamp(0.0, 100.0);
// 端点直接给纯黑/纯白:近黑处的 gamut 容差会放进微小彩度,导致 tone 0 偏色。
if lstar <= 0.0 {
return Color::BLACK;
/// 黄相(105°..125°)的 tone 99 是官方的特例:那里 `L*` 与 sRGB 平面不平滑,
/// 直接横向取自 tone 98 与 tone 100 的中点,避免出一格色带。
pub fn hct(self, tone: f64) -> Hct {
if tone == 99.0 && Hct::is_yellow(self.hue_deg) {
let low = self.hct(98.0).to_color();
let high = self.hct(100.0).to_color();
return Hct::from_color(average(low, high));
}
if lstar >= 100.0 {
return Color::WHITE;
}
let c_max = space::max_chroma(lstar, self.hue_deg);
let factor = if reference_chroma > 1.0e-6 {
(c_max / reference_chroma).min(1.0)
} else {
1.0
};
let chroma = (self.chroma * factor).min(c_max).max(0.0);
let lightness = space::oklab_lightness_for_tone(chroma, self.hue_deg, lstar);
Color::from_linear_f32(space::oklch_to_linear(lightness, chroma, self.hue_deg))
Hct::from_components(self.hue_deg, self.chroma, tone)
}
}
/// 两色的通道中点(官方 `averageArgb`,四舍五入)。
fn average(a: Color, b: Color) -> Color {
let mid = |x: u8, y: u8| {
let sum = u16::from(x) + u16::from(y);
u8::try_from(sum.div_ceil(2)).unwrap_or(u8::MAX)
};
Color::rgb(mid(a.r, b.r), mid(a.g, b.g), mid(a.b, b.b))
}
#[cfg(test)]
mod tests {
use super::*;
const SEED: Color = Color::rgb(0x67, 0x50, 0xA4);
fn lstar_of(color: Color) -> f32 {
color.lstar()
}
#[test]
fn tone_matches_lstar_within_one_unit() {
let palette = TonalPalette::from_color(SEED);
fn tone_is_hit_within_quantisation_error() {
let palette = TonalPalette::from_color(SEED).with_chroma(36.0);
for tone in [
0.0_f32, 6.0, 10.0, 30.0, 40.0, 50.0, 80.0, 90.0, 95.0, 98.0, 100.0,
4.0_f64, 6.0, 10.0, 12.0, 17.0, 20.0, 30.0, 40.0, 50.0, 80.0, 87.0, 90.0, 92.0, 94.0,
95.0, 96.0, 98.0,
] {
let got = lstar_of(palette.tone(tone));
let got = Hct::from_color(palette.tone(tone)).tone;
assert!(
(got - tone).abs() <= 1.0,
"tone {tone} 的 L* 是 {got},偏差超过 1(量化误差上限)"
"tone {tone} 的 L* 是 {got},偏差超过 1(8 bit 量化上限)"
);
}
}
@@ -124,40 +111,40 @@ mod tests {
}
#[test]
fn seed_hue_is_preserved_across_tones() {
let palette = TonalPalette::from_color(SEED);
for tone in [30.0_f32, 50.0, 70.0] {
let lch = Oklch::from_color(palette.tone(tone));
let delta = (lch.hue_deg - palette.hue_deg()).abs();
fn hue_is_preserved_across_the_whole_ramp() {
let palette = TonalPalette::new(298.980_997, 36.0);
for tone in [10.0_f64, 30.0, 50.0, 70.0, 90.0] {
let hct = palette.hct(tone);
let delta = (hct.hue - palette.hue_deg()).abs();
let delta = delta.min(360.0 - delta);
assert!(delta < 3.0, "tone {tone} 色相偏移 {delta}° 过大");
assert!(delta < 1.0, "tone {tone} 色相偏移 {delta}°");
}
}
#[test]
fn neutral_palette_is_gray() {
fn chroma_zero_is_neutral_gray() {
let neutral = TonalPalette::new(120.0, 0.0);
let c = neutral.tone(50.0);
let spread = f32::from(c.r.max(c.g).max(c.b) - c.r.min(c.g).min(c.b));
assert!(
spread <= 1.0,
"彩度 0 的调色板应当接近中性灰,实测通道差 {spread}"
);
for tone in [6.0_f64, 30.0, 50.0, 90.0, 98.0] {
let color = neutral.tone(tone);
assert!(color.r == color.g && color.g == color.b, "{color:?}");
}
}
#[test]
fn chroma_never_leaves_gamut() {
// 高彩度种子在极端 tone 上必须被裁到 gamut 内(不出现「负 RGB」削顶后的色带)。
let hot = TonalPalette::new(30.0, 0.4);
for tone in [5.0_f32, 20.0, 40.0, 60.0, 80.0, 95.0] {
let lch = Oklch::from_color(hot.tone(tone));
assert!(
lch.chroma <= 0.4 + 1.0e-3,
"tone {tone} 彩度 {} 超出请求上限",
lch.chroma
);
let got = lstar_of(hot.tone(tone));
assert!((got - tone).abs() <= 1.0, "tone {tone} 的 L* 是 {got}");
}
fn yellow_tone_99_uses_the_official_average() {
let palette = TonalPalette::new(110.0, 40.0);
let expected = average(palette.tone(98.0), palette.tone(100.0));
assert_eq!(palette.tone(99.0), expected);
// 非黄相不触发特例:tone 99 走正常求解。
let normal = TonalPalette::new(300.0, 40.0);
assert_eq!(normal.tone(99.0), normal.hct(99.0).to_color());
}
#[test]
fn requested_chroma_is_respected_when_the_gamut_allows() {
// tone 50 是最「宽」的色调面,彩度 36 一定装得下。
let palette = TonalPalette::new(298.980_997, 36.0);
let hct = palette.hct(50.0);
assert!((hct.chroma - 36.0).abs() < 0.5, "彩度 {}", hct.chroma);
}
}
@@ -0,0 +1,165 @@
//! CAM16 的**观察条件**(viewing conditions)。
//!
//! HCT 的色相/彩度来自 CAM16,而 CAM16 是「在外界光环境下看起来如何」的模型——同一个 hex,
//! 换观察条件就是不同的 hue/chroma。M3 固定用 sRGB 默认条件:D65 白点、200 lux(适应亮度)、
//! 背景 `L* = 50`、surround = 2.0、**不做**光源折扣(自发光屏幕)。
//!
//! 照搬 `java/hct/ViewingConditions.java`(Apache-2.0,见 `NOTICE`)。
use super::space;
/// CAM16 观察条件里与颜色无关的那堆中间量(只为避免重复计算)。
#[derive(Debug, Clone, Copy)]
pub(crate) struct ViewingConditions {
/// 白点响应(achromatic response to the white point)。
pub(crate) aw: f64,
/// 背景亮度因子。
pub(crate) n: f64,
/// 亮度指数。
pub(crate) nbb: f64,
/// 彩度指数。
pub(crate) ncb: f64,
/// 环绕度决定的对比度系数。
pub(crate) c: f64,
/// 环绕度。
pub(crate) nc: f64,
/// 三通道的色适应折扣系数。
pub(crate) rgb_d: [f64; 3],
/// 亮度适应因子。
pub(crate) fl: f64,
/// 基函数。
pub(crate) z: f64,
}
// 与 XYZ 有关的常量矩阵在 CAM16 里也要用,从 space 引一次保持单一来源。
use super::cam16::XYZ_TO_CAM16_RGB;
impl ViewingConditions {
/// 按官方参数构造(`white_point` 是 XYZ,`adapting_luminance` 由 lux 换算)。
pub(crate) fn make(
white_point: [f64; 3],
adapting_luminance: f64,
background_lstar: f64,
surround: f64,
discounting_illuminant: bool,
) -> Self {
// 纯黑背景是非物理的,会让「任何颜色都看不见」这类无穷大出现,官方取下限 0.1。
let background_lstar = background_lstar.max(0.1);
let [r_w, g_w, b_w] = space::matrix_multiply(white_point, XYZ_TO_CAM16_RGB);
let f = 0.8 + surround / 10.0;
let c = if f >= 0.9 {
space::lerp(0.59, 0.69, (f - 0.9) * 10.0)
} else {
space::lerp(0.525, 0.59, (f - 0.8) * 10.0)
};
let d = if discounting_illuminant {
1.0
} else {
let raw = f * (1.0 - (1.0 / 3.6) * ((-adapting_luminance - 42.0) / 92.0).exp());
space::clamp(0.0, 1.0, raw)
};
let nc = f;
let rgb_d = [
d * (100.0 / r_w) + 1.0 - d,
d * (100.0 / g_w) + 1.0 - d,
d * (100.0 / b_w) + 1.0 - d,
];
let k = 1.0 / (5.0 * adapting_luminance + 1.0);
let k4 = k * k * k * k;
let k4_f = 1.0 - k4;
let fl = k4 * adapting_luminance + 0.1 * k4_f * k4_f * (5.0 * adapting_luminance).cbrt();
let n = space::y_from_lstar(background_lstar) / white_point[1];
let z = 1.48 + n.sqrt();
let nbb = 0.725 / n.powf(0.2);
let ncb = nbb;
let rgb_a_factors = [
(fl * rgb_d[0] * r_w / 100.0).powf(0.42),
(fl * rgb_d[1] * g_w / 100.0).powf(0.42),
(fl * rgb_d[2] * b_w / 100.0).powf(0.42),
];
let rgb_a = [
400.0 * rgb_a_factors[0] / (rgb_a_factors[0] + 27.13),
400.0 * rgb_a_factors[1] / (rgb_a_factors[1] + 27.13),
400.0 * rgb_a_factors[2] / (rgb_a_factors[2] + 27.13),
];
let aw = (2.0 * rgb_a[0] + rgb_a[1] + 0.05 * rgb_a[2]) * nbb;
Self {
aw,
n,
nbb,
ncb,
c,
nc,
rgb_d,
fl,
z,
}
}
/// 指定背景 `L*` 的 sRGB 默认条件。
pub(crate) fn default_with_background_lstar(background_lstar: f64) -> Self {
Self::make(
space::WHITE_POINT_D65,
200.0 / std::f64::consts::PI * space::y_from_lstar(50.0) / 100.0,
background_lstar,
2.0,
false,
)
}
}
/// 全仓共用的默认观察条件(sRGB 屏,背景 `L* = 50`)。
pub(crate) fn default_conditions() -> &'static ViewingConditions {
static DEFAULT: std::sync::OnceLock<ViewingConditions> = std::sync::OnceLock::new();
DEFAULT.get_or_init(|| ViewingConditions::default_with_background_lstar(50.0))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_conditions_match_the_official_constants() {
// 官方 material-color-utilities 的 ViewingConditions.DEFAULT 解出来的值(Java 实测,
// `hct.Count` 打印)。
let vc = default_conditions();
assert!(
(vc.n - 0.184_186_518_512_444_16).abs() < 1.0e-12,
"n = {}",
vc.n
);
assert!(
(vc.aw - 29.980_997_194_447_333).abs() < 1.0e-12,
"aw = {}",
vc.aw
);
assert!(
(vc.nbb - 1.016_919_180_445_875_5).abs() < 1.0e-12,
"nbb = {}",
vc.nbb
);
assert!((vc.c - 0.69).abs() < 1.0e-12);
assert!((vc.nc - 1.0).abs() < 1.0e-12);
assert!(
(vc.fl - 0.388_481_453_780_035_3).abs() < 1.0e-12,
"fl = {}",
vc.fl
);
assert!(
(vc.z - 1.909_169_568_483_652).abs() < 1.0e-12,
"z = {}",
vc.z
);
assert!((vc.rgb_d[0] - 1.021_177_702_757_52).abs() < 1.0e-12);
assert!((vc.rgb_d[1] - 0.986_307_729_428_012_4).abs() < 1.0e-12);
assert!((vc.rgb_d[2] - 0.933_960_508_280_229_9).abs() < 1.0e-12);
}
#[test]
fn white_point_has_zero_chroma_in_cam16() {
// CAM16 的白点在默认条件下应当接近中性(这才是「色适应」的意义)。
use super::super::cam16::Cam16;
let white = Cam16::from_color(super::super::Color::WHITE);
assert!(white.chroma < 5.0, "白点彩度 {}", white.chroma);
}
}
+179
View File
@@ -0,0 +1,179 @@
//! 组件级微度量(`ComponentMetrics`):**单个控件内部的几何**,不是窗口骨架。
//!
//! 为什么要有这个结构(M69 / G10):Checkbox 的 20×20、圆角 2、描边 2、状态层 Ø40 这类值,
//! `ShapeScale`(刻度)装不了、`LayoutMetrics`(骨架)也不该装——M66 对账时它们只能写在组件
//! 实现里,而 D30 禁止组件层自定义数值。这里给它们一个**唯一落点**。
//!
//! ## 与 `LayoutMetrics` 的分工(两把尺子,量不同的东西)
//!
//! | | [`crate::spacing::LayoutMetrics`] | `ComponentMetrics`(本模块) |
//! |---|---|---|
//! | 管什么 | 窗口骨架与布局节奏:标题栏/页头/行高、dock 宽度、命中区 | 单个控件方框内的几何:外框、微圆角、描边/环线宽、状态层、内点、图标偏移 |
//! | 随什么变 | 窗口尺寸、列宽、用户折叠导航 | 只随组件自身规格变 |
//! | 例子 | `nav_drawer_width = 240`、`search_bar_height = 56` | checkbox 20×20、描边 2、状态层 Ø40 |
//! | 判据 | 决定「一块面板/一行占多宽多高」 | 决定「画进这个方框里的那几个像素」 |
//!
//! 冲突时的规则是**同一个量只许有一份**:搜索条高 56 属骨架级,所以
//! [`ComponentMetrics::SEARCH_BAR`] 的 `height` 留 `0.0` 并在文档里指回 LM;drawer 宽同理。
//! 测试 `component_metrics_never_duplicate_layout_metrics` 把这条钉死。
//!
//! 数值来源:`androidx.compose.material3:material3-android:1.4.0` 源码(A 级,M66 逐行核对,
//! 见 `research/15` §18~§23)。逐字段的出处写在下面各组件的关联常量上;**团队口径**(官方没有
//! token 的)单独注明。
use super::Component;
use crate::icons::IconSize;
use crate::shape::ComponentRadius;
use crate::spacing::Space;
/// 单个控件的内部几何。
///
/// `0.0` 表示**该控件没有这一项**(不是「默认值 0」)——拿 `0.0` 反推数值一定错。
/// 骨架级尺寸(条高、面板宽)不在这里,去 [`crate::spacing::LayoutMetrics`] 拿。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ComponentMetrics {
/// 可见外框宽(dp;`0.0` = 由内容或骨架决定)。
pub width: f32,
/// 可见外框高(dp;`0.0` = 由内容或骨架决定)。
pub height: f32,
/// 微控件圆角(D32 第二层)。`None` = 该控件的圆角走 `ShapeScale`(见 `ComponentSpec::shape`)。
pub radius: Option<ComponentRadius>,
/// 描边 / 环线宽度(dp)。**禁用态也不许变细**——官方 disabled 只改透明度。
pub stroke: f32,
/// 状态层(hover / focus / pressed)圆的直径(dp;`0.0` = 不画状态层)。
pub state_layer: f32,
/// 内点直径(dp):radio 选中时的实心点,其余控件 `0.0`。
pub inner_dot: f32,
/// 控件相对容器的外距(dp):drawer 项 = 12,决定选中指示器左右各内缩 12。
pub inset: f32,
/// 内容起始侧内边距(dp)。**图标不算在内**:图标可视边 = `pad_start + icon_offset`。
pub pad_start: f32,
/// 内容结束侧内边距(dp)。同起始侧,图标可视边 = `pad_end + icon_offset`。
pub pad_end: f32,
/// 图标边长(dp;`0.0` = 无图标)。
pub icon: f32,
/// 图标相对内边距的**额外**偏移(dp)。图标可视边 = 内边距 + 本值(search bar:12 + 4 = 16),
/// **不要**把内边距本身当图标边用,也不要再手抄那个 12 / 16。
pub icon_offset: f32,
/// 图标与文字(或相邻元素)的间距(dp)。
pub gap: f32,
}
impl ComponentMetrics {
/// Checkbox(`research/15` §18 / §23)。
///
/// A 级出处:`Checkbox.kt:737 CheckboxSize = 20.dp`、`:738 RadiusSize = 2.dp`(圆角走 D32 的
/// [`ComponentRadius::Micro`])、`CheckboxTokens.UnselectedOutlineWidth = 2`、
/// `StateLayerSize = 40` + `StateLayerShape = CornerFull`。
/// 注意 `CheckboxTokens.ContainerWidth/Height = 18` 是**死值**(全仓 0 引用),Compose 实际画
/// 20(§26.2 矛盾 ①)——判据取 20。
/// 勾选控件 ↔ 标签间距 8 是**团队口径**:官方没有「框 + 标签」组合的 token(§18.9,估)。
pub const CHECKBOX: Self = Self {
width: 20.0,
height: 20.0,
radius: Some(ComponentRadius::Micro),
stroke: 2.0,
state_layer: 40.0,
inner_dot: 0.0,
inset: 0.0,
pad_start: 0.0,
pad_end: 0.0,
icon: 0.0,
icon_offset: 0.0,
gap: Space::X2.dp(),
};
/// Radio(`research/15` §19 / §23)。
///
/// A 级出处:`RadioButtonTokens.IconSize = 20`、`RadioStrokeWidth = 2.dp`;内点名义
/// `RadioButtonDotSize = 12`,但绘制半径 = 12/2 − stroke/2 = 5 → **实际内点直径 10**
/// (§19.2:抄 12 会明显偏大)。圆角走 `ShapeScale::Full`(真圆),不是微半径。
/// 标签间距 8 同 Checkbox(团队口径)。
pub const RADIO: Self = Self {
width: 20.0,
height: 20.0,
radius: None,
stroke: 2.0,
state_layer: 40.0,
inner_dot: 10.0,
inset: 0.0,
pad_start: 0.0,
pad_end: 0.0,
icon: 0.0,
icon_offset: 0.0,
gap: Space::X2.dp(),
};
/// Search bar(`research/15` §20A / §23)。
///
/// 高度与宽度**不在这里**:条高 56 是骨架级(`LayoutMetrics::search_bar_height`),
/// 最小宽 360 同样属骨架级(尚未进 LM,M69 报告里登记为待办)。这里只装画进 bar 里的几何:
/// 图标 24(`IconSize::Toolbar`)、**图标可视边 = `pad_start + icon_offset`** = 12 + 4 = **16**
/// (右侧同式为 16,§20.4)、圆角走 `ShapeScale::Full`(56 → 28 = 官方 `SearchBarCornerRadius`),
/// 所以 `radius` 是 `None`。
///
/// 为什么要拆成 12 + 4 而不是直接写 16:官方 = 文本框内容内边距 **12**(`TextFieldImpl` 默认)
/// 加上 `SearchBarIconOffsetX = 4`。拆开才能让「文本从 12 起、图标从 16 起」都被消费方拿到;
/// 只写 16 的话文本内边距没有落点,消费方只能自己写 `12.0`——正是 G10 要消灭的字面量。
/// 图标位置一律按 `pad + icon_offset` 求和,不要单独把 `pad` 当图标边用。
pub const SEARCH_BAR: Self = Self {
width: 0.0,
height: 0.0,
radius: None,
stroke: 0.0,
state_layer: 0.0,
inner_dot: 0.0,
inset: 0.0,
pad_start: Space::X3.dp(),
pad_end: Space::X3.dp(),
icon: IconSize::Toolbar.px(),
icon_offset: 4.0,
// 图标 ↔ 文本 8 是**团队口径 [估]**:`research/15` §20.4 只给了图标尺寸/位置与 48 命中框,
// 没有「图标 ↔ 文字」的 token(Compose 由 `TextFieldImpl` 内部排版决定)。
gap: Space::X2.dp(),
};
/// Navigation drawer(`research/15` §21 / §23)。
///
/// 宽度与项高**不在这里**:drawer 240 / rail 88 在 `LayoutMetrics`,项高 44 =
/// `LayoutMetrics::nav_item_height`。这里装项内几何(§21.6,A 级:
/// `NavigationDrawerItemDefaults.ItemPadding = 12`、项内 start 16 / end 24、
/// `IconSize = 24`、图标 ↔ label 12)。
/// 容器形状 `CornerLargeEnd`(只有靠内容那一侧两角 16)由 `ComponentSpec::shape` 的
/// `Large` + 组件实现按侧位决定哪两个角,度量层不表达「部分角」。
pub const NAVIGATION_DRAWER: Self = Self {
width: 0.0,
height: 0.0,
radius: None,
stroke: 0.0,
state_layer: 0.0,
inner_dot: 0.0,
inset: 12.0,
pad_start: Space::X4.dp(),
pad_end: Space::X6.dp(),
icon: IconSize::Toolbar.px(),
icon_offset: 0.0,
gap: Space::X3.dp(),
};
/// drawer 选中指示器宽度:容器宽 − 2 × [`ComponentMetrics::inset`]。
///
/// 240 − 24 = **216**;官方 360 − 24 = 336 是同一个公式(§21.5)——写死 336 就是忘了推导。
pub fn indicator_width(&self, container_width: f32) -> f32 {
(container_width - self.inset * 2.0).max(0.0)
}
}
/// 组件 → 组件级微度量。`None` = 该组件没有控件内部几何(只有骨架值,去 LM 拿)。
///
/// Divider 是 `None` 的显式例子:它的唯一几何量是厚度,而厚度是**一像素线**
/// (`LayoutMetrics::divider_thickness` = `stroke::HAIRLINE`),不是「控件内部」的东西。
pub(super) const fn metrics_of(component: Component) -> Option<ComponentMetrics> {
match component {
Component::Checkbox => Some(ComponentMetrics::CHECKBOX),
Component::Radio => Some(ComponentMetrics::RADIO),
Component::Search => Some(ComponentMetrics::SEARCH_BAR),
Component::NavigationDrawer => Some(ComponentMetrics::NAVIGATION_DRAWER),
_ => None,
}
}
+72 -132
View File
@@ -8,16 +8,25 @@
//! - 形状档位见 [`crate::shape::ShapeScale`],高程见 [`crate::elevation::Elevation`];
//! - `text` 是主要文字角色,`support` 是辅助文字(没有就 `None`);
//! - `motion` + `duration` 是状态过渡(hover/press/展开),不是页面切换;
//! - `morph` 是按下/选中时的形状形变(Expressive 特征),不需要就 `None`。
//! - `morph` 是按下/选中时的形状形变(Expressive 特征),不需要就 `None`;
//! - 圆角分两层(D32):容器级查 [`crate::shape::ShapeScale`],≤24dp 的微控件查
//! [`crate::shape::ComponentRadius`](落在 [`ComponentSpec::micro_radius`])。
//!
//! 档位(形状/高程/字级/动效)之外,**控件内部的微几何**(20×20、描边 2、状态层 Ø40 …)在
//! [`ComponentMetrics`] 里,入口是 [`Component::metrics`]——两者分工见该项的文档
//! (`component_map::metrics` 模块)。
//!
//! **改这里的值 = 改全站观感**,必须同步 `DevDocs/SubAgentsTalk/design-令牌约定.md`;
//! 页面线不许在页面里覆盖这些档位。
mod metrics;
mod table;
pub use metrics::ComponentMetrics;
use crate::elevation::Elevation;
use crate::motion::{DurationToken, Easing};
use crate::shape::{ShapeMorph, ShapeScale};
use crate::shape::{ComponentRadius, ShapeMorph, ShapeScale};
use crate::typography::TypeRole;
/// 组件的排版 + 形状 + 动效档位。
@@ -25,15 +34,24 @@ use crate::typography::TypeRole;
pub struct ComponentSpec {
/// 该档位属于哪个组件(表自描述,便于测试与文档生成)。
pub component: Component,
/// 圆角档位。
/// 圆角档位(容器级,[`ShapeScale`] 的严格刻度)。
pub shape: ShapeScale,
/// 微控件圆角(D32 第二层)。
///
/// `Some` 时**本字段才是半径真源**,此时 [`ComponentSpec::shape`] 必须是
/// [`ShapeScale::None`](占位、不参与渲染)——因为 2dp 不在 `ShapeScale` 刻度里,
/// 硬塞进 `shape` 会破坏「圆角只取刻度值」。这层配对由测试钉死。
pub micro_radius: Option<ComponentRadius>,
/// 高程级别(决定 surface container 角色与是否画阴影)。
pub elevation: Elevation,
/// 主要文字角色。
/// 主要文字角色。**无文字的组件(如 `Divider`)这里是占位值**,消费方不得据此排版。
pub text: TypeRole,
/// 辅助文字角色。
pub support: Option<TypeRole>,
/// 状态过渡缓动。
///
/// 该字段不是 `Option`,所以**没有动效语义的组件(如 `Divider`)这里是占位值**——
/// 读之前先确认组件本身有状态过渡,无过渡的组件不得据此排动效。
pub motion: Easing,
/// 状态过渡时长。
pub duration: DurationToken,
@@ -41,7 +59,10 @@ pub struct ComponentSpec {
pub morph: Option<ShapeMorph>,
}
/// 需要固定档位的组件清单(M53 页面骨架里实际用到的那些)。
/// 需要固定档位的组件清单(M53 页面骨架 + M69 组件线补的 5 个)。
///
/// **冻结为只可末尾追加**:判别值当索引用(`table::SPECS[self as usize]`),改名/换位等于
/// 让整张表错行。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Component {
/// 顶部标题栏(第一层 chrome)。
@@ -84,11 +105,35 @@ pub enum Component {
Tooltip,
/// 空态(图标 + 标题 + 说明 + 标准按钮)。
EmptyState,
/// 复选框(安装选项 / mod 启用 / 日志过滤 / Java 管理勾选)。
///
/// 圆角 **2dp** 不在 `ShapeScale` 刻度里 → 走 [`ComponentSpec::micro_radius`](D32 第二层);
/// 20×20 / 描边 2 / 状态层 Ø40 见 [`ComponentMetrics::CHECKBOX`]。
Checkbox,
/// 单选框(Java 版本单选 / 安装模式选择)。
///
/// 真圆(`ShapeScale::Full`);20 / 环线 2 / 内点 10 / 状态层 Ø40 见 [`ComponentMetrics::RADIO`]。
Radio,
/// 搜索条(实例 / 版本 / mod 搜索,页级入口)。
///
/// 高 56 是骨架级(`LayoutMetrics::search_bar_height`),圆角 `Full`(56 → 28);
/// 图标 24 + 偏移 4 见 [`ComponentMetrics::SEARCH_BAR`]。高程取 **L0**(源码矛盾已登记)。
Search,
/// 导航抽屉(左侧常驻导航的展开形态,D33)。
///
/// 形状 `CornerLargeEnd`(只有靠内容那侧两角 16),宽 240 = `LayoutMetrics::nav_drawer_width`;
/// 项内几何见 [`ComponentMetrics::NAVIGATION_DRAWER`]。
NavigationDrawer,
/// 分隔线(列表行分隔 / 区块分隔)。
///
/// **无高程、无圆角**;厚度是一像素线(`LayoutMetrics::divider_thickness` = `stroke::HAIRLINE`),
/// 颜色 `OutlineVariant`(列表内)或 `Outline`(search view 头部分隔,见 §20.8)。
Divider,
}
impl Component {
/// 全部组件。
pub const ALL: [Component; 20] = [
pub const ALL: [Component; 25] = [
Component::TopAppBar,
Component::NavigationItem,
Component::PrimaryButton,
@@ -109,6 +154,12 @@ impl Component {
Component::Snackbar,
Component::Tooltip,
Component::EmptyState,
// M69 G9:末尾追加的 5 个(前 20 个次序不许动)。
Component::Checkbox,
Component::Radio,
Component::Search,
Component::NavigationDrawer,
Component::Divider,
];
/// 该组件的档位。
@@ -116,6 +167,14 @@ impl Component {
table::SPECS[self as usize]
}
/// 该组件的**控件内部**微度量;`None` = 只有骨架值(去 `LayoutMetrics` 拿)。
///
/// 这一条是 G10 的消费入口:组件线拿到 `Component::Checkbox.metrics()` 就有 20×20 / 圆角 2 /
/// 描边 2 / 状态层 Ø40,**不许再在实现里写这些字面量**。
pub fn metrics(self) -> Option<ComponentMetrics> {
metrics::metrics_of(self)
}
/// 稳定字符串名(kebab-case)。
pub const fn name(self) -> &'static str {
match self {
@@ -139,134 +198,15 @@ impl Component {
Component::Snackbar => "snackbar",
Component::Tooltip => "tooltip",
Component::EmptyState => "empty-state",
// M69 G9:末尾追加(`name()` 是持久化字符串 id,同样只许末尾追加)。
Component::Checkbox => "checkbox",
Component::Radio => "radio",
Component::Search => "search",
Component::NavigationDrawer => "navigation-drawer",
Component::Divider => "divider",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_matches_component_order() {
for (i, spec) in table::SPECS.iter().enumerate() {
assert_eq!(
spec.component as usize, i,
"SPECS 第 {i} 行顺序与枚举不一致"
);
}
}
#[test]
fn every_component_resolves() {
assert_eq!(Component::ALL.len(), table::SPECS.len());
for component in Component::ALL {
let spec = component.spec();
assert_eq!(spec.component, component);
assert!(spec.text.style().size_px >= 11.0);
}
}
#[test]
fn names_are_unique() {
let mut seen = std::collections::BTreeSet::new();
for component in Component::ALL {
assert!(
seen.insert(component.name()),
"组件名重复:{}",
component.name()
);
}
}
#[test]
fn elevation_matches_official_component_tokens() {
// A 级来源(androidx.compose.material3.tokens):
// FilledCardTokens/OutlinedCardTokens.ContainerElevation = Level0
// MenuTokens.ContainerElevation = Level2
// DialogTokens.ContainerElevation = Level3
// SnackbarTokens.ContainerElevation = Level3
// AppBarTokens = Level0、ListTokens.ItemContainerElevation = Level0
// 偏离只允许出现在有登记的地方(Tooltip 是 B 级:token 文件里没有 Elevation 行)。
for (component, expect) in [
(Component::FilledCard, Elevation::Level0),
(Component::OutlinedCard, Elevation::Level0),
(Component::Menu, Elevation::Level2),
(Component::Dialog, Elevation::Level3),
(Component::Snackbar, Elevation::Level3),
(Component::TopAppBar, Elevation::Level0),
(Component::ListItem, Elevation::Level0),
(Component::TextField, Elevation::Level0),
] {
assert_eq!(
component.spec().elevation,
expect,
"{} 的高程与官方组件令牌不一致",
component.name()
);
}
// R4「全屏都在飘」的守门:一屏常见元素里带阴影(>Level0)的只允许是浮层。
for component in Component::ALL {
if component.spec().elevation > Elevation::Level0 {
assert!(
matches!(
component,
Component::Menu
| Component::Dialog
| Component::Snackbar
| Component::Tooltip
),
"{} 不该有阴影(只有浮层允许)",
component.name()
);
}
}
}
#[test]
fn shapes_match_official_component_tokens() {
// 官方:卡片 CornerMedium(12)、对话框 CornerExtraLarge(28)、菜单 CornerExtraSmall(4)、
// 按钮/导航指示条 CornerFull、chip CornerSmall(8)。
assert_eq!(Component::FilledCard.spec().shape, ShapeScale::Medium);
assert_eq!(Component::OutlinedCard.spec().shape, ShapeScale::Medium);
assert_eq!(Component::Dialog.spec().shape, ShapeScale::ExtraLarge);
assert_eq!(Component::Menu.spec().shape, ShapeScale::ExtraSmall);
assert_eq!(Component::PrimaryButton.spec().shape, ShapeScale::Full);
assert_eq!(Component::Chip.spec().shape, ShapeScale::Small);
}
#[test]
fn only_primary_actions_use_filled_emphasis_and_one_dialog_layer() {
// 契约 §1:状态色不复用 accent、每屏最多一个实心 accent → 只有 primary-button 是
// 「实心强调」,其余按钮的形态由页面决定但不能是 Full + Level0 + Emphasized 的组合。
let primary = Component::PrimaryButton.spec();
assert_eq!(primary.shape, ShapeScale::Full);
assert_eq!(primary.text, TypeRole::LabelLarge);
// 浮层类必须比卡片层高。
assert!(Component::Dialog.spec().elevation > Component::OutlinedCard.spec().elevation);
assert!(Component::Menu.spec().elevation > Component::FilledCard.spec().elevation);
}
#[test]
fn morphs_always_expand_upwards() {
for component in Component::ALL {
if let Some(morph) = component.spec().morph {
assert_ne!(
morph.from,
morph.to,
"{} 的形变起止不能相同",
component.name()
);
// 方向必须「沿阶梯胀开」(morph 对 `Full` 也没问题,rank 把它排在末尾),
// 不比较像素跨度:`Full` 的半径依赖边长,用固定边长比会被 `None` 之外的大档拉爆。
assert!(
morph.from.rank() < morph.to.rank(),
"{} 的形变方向反了:{:?} → {:?}",
component.name(),
morph.from,
morph.to
);
}
}
}
}
mod tests;
+106 -3
View File
@@ -2,6 +2,10 @@
//!
//! 一行一个组件。改值就是在改全站观感——同步协作文档再改。
//!
//! **末尾 5 行(M69 G9)** 的来源是 `research/15` §18~§23(同一套 `material3-android:1.4.0` 源码):
//! Checkbox / Radio / Search / NavigationDrawer / Divider。其中 Checkbox 的 **2dp** 圆角走
//! [`ComponentRadius`](D32 第二层,不塞进 `ShapeScale`),Divider 的厚度是一像素线(G12)而不在这张表里。
//!
//! **高程/形状取值来源(A 级)**:`androidx.compose.material3.tokens` 的各组件 `<X>Tokens.kt`
//! (FilledCard/OutlinedCard/ElevatedCard/Menu/Dialog/Snackbar/AppBar/List)。已登记的两处偏离:
//! - `Tooltip`:`PlainTooltipTokens.kt` 只有 Corner 行、**没有 Elevation 行**,Table 的 Level2 是
@@ -14,7 +18,7 @@ use crate::elevation::Elevation;
use crate::motion::{
DurationToken, Easing, SPRING_EXPRESSIVE_SPATIAL_DEFAULT, SPRING_EXPRESSIVE_SPATIAL_FAST,
};
use crate::shape::{ShapeMorph, ShapeScale};
use crate::shape::{ComponentRadius, ShapeMorph, ShapeScale};
use crate::typography::TypeRole;
/// 一个组件的档位行(`const fn` 便捷构造,字段名在 [`ComponentSpec`] 里)。
@@ -31,6 +35,7 @@ const fn spec(
ComponentSpec {
component,
shape,
micro_radius: None,
elevation,
text,
support,
@@ -40,8 +45,35 @@ const fn spec(
}
}
/// 20 个组件的档位表。
pub(super) const SPECS: [ComponentSpec; 20] = [
/// 圆角走「微控件层」(D32)的档位行:`shape` 自动填占位 `None`,半径取白名单值。
///
/// 为什么单独一个构造器:`micro_radius` 与 `shape = None` 必须成对出现,用构造器保证配对,
/// 不让 25 行表里出现「填了微半径忘了把 shape 清掉」。
const fn spec_micro(
component: Component,
micro_radius: ComponentRadius,
elevation: Elevation,
text: TypeRole,
support: Option<TypeRole>,
motion: Easing,
duration: DurationToken,
morph: Option<ShapeMorph>,
) -> ComponentSpec {
ComponentSpec {
component,
shape: ShapeScale::None,
micro_radius: Some(micro_radius),
elevation,
text,
support,
motion,
duration,
morph,
}
}
/// 25 个组件的档位表。
pub(super) const SPECS: [ComponentSpec; 25] = [
spec(
Component::TopAppBar,
ShapeScale::None,
@@ -265,4 +297,75 @@ pub(super) const SPECS: [ComponentSpec; 20] = [
DurationToken::Medium2,
None,
),
// ---- M69 G9:末尾追加的 5 个(`research/15` §18~§23;前 20 行次序不许动)----
// Checkbox:圆角 **2dp** 不在 `ShapeScale` 刻度里 → 走 `spec_micro`(D32 第二层)。
// 高程 L0:`CheckboxTokens` 没有 Elevation 行,容器是「描边/填充」两态而非浮层。
// 字级 `BodyLarge` 是**团队口径**:官方没有「框 + 标签」组合 token(§18.9),取列表行同一档。
// 动效取 M3 motion scheme 的 fast-effects(100ms standard)——[推导],非 token 原文。
spec_micro(
Component::Checkbox,
ComponentRadius::Micro,
Elevation::Level0,
TypeRole::BodyLarge,
None,
Easing::Standard,
DurationToken::Short2,
None,
),
// Radio:真圆(`RadioButtonTokens.IconSize = 20` + `CornerFull`),内点/环线宽见
// `ComponentMetrics::RADIO`。字级同 Checkbox(团队口径)。
spec(
Component::Radio,
ShapeScale::Full,
Elevation::Level0,
TypeRole::BodyLarge,
None,
Easing::Standard,
DurationToken::Short2,
None,
),
// Search bar:圆角 `CornerFull` / `SearchBarCornerRadius = 56/2 = 28`。
// **高程取 L0**——`SearchBarTokens.ContainerElevation = Level3` 与
// `SearchBarDefaults.TonalElevation/ShadowElevation = Level0` 冲突(§26.2 矛盾 ②),
// 判据按观感取「无可见阴影 + tone 分层」,这条已在 `design-令牌约定` 登记。
// 无 shape morph:bar ⇄ docked 展开时**半径不变**(28 → 28),只长面积(§20C)。
spec(
Component::Search,
ShapeScale::Full,
Elevation::Level0,
TypeRole::BodyLarge,
Some(TypeRole::BodySmall),
Easing::Emphasized,
DurationToken::Medium2,
None,
),
// Navigation drawer:官方 `ContainerShape = CornerLargeEnd`(只有靠内容那侧两角 16,
// 贴边侧直角)。`ShapeScale` 表达不了「部分角」,这里记内侧角的 `Large(16)`,
// 哪两个角由组件按侧位决定;写四角 16 = 变成浮动卡片(§21.2 判据)。
// 常驻形态 `Surface` + L0(模态才用 `SurfaceContainerLow` + scrim,本项目只在 <600 窗宽兜底)。
spec(
Component::NavigationDrawer,
ShapeScale::Large,
Elevation::Level0,
TypeRole::LabelLarge,
Some(TypeRole::TitleSmall),
Easing::Emphasized,
DurationToken::Medium2,
None,
),
// Divider:无高程、无圆角、**无文字**(`text` 是占位值,消费方不得据此排版);
// 厚度是一像素线 `LayoutMetrics::divider_thickness`,颜色 `OutlineVariant`
// (search view 头部分隔是 `Outline`,两个不同角色,§20.8)。
// **`motion` / `duration` 也是占位值**(`ComponentSpec.motion` 不是 `Option`):Divider 没有
// 官方状态过渡令牌,消费方**不得据此排动效**;下轮把该字段改成 `Option` 更彻底。
spec(
Component::Divider,
ShapeScale::None,
Elevation::Level0,
TypeRole::BodySmall,
None,
Easing::Standard,
DurationToken::Short2,
None,
),
];
+202
View File
@@ -0,0 +1,202 @@
//! `component_map` 的守门测试:表槽位 ↔ 枚举次序、官方高程/形状、圆角分层(D32)、微度量入口。
//!
//! 拆成独立文件是为了让 `mod.rs` 留出文档空间(项目硬约束:单文件 ≤400 行)。
use super::*;
#[test]
fn table_matches_component_order() {
for (i, spec) in table::SPECS.iter().enumerate() {
assert_eq!(
spec.component as usize, i,
"SPECS 第 {i} 行顺序与枚举不一致"
);
}
}
#[test]
fn every_component_resolves() {
assert_eq!(Component::ALL.len(), table::SPECS.len());
for component in Component::ALL {
let spec = component.spec();
assert_eq!(spec.component, component);
assert!(spec.text.style().size_px >= 11.0);
}
}
#[test]
fn names_are_unique() {
let mut seen = std::collections::BTreeSet::new();
for component in Component::ALL {
assert!(
seen.insert(component.name()),
"组件名重复:{}",
component.name()
);
}
}
#[test]
fn elevation_matches_official_component_tokens() {
// A 级来源(androidx.compose.material3.tokens):
// FilledCardTokens/OutlinedCardTokens.ContainerElevation = Level0
// MenuTokens.ContainerElevation = Level2
// DialogTokens.ContainerElevation = Level3
// SnackbarTokens.ContainerElevation = Level3
// AppBarTokens = Level0、ListTokens.ItemContainerElevation = Level0
// 偏离只允许出现在有登记的地方(Tooltip 是 B 级:token 文件里没有 Elevation 行)。
for (component, expect) in [
(Component::FilledCard, Elevation::Level0),
(Component::OutlinedCard, Elevation::Level0),
(Component::Menu, Elevation::Level2),
(Component::Dialog, Elevation::Level3),
(Component::Snackbar, Elevation::Level3),
(Component::TopAppBar, Elevation::Level0),
(Component::ListItem, Elevation::Level0),
(Component::TextField, Elevation::Level0),
] {
assert_eq!(
component.spec().elevation,
expect,
"{} 的高程与官方组件令牌不一致",
component.name()
);
}
// R4「全屏都在飘」的守门:一屏常见元素里带阴影(>Level0)的只允许是浮层。
for component in Component::ALL {
if component.spec().elevation > Elevation::Level0 {
assert!(
matches!(
component,
Component::Menu | Component::Dialog | Component::Snackbar | Component::Tooltip
),
"{} 不该有阴影(只有浮层允许)",
component.name()
);
}
}
}
#[test]
fn shapes_match_official_component_tokens() {
// 官方:卡片 CornerMedium(12)、对话框 CornerExtraLarge(28)、菜单 CornerExtraSmall(4)、
// 按钮/导航指示条 CornerFull、chip CornerSmall(8)。
assert_eq!(Component::FilledCard.spec().shape, ShapeScale::Medium);
assert_eq!(Component::OutlinedCard.spec().shape, ShapeScale::Medium);
assert_eq!(Component::Dialog.spec().shape, ShapeScale::ExtraLarge);
assert_eq!(Component::Menu.spec().shape, ShapeScale::ExtraSmall);
assert_eq!(Component::PrimaryButton.spec().shape, ShapeScale::Full);
assert_eq!(Component::Chip.spec().shape, ShapeScale::Small);
}
#[test]
fn only_primary_actions_use_filled_emphasis_and_one_dialog_layer() {
// 契约 §1:状态色不复用 accent、每屏最多一个实心 accent → 只有 primary-button 是
// 「实心强调」,其余按钮的形态由页面决定但不能是 Full + Level0 + Emphasized 的组合。
let primary = Component::PrimaryButton.spec();
assert_eq!(primary.shape, ShapeScale::Full);
assert_eq!(primary.text, TypeRole::LabelLarge);
// 浮层类必须比卡片层高。
assert!(Component::Dialog.spec().elevation > Component::OutlinedCard.spec().elevation);
assert!(Component::Menu.spec().elevation > Component::FilledCard.spec().elevation);
}
#[test]
fn morphs_always_expand_upwards() {
for component in Component::ALL {
if let Some(morph) = component.spec().morph {
assert_ne!(
morph.from,
morph.to,
"{} 的形变起止不能相同",
component.name()
);
// 方向必须「沿阶梯胀开」(morph 对 `Full` 也没问题,rank 把它排在末尾),
// 不比较像素跨度:`Full` 的半径依赖边长,用固定边长比会被 `None` 之外的大档拉爆。
assert!(
morph.from.rank() < morph.to.rank(),
"{} 的形变方向反了:{:?} → {:?}",
component.name(),
morph.from,
morph.to
);
}
}
}
#[test]
fn appended_variants_keep_the_frozen_order() {
// 枚举冻结为「只可末尾追加」:前 20 个的次序不能动(M69 只在末尾加 5 个)。
assert_eq!(Component::ALL.len(), 25);
assert_eq!(Component::ALL[0], Component::TopAppBar);
assert_eq!(Component::ALL[19], Component::EmptyState);
assert_eq!(Component::ALL[20], Component::Checkbox);
assert_eq!(Component::ALL[24], Component::Divider);
for (i, component) in Component::ALL.iter().enumerate() {
assert_eq!(*component as usize, i, "ALL 的次序与枚举判别值不一致");
}
}
#[test]
fn micro_radius_only_on_micro_controls() {
// D32:微半径只能给 ≤24dp 的微控件,且此时 `shape` 必须是占位 None(不参与渲染)。
let mut owners = Vec::new();
for component in Component::ALL {
let spec = component.spec();
let Some(radius) = spec.micro_radius else {
continue;
};
owners.push(component);
assert_eq!(radius, ComponentRadius::Micro, "白名单外的微半径不许过表");
assert_eq!(
spec.shape,
ShapeScale::None,
"{} 有微半径时 shape 必须占位 None(2dp 不在刻度里)",
component.name()
);
let metrics = component
.metrics()
.expect("带微半径的组件必须有 ComponentMetrics");
assert!(
metrics.width <= crate::shape::MICRO_CONTROL_MAX_SIZE
&& metrics.height <= crate::shape::MICRO_CONTROL_MAX_SIZE,
"{} 超出微控件尺寸上限",
component.name()
);
assert_eq!(
radius.radius_px(metrics.width),
Some(radius.dp()),
"{} 的微半径按自身尺寸取不到",
component.name()
);
}
assert_eq!(
owners,
vec![Component::Checkbox],
"当前只允许 Checkbox 用微半径"
);
}
#[test]
fn metrics_resolve_only_for_controls_with_inner_geometry() {
for component in [
Component::Checkbox,
Component::Radio,
Component::Search,
Component::NavigationDrawer,
] {
assert!(
component.metrics().is_some(),
"{} 缺 ComponentMetrics",
component.name()
);
}
// Divider 只有厚度,而厚度是一像素线(LM),不该出现在 CM 里。
assert!(Component::Divider.metrics().is_none());
// 骨架级尺寸不许在 CM 里重复一份(Single Source of Truth)。
for component in [Component::Search, Component::NavigationDrawer] {
let metrics = component.metrics().unwrap();
assert_eq!(metrics.width, 0.0, "{} 的宽度属骨架级", component.name());
assert_eq!(metrics.height, 0.0, "{} 的高度属骨架级", component.name());
}
}
+11 -4
View File
@@ -15,14 +15,16 @@
//!
//! ## 分模块
//!
//! - [`color`]:M3 语义色角色(49 个)× light/dark 双套 + tone 调色板(OKLCh 近似,口径见模块文档)
//! - [`color`]:M3 语义色角色(49 个)× light/dark 双套 + tone 调色板(**真 HCT**:CAM16 色相/彩度
//! + CIELAB `L*`,官方 `ColorSpec2021` tone 表,与官方 Java 实现逐通道对齐——口径见模块文档)
//! - [`typography`]:M3 type scale 15 档 + 三平台字体族回退栈
//! - [`shape`]:M3 Expressive 形状刻度 + shape morph 数据
//! - [`shape`]:M3 Expressive 形状刻度 + shape morph 数据 + [`shape::ComponentRadius`](微控件圆角层,D32)
//! - [`elevation`]:elevation level 与 surface tint / surface container 规则
//! - [`motion`]:时长、easing(可采样)、Expressive 弹簧参数
//! - [`spacing`]:4px 栅格与骨架尺寸(原 `metrics.slint` 的框架无关部分)
//! - [`spacing`]:4px 栅格、骨架尺寸(原 `metrics.slint` 的框架无关部分)、[`stroke::HAIRLINE`](一像素线)
//! - [`icons`]:24 网格图标几何(SVG path 字符串)+ 描边语义
//! - [`component_map`]:组件 → 档位映射表(哪个组件用哪档形状/高程/动效/字体)
//! + [`component_map::ComponentMetrics`](控件内部微几何,与 `LayoutMetrics` 分工见该模块文档)
//!
//! ## 最小用法
//!
@@ -92,6 +94,7 @@ pub mod typography;
pub use color::{Color, Role, Scheme, SchemeFlavor};
pub use motion::MotionScale;
pub use spacing::stroke;
pub use typography::TypeRole;
/// 设计令牌的整体入口:一份「当前该长什么样」的聚合快照。
@@ -109,7 +112,11 @@ pub struct DesignTokens {
}
impl DesignTokens {
/// 按模式给一套出厂令牌(baseline seed `#6750A4`,等价 M3 baseline 调色板)。
/// 按模式给一套**产品默认**令牌:种子 `#3C8DBC`(M70 ④ 天蓝,用户 2026-11-01 拍板)
/// + 中性彩度 12,即 `color::palettes::DEFAULT_SEED` / `DEFAULT_NEUTRAL_CHROMA`。
///
/// 想要**官方 M3 baseline** 口径(种子 `#6750A4` + 中性彩度 6 / neutral-variant 8)请走
/// `color::Scheme::official_baseline(flavor)`——两条路径的算法完全相同,只差入参。
pub fn baseline(flavor: SchemeFlavor) -> Self {
Self {
flavor,
+134
View File
@@ -2,6 +2,17 @@
//!
//! 形状是「唯一真源」的一部分:组件不许写裸半径(`border_radius(8)`),一律引用
//! [`ShapeScale`];需要动起来的(按下、选中、展开)用 [`ShapeMorph`] 的 from→to 数据。
//!
//! ## 圆角分两层(D32)
//!
//! 官方实现与官方刻度表并不总一致:Checkbox 的圆角是 **2dp**,但 `ShapeScale` 的刻度是
//! `{0,4,8,12,16,20,28,32,48,full}`——2 不在里面。D32 的判决是**分层**而不是放宽判据:
//!
//! - **容器级**(卡片、弹窗、抽屉、按钮、面板、输入框外壳)只许用 [`ShapeScale`] 的刻度值;
//! - **微控件级**(≤24dp 的 Checkbox 等)用 [`ComponentRadius`] 的白名单值。
//!
//! 一个元素该查哪一层由 [`radius_layer_for`] 判定(按尺寸,唯一入口);微控件层取值走
//! [`ComponentRadius::radius_px`],**尺寸超界时返回 `None`**——这不是文档约定,是签名保证。
use crate::motion::Spring;
@@ -87,6 +98,88 @@ impl ShapeScale {
}
}
/// 微控件(≤24dp)的尺寸上限(dp)。
///
/// 超过这条线的元素属**容器级**,圆角必须查 [`ShapeScale`] 的严格刻度;[`ComponentRadius`]
/// 只在这条线以内有效——由 [`ComponentRadius::radius_px`] 在运行期拦住(传大尺寸拿不到微半径)。
/// 24 这个门槛不是官方数字,是 D32 给「微控件」划的界(Checkbox 20、指示点、徽标都在以内)。
pub const MICRO_CONTROL_MAX_SIZE: f32 = 24.0;
/// 微控件圆角(D32 第二层):**来源官方组件实现,不是 [`ShapeScale`] 的成员**。
///
/// 为什么要有这一层:官方 Checkbox 的圆角是 **2dp**(`Checkbox.kt:738 RadiusSize = 2.dp`,
/// `CheckboxTokens.ContainerShape = RoundedCornerShape(2)`),而 M3 形状刻度只有
/// `{0,4,8,12,16,20,28,32,48,full}`。把 2 塞进刻度会废掉「无任意圆角」这条守门(D30 rubric K1),
/// 不收又只能在组件里抄 `2.0` 字面量。D32 的判决是**分层**,不是放宽:
///
/// | 层 | 管什么 | 取值 |
/// |---|---|---|
/// | [`ShapeScale`] | 容器级:卡片/弹窗/抽屉/按钮/面板/输入框外壳 | 严格刻度,不许自造 |
/// | `ComponentRadius` | 微控件级:Checkbox 等 ≤24dp 的元素 | 白名单,当前只有 [`ComponentRadius::Micro`] |
///
/// 用法:先 [`radius_layer_for`] 判层,微控件再 `radius_px(size)`。**返回 `None` 说明尺寸超界,
/// 必须改查 [`ShapeScale`]**,不许把微半径硬套到容器上。
///
/// 公开的**唯一取值口是 [`ComponentRadius::radius_px`]**(带尺寸闸):本类型的 `dp()` 是
/// `pub(crate)`,故意不对外开放——否则任何调用方都能无条件拿到 `2.0`,「只许 ≤24dp」就成了
/// 自觉而不是约束(D32 的原意是拦住,不是提醒)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ComponentRadius {
/// 2dp:官方 Checkbox 容器圆角。
Micro,
}
impl ComponentRadius {
/// 白名单里的全部微半径(D32 当前只有一项;加值必须先在 `design-令牌约定` 登记)。
///
/// 枚举得出的只是**变体**(`Micro` 这个名字),拿不到数值——取值必须走 [`Self::radius_px`]。
pub const ALL: [ComponentRadius; 1] = [ComponentRadius::Micro];
/// 半径(dp)。**`pub(crate)`:crate 内推导与自己用,不对外开放。**
///
/// 对任何尺寸都返回同一个数,所以它天然绕开 D32 的尺寸闸;对外只留
/// [`Self::radius_px`](`Option<f32>`,>24dp 返回 `None`)。
pub(crate) const fn dp(self) -> f32 {
match self {
ComponentRadius::Micro => 2.0,
}
}
/// 微控件的实际半径:尺寸超过 [`MICRO_CONTROL_MAX_SIZE`] 时返回 **`None`**。
///
/// 这是 D32 里「能编译期拦住就别靠自觉」的落点——文档约定拦不住,签名拦得住:
/// 拿到 `None` 的调用点没法「顺手用一下 2dp」,只能改查 [`ShapeScale`]。
/// 另外半径不超过边长一半(2dp 半径放到更小的元素上会溢成圆)。
pub fn radius_px(self, size_px: f32) -> Option<f32> {
if size_px > 0.0 && size_px <= MICRO_CONTROL_MAX_SIZE {
Some(self.dp().min(size_px / 2.0))
} else {
None
}
}
}
/// 一个元素的圆角该查哪一层([`radius_layer_for`] 的返回)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RadiusLayer {
/// 容器级:查 [`ShapeScale`] 的严格刻度。
Scale,
/// 微控件级(≤24dp):查 [`ComponentRadius`] 的白名单。
Component,
}
/// 纯函数:按元素尺寸判定圆角该查哪一层。
///
/// 「某尺寸的元素该查哪一个」是 D32 里唯一需要判断的地方,所以只留这一个入口——
/// 组件层不许自己写 `if size <= 24.0`,那等于把阈值抄了第二份。
pub fn radius_layer_for(size_px: f32) -> RadiusLayer {
if size_px > 0.0 && size_px <= MICRO_CONTROL_MAX_SIZE {
RadiusLayer::Component
} else {
RadiusLayer::Scale
}
}
/// 一次形状形变(Expressive 的「按下胀开」「展开成药丸」等)。
///
/// 数据形态是纯的:起止档位 + 弹簧参数。适配层负责跑时钟,`progress` 由弹簧/缓动给出。
@@ -165,4 +258,45 @@ mod tests {
assert_eq!(morph.radius_px(200.0, -2.0), 8.0);
assert_eq!(morph.radius_px(200.0, 3.0), 20.0);
}
#[test]
fn component_radius_is_whitelisted_and_refuses_containers() {
// D32:微半径白名单当前只有 2dp,且永远不是 ShapeScale 的成员。
assert_eq!(ComponentRadius::ALL.len(), 1);
assert_eq!(ComponentRadius::Micro.dp(), 2.0);
for scale in [
ShapeScale::None,
ShapeScale::ExtraSmall,
ShapeScale::Small,
ShapeScale::Medium,
ShapeScale::Large,
ShapeScale::LargeIncreased,
ShapeScale::ExtraLarge,
ShapeScale::ExtraLargeIncreased,
ShapeScale::ExtraExtraLarge,
] {
assert_ne!(scale.dp(), 2.0, "{scale:?} 不该等于微半径(2 不在刻度里)");
}
// 20×20 的 checkbox 拿 2dp;超 24 的元素必须拿不到。
assert_eq!(ComponentRadius::Micro.radius_px(20.0), Some(2.0));
assert_eq!(ComponentRadius::Micro.radius_px(24.0), Some(2.0));
assert_eq!(ComponentRadius::Micro.radius_px(24.5), None);
assert_eq!(ComponentRadius::Micro.radius_px(200.0), None);
assert_eq!(ComponentRadius::Micro.radius_px(0.0), None);
// 半径不超过边长一半。
assert_eq!(ComponentRadius::Micro.radius_px(2.0), Some(1.0));
}
#[test]
fn radius_layer_follows_the_size_threshold() {
assert_eq!(radius_layer_for(20.0), RadiusLayer::Component);
assert_eq!(
radius_layer_for(MICRO_CONTROL_MAX_SIZE),
RadiusLayer::Component
);
assert_eq!(radius_layer_for(24.5), RadiusLayer::Scale);
assert_eq!(radius_layer_for(320.0), RadiusLayer::Scale);
assert_eq!(radius_layer_for(0.0), RadiusLayer::Scale);
}
}
+99 -7
View File
@@ -1,8 +1,37 @@
//! 形状 / 高程 / 动效之外的「尺子」:4px 栅格与骨架尺寸。
//! 形状 / 高程 / 动效之外的「尺子」:4px 栅格、骨架尺寸、一像素线。
//!
//! 源流有两条:`DevDocs/ops/slint-reference/crates/app/ui/theme/metrics.slint`(M52 定稿的骨架节奏),
//! 以及 `ui-integration-contract.md` §3「间距只允许 4/8/12/16/24/32」。M3 同样用 4dp 栅格,
//! 所以这层**不用改口径**,只是把值搬进唯一真源。
//!
//! ## 职责边界:`LayoutMetrics` vs `ComponentMetrics`(M69 / G10)
//!
//! | 结构 | 管什么 | 例子 |
//! |---|---|---|
//! | [`LayoutMetrics`] | **窗口骨架与布局节奏**:标题栏/页头/行的节奏、dock 宽度(导航、AI 面板)、命中区 | [`LayoutMetrics::nav_drawer_width`] / [`LayoutMetrics::search_bar_height`] / `touch_target` |
//! | `component_map::ComponentMetrics` | **单个控件内部几何**:外框尺寸、微圆角、描边/环线宽、状态层直径、内点、图标偏移 | checkbox 20×20 + 描边 2 + 状态层 Ø40 |
//! | [`stroke::HAIRLINE`] | **一像素线**的唯一真源:分隔线厚度、容器描边、聚焦指示 | [`LayoutMetrics::divider_thickness`] |
//!
//! 判据:一个值若随窗口/列宽变化、或决定一块面板占多宽,归 LM;若它只在某个控件自己的方框
//! 内起作用(画进去的那几个像素),归 CM。**同一个值不许两边各写一份**。
/// 一像素线(G12):全站 1px 线的**唯一真源**。
///
/// 为什么要单独开一个命名空间:`1.0` 在项目里同时承担三种语义——分隔线厚度、容器描边宽度、
/// 聚焦指示描边宽度。各自抄字面量就会漂成 1.0 / 1.5 / 2.0 混排(M66 的 G12 正是这么来的)。
/// 从这里取,语义和数值只有一个落点。
///
/// iced 侧怎么落地(适配层照此抄,别再自己定 `STROKE_PX`):
/// - **容器描边**:`Border { width: unlucky_design::stroke::HAIRLINE, .. }`(卡片/输入框/chip/菜单);
/// - **分隔线**:[`LayoutMetrics::divider_thickness`](数值就是 [`stroke::HAIRLINE`])——
/// 横向分隔线是容器高度、纵向是宽度,都取它;
/// - **聚焦环**:内描边与外描边**都用**它(M3 的聚焦指示是 1dp,做成 2dp 就是加粗);
/// - **不用于**:elevation 的 blur / offset(那是 [`crate::elevation`] 的量)、
/// 图标描边(图标走 SVG 的 viewBox 单位,见 [`crate::icons::stroke_width_for`])。
pub mod stroke {
/// 一像素线宽度(逻辑 px @100% DPI = 1dp;DividerTokens.Thickness)。
pub const HAIRLINE: f32 = 1.0;
}
/// 栅格基数(dp)。
pub const GRID: f32 = 4.0;
@@ -63,10 +92,31 @@ pub struct LayoutMetrics {
pub titlebar_height: f32,
/// 标题栏按钮宽度(关闭/最大化/最小化)。
pub titlebar_button_width: f32,
/// 展开态导航宽度。
pub nav_expanded_width: f32,
/// 收起态导航宽度。
pub nav_collapsed_width: f32,
/// 展开态导航(drawer 形态)宽度。
///
/// **G11 语义修正(M69 / D33)**:D16「左侧固定、允许折叠」落地为**同一个常驻容器**的两种
/// 宽度——展开 240(drawer)、折叠 88(rail)。旧字段 `nav_expanded_width = 88` 的名字与值
/// 相反(88 其实是 rail),已删除,**不留别名**:两个名字指同一个量等于把选择权还给组件层。
///
/// 官方 `NavigationDrawerTokens.ContainerWidth = 360`、`MinimumDrawerWidth = 240`;
/// 本项目取官方下限 240——360 + AI 面板 320 = 680 = 1180 窗宽的 58%,内容区只剩 500 放不下两列卡片。
pub nav_drawer_width: f32,
/// 折叠态导航(rail 形态)宽度。
///
/// 88 = 12b §5.1 对官方 rail 96 的登记偏离(−8):指示器宽 88 − 2×16 = 56 仍成立,
/// 再窄就装不下 56 宽的选中指示器。
pub nav_rail_width: f32,
/// 搜索条高度(G11)。
///
/// 官方 `SearchBarTokens.ContainerHeight = 56`。**不走 [`LayoutMetrics::control_height`](40)**:
/// 搜索条是页级入口而不是密集行控件;56 也顺带 ≥ 命中下限 48。圆角由 `ShapeScale::Full`
/// 按边长折算(56 → 28,正好等于官方 `SearchBarCornerRadius`)。
pub search_bar_height: f32,
/// 分隔线厚度(G11 + G12)。
///
/// 官方 `DividerTokens.Thickness = 1.0.dp`。值就是 [`stroke::HAIRLINE`]——**一像素线不受 4px
/// 栅格约束**,所以它不在栅格测试的表里(测试里显式豁免并写明原因)。
pub divider_thickness: f32,
/// 导航项高度。
pub nav_item_height: f32,
/// 导航项间距。
@@ -103,8 +153,14 @@ impl LayoutMetrics {
pub const DESKTOP: Self = Self {
titlebar_height: 48.0,
titlebar_button_width: 46.0,
nav_expanded_width: 88.0,
nav_collapsed_width: 48.0,
// G11 / D33:展开 = drawer 240,折叠 = rail 88(旧 `nav_expanded_width=88` /
// `nav_collapsed_width=48` 的名字与值相反,已删)。
nav_drawer_width: 240.0,
nav_rail_width: 88.0,
// G11:官方 SearchBarTokens.ContainerHeight = 56。
search_bar_height: 56.0,
// G11/G12:官方 DividerTokens.Thickness = 1.0dp → 一像素线的唯一真源。
divider_thickness: stroke::HAIRLINE,
nav_item_height: 44.0,
nav_item_gap: 4.0,
page_header_height: 56.0,
@@ -146,8 +202,13 @@ mod tests {
// 受 4px 栅格约束的字段(间距/高度/宽度类)。
// `titlebar_button_width` 刻意不在表内:46 对齐 Windows 11 原生 caption 按钮宽度,
// 属 OS 惯例值,单列一条断言钉住,不让它静默漂移。
// `divider_thickness` 同理不在表内:它是**一像素线**(1 不是 4 的倍数),
// 归 `stroke::HAIRLINE` 那条规则管,下面单独断言。
for (name, value) in [
("titlebar_height", m.titlebar_height),
("nav_drawer_width", m.nav_drawer_width),
("nav_rail_width", m.nav_rail_width),
("search_bar_height", m.search_bar_height),
("nav_item_height", m.nav_item_height),
("nav_item_gap", m.nav_item_gap),
("page_header_height", m.page_header_height),
@@ -165,6 +226,37 @@ mod tests {
m.titlebar_button_width, 46.0,
"标题栏按钮宽度应对齐 Win11 原生 caption"
);
// 一像素线的显式豁免(不是「忘了加进表」,是它本来就不受栅格约束)。
assert_eq!(m.divider_thickness, stroke::HAIRLINE);
assert_ne!(m.divider_thickness % GRID, 0.0);
}
#[test]
fn nav_widths_follow_the_two_form_decision() {
// D33:左侧导航是同一容器的两种宽度,展开(drawer)必须比折叠(rail)宽。
let m = LayoutMetrics::DESKTOP;
assert_eq!(m.nav_drawer_width, 240.0, "展开 drawer 取官方下限 240");
assert_eq!(m.nav_rail_width, 88.0, "折叠 rail 为 88(登记偏离官方 96)");
assert!(m.nav_rail_width < m.nav_drawer_width);
// rail 里的选中指示器 = 容器宽 − 2×16;88 − 32 = 56,再窄就装不下 56 宽指示器。
assert_eq!(m.nav_rail_width - Space::X4.dp() * 2.0, 56.0);
// 内容区下限:1180 窗宽 − drawer − AI 面板 仍 ≥600(够放两列卡片)。
assert!(1180.0 - m.nav_drawer_width - m.ai_panel_width >= 600.0);
}
#[test]
fn search_bar_height_is_a_page_level_entry() {
let m = LayoutMetrics::DESKTOP;
assert_eq!(m.search_bar_height, 56.0, "官方 ContainerHeight = 56");
assert!(
m.search_bar_height >= m.touch_target,
"页级入口必须 ≥ 命中下限"
);
// 圆角走 ShapeScale::Full,56 → 28,正好是官方 SearchBarCornerRadius。
assert_eq!(
crate::shape::ShapeScale::Full.radius_px(m.search_bar_height),
28.0
);
}
#[test]
+58 -2
View File
@@ -54,13 +54,13 @@ const EXCEPTIONS: [(Role, Role, f32, &str); 3] = [
Role::Outline,
Role::Surface,
AA_NON_TEXT,
"输入框/卡片描边是 M3 定义的非文字元素,规范目标 3:1(light 实测 4.29:1、dark 6.5:1 已高于目标)",
"输入框/卡片描边是 M3 定义的非文字元素,规范目标 3:1(light 实测 4.26:1、dark 5.89:1 已高于目标)",
),
(
Role::OutlineVariant,
Role::Surface,
AA_NON_TEXT * 0.5,
"分隔线是纯装饰,M3 不要求对比度下限;1.6:1 是「几乎看不见但要能数出层级」的刻意取值",
"分隔线是纯装饰,M3 不要求对比度下限;light 1.62:1 / dark 1.99:1 是「几乎看不见但要能数出层级」的取值",
),
(
Role::SurfaceVariant,
@@ -157,6 +157,62 @@ fn baseline_light_and_dark_both_pass() {
}
}
#[test]
fn sky_seed_candidates_meet_wcag_aa() {
// M70:四档默认色候选(种子 + 中性彩度)必须过同一条 26 组门禁。
// 中性彩度改变只影响色相/彩度,不影响 tone 轴,所以理论上恒过——这里就是要**证明**它恒过,
// 而不是相信它。任何一档不达标,该档直接判为不可用(不许为了好看放宽阈值)。
let candidates = [
("① 保守蓝", "#2196F3", 6.0_f64),
("② 冷调蓝", "#2196F3", 12.0),
("③ 浅冷蓝", "#87CEEB", 12.0),
("④ 天蓝", "#3C8DBC", 12.0),
];
let mut failures = Vec::new();
let mut worst = (f32::MAX, String::new());
let mut checked = 0;
for (title, seed_hex, neutral_chroma) in candidates {
let seed = Color::from_hex(seed_hex).expect("候选种子");
let palettes =
unlucky_design::color::Palettes::from_seed_with_neutral_chroma(seed, neutral_chroma);
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let scheme = Scheme::from_palettes(&palettes, flavor);
for (fg, bg) in TEXT_PAIRS {
let ratio = scheme.contrast(fg, bg);
checked += 1;
if ratio < worst.0 {
worst = (
ratio,
format!(
"{title}/{} 的 {} on {}",
flavor.name(),
fg.name(),
bg.name()
),
);
}
if ratio < AA_TEXT {
failures.push(format!(
"{title}({seed_hex} / 中性 {neutral_chroma})/ {} / {} on {} = {ratio:.2}",
flavor.name(),
fg.name(),
bg.name()
));
}
}
}
}
println!(
"候选 WCAG:{checked} 项,最紧的一对 {} = {:.2}:1(阈值 {AA_TEXT})",
worst.1, worst.0
);
assert!(
failures.is_empty(),
"以下候选未达 WCAG AA(4.5:1),该档不可用:\n{}",
failures.join("\n")
);
}
#[test]
fn accent_override_cannot_break_contrast() {
// 换了强调色之后,primary 族仍必须过 AA——这是「tone 轴固定」带来的结构性保证。
+307
View File
@@ -0,0 +1,307 @@
//! 自定义强调色 × 官方算法的**逐角色对齐**(`Scheme::from_accent`)。
//!
//! 期望值来自官方 Java 实现:`SchemeTonalSpot(seed, isDark, 0, SPEC_2021)` 的强调族角色。
//! 判据:每通道误差 ≤1(见 `official_baseline.rs` 的说明)。
//!
//! 为什么只比强调族(primary/secondary/tertiary 共 12 个角色):本项目的产品语义是
//! 「换强调色不动底色」——中性表面与 error 刻意留在 baseline(见 `Palettes::with_accent`),
//! 而官方 `from_seed` 会把中性色也染上种子色相。中性派生的正确性由本文件末尾的
//! `from_seed_*` 用例单独验证(那一处必须与官方完全一致)。
use unlucky_design::color::{Color, Role, Scheme, SchemeFlavor};
/// 每通道允许的最大误差。
const CHANNEL_TOLERANCE: i16 = 1;
/// 一条官方期望值:角色 + light 色值 + dark 色值。
type RoleRow = (Role, &'static str, &'static str);
/// 一个种子 + 它 12 个强调族角色的官方色值。
type SeedRows = (&'static str, [RoleRow; 12]);
/// 八个种子:官方基线紫、纯红/绿/蓝、低彩度灰蓝、高彩度紫,外加纯黑与纯白两个边缘用例
/// (无彩色种子的色相是病态的,官方的处理方式见 `Hct`:黑 → 色相 0、白 → 色相 209.5)。
/// 每个种子带 12 个强调族角色的官方色值(light, dark)。
const ACCENT_SEEDS: [SeedRows; 8] = [
(
"#6750A4",
[
(Role::Primary, "#65558F", "#CFBDFE"),
(Role::OnPrimary, "#FFFFFF", "#36275D"),
(Role::PrimaryContainer, "#E9DDFF", "#4D3D75"),
(Role::OnPrimaryContainer, "#4D3D75", "#E9DDFF"),
(Role::Secondary, "#625B71", "#CBC2DB"),
(Role::OnSecondary, "#FFFFFF", "#332D41"),
(Role::SecondaryContainer, "#E8DEF8", "#4A4458"),
(Role::OnSecondaryContainer, "#4A4458", "#E8DEF8"),
(Role::Tertiary, "#7E5260", "#EFB8C8"),
(Role::OnTertiary, "#FFFFFF", "#4A2532"),
(Role::TertiaryContainer, "#FFD9E3", "#633B48"),
(Role::OnTertiaryContainer, "#633B48", "#FFD9E3"),
],
),
(
"#FF0000",
[
(Role::Primary, "#904B40", "#FFB4A8"),
(Role::OnPrimary, "#FFFFFF", "#561E16"),
(Role::PrimaryContainer, "#FFDAD4", "#73342A"),
(Role::OnPrimaryContainer, "#73342A", "#FFDAD4"),
(Role::Secondary, "#775651", "#E7BDB6"),
(Role::OnSecondary, "#FFFFFF", "#442925"),
(Role::SecondaryContainer, "#FFDAD4", "#5D3F3B"),
(Role::OnSecondaryContainer, "#5D3F3B", "#FFDAD4"),
(Role::Tertiary, "#705C2E", "#DEC48C"),
(Role::OnTertiary, "#FFFFFF", "#3E2E04"),
(Role::TertiaryContainer, "#FBDFA6", "#564419"),
(Role::OnTertiaryContainer, "#564419", "#FBDFA6"),
],
),
(
"#00FF00",
[
(Role::Primary, "#406836", "#A5D395"),
(Role::OnPrimary, "#FFFFFF", "#11380B"),
(Role::PrimaryContainer, "#C0EFB0", "#285020"),
(Role::OnPrimaryContainer, "#285020", "#C0EFB0"),
(Role::Secondary, "#54634D", "#BBCBB2"),
(Role::OnSecondary, "#FFFFFF", "#263422"),
(Role::SecondaryContainer, "#D7E8CD", "#3C4B37"),
(Role::OnSecondaryContainer, "#3C4B37", "#D7E8CD"),
(Role::Tertiary, "#386568", "#A0CFD2"),
(Role::OnTertiary, "#FFFFFF", "#003739"),
(Role::TertiaryContainer, "#BCEBEE", "#1E4D50"),
(Role::OnTertiaryContainer, "#1E4D50", "#BCEBEE"),
],
),
(
"#0000FF",
[
(Role::Primary, "#555992", "#BEC2FF"),
(Role::OnPrimary, "#FFFFFF", "#272B60"),
(Role::PrimaryContainer, "#E0E0FF", "#3E4278"),
(Role::OnPrimaryContainer, "#3E4278", "#E0E0FF"),
(Role::Secondary, "#5C5D72", "#C5C4DD"),
(Role::OnSecondary, "#FFFFFF", "#2E2F42"),
(Role::SecondaryContainer, "#E1E0F9", "#444559"),
(Role::OnSecondaryContainer, "#444559", "#E1E0F9"),
(Role::Tertiary, "#78536B", "#E8B9D5"),
(Role::OnTertiary, "#FFFFFF", "#46263B"),
(Role::TertiaryContainer, "#FFD8EE", "#5E3C52"),
(Role::OnTertiaryContainer, "#5E3C52", "#FFD8EE"),
],
),
(
"#6E7C8A",
[
(Role::Primary, "#2B638B", "#98CCF9"),
(Role::OnPrimary, "#FFFFFF", "#003350"),
(Role::PrimaryContainer, "#CCE5FF", "#044B71"),
(Role::OnPrimaryContainer, "#044B71", "#CCE5FF"),
(Role::Secondary, "#50606F", "#B8C8D9"),
(Role::OnSecondary, "#FFFFFF", "#23323F"),
(Role::SecondaryContainer, "#D4E4F6", "#394856"),
(Role::OnSecondaryContainer, "#394856", "#D4E4F6"),
(Role::Tertiary, "#66587B", "#D1BFE7"),
(Role::OnTertiary, "#FFFFFF", "#372A4A"),
(Role::TertiaryContainer, "#ECDCFF", "#4E4162"),
(Role::OnTertiaryContainer, "#4E4162", "#ECDCFF"),
],
),
(
"#D000FF",
[
(Role::Primary, "#794F81", "#E9B5EF"),
(Role::OnPrimary, "#FFFFFF", "#472150"),
(Role::PrimaryContainer, "#FDD6FF", "#603768"),
(Role::OnPrimaryContainer, "#603768", "#FDD6FF"),
(Role::Secondary, "#6A596C", "#D6C0D6"),
(Role::OnSecondary, "#FFFFFF", "#3A2B3C"),
(Role::SecondaryContainer, "#F3DBF2", "#524153"),
(Role::OnSecondaryContainer, "#524153", "#F3DBF2"),
(Role::Tertiary, "#82524C", "#F5B7B0"),
(Role::OnTertiary, "#FFFFFF", "#4C2521"),
(Role::TertiaryContainer, "#FFDAD6", "#673B36"),
(Role::OnTertiaryContainer, "#673B36", "#FFDAD6"),
],
),
(
"#000000",
[
(Role::Primary, "#8C4A60", "#FFB1C8"),
(Role::OnPrimary, "#FFFFFF", "#541D32"),
(Role::PrimaryContainer, "#FFD9E2", "#703348"),
(Role::OnPrimaryContainer, "#703348", "#FFD9E2"),
(Role::Secondary, "#74565F", "#E3BDC6"),
(Role::OnSecondary, "#FFFFFF", "#422931"),
(Role::SecondaryContainer, "#FFD9E2", "#5A3F47"),
(Role::OnSecondaryContainer, "#5A3F47", "#FFD9E2"),
(Role::Tertiary, "#7C5635", "#EFBD94"),
(Role::OnTertiary, "#FFFFFF", "#48290B"),
(Role::TertiaryContainer, "#FFDCC1", "#623F20"),
(Role::OnTertiaryContainer, "#623F20", "#FFDCC1"),
],
),
(
"#FFFFFF",
[
(Role::Primary, "#006874", "#82D3E0"),
(Role::OnPrimary, "#FFFFFF", "#00363D"),
(Role::PrimaryContainer, "#9EEFFD", "#004F58"),
(Role::OnPrimaryContainer, "#004F58", "#9EEFFD"),
(Role::Secondary, "#4A6267", "#B1CBD0"),
(Role::OnSecondary, "#FFFFFF", "#1C3438"),
(Role::SecondaryContainer, "#CDE7EC", "#334B4F"),
(Role::OnSecondaryContainer, "#334B4F", "#CDE7EC"),
(Role::Tertiary, "#525E7D", "#BAC6EA"),
(Role::OnTertiary, "#FFFFFF", "#24304D"),
(Role::TertiaryContainer, "#DAE2FF", "#3B4664"),
(Role::OnTertiaryContainer, "#3B4664", "#DAE2FF"),
],
),
];
/// 官方 `SchemeTonalSpot(seed)` 的中性表面(surface 与 surface-container-highest,light/dark)。
const SEED_NEUTRALS: [(&str, &str, &str, &str, &str); 8] = [
("#6750A4", "#FDF7FF", "#141218", "#E6E0E9", "#36343A"),
("#FF0000", "#FFF8F6", "#1A1110", "#F1DFDC", "#3D3230"),
("#00FF00", "#F8FBF1", "#11140F", "#E1E4DA", "#32362F"),
("#0000FF", "#FBF8FF", "#131318", "#E4E1E9", "#34343A"),
("#6E7C8A", "#F7F9FF", "#101418", "#E0E2E8", "#313539"),
("#D000FF", "#FFF7FA", "#171217", "#EAE0E7", "#393338"),
("#000000", "#FFF8F8", "#191113", "#EFDFE1", "#3C3235"),
("#FFFFFF", "#F5FAFB", "#0E1415", "#DEE3E5", "#303637"),
];
fn worst_channel(got: Color, want: Color) -> i16 {
[(got.r, want.r), (got.g, want.g), (got.b, want.b)]
.iter()
.map(|(a, b)| (i16::from(*a) - i16::from(*b)).abs())
.max()
.unwrap_or(0)
}
#[test]
fn accent_schemes_match_official_on_the_accent_families() {
let mut worst = 0;
let mut off_by_one = 0;
let mut checked = 0;
let mut failures = Vec::new();
for (seed_hex, roles) in ACCENT_SEEDS {
let accent = Color::from_hex(seed_hex).expect("合法 hex");
for (flavor, is_dark) in [(SchemeFlavor::Light, false), (SchemeFlavor::Dark, true)] {
let scheme = Scheme::from_accent(flavor, accent);
for (role, want_light, want_dark) in roles {
let want_hex = if is_dark { want_dark } else { want_light };
let got = scheme.get(role);
let want = Color::from_hex(want_hex).expect("官方 hex 必须合法");
let delta = worst_channel(got, want);
worst = worst.max(delta);
checked += 1;
if delta > 0 {
off_by_one += 1;
}
if delta > CHANNEL_TOLERANCE {
failures.push(format!(
"{seed_hex}/{}/{} 是 {},官方 {want_hex}(差 {delta})",
flavor.name(),
role.name(),
got.to_hex()
));
}
}
}
}
assert!(
failures.is_empty(),
"{} 项强调色与官方不一致(容差 {CHANNEL_TOLERANCE}):\n{}",
failures.len(),
failures.join("\n")
);
println!("强调色对齐:{checked} 项,最大通道误差 {worst},非零误差 {off_by_one} 项");
}
#[test]
fn accent_scheme_keeps_baseline_surfaces() {
// 产品语义:换强调色不许让底色变脏(这是 `from_accent` 与官方 `from_seed` 的唯一差别)。
let base = Scheme::baseline(SchemeFlavor::Light);
for (seed_hex, _) in ACCENT_SEEDS {
let accent = Color::from_hex(seed_hex).expect("合法 hex");
let custom = Scheme::from_accent(SchemeFlavor::Light, accent);
assert_eq!(custom.get(Role::Surface), base.get(Role::Surface));
assert_eq!(custom.get(Role::Outline), base.get(Role::Outline));
assert_eq!(custom.get(Role::Error), base.get(Role::Error));
}
}
#[test]
fn from_seed_matches_official_neutrals() {
// 全量 `from_seed` 的中性派生必须与官方一致(否则「整窗染色」的色相就是错的)。
for (seed_hex, light_surface, dark_surface, light_highest, dark_highest) in SEED_NEUTRALS {
let seed = Color::from_hex(seed_hex).expect("合法 hex");
for (flavor, want_surface, want_highest) in [
(SchemeFlavor::Light, light_surface, light_highest),
(SchemeFlavor::Dark, dark_surface, dark_highest),
] {
let scheme = Scheme::from_seed(flavor, seed);
for (role, want_hex) in [
(Role::Surface, want_surface),
(Role::SurfaceContainerHighest, want_highest),
] {
let got = scheme.get(role);
let want = Color::from_hex(want_hex).expect("官方 hex 必须合法");
assert!(
worst_channel(got, want) <= CHANNEL_TOLERANCE,
"{seed_hex}/{}/{} 是 {},官方 {want_hex}",
flavor.name(),
role.name(),
got.to_hex()
);
}
}
}
}
#[test]
fn from_seed_and_from_accent_agree_on_the_accent_families() {
// 两个入口的强调族应当逐像素相同:换种子时中性色不同,强调色只取决于种子色相。
for (seed_hex, _) in ACCENT_SEEDS {
let seed = Color::from_hex(seed_hex).expect("合法 hex");
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let by_seed = Scheme::from_seed(flavor, seed);
let by_accent = Scheme::from_accent(flavor, seed);
for role in [
Role::Primary,
Role::OnPrimary,
Role::PrimaryContainer,
Role::OnPrimaryContainer,
Role::Secondary,
Role::SecondaryContainer,
Role::Tertiary,
Role::TertiaryContainer,
] {
assert_eq!(
by_seed.get(role),
by_accent.get(role),
"{seed_hex}/{} 的 {} 两个入口不一致",
flavor.name(),
role.name()
);
}
}
}
}
#[test]
fn accent_hue_is_not_clipped_away() {
// 旧缺陷②:强调色色相被 gamut 硬裁导致偏移。现在色相由 HCT 保住(只压彩度)。
use unlucky_design::color::Hct;
for (seed_hex, _) in ACCENT_SEEDS {
let seed = Color::from_hex(seed_hex).expect("合法 hex");
let seed_hue = Hct::from_color(seed).hue;
let primary = Scheme::from_accent(SchemeFlavor::Light, seed).get(Role::Primary);
let got = Hct::from_color(primary).hue;
let delta = (got - seed_hue).abs().min(360.0 - (got - seed_hue).abs());
assert!(delta < 1.0, "{seed_hex} 的 primary 色相偏了 {delta}°");
}
}
+198
View File
@@ -0,0 +1,198 @@
//! 与官方 `material-color-utilities` 的**逐角色对齐**(baseline 方案)。
//!
//! 期望值不是「我们觉得对的颜色」,而是把官方 Java 实现(Apache-2.0)编译跑出来的结果:
//! `new SchemeTonalSpot(Hct.fromInt(0xFF6750A4), isDark, 0.0, SpecVersion.SPEC_2021, Platform.PHONE)`
//! 配上 `ColorSpec2021` 的 49 个动态色。跑法见 `NOTICE` 的归属声明——期望值表就是那份输出的
//! 抄本(`seed,flavor,role,hex`)。
//!
//! 判据是**每通道误差 ≤1**:8 bit 量化本身就有半格误差,再加上 Java `double` 与 Rust `f64`
//! 的末位差异,1 是「同一个算法」的合理上限;2 以上就是实现真的不一样了。
use unlucky_design::color::{Color, Hct, Role, Scheme, SchemeFlavor, BASELINE_SEED};
/// 每通道允许的最大误差。
const CHANNEL_TOLERANCE: i16 = 1;
/// 官方 baseline 输出(seed `#6750A4`,Tonal Spot,ColorSpec2021,contrastLevel 0)。
const BASELINE: [(Role, &str, &str); 49] = [
(Role::Primary, "#65558F", "#CFBDFE"),
(Role::OnPrimary, "#FFFFFF", "#36275D"),
(Role::PrimaryContainer, "#E9DDFF", "#4D3D75"),
(Role::OnPrimaryContainer, "#4D3D75", "#E9DDFF"),
(Role::Secondary, "#625B71", "#CBC2DB"),
(Role::OnSecondary, "#FFFFFF", "#332D41"),
(Role::SecondaryContainer, "#E8DEF8", "#4A4458"),
(Role::OnSecondaryContainer, "#4A4458", "#E8DEF8"),
(Role::Tertiary, "#7E5260", "#EFB8C8"),
(Role::OnTertiary, "#FFFFFF", "#4A2532"),
(Role::TertiaryContainer, "#FFD9E3", "#633B48"),
(Role::OnTertiaryContainer, "#633B48", "#FFD9E3"),
(Role::Error, "#BA1A1A", "#FFB4AB"),
(Role::OnError, "#FFFFFF", "#690005"),
(Role::ErrorContainer, "#FFDAD6", "#93000A"),
(Role::OnErrorContainer, "#93000A", "#FFDAD6"),
(Role::Surface, "#FDF7FF", "#141218"),
(Role::OnSurface, "#1D1B20", "#E6E0E9"),
(Role::SurfaceVariant, "#E7E0EB", "#49454E"),
(Role::OnSurfaceVariant, "#49454E", "#CAC4CF"),
(Role::Background, "#FDF7FF", "#141218"),
(Role::OnBackground, "#1D1B20", "#E6E0E9"),
(Role::SurfaceDim, "#DED8E0", "#141218"),
(Role::SurfaceBright, "#FDF7FF", "#3B383E"),
(Role::SurfaceContainerLowest, "#FFFFFF", "#0F0D13"),
(Role::SurfaceContainerLow, "#F8F2FA", "#1D1B20"),
(Role::SurfaceContainer, "#F2ECF4", "#211F24"),
(Role::SurfaceContainerHigh, "#ECE6EE", "#2B292F"),
(Role::SurfaceContainerHighest, "#E6E0E9", "#36343A"),
(Role::Outline, "#7A757F", "#948F99"),
(Role::OutlineVariant, "#CAC4CF", "#49454E"),
(Role::InverseSurface, "#322F35", "#E6E0E9"),
(Role::InverseOnSurface, "#F5EFF7", "#322F35"),
(Role::InversePrimary, "#CFBDFE", "#65558F"),
(Role::Shadow, "#000000", "#000000"),
(Role::Scrim, "#000000", "#000000"),
(Role::SurfaceTint, "#65558F", "#CFBDFE"),
(Role::PrimaryFixed, "#E9DDFF", "#E9DDFF"),
(Role::PrimaryFixedDim, "#CFBDFE", "#CFBDFE"),
(Role::OnPrimaryFixed, "#201047", "#201047"),
(Role::OnPrimaryFixedVariant, "#4D3D75", "#4D3D75"),
(Role::SecondaryFixed, "#E8DEF8", "#E8DEF8"),
(Role::SecondaryFixedDim, "#CBC2DB", "#CBC2DB"),
(Role::OnSecondaryFixed, "#1E192B", "#1E192B"),
(Role::OnSecondaryFixedVariant, "#4A4458", "#4A4458"),
(Role::TertiaryFixed, "#FFD9E3", "#FFD9E3"),
(Role::TertiaryFixedDim, "#EFB8C8", "#EFB8C8"),
(Role::OnTertiaryFixed, "#31101D", "#31101D"),
(Role::OnTertiaryFixedVariant, "#633B48", "#633B48"),
];
/// 官方 `Hct.from(hue, chroma, tone)` 的色值(求解器与 gamut 映射的直接证据)。
const HCT_PROBES: [(f64, f64, f64, &str); 12] = [
(95.0, 36.0, 40.0, "#705D0D"),
(95.0, 36.0, 90.0, "#FCE186"),
(95.0, 16.0, 90.0, "#F0E2BB"),
(96.0, 24.0, 80.0, "#D7C68D"),
(25.0, 84.0, 40.0, "#BA1A1A"),
(0.0, 0.0, 50.0, "#777777"),
(200.0, 120.0, 70.0, "#00BEC3"),
(30.0, 0.5, 98.0, "#FDF8F7"),
(270.0, 200.0, 10.0, "#001847"),
(120.0, 60.0, 99.0, "#FCFFDD"),
(60.0, 6.0, 6.0, "#19120C"),
(95.0, 36.0, 30.0, "#554500"),
];
/// 三通道里最大的绝对误差。
fn worst_channel(got: Color, want: Color) -> i16 {
let pairs = [(got.r, want.r), (got.g, want.g), (got.b, want.b)];
pairs
.iter()
.map(|(a, b)| (i16::from(*a) - i16::from(*b)).abs())
.max()
.unwrap_or(0)
}
#[test]
fn baseline_matches_official_on_all_forty_nine_roles() {
// 注意:对齐的是**官方锚点**(种子 #6750A4 + 中性彩度 6),不是产品默认(M70 起是浅冷蓝)。
let light = Scheme::official_baseline(SchemeFlavor::Light);
let dark = Scheme::official_baseline(SchemeFlavor::Dark);
let mut worst = 0;
let mut off_by_one = 0;
let mut failures = Vec::new();
for (role, want_light, want_dark) in BASELINE {
for (flavor, scheme, want_hex) in [
(SchemeFlavor::Light, light, want_light),
(SchemeFlavor::Dark, dark, want_dark),
] {
let got = scheme.get(role);
let want = Color::from_hex(want_hex).expect("官方 hex 必须合法");
let delta = worst_channel(got, want);
worst = worst.max(delta);
if delta > 0 {
off_by_one += 1;
}
if delta > CHANNEL_TOLERANCE {
failures.push(format!(
"{}/{} 是 {},官方 {want_hex}(差 {delta})",
flavor.name(),
role.name(),
got.to_hex()
));
}
}
}
assert!(
failures.is_empty(),
"{} 个角色与官方不一致(容差 {CHANNEL_TOLERANCE}):\n{}",
failures.len(),
failures.join("\n")
);
// 把误差统计打出来(`cargo test -- --nocapture` 可见),报告里要引用。
println!("baseline 对齐:98 项,最大通道误差 {worst},非零误差 {off_by_one} 项");
}
#[test]
fn hct_solver_matches_official_color_spec_2021() {
for (hue, chroma, tone, want_hex) in HCT_PROBES {
let got = Hct::from_components(hue, chroma, tone).to_color();
let want = Color::from_hex(want_hex).expect("官方 hex 必须合法");
assert!(
worst_channel(got, want) <= CHANNEL_TOLERANCE,
"HCT({hue}, {chroma}, {tone}) 是 {},官方 {want_hex}",
got.to_hex()
);
}
}
#[test]
fn baseline_primary_is_not_the_seed_and_that_is_official() {
// 常见误解:官方基线的 primary 应该等于种子 #6750A4。官方不是——Tonal Spot 变体把
// primary 的彩度固定成 36,而 #6750A4 的 HCT 彩度是 47.86,所以 primary 被收到 #65558F。
// 这也是 Flutter `ColorScheme.fromSeed(0xFF6750A4)` 的 primary 值。
let seed = Hct::from_color(BASELINE_SEED);
assert!(
(seed.chroma - 47.856_526).abs() < 1.0e-4,
"种子彩度 {}",
seed.chroma
);
let primary = Scheme::official_baseline(SchemeFlavor::Light).get(Role::Primary);
assert_eq!(primary.to_hex(), "#65558F");
assert_ne!(primary, BASELINE_SEED);
}
#[test]
fn baseline_tone_axis_is_still_exact_lstar() {
// gamut 映射只动彩度,不动 tone:每个角色的实测 L* 与它声明的 tone 相差 ≤1。
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let scheme = Scheme::official_baseline(flavor);
for (role, color) in scheme.iter() {
let tone = Scheme::tone_of(role, flavor);
let got = f64::from(color.lstar());
assert!(
(got - tone).abs() <= 1.0,
"{}/{} 的 tone 声明 {tone}、实测 {got}",
flavor.name(),
role.name()
);
}
}
}
#[test]
fn same_family_containers_are_separated_by_hct_chroma() {
// 旧缺陷①:light 下 primary-container 与 secondary-container 撞色。
// 现在两者彩度不同(36 vs 16),在 tone 90 上都不会被色域裁到同一个上限。
let light = Scheme::official_baseline(SchemeFlavor::Light);
let primary = light.get(Role::PrimaryContainer);
let secondary = light.get(Role::SecondaryContainer);
assert_ne!(primary, secondary);
let c_primary = Hct::from_color(primary).chroma;
let c_secondary = Hct::from_color(secondary).chroma;
assert!(
c_secondary < c_primary,
"secondary-container 彩度 {c_secondary} 应低于 primary-container {c_primary}"
);
// 深层原因:tone 90 上彩度 36 装得下,所以容器色是「真的有彩度的浅色」而不是被裁到灰。
assert!(c_primary > 12.0, "primary-container 彩度只剩 {c_primary}");
}
+341
View File
@@ -0,0 +1,341 @@
//! M70:默认色候选(保守蓝 / 冷调蓝 / 浅冷蓝 / 天蓝)——四档的**唯一真源**与 pin。
//!
//! ## 这档候选到底改了什么
//!
//! 四档走的是**同一条** HCT 管道(`Palettes::from_seed_with_neutral_chroma` → `ColorSpec2021`
//! tone 表 → `HctSolver` gamut 映射),差别只有两个入参:
//!
//! | 档 | 种子 | 种子 HCT 色相 | 中性调色板彩度 | 表面 | 主色观感 |
//! |---|---|---|---|---|---|
//! | ① 保守蓝 | `#2196F3` | 253.7° | 6(官方) | 近白/近黑,几乎无色 | 钢蓝 |
//! | ② 冷调蓝 | `#2196F3` | 253.7° | 12 | 冷白/冷黑,带淡蓝调 | 钢蓝 |
//! | ③ 浅冷蓝(备选) | `#87CEEB`(CSS `skyblue`) | 225.7° | 12 | 浅天蓝 | 偏青(teal) |
//! | ④ 天蓝(**出厂默认,用户 2026-11-01 拍板**) | `#3C8DBC` | 239.5° | 12 | 浅天蓝 | 偏蓝的天蓝 |
//!
//! ③ 与 ④ 是同一件事的两种「蓝」:色相越低越偏青(teal),越高越偏钢蓝;
//! 239.5° 正好夹在中间,主色从 ③ 的 `#0C6780` 变成 `#236488`。
//!
//! 两条必须知道的物理事实(写在测试里,免得下次又有人想「把主色调浅一点」):
//!
//! 1. **种子彩度是废参数**:Tonal Spot 把 primary 彩度钉在 36,种子只贡献色相。
//! 所以「低彩度天蓝种子」得到的色板与「高彩度天蓝种子」**逐像素相同**
//! (`seed_chroma_never_leaks_into_the_primary_ramp` 在 `palettes.rs` 里也钉了这条)。
//! ③ 与 ② 的真正差别是**色相**(226° vs 254°),不是彩度。
//! 2. **primary 的 tone 被钉在 40**,所以四档主色都是「中蓝」,不会更浅。
//! 「浅」只能落在中性色上——这就是②/③/④提高中性彩度的原因,也是本项目唯一一处
//! 对官方 Tonal Spot 的**有意偏离**(登记在 `color` 模块文档的「出厂默认色」小节)。
//!
//! ## 换默认色要动哪里
//!
//! `palettes::DEFAULT_SEED` / `palettes::DEFAULT_NEUTRAL_CHROMA` 两行常量,
//! 然后重跑本文件:pin 表会逐角色告诉你哪几个 hex 变了,照抄即可(pin 是**抄本**,不是推导)。
use unlucky_design::color::{
Color, Hct, Palettes, Role, Scheme, SchemeFlavor, DEFAULT_NEUTRAL_CHROMA, DEFAULT_SEED,
};
/// 一档候选:只有两个变量(种子 + 中性彩度),其余全是官方常数。
struct Candidate {
/// 短标签(色卡与 CSV 用)。
tag: &'static str,
/// 中文档名。
title: &'static str,
/// 种子 hex。
seed_hex: &'static str,
/// 中性调色板彩度(官方 Tonal Spot 是 6)。
neutral_chroma: f64,
/// 9 个展示角色的 **(角色, light, dark)** 抄本。
pins: [(Role, &'static str, &'static str); 9],
}
/// 四档候选(色卡 `DevDocs/ops/skyseed-candidates/` 用的就是这份数据)。
const CANDIDATES: [Candidate; 4] = [
Candidate {
tag: "C1",
title: "① 保守蓝(中性彩度 6 = 官方)",
seed_hex: "#2196F3",
neutral_chroma: 6.0,
pins: [
(Role::Primary, "#36618E", "#A0CAFD"),
(Role::OnPrimary, "#FFFFFF", "#003258"),
(Role::PrimaryContainer, "#D1E4FF", "#194975"),
(Role::Secondary, "#535F70", "#BBC7DB"),
(Role::Tertiary, "#6B5778", "#D6BEE4"),
(Role::Surface, "#F8F9FF", "#111418"),
(Role::SurfaceContainerLow, "#F2F3FA", "#191C20"),
(Role::SurfaceContainerHighest, "#E1E2E8", "#32353A"),
(Role::Outline, "#73777F", "#8D9199"),
],
},
Candidate {
tag: "C2",
title: "② 冷调蓝(中性彩度 12)",
seed_hex: "#2196F3",
neutral_chroma: 12.0,
pins: [
(Role::Primary, "#36618E", "#A0CAFD"),
(Role::OnPrimary, "#FFFFFF", "#003258"),
(Role::PrimaryContainer, "#D1E4FF", "#194975"),
(Role::Secondary, "#535F70", "#BBC7DB"),
(Role::Tertiary, "#6B5778", "#D6BEE4"),
(Role::Surface, "#F8F9FF", "#0C141E"),
(Role::SurfaceContainerLow, "#EEF4FF", "#141C26"),
(Role::SurfaceContainerHighest, "#DBE3F1", "#2D3540"),
(Role::Outline, "#6D7887", "#8791A1"),
],
},
Candidate {
tag: "C3",
title: "③ 浅冷蓝(中性彩度 12 + 偏青天蓝种子)",
seed_hex: "#87CEEB",
neutral_chroma: 12.0,
pins: [
(Role::Primary, "#0C6780", "#89D0ED"),
(Role::OnPrimary, "#FFFFFF", "#003544"),
(Role::PrimaryContainer, "#BAEAFF", "#004D62"),
(Role::Secondary, "#4C626B", "#B3CAD5"),
(Role::Tertiary, "#5B5B7E", "#C4C3EB"),
(Role::Surface, "#F3FAFF", "#07151B"),
(Role::SurfaceContainerLow, "#E7F6FE", "#0F1D23"),
(Role::SurfaceContainerHighest, "#D6E5ED", "#29373D"),
(Role::Outline, "#687A82", "#81949C"),
],
},
Candidate {
tag: "C4",
title: "④ 天蓝(中性彩度 12 + 夹在中间的天蓝种子)",
seed_hex: "#3C8DBC",
neutral_chroma: 12.0,
pins: [
(Role::Primary, "#236488", "#93CDF6"),
(Role::OnPrimary, "#FFFFFF", "#00344C"),
(Role::PrimaryContainer, "#C8E6FF", "#004C6D"),
(Role::Secondary, "#4F616E", "#B7C9D8"),
(Role::Tertiary, "#63597C", "#CDC0E9"),
(Role::Surface, "#F6FAFF", "#09151C"),
(Role::SurfaceContainerLow, "#EBF5FF", "#111D25"),
(Role::SurfaceContainerHighest, "#D8E4EF", "#2B363F"),
(Role::Outline, "#6A7985", "#84939F"),
],
},
];
/// 四档共同点:种子的 HCT 坐标(色相是候选之间**唯一**有效的差别)。
fn seed_hct(candidate: &Candidate) -> Hct {
Hct::from_color(Color::from_hex(candidate.seed_hex).expect("候选种子必须是合法 hex"))
}
/// 按候选参数走**产品同一条**派生路径。
fn scheme(candidate: &Candidate, flavor: SchemeFlavor) -> Scheme {
Scheme::from_palettes(
&Palettes::from_seed_with_neutral_chroma(
seed_hct(candidate).to_color(),
candidate.neutral_chroma,
),
flavor,
)
}
#[test]
fn candidates_match_their_pins() {
let mut drift = Vec::new();
for candidate in &CANDIDATES {
for (role, want_light, want_dark) in candidate.pins {
for (flavor, want_hex) in [
(SchemeFlavor::Light, want_light),
(SchemeFlavor::Dark, want_dark),
] {
let got = scheme(candidate, flavor).get(role).to_hex();
if got != want_hex {
drift.push(format!(
"{}{} / {}/{} 实测 {got},pin {want_hex}",
candidate.tag,
if flavor.is_dark() { "-dark" } else { "" },
candidate.title,
role.name()
));
}
}
}
}
assert!(
drift.is_empty(),
"候选色值漂了(换默认色后请照抄下面这些 hex 到 pin 表):\n{}",
drift.join("\n")
);
}
#[test]
fn candidates_only_move_the_two_declared_inputs() {
// 「不绕算法」的证据:四档的调色板彩度与官方 Tonal Spot 逐项一致,
// 唯一的差别是中性彩度的入参(以及种子色相)。
let official = Palettes::official_baseline();
for candidate in &CANDIDATES {
let p = Palettes::from_seed_with_neutral_chroma(
seed_hct(candidate).to_color(),
candidate.neutral_chroma,
);
assert_eq!(p.primary.chroma(), official.primary.chroma());
assert_eq!(p.secondary.chroma(), official.secondary.chroma());
assert_eq!(p.tertiary.chroma(), official.tertiary.chroma());
assert_eq!(p.error, official.error, "error 族不该被候选影响");
assert_eq!(p.neutral.chroma(), candidate.neutral_chroma);
assert!(
(p.tertiary.hue_deg() - (p.primary.hue_deg() + 60.0)).abs() < 1.0e-9,
"{} 的 tertiary 不再是 +60°",
candidate.tag
);
}
}
#[test]
fn the_candidates_are_actually_distinguishable() {
// 「四档不像同一档」的机器判据。注意 light 的 surface(tone 98)在 8 bit 下已经贴顶,
// 中性彩度 6→12 会量化到同一个 #F8F9FF——所以 ①② 的判据落在 container 系与 dark 模式上,
// 这也是色卡上 ①② 的差别主要体现在卡片/浮层与深色模式的原因。
let light = |c: &Candidate| scheme(c, SchemeFlavor::Light);
let dark = |c: &Candidate| scheme(c, SchemeFlavor::Dark);
let (c1, c2, c3, c4) = (
light(&CANDIDATES[0]),
light(&CANDIDATES[1]),
light(&CANDIDATES[2]),
light(&CANDIDATES[3]),
);
for role in [Role::SurfaceContainerLow, Role::SurfaceContainerHighest] {
assert_ne!(
c1.get(role),
c2.get(role),
"①与②的 {} 相同:中性彩度没起作用",
role.name()
);
assert_ne!(c2.get(role), c3.get(role), "②与③的 {} 相同", role.name());
// ③④ 的中性彩度相同,差别只允许来自色相
assert_ne!(c3.get(role), c4.get(role), "③与④的 {} 相同", role.name());
}
assert_ne!(
c3.get(Role::Primary),
c4.get(Role::Primary),
"③与④的主色必须真的不同(否则第 4 档白加)"
);
assert_ne!(
dark(&CANDIDATES[0]).get(Role::Surface),
dark(&CANDIDATES[1]).get(Role::Surface),
"深色模式的 surface 应当能看出中性彩度差别"
);
let hue_gap = |a: &Candidate, b: &Candidate| {
let (x, y) = (seed_hct(a).hue, seed_hct(b).hue);
(x - y).abs().min(360.0 - (x - y).abs())
};
assert!(
hue_gap(&CANDIDATES[0], &CANDIDATES[1]) < 0.01,
"①② 应当只差中性彩度"
);
// ③ 偏青 / ④ 居中的分界靠色相:②→③ 与 ③→④ 都要肉眼可分的量级。
for (i, j) in [(1, 2), (2, 3)] {
assert!(
(10.0..=40.0).contains(&hue_gap(&CANDIDATES[i], &CANDIDATES[j])),
"{}→{} 的色相差应当明显(10°~40°),实测 {}",
CANDIDATES[i].tag,
CANDIDATES[j].tag,
hue_gap(&CANDIDATES[i], &CANDIDATES[j])
);
}
}
#[test]
fn product_default_is_the_adopted_candidate() {
// 用户 2026-11-01 拍板采用 **④ 天蓝**。这条用例就是「默认值 ↔ 候选表」的绑定器:
// 谁改了 `DEFAULT_SEED`/`DEFAULT_NEUTRAL_CHROMA` 却没同步这里(或反过来),它会立刻红。
// 断言一律比 **hex / 数值**,这样失败信息里直接能读到「期望 ④、实测几」。
let adopted = &CANDIDATES[3];
assert_eq!(
DEFAULT_SEED.to_hex(),
"#3C8DBC",
"DEFAULT_SEED 必须仍是已采用的 ④ 天蓝:期望 #3C8DBC(④),实测 {}({})",
DEFAULT_SEED.to_hex(),
if DEFAULT_SEED.to_hex() == CANDIDATES[2].seed_hex {
"③ 浅冷蓝——要改回 ③ 请连偏离登记与 pin 一起改"
} else {
"不是候选表里的任何一个种子"
}
);
assert_eq!(
DEFAULT_SEED.to_hex(),
adopted.seed_hex,
"DEFAULT_SEED({})与已采用档 {} 的种子({})不一致",
DEFAULT_SEED.to_hex(),
adopted.tag,
adopted.seed_hex
);
assert_eq!(
DEFAULT_NEUTRAL_CHROMA, 12.0,
"DEFAULT_NEUTRAL_CHROMA 必须仍是已采用的 12,实测 {}",
DEFAULT_NEUTRAL_CHROMA
);
assert_eq!(
DEFAULT_NEUTRAL_CHROMA, adopted.neutral_chroma,
"DEFAULT_NEUTRAL_CHROMA({})与已采用档 {} 的中性彩度({})不一致",
DEFAULT_NEUTRAL_CHROMA, adopted.tag, adopted.neutral_chroma
);
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let product = Scheme::baseline(flavor);
let expected = scheme(adopted, flavor);
for (role, got) in product.iter() {
assert_eq!(
got.to_hex(),
expected.get(role).to_hex(),
"产品默认与已采用档 {} 的 {}({} 模式)不一致",
adopted.tag,
role.name(),
flavor.name()
);
}
}
}
#[test]
fn surface_tint_stays_a_tint() {
// 门禁:中性彩度提高是「淡蓝调」不是「把窗口染成蓝纸」。surface 的 HCT 彩度必须
// 明显低于 primary 的 36,且 surface 的 Y 仍 > 0.85(浅色模式还是浅底)。
for candidate in &CANDIDATES {
let light = scheme(candidate, SchemeFlavor::Light);
let surface = light.get(Role::Surface);
let chroma = Hct::from_color(surface).chroma;
assert!(
chroma <= 1.2 * candidate.neutral_chroma,
"{} 的 surface 彩度 {chroma} 超过中性彩度入参的两倍",
candidate.tag
);
assert!(surface.relative_luminance() > 0.85);
}
}
#[test]
fn dump_candidates_for_the_color_card() {
// 色卡脚本读的就是这两行格式(`SEED|` / `ROLE|`)。跑法:
// cargo test -p unlucky-design --test skyseed_candidates -- --nocapture
for candidate in &CANDIDATES {
let hct = seed_hct(candidate);
println!(
"SEED|{}|{}|{}|{}|hue={:.2}|chroma={:.2}",
candidate.tag,
candidate.title,
candidate.seed_hex,
candidate.neutral_chroma,
hct.hue,
hct.chroma
);
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let s = scheme(candidate, flavor);
for role in Role::ALL {
println!(
"ROLE|{}|{}|{}|{}",
candidate.tag,
flavor.name(),
role.name(),
s.get(role).to_hex().trim_start_matches('#')
);
}
}
}
}
+8 -4
View File
@@ -182,7 +182,8 @@ fn spacing_and_layout_metrics_are_shared() {
#[test]
fn component_map_covers_the_page_skeleton() {
assert_eq!(Component::ALL.len(), 20);
// M69(G9)在末尾追加 Checkbox/Radio/Search/NavigationDrawer/Divider:20 → 25。
assert_eq!(Component::ALL.len(), 25);
for component in Component::ALL {
let spec = component.spec();
assert_eq!(spec.component, component);
@@ -191,6 +192,9 @@ fn component_map_covers_the_page_skeleton() {
assert_eq!(Component::PrimaryButton.spec().shape, ShapeScale::Full);
// 浮层必须高于卡片层。
assert!(Component::Dialog.spec().elevation > Component::OutlinedCard.spec().elevation);
// 末尾追加的 5 个不许改动既有次序。
assert_eq!(Component::ALL[0], Component::TopAppBar);
assert_eq!(Component::ALL[19], Component::EmptyState);
}
#[test]
@@ -212,11 +216,11 @@ fn design_tokens_entry_point() {
#[test]
fn tonal_palette_hits_requested_tones() {
let palette = TonalPalette::new(150.0, 0.1);
for tone in [20.0_f32, 40.0, 60.0, 80.0] {
let palette = TonalPalette::new(150.0, 36.0);
for tone in [20.0_f64, 40.0, 60.0, 80.0] {
let color = palette.tone(tone);
assert!(
(color.lstar() - tone).abs() <= 1.0,
(f64::from(color.lstar()) - tone).abs() <= 1.0,
"tone {tone} 实际 {}",
color.lstar()
);
+8 -8
View File
@@ -8,7 +8,7 @@ use unlucky_design::icons::IconKind;
use unlucky_design::motion::{DurationToken, Easing};
use unlucky_design::shape::ShapeScale;
fn expect_tone(role: Role, light: f32, dark: f32) {
fn expect_tone(role: Role, light: f64, dark: f64) {
assert_eq!(
Scheme::tone_of(role, SchemeFlavor::Light),
light,
@@ -28,24 +28,24 @@ fn all_forty_nine_tones_are_pinned() {
expect_tone(Role::Primary, 40.0, 80.0);
expect_tone(Role::OnPrimary, 100.0, 20.0);
expect_tone(Role::PrimaryContainer, 90.0, 30.0);
expect_tone(Role::OnPrimaryContainer, 10.0, 90.0);
expect_tone(Role::OnPrimaryContainer, 30.0, 90.0);
expect_tone(Role::Secondary, 40.0, 80.0);
expect_tone(Role::OnSecondary, 100.0, 20.0);
expect_tone(Role::SecondaryContainer, 90.0, 30.0);
expect_tone(Role::OnSecondaryContainer, 10.0, 90.0);
expect_tone(Role::OnSecondaryContainer, 30.0, 90.0);
expect_tone(Role::Tertiary, 40.0, 80.0);
expect_tone(Role::OnTertiary, 100.0, 20.0);
expect_tone(Role::TertiaryContainer, 90.0, 30.0);
expect_tone(Role::OnTertiaryContainer, 10.0, 90.0);
expect_tone(Role::OnTertiaryContainer, 30.0, 90.0);
expect_tone(Role::Error, 40.0, 80.0);
expect_tone(Role::OnError, 100.0, 20.0);
expect_tone(Role::ErrorContainer, 90.0, 30.0);
expect_tone(Role::OnErrorContainer, 10.0, 90.0);
expect_tone(Role::OnErrorContainer, 30.0, 90.0);
expect_tone(Role::Surface, 98.0, 6.0);
expect_tone(Role::OnSurface, 10.0, 90.0);
expect_tone(Role::SurfaceVariant, 90.0, 30.0);
expect_tone(Role::OnSurfaceVariant, 30.0, 80.0);
expect_tone(Role::Background, 99.0, 10.0);
expect_tone(Role::Background, 98.0, 6.0);
expect_tone(Role::OnBackground, 10.0, 90.0);
expect_tone(Role::SurfaceDim, 87.0, 6.0);
expect_tone(Role::SurfaceBright, 98.0, 24.0);
@@ -241,8 +241,8 @@ fn same_family_containers_do_not_collide() {
"同族容器撞色(彩度压缩没生效):{primary:?}"
);
// secondary-container 必须比 primary-container 更灰(M3 的「次级」语义)。
let c_primary = unlucky_design::color::space::Oklch::from_color(primary).chroma;
let c_secondary = unlucky_design::color::space::Oklch::from_color(secondary).chroma;
let c_primary = unlucky_design::color::Hct::from_color(primary).chroma;
let c_secondary = unlucky_design::color::Hct::from_color(secondary).chroma;
assert!(
c_secondary < c_primary,
"secondary-container 彩度 {c_secondary} 应低于 primary-container {c_primary}"
+297
View File
@@ -0,0 +1,297 @@
//! M69(TOKEXT)的数值钉死:G9~G13 五项缺口逐条关门。
//!
//! 期望值全部**独立于实现重写**(照 `research/15` §18~§23 与 `00-decision-log.md` D32/D33 抄),
//! 实现改一个数、这里不改就红。覆盖:
//! - **G13** `ComponentRadius` 白名单 + 尺寸闸(2 不在 `ShapeScale` 里,刻度不许被加宽);
//! - **G11** `LayoutMetrics` 的 4 个新骨架值 + nav 语义修正;
//! - **G10** `ComponentMetrics` 逐字段;
//! - **G9** `Component` 25 个变体与档位表一行对一行;
//! - **G12** 一像素线单一真源。
use unlucky_design::component_map::{Component, ComponentMetrics};
use unlucky_design::elevation::Elevation;
use unlucky_design::icons::IconSize;
use unlucky_design::shape::{radius_layer_for, ComponentRadius, RadiusLayer, ShapeScale};
use unlucky_design::spacing::{stroke, LayoutMetrics, Space};
use unlucky_design::typography::TypeRole;
// ---------- G13:圆角分层(D32) ----------
#[test]
fn shape_scale_is_unchanged_and_never_contains_two() {
// 刻度一个字都不许动(D32 第 1 条:ShapeScale 保持严格)。
let ladder: [(ShapeScale, f32); 9] = [
(ShapeScale::None, 0.0),
(ShapeScale::ExtraSmall, 4.0),
(ShapeScale::Small, 8.0),
(ShapeScale::Medium, 12.0),
(ShapeScale::Large, 16.0),
(ShapeScale::LargeIncreased, 20.0),
(ShapeScale::ExtraLarge, 28.0),
(ShapeScale::ExtraLargeIncreased, 32.0),
(ShapeScale::ExtraExtraLarge, 48.0),
];
for (scale, dp) in ladder {
assert_eq!(scale.dp(), dp, "{scale:?} 的刻度被改动了");
// 2 是官方 Checkbox 的圆角,但它**不在**刻度里——加进来就等于废掉 K1 守门。
assert_ne!(dp, 2.0, "2dp 不许进 ShapeScale(走 ComponentRadius)");
}
assert_eq!(ShapeScale::Full.radius_px(64.0), 32.0);
}
#[test]
fn component_radius_is_a_one_item_whitelist() {
assert_eq!(ComponentRadius::ALL, [ComponentRadius::Micro]);
// 2dp 只能通过带尺寸闸的 `radius_px` 拿到(`dp()` 是 crate 内部口,外部看不见)。
assert_eq!(
ComponentRadius::Micro.radius_px(20.0),
Some(2.0),
"官方 Checkbox RadiusSize = 2.dp"
);
// 尺寸闸:>24dp 一律拿不到微半径(D32 的「只允许 ≤24dp 微控件」)。
assert_eq!(ComponentRadius::Micro.radius_px(24.0), Some(2.0));
assert_eq!(ComponentRadius::Micro.radius_px(25.0), None);
assert_eq!(ComponentRadius::Micro.radius_px(240.0), None);
assert_eq!(ComponentRadius::Micro.radius_px(0.0), None);
// 边长小于两倍半径时按边长一半折算(2dp 元素不能有 2dp 圆角)。
assert_eq!(ComponentRadius::Micro.radius_px(2.0), Some(1.0));
}
#[test]
fn radius_layer_for_routes_by_size() {
assert_eq!(radius_layer_for(20.0), RadiusLayer::Component);
assert_eq!(radius_layer_for(24.0), RadiusLayer::Component);
assert_eq!(radius_layer_for(28.0), RadiusLayer::Scale);
assert_eq!(radius_layer_for(360.0), RadiusLayer::Scale);
// 抽屉/面板这类容器永远不该被判成微控件。
for container in [
LayoutMetrics::DESKTOP.nav_drawer_width,
LayoutMetrics::DESKTOP.ai_panel_width,
] {
assert_eq!(radius_layer_for(container), RadiusLayer::Scale);
}
}
// ---------- G11:骨架值 + nav 语义修正 ----------
#[test]
fn layout_metrics_new_skeleton_values_are_pinned() {
let m = LayoutMetrics::DESKTOP;
// A 级:SearchBarTokens.ContainerHeight = 56。
assert_eq!(m.search_bar_height, 56.0);
// A 级:NavigationDrawer 下限 240(官方 ContainerWidth = 360,1180 窗下过宽,D33 取 240)。
assert_eq!(m.nav_drawer_width, 240.0);
// 12b §5.1 登记偏离:官方 rail 96 → 本项目 88。
assert_eq!(m.nav_rail_width, 88.0);
// A 级:DividerTokens.Thickness = 1.0dp(一像素线,不受 4px 栅格约束)。
assert_eq!(m.divider_thickness, 1.0);
}
#[test]
fn nav_widths_mean_what_their_names_say() {
// D33:展开(drawer)比折叠(rail)宽;旧字段 nav_expanded_width=88 / nav_collapsed_width=48
// 的名字与值相反,已在 M69 改名并删除,这里钉住「新名字不会再次反向」。
let m = LayoutMetrics::DESKTOP;
assert!(
m.nav_drawer_width > m.nav_rail_width,
"展开态(drawer {})必须比折叠态(rail {})宽",
m.nav_drawer_width,
m.nav_rail_width
);
// rail 里 56 宽的选中指示器要放得下(88 − 2×16)。
assert_eq!(m.nav_rail_width - Space::X4.dp() * 2.0, 56.0);
// drawer 里 216 宽的指示器同样由「宽 − 2×12」推出(见 ComponentMetrics::indicator_width)。
assert_eq!(
ComponentMetrics::NAVIGATION_DRAWER.indicator_width(m.nav_drawer_width),
216.0
);
}
// ---------- G10:组件级微度量 ----------
#[test]
fn checkbox_metrics_are_pinned() {
let cm = ComponentMetrics::CHECKBOX;
// 20×20(不是 token 文件里那个死值 18,见 research/15 §26.2 矛盾 ①)。
assert_eq!((cm.width, cm.height), (20.0, 20.0));
assert_eq!(
cm.radius,
Some(ComponentRadius::Micro),
"圆角 2dp 走 D32 第二层"
);
assert_eq!(cm.stroke, 2.0, "CheckboxTokens.UnselectedOutlineWidth = 2");
assert_eq!(cm.state_layer, 40.0, "StateLayerSize = 40");
assert_eq!(cm.inner_dot, 0.0);
assert_eq!(cm.gap, Space::X2.dp(), "框 ↔ 标签 8(团队口径)");
assert_eq!(
Component::Checkbox.metrics(),
Some(ComponentMetrics::CHECKBOX)
);
}
#[test]
fn radio_metrics_are_pinned() {
let cm = ComponentMetrics::RADIO;
assert_eq!(
(cm.width, cm.height),
(20.0, 20.0),
"RadioButtonTokens.IconSize = 20"
);
assert_eq!(cm.stroke, 2.0, "RadioStrokeWidth = 2.dp");
// 内点 10 不是 12:12 是含描边前的名义值(绘制半径 = 6 − 1 = 5)。
assert_eq!(cm.inner_dot, 10.0);
assert_eq!(cm.state_layer, 40.0);
assert_eq!(cm.radius, None, "radio 是真圆,圆角走 ShapeScale::Full");
assert_eq!(Component::Radio.spec().shape, ShapeScale::Full);
}
#[test]
fn search_bar_metrics_are_pinned() {
let cm = ComponentMetrics::SEARCH_BAR;
// 高 56 属骨架级:CM 里必须留 0,不许重复一份。
assert_eq!((cm.width, cm.height), (0.0, 0.0));
assert_eq!(cm.icon, IconSize::Toolbar.px(), "leading/trailing 图标 24");
// A 级(§20.4):图标可视左缘 16 = 文本框内容内边距 12 + `SearchBarIconOffsetX = 4`。
assert_eq!(cm.pad_start, Space::X3.dp(), "内容内边距 12");
assert_eq!(cm.icon_offset, 4.0, "SearchBarIconOffsetX = 4");
assert_eq!(cm.pad_start + cm.icon_offset, 16.0, "图标可视左缘必须是 16");
// 右侧同式:图标右缘也是 16。
assert_eq!(cm.pad_end, Space::X3.dp());
assert_eq!(cm.pad_end + cm.icon_offset, 16.0, "图标可视右缘必须是 16");
// 守门:不许把 pad 本身写成 16(否则 pad + offset = 20,越过 rubric 的 ±2px)。
assert_ne!(cm.pad_start, 16.0);
assert_ne!(cm.pad_end, 16.0);
// 搜索条圆角 = 高/2 = 28,等于官方 SearchBarCornerRadius。
assert_eq!(
ShapeScale::Full.radius_px(LayoutMetrics::DESKTOP.search_bar_height),
28.0
);
}
#[test]
fn navigation_drawer_metrics_are_pinned() {
let cm = ComponentMetrics::NAVIGATION_DRAWER;
assert_eq!((cm.width, cm.height), (0.0, 0.0), "宽/项高属骨架级");
assert_eq!(
cm.inset, 12.0,
"NavigationDrawerItemDefaults.ItemPadding = 12"
);
assert_eq!(cm.pad_start, Space::X4.dp(), "项内 start 16");
assert_eq!(cm.pad_end, Space::X6.dp(), "项内 end 24");
assert_eq!(cm.icon, IconSize::Toolbar.px());
// 与 search bar 同一条公式:图标边 = pad + icon_offset(抽屉的 offset 是 0)。
assert_eq!(cm.pad_start + cm.icon_offset, 16.0, "图标起点 = 项内 start");
assert_eq!(cm.gap, Space::X3.dp(), "图标 ↔ label 12");
// 指示器宽 = 容器宽 − 24;官方 360 − 24 = 336,本项目 240 − 24 = 216。
assert_eq!(cm.indicator_width(360.0), 336.0);
assert_eq!(cm.indicator_width(240.0), 216.0);
}
#[test]
fn component_metrics_never_duplicate_layout_metrics() {
// 同一量只许一份:CM 里出现的骨架级尺寸必须留 0。
let m = LayoutMetrics::DESKTOP;
for component in [Component::Search, Component::NavigationDrawer] {
let cm = component.metrics().expect("这两个控件必须有 CM");
assert_eq!(cm.width, 0.0, "{} 的宽度属 LM", component.name());
assert_eq!(cm.height, 0.0, "{} 的高度属 LM", component.name());
assert_ne!(cm.height, m.search_bar_height);
assert_ne!(cm.width, m.nav_drawer_width);
}
// Divider 的厚度也是 LM(一像素线)→ 它没有 CM。
assert_eq!(Component::Divider.metrics(), None);
assert_eq!(m.divider_thickness, stroke::HAIRLINE);
}
// ---------- G12:一像素线 ----------
#[test]
fn hairline_is_the_single_source_of_one_pixel_lines() {
assert_eq!(stroke::HAIRLINE, 1.0);
// 分隔线厚度直接引用它,不是另写一个字面量。
assert_eq!(LayoutMetrics::DESKTOP.divider_thickness, stroke::HAIRLINE);
// 一像素线不受 4px 栅格约束(栅格测试里显式豁免,这里反向钉一次)。
assert_ne!(stroke::HAIRLINE % unlucky_design::spacing::GRID, 0.0);
}
// ---------- G9:枚举 25 变体 ↔ 表槽位 ----------
#[test]
fn component_enum_is_the_frozen_twenty_five() {
let names = [
"top-app-bar",
"navigation-item",
"primary-button",
"secondary-button",
"text-button",
"icon-button",
"outlined-card",
"filled-card",
"list-item",
"text-field",
"segmented-button",
"chip",
"switch",
"slider",
"progress-indicator",
"dialog",
"menu",
"snackbar",
"tooltip",
"empty-state",
// M69 末尾追加的 5 个
"checkbox",
"radio",
"search",
"navigation-drawer",
"divider",
];
assert_eq!(Component::ALL.len(), names.len(), "M66 的 G9:20 → 25");
for (i, component) in Component::ALL.iter().enumerate() {
assert_eq!(component.name(), names[i], "第 {i} 个组件被换位/改名了");
// 表槽位与枚举判别值一一对应(`spec()` 就是按下标取的)。
assert_eq!(*component as usize, i);
assert_eq!(component.spec().component, *component);
}
}
#[test]
fn new_components_carry_the_official_elevation_and_shape() {
// Checkbox / Radio / Search / NavigationDrawer / Divider:全部 L0(都不是浮层)。
for component in [
Component::Checkbox,
Component::Radio,
Component::Search,
Component::NavigationDrawer,
Component::Divider,
] {
assert_eq!(
component.spec().elevation,
Elevation::Level0,
"{} 不该有阴影(R4:只有浮层允许)",
component.name()
);
}
// Checkbox 的圆角真源是 micro_radius,shape 只是占位 None。
let checkbox = Component::Checkbox.spec();
assert_eq!(checkbox.micro_radius, Some(ComponentRadius::Micro));
assert_eq!(checkbox.shape, ShapeScale::None);
// 其余 4 个不碰微半径层。
for component in [
Component::Radio,
Component::Search,
Component::NavigationDrawer,
Component::Divider,
] {
assert_eq!(component.spec().micro_radius, None, "{}", component.name());
}
// NavigationDrawer = CornerLargeEnd 的内侧角 16;Divider 无圆角。
assert_eq!(Component::NavigationDrawer.spec().shape, ShapeScale::Large);
assert_eq!(Component::Divider.spec().shape, ShapeScale::None);
// Checkbox / Radio 的标签字级是团队口径(官方无「框 + 标签」token)。
assert_eq!(Component::Checkbox.spec().text, TypeRole::BodyLarge);
assert_eq!(Component::Radio.spec().text, TypeRole::BodyLarge);
// Search 的字级:输入 BodyLarge、占位/辅助 BodySmall。
assert_eq!(Component::Search.spec().text, TypeRole::BodyLarge);
assert_eq!(Component::Search.spec().support, Some(TypeRole::BodySmall));
}