24 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
34 changed files with 7671 additions and 244 deletions

No files matched your search

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]
+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
);
}
}
+45 -3
View File
@@ -27,7 +27,8 @@
//! | tone | CIELAB `L*` | CIELAB `L*`(同一个 `Y` 轴) | 逐值一致 |
//! | 色相 / 彩度 | CAM16 | CAM16(D65 默认观察条件) | 逐值一致(误差 < 1e-4) |
//! | gamut 映射 | `HctSolver` | 同一套两段求解器,关键平面表原样搬运 | 逐通道一致 |
//! | 调色板彩度 | Tonal Spot 变体:36 / 16 / 24 / 6 / 8,error 25°/84 | 同 | 逐值一致 |
//! | 调色板彩度 | Tonal Spot 变体:36 / 16 / 24 / 6 / 8,error 25°/84 | 同(primary/secondary/tertiary/neutral-variant/error 逐值一致) | 逐值一致 |
//! | 中性彩度 | 6(几乎无色) | 官方口径 6;**产品默认用 12**(有意偏离,见下节「出厂默认色」) | 仅此处偏离 |
//! | 角色 tone 表 | ColorSpec2021 | 同(49 角色 × light/dark) | 逐值一致 |
//!
//! 对齐证据在 `tests/official_baseline.rs` 与 `tests/official_accent.rs`:期望值全部由
@@ -50,11 +51,52 @@
//! 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.26:1 / dark 5.89:1;
@@ -76,7 +118,7 @@ mod space;
mod viewing_conditions;
pub use hct::Hct;
pub use palettes::{PaletteSlot, Palettes, BASELINE_SEED};
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;
+145 -23
View File
@@ -8,13 +8,56 @@
//! (色相 +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 {
@@ -24,9 +67,9 @@ pub enum PaletteSlot {
Secondary,
/// 第三调色板(种子色相 +60°,彩度 24)。
Tertiary,
/// 中性调色板(同色相,彩度 6)。
/// 中性调色板(同色相;官方彩度 6,产品默认 12——见 [`DEFAULT_NEUTRAL_CHROMA`])。
Neutral,
/// 中性变体调色板(同色相,彩度 8)。
/// 中性变体调色板(同色相,彩度 = neutral + 2;官方 8)。
NeutralVariant,
/// 错误调色板(固定红相 25°,彩度 84)。
Error,
@@ -38,10 +81,15 @@ const PRIMARY_CHROMA: f64 = 36.0;
const SECONDARY_CHROMA: f64 = 16.0;
/// tertiary 彩度。
const TERTIARY_CHROMA: f64 = 24.0;
/// neutral 彩度(几乎是灰)。
const NEUTRAL_CHROMA: f64 = 6.0;
/// neutral-variant 彩度。
const NEUTRAL_VARIANT_CHROMA: f64 = 8.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 的彩度。
@@ -67,15 +115,41 @@ pub struct Palettes {
}
impl Palettes {
/// 出厂基线(种子 `#6750A4`)。
/// **产品出厂默认**:种子 [`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 变体)。
/// 从一个种子色派生全套调色板(官方 Tonal Spot 变体,中性彩度取官方 6)。
pub fn from_seed(seed: Color) -> Self {
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)
Self::tonal_spot(hue, neutral_chroma)
}
/// 只换强调色:primary/secondary/tertiary 跟着新强调色的色相走,
@@ -94,14 +168,14 @@ impl Palettes {
}
}
/// 官方 Tonal Spot 的六条调色板(色相来自种子,彩度取官方常数)。
fn tonal_spot(hue: f64) -> Self {
/// 官方 Tonal Spot 的六条调色板(色相来自种子,彩度取官方常数,neutral 可调)。
fn tonal_spot(hue: f64, neutral_chroma: f64) -> Self {
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_VARIANT_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),
}
}
@@ -129,8 +203,8 @@ mod tests {
use super::*;
#[test]
fn baseline_palettes_use_the_official_tonal_spot_chromas() {
let palettes = Palettes::baseline();
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);
@@ -140,9 +214,64 @@ mod tests {
assert_eq!(palettes.error.chroma(), 84.0);
}
#[test]
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::baseline();
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);
@@ -168,11 +297,4 @@ mod tests {
assert_ne!(swapped.primary.hue_deg(), base.primary.hue_deg());
assert_eq!(swapped.primary.chroma(), 36.0);
}
#[test]
fn neutral_variant_sits_between_neutral_and_secondary() {
let palettes = Palettes::baseline();
assert!(palettes.neutral.chroma() < palettes.neutral_variant.chroma());
assert!(palettes.neutral_variant.chroma() < palettes.secondary.chroma());
}
}
+11 -1
View File
@@ -178,11 +178,21 @@ pub struct Scheme {
}
impl Scheme {
/// 出厂基线方案(种子 `#6750A4`,官方 Tonal Spot 输出)。
/// **产品出厂默认方案**(种子 [`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)
+5 -1
View File
@@ -112,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,
+56
View File
@@ -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 轴固定」带来的结构性保证。
+8 -7
View File
@@ -94,8 +94,9 @@ fn worst_channel(got: Color, want: Color) -> i16 {
#[test]
fn baseline_matches_official_on_all_forty_nine_roles() {
let light = Scheme::baseline(SchemeFlavor::Light);
let dark = Scheme::baseline(SchemeFlavor::Dark);
// 注意:对齐的是**官方锚点**(种子 #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();
@@ -146,7 +147,7 @@ fn hct_solver_matches_official_color_spec_2021() {
#[test]
fn baseline_primary_is_not_the_seed_and_that_is_official() {
// 常见误解:baseline() 的 primary 应该等于种子 #6750A4。官方不是——Tonal Spot 变体把
// 常见误解:官方基线的 primary 应该等于种子 #6750A4。官方不是——Tonal Spot 变体把
// primary 的彩度固定成 36,而 #6750A4 的 HCT 彩度是 47.86,所以 primary 被收到 #65558F。
// 这也是 Flutter `ColorScheme.fromSeed(0xFF6750A4)` 的 primary 值。
let seed = Hct::from_color(BASELINE_SEED);
@@ -155,7 +156,7 @@ fn baseline_primary_is_not_the_seed_and_that_is_official() {
"种子彩度 {}",
seed.chroma
);
let primary = Scheme::baseline(SchemeFlavor::Light).get(Role::Primary);
let primary = Scheme::official_baseline(SchemeFlavor::Light).get(Role::Primary);
assert_eq!(primary.to_hex(), "#65558F");
assert_ne!(primary, BASELINE_SEED);
}
@@ -164,7 +165,7 @@ fn baseline_primary_is_not_the_seed_and_that_is_official() {
fn baseline_tone_axis_is_still_exact_lstar() {
// gamut 映射只动彩度,不动 tone:每个角色的实测 L* 与它声明的 tone 相差 ≤1。
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let scheme = Scheme::baseline(flavor);
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());
@@ -182,7 +183,7 @@ fn baseline_tone_axis_is_still_exact_lstar() {
fn same_family_containers_are_separated_by_hct_chroma() {
// 旧缺陷①:light 下 primary-container 与 secondary-container 撞色。
// 现在两者彩度不同(36 vs 16),在 tone 90 上都不会被色域裁到同一个上限。
let light = Scheme::baseline(SchemeFlavor::Light);
let light = Scheme::official_baseline(SchemeFlavor::Light);
let primary = light.get(Role::PrimaryContainer);
let secondary = light.get(Role::SecondaryContainer);
assert_ne!(primary, secondary);
@@ -192,6 +193,6 @@ fn same_family_containers_are_separated_by_hct_chroma() {
c_secondary < c_primary,
"secondary-container 彩度 {c_secondary} 应低于 primary-container {c_primary}"
);
// 深层原因:tone 90 上彩度 36 装得下,所以容器色是「真的有彩度的浅紫」而不是被裁到灰。
// 深层原因: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('#')
);
}
}
}
}