183 Commits
Author SHA1 Message Date
AskaEth 18e36c6514 Merge branch 'feat/p6-fix-macos-gpu-probe-silently-falls-ba' 2026-11-03 00:27:04 +08:00
AskaEth 53b27eac45 fix(renderer): M97 r2 —— 退出码透传拆成可测单元(构造 / 执行),4 个变异全抓
上一轮把降级重开的 env 钉住了,但 `respawn` 里还剩一段不可测的接线:`status.code()`
→ `process::exit`。它的失效方式不止「崩溃」,而是**安静地给出一个错结论**——CI 把失败
当成功(或反过来),然后有人基于它做决定。这类和今晚一直在打的东西同类,所以补上。

## 拆法

- `exit_code(Option<i32>) -> i32`:纯函数。`None`(子进程被信号杀死,只有 Unix 会这样)
  走兜底码 2,**不 panic、不当成功**。在 Windows 上也能测(测的正是「拿不到码」那一刻)。
- `fallback_command(program, args, fallback) -> Command`:program / args 是参数而不是
  在函数里读 `std::env`,唯一目的是让测试能喂假命令(`cmd /c exit 7`)验退出码。
  三路 stdio **显式** `inherit()`——`Command` 默认就是继承,但显式写出来,将来有人为了
  「安静」加 `.stderr(Stdio::null())` 时 diff 里看得见;降级那次的子进程日志必须和父进程
  同一路输出,否则验收脚本抓不到。
- `respawn` 只剩 `current_exe()` + 日志 + `exit_code(status.code())`。

## 新增测试(4 条,全部变异验证过)

| 变异 | 结果 |
|---|---|
| X1 `unwrap_or(0)`(拿不到码时当成功) | `signal_death_falls_back_to_a_nonzero_code` FAILED |
| X2 `Some(_)` 一律 0(不透传) | `fallback_command_really_passes_the_child_exit_code` FAILED(真起 `cmd /c exit 7`,父进程拿到 7) |
| X3 重开时丢掉 argv | `fallback_command_passes_program_argv_and_envs` FAILED |
| X4 重开时不传 env | 同上 FAILED |

复原后 `cargo test -p unlucky-app-iced --lib` = 337 passed。

**stdio 继承没法用 getter 断言**(`Command` 在 stable 上没有 stdio getter),所以它的证据是
运行时观察:交互降级那次,父进程的 `[gpu] 降级 → …` 与子进程的 `[probe] 本次是降级重开`
落在**同一个 stderr 文件**里(见报告 §运行时复核)。

## 顺带

- `boot.rs` 超 400 行(492),按仓库既有做法把测试拆到 `src/app/boot/tests.rs`
  (`#[path = "boot/tests.rs"]`,同 `pages/harness/tests.rs`):boot.rs 311 行、tests.rs 187 行。
- `tests.rs` 模块文档里留了**变异验证的工具纪律**:只在函数体内替换、把测试里的同一字面量
  排除在外——全局 `sed` 会连断言一起改掉,于是「实现和期望被换成同一个错值」,测试照样绿,
  **验证的是变异脚本自己**。M97 r1 第一次跑就这么被骗了三个变异(假绿第十种形态),
  这条写给将来改这套测试的人。
2026-11-03 00:26:56 +08:00
AskaEth fa9dd170d3 fix(renderer): M97 r1 —— 降级/取证两条路改成可被变异测试抓到的纯函数
复审在 `git archive 72f20d8` 上做了变异:删掉 `respawn` 的
`.env("ICED_BACKEND", fallback.env_value())`,**327 passed / 0 failed**,那条名为
`retry_fallback_flips_the_tier_to_cpu` 的测试照样绿。

名字说它守住了「降级后档位翻转」,实际它只把 `"wgpu"` / `"tiny-skia"` 两个字面量喂给
`backend_from_env` 和 `effective_panel_alpha`,**`respawn` 一行都没执行**(它 `-> !`,
走 `process::exit`)。这和 M97 要修的那行常量日志是同一个结构:**在需要结果的地方验了旁边的东西**。

## 改了什么

1. `respawn` 的两组 env 抽成纯函数 `fallback_env(fallback)`,返回值是
   **`[(&'static str, &'static str); 2]`**——数量由类型钉住(删一条直接编译不过,
   比测试更早的闸门),键与值由测试钉住。
2. 子进程那一轮的决策抽成 `renderer::decision::fallback_round(kind)`,**兜底恒为 `None`**
   (防无限递归重开),`boot_probe` 不再就地拼 `Decision`。
3. 失败分流抽成纯函数 `on_failure(decision, evidence)` → `Respawn(Kind) | Refuse`:
   交互降级 / 取证拒绝两条路各自被测试钉住(判据表第 8 行 vs 第 9 行)。
4. 补上复审点名的两处 0 覆盖:`renderer::strict`(剥兜底)与 `renderer::run_once`
   (传下去的是 `decision.primary`,且进程内只跑一次)。
5. 改名并改文档 `retry_fallback_flips_the_tier_to_cpu` →
   `each_backend_value_maps_to_its_tier_and_alpha`,明确写「本测试**不**守翻转」,
   并指到真正守它的那条。

## 变异验证(8/8 被抓到,全部在实现行上做,测试里的同一字面量不动)

| 变异 | 结果 |
|---|---|
| M1 删掉 `ICED_BACKEND` 那条(=复审的变异) | **编译期**红:`expected an array with a size of 2, found one with a size of 1` |
| M2 键打错 `ICED_BAKEND` | `fallback_env_carries_the_backend_and_the_gate` FAILED |
| M3 闸门键打错 `ULL_GPU_FALLBACK_TYPO` | 同上 FAILED |
| M4 闸门值写死(不跟 fallback 走) | 同上 FAILED |
| M5 `fallback_round` 又给兜底 | `fallback_round_never_degrades_again` FAILED |
| M6 取证启动改成也降级 | `evidence_start_refuses_instead_of_degrading` FAILED |
| M7 `strict` 忘了清兜底 | `strict_strips_the_fallback` FAILED |
| M8 `run_once` 传死值 | `run_once_passes_the_decision_primary` FAILED |

复原后 `cargo test -p unlucky-app-iced --lib` = 334 passed。第一次跑变异时 A/B/C 三个
「照样绿」是我脚本自己的 bug:`sed` 把测试期望里的同一字面量一起改了(自指式变异盲区),
改成只在函数体内替换才暴露真实结果——这条也记进报告。

## 顺带(p3-1)

`boot.rs` 模块文档里「字体栈不重算」是旧实现的残留:改成换进程之后,子进程会把字体栈、
配置、presenter 全部重算(实测首帧 493~531 ms),文档已按实际行为重写,并把判据表
第 8/9 行两条路的差异写进模块头部。

运行时行为未变(refactor 只做抽取),已复测:取证启动 + wgpu 不可用 → exit 1、无 PNG、
具名错误 + 「拒绝降级出图」;交互启动 → 具名错误 + 换进程降级 + `[probe] 本次是降级重开`。
2026-11-03 00:26:56 +08:00
AskaEth 31cdfd5bbd fix(renderer): M97 后端判据 —— 静默降级改具名错误,取证通道拒绝降级出图
M97 的入口是一份 macOS M1 真机报告:探测认出了 Apple M1(adapters=1 /
hardware=true),日志却写着 `renderer = auto (wgpu -> tiny-skia)`,看起来
像在跑 CPU。读源码发现那行是**常量**:`probe.rs` 按 `ICED_BACKEND` 未设就把
`"auto (wgpu -> tiny-skia)"` 打出来,与实际后端无关。真正的失败被 iced 吞掉:
`iced_renderer-0.14.0/src/fallback.rs` 的 `Compositor::with_backend` 把 wgpu 的
错误装进 `errors` 向量,tiny-skia 一成功就整体丢弃。

## 判据改为单项,不再交给 iced 回退

`ICED_BACKEND` 现在**永远是单项**(`wgpu` / `tiny-skia`):能跑起来就必定是它;
起不来就必然是 `iced::Error::GraphicsCreationFailed` 里的具名错误。判据是纯函数
`renderer::decide(request, probe, gpu_compiled)`,落在 `renderer/decision.rs`,
表驱动测试逐行钉死 1~6 行 + 「除强制 CPU / 未编 wgpu 外必须留兜底」的守门。
判据表(条件 / 选哪个 / 日志该打什么 / 谁能验证)在 `renderer/mod.rs` 顶部。

## 安全网改成换进程

第一版是「打印错误 → 在本进程再 run() 一次」,Windows 实测直接 panic:
`Create event loop: RecreationAttempt`(winit 一个进程只能建一次事件循环)。
改成 `respawn`:子进程只换 `ICED_BACKEND` + `ULL_GPU_FALLBACK`,父进程透传它的
退出码。子进程看到 `ULL_GPU_FALLBACK` 必须跳过探测,否则会无限递归重开。

## 取证通道不许自己降级

带 `--screenshot`(含 `--screen=gallery`)时 wgpu 起不来就**不出图**、退出码 1,
因为换了后端出的图会撒谎;交互启动才允许降级。每张图的日志现在打**实际**后端
(`[shot] … renderer=wgpu`),以前打的是请求值 `auto`——同一个病(在需要结果的
地方打请求)在仓库里出现了两次,这是第二次。

## 可观测性

- `log` 桥(`renderer/log_bridge.rs`)把 `iced_wgpu` 的
  `Selected: AdapterInfo{…}` 接到 stderr:这是本仓库第一次能自证「真在用 GPU」。
  按 target 分级(`iced_wgpu*` Info、`wgpu*` Warn),否则 Vulkan loader 会刷 90 行噪声。
- `renderer/preflight.rs`(默认关,`ULL_PREFLIGHT=1`):开窗前拿 wgpu 的具名错误。
  实测 Windows RX 6600 XT 上 took=717ms,所以不进默认路径。
- `app/backend.rs` 的档位判据跟着改成读单项;**真降级到 CPU 的那一次面板 α 现在会
  正确强制 1.0**(以前静默降级后仍按 GPU 档判)。

Windows 实测:auto → wgpu + `Selected: AdapterInfo{…backend: Vulkan}` 出图;
`--renderer=cpu` 逐字不变;`WGPU_BACKEND=metal` 造失败 → 具名错误 → 交互路径降级
不崩(首帧 531ms)/ 取证路径拒绝出图 exit 1。macOS Metal 路径需真机复核,
命令与期望日志行写在 `renderer/mod.rs` 的「真机验收」一节。
2026-11-03 00:26:55 +08:00
AskaEth 45cd44f756 Merge branch 'feat/p6-build-profile-fast-bins-and-ci-flags' 2026-11-03 00:23:29 +08:00
AskaEth f6e55c5c7e docs(build): 修正三处「文档说法与实测不符」的注释(M96 复审 P2-1/2/3)
只改措辞与注释,**profile 数值一个没动**(release.package 仍是 opt-level="z" + cu16,
fast 仍是 opt-2/thin/cu16/unwind)。

P2-1 `panic = "unwind"` 的理由原本写错了:原注释说「漏了这一行 `cargo test --profile fast`
起不来(test harness 依赖 unwind)」。实测三连否掉:`cargo build --profile release -v` 的
rustc 参数里**有** `panic=abort`、`--profile fast` 里没有(这行确实生效,可作证据);但
`cargo test --profile release -p unlucky-i18n --no-run`(abort 档)照样 Finished 并产出测试
二进制,`-v` 里 test 单元**没有** `-Cpanic=`,真跑 23 个测试全过 —— cargo 对 test 目标自己强制
unwind。所以理由改成「让 fast 档编出来的**二进制**走 unwind」,并把「能跑 cargo test」明确标为
**没有区分度**的证据。顺带记下 fast 继承 `strip="symbols"` ⇒ 能 unwind 但没有符号回溯。

P2-2 `[profile.fast.package."*"]` 的注释把机制说反了:原注释说「package 覆盖按 profile 分别
生效,release 的不影响 fast」。实测删掉这块后,fast 档的**外部依赖**直接变成 `opt-level="z"`
(`slotmap`:1 那次 3 个单元 opt-2 → 删除后 3 个单元 opt-z),说明 `inherits` 会把 release 的
package 覆盖一起带过来。所以这块是**承重墙**,删了依赖会静默退回 z。注释按实测重写。

P2-3 「收益全在 `codegen-units = 16`」删掉:三组数据的唯一自变量是 opt-level(两臂都是 cu16),
cu1→cu16 的收益本轮**没有同条件对照**,已明确标成未测、留待后续归因。同时补一句:GUI 相对旧
基线 +12.9% 里「代码增长」与「依赖 cu16」未分离(ull 只 +1.7%,在 +1~3% 口径内)。

注:P2-1 那条原始错误表述来自 tower 的 mission 任务书,不是 worker 的臆造。

验证:`cargo build --release --bins` rc=0(16m21s,产物字节与 §7.1 记录完全一致:
6,256,128 / 13,910,528 / 16,479,232),`cargo metadata` 通过,README 仍为 400 行(未破门禁)。
2026-11-03 00:23:26 +08:00
AskaEth b9eae2bb73 perf(build): 加 fast 构建档、依赖并行 codegen、验收脚本一次 --bins
三条慢因里两条落在构建配置上,发布档(release)的体积取向不动:

1. `[profile.fast]`(inherits = release):opt-level 2 / lto thin / codegen-units 16,
   日常迭代与验收用它,发布仍走 release。`panic = "unwind"` 是**必须显式写**的一行——
   release 是 panic = "abort",inherits 会照抄,漏了它 `cargo test --profile fast` 起不来
   (test harness 依赖 unwind)。
2. `[profile.release.package."*"]`:依赖的 codegen-units 放到 16(并行 codegen),
   **opt-level 保持 "z"**。最初按「优化等级高 = 编译快」把依赖抬到 opt-level 3,
   同机冷编译实测反了:opt-3 是 22m45s / GUI 19.46 MiB,opt-"z" 是 16m01s / 15.72 MiB——
   又慢 29.6% 又大 19~24%,收益全部来自 codegen-units 而不是 opt-level。
   只调优化强度,不碰 panic / strip(那两项改的是整个二进制的运行时行为)。
3. `[profile.fast.package."*"]` 显式写一遍:package 覆盖按 profile 分别生效,不赌 inherits
   会不会连覆盖一起继承(实测 594 个编译单元全部 opt-level=2 + cu16,含依赖)。

实测写进 packaging/README §6/§7.1(同机、删空 target 的冷编译、无并发负载):
release 16m01s(GUI 15.72 / iced 13.27 / ull 5.97 MiB),fast 24m27s(36.41 / 32.02 / 15.23 MiB)。
**fast 档在本机冷编译没有收益甚至更慢**,瓶颈是两个大 UI crate + 链接 + 机械盘 IO,
它的定位是 SSD 机器与增量重编——这条照实写进文档,不为了贴近预期挑数字。

DevDocs/ops/macos-acceptance.sh 的构建段改动(DevDocs 被 gitignore,无法入库)见完成报告。
2026-11-03 00:23:26 +08:00
AskaEth b16932a312 Merge branch 'feat/p5-logs-page-real-components-diagnostic' 2026-11-02 22:51:32 +08:00
AskaEth 4fde22067e chore(app-iced): M95 解 rebase 冲突的收尾 —— pages/mod.rs 收到 400 行
M94 合入后 `pages/mod.rs` 两侧增量叠加到了 **406 行**(全仓硬上限 400)。本提交只做三件
等价收紧,没有行为变化:

1. `demo_model` 的三个 `if` 收成一个 `match screen`(「哪页带演示数据」也更好读);
2. 我给 `Message::CopySummary` 写的 3 行 doc 收成 1 行(M94 的变体 doc 都是单行);
3. 删掉 `#[cfg(test)] mod tests;` 与 `pub use` 之间的一行空行。

顺带把 `demo_model` 的「只有实例页带数据」改成事实(实例/下载/日志三页都有演示数据——
这句在 M94 合入后就不成立了)。

收完 **正好 400 行**,仓库自己的守门测试(`components::gate` 的
`every_source_file_stays_under_the_line_limit`,扫 `src/**`)仍然绿。
2026-11-02 22:31:47 +08:00
AskaEth 0eb069fa37 fix(app-iced): M95 r4 —— 归属判据回归 / 置信度档 / 运行中页头
代码复审 r3 的四条 p2,修三条、登记一条(P2-4 页脚余量 13px,后置)。

**P2-1(回归修复)**:r3 把归属判据写成「有选中就只认选中」,把**导入崩溃文件**与 `open_session`
这两条路径的诊断一并关掉了——`559ba20` 是**无条件**画的,所以这是 r3 引入的**代码级回归**,
不是既有缺口(今天 iced 侧没有 dispatch 站点,那些还在遗留的 Slint shell 里,故复审未判 p1)。
判据改成三分支(`app/view/logs.rs`):

| live_session | selected_session | 报告算数的条件 |
|---|---|---|
| Some(s) | 任意 | `report.session == s`(只有它在线时日志行是它在追加,别人的报告挂上来就是串场) |
| None | Some(s) | `report.session == s` **或** 报告不属于任何已知会话(导入用 `file:<文件名>`,永不在索引里) |
| None | None | 全部算数(此时日志行只可能来自 `open_session`/导入,与报告同一次调用发出) |

新增两条测试(都走真实 reducer):`an_imported_crash_file_still_shows_its_findings`、
`a_live_run_says_it_is_running_instead_of_nothing_runs`。
残留(今天不可达,已登记):`open_session(A)` 在 `selected_session` 仍停在上场 B 时会被挡掉——
真要修得让 presenter 在 `LogLinesChanged` 记下「日志行属于哪一场」。

**P2-2 置信度档(视觉变更)**:`Severity`(借 core 的 `critical/warning/notice` 词汇)→
`Confidence`(`High/Medium/Low`)+ 新 key `LogsConfidenceHigh/Medium/Low`(中英齐,标题明确写
「高/中/低置信度」)。阈值仍是 80/50,但它现在是**档位定义**而不是「严重度代理」。
摘要里的 `findings=rule:high(85)` 同步换档位 slug。DTO 补齐 `severity` 之后应换回真严重度(finding)。

**P2-3 运行中的页头**:session 索引只在**结束**时追加,运行中的那一场不在 `log_sessions` 里 ⇒
拿不到实例名与结局 ⇒ r3 之前页头在日志滚动时一直说「当前没有运行中的实例」(demo 伪造了身份,
截图照不出来)。`Logs` 新增 `live: bool`(`live_session.is_some()`),`with_status` 三级回退:
有实例+结局 → `实例 · 时间 · 结局`;运行中但没结局 → 新 key `LogsSubtitleLive`(「Running now —
logs stream as they arrive」);都没有 → `LogsSubtitleIdle`。
新增 `a_live_run_says_it_is_running_instead_of_nothing_runs`(真实 reducer:只有
`LogAppended(running=true)`,断言副标题不是 Idle 文案且含 `Running now`)。
新增 demo 状态 `ULL_LOGS_FIXTURE=running` 用于出图。

**变异实测**(每条:回退 → 红 → 放回 → 绿):见报告。

验证:`fmt --check` 通过;`clippy -p unlucky-app-iced -p unlucky-i18n --all-targets -D warnings` 干净;
`cargo test` **280 + 6 + 23 passed / 0 failed**;行数最大 `pages/mod.rs` 367、`app/view.rs` 324、
`app/view/logs.rs` 337。
2026-11-02 22:27:01 +08:00
AskaEth 5ed2bf3a7d fix(app-iced): M95 r3 摘要页脚占比 4:2 + 演示数据自洽 + 样例解析不再 panic
视觉复审 r2 的 P2-A 与两个顺手的 p3。

**P2-A 摘要页脚占比过大(3:2 → 4:2)**:1180×760 下诊断卡 338px,摘要页脚固定吃掉 ~150px
(标签+按钮 40 + 摘要折 5 行 80 + 间距/分割 30),滚动视口只剩 141px,而一条完整结论
(分组标题 → 建议动作 → 置信度)要 165px ⇒ 首屏看不到「建议动作」,那是任务清单 T1/T3 要求的
可见项。改成 `DIAGNOSIS_SHARE = 4`(`LOGS_SHARE` 仍 2)。**数值实测**(`r3-crash-light.png`
逐像素量,非估算):诊断卡 376px、滚动视口 **178px**(y 201–378,两侧 1px 分割线的位置)、
一条完整结论块 **165px**、底部余量 **13px** —— 建议动作与 `Confidence 85%` 整块在首屏可见。

**p3 演示数据自相矛盾**:`live` 状态页头写「Finished normally」却配崩溃日志尾行
(`启动失败:exit code 1`)。新增 `sample_live.log`(一场正常跑完的会话:加载 → 进世界 →
停服),`State::Live` 用它,不再复用崩溃样例。

**p3 `demo::parse` 对非 ASCII 首字符 panic**:`raw.split_at(1)` 会切在多字节序列中间。
改成按字符取前缀(`chars().next()` + `chars.as_str()`);顺带把它提成 `pub(super)` 以便测。
新增 `the_fixture_parser_survives_a_non_ascii_first_char`:非 ASCII 首字符不 panic(静默跳过),
并钉住 `I/W/E` 前缀与注释/空行的解析。

测试:**278 passed / 0 failed**(lib)+ 6(`no_hardcoded_tokens`)+ 23(i18n)全绿;
`fmt --check` 通过;`clippy -p unlucky-app-iced -p unlucky-i18n --all-targets -D warnings` 干净。
出图:`C:\Temp\ull-shots-m95\r3-{crash-light,crash-dark,live-light,empty-light}.png`(1180×760)。
2026-11-02 22:27:00 +08:00
AskaEth 62d4af150e fix(app-iced): M95 r3 跨会话串场 —— 诊断结论只在归属会话就是当前会话时才画
代码复审(绑 559ba20)抓的 p2:r2 的 `logs_model` 先按 `state.diagnosis` 定「这是哪一场」,
而日志行来自当前那一场;`state.diagnosis` 全仓**只写不清**(`apply_core` 里
`CoreEvent::DiagnosisReady` 是唯一赋值点,没有清零点),host 也只在崩溃 / 打开会话 / 导入时
发它。于是「崩溃一场 → 再启动一场正常跑」时:页头把新会话标成上一场的 `The game crashed`、
诊断卡挂着上一场的结论,而用户复制去贴 issue 的摘要里 `lines=` 是**新的**那一场的行数 ——
结论与行数不同源,用户会把错诊断递出去。

修法(只在页面侧判别,不动 presenter 的写入口):

- **身份**只认 `live_session.or(selected_session)`(正在跑 → 打开/刚结束的那一场),
  **不从 `diagnosis` 反推**;
- 诊断结论只在 `diagnosis.session == 身份` 时画;身份为空时才信任报告(那时日志行只可能来自
  `open_session`,它与报告是同一次调用发的);
- 实例/时间/结局的描述同样是身份优先,身份为空才退到报告的 session。

测试(`app/view/logs.rs` 内,走**真实数据路径**):
`crashed_only()` 用 `apply_core` 依次灌 `LogSessionsChanged` → 第一场 `LogAppended(running)`
→ `DiagnosisReady` → `LaunchFinished(Crashed)`,`after_a_new_run()` 再灌第二场 `LogAppended(running)`;
`state.diagnosis` 全程不清(这就是线上串场的条件)。

- `a_new_run_does_not_inherit_the_previous_crash`:断言 outcome=Ok、页头不含 `The game crashed`、
  findings 为空、摘要 `outcome=ok` 且不含旧规则且 `lines=` 与当前行数一致。
- `the_crash_itself_is_still_reported`(对照,防「永远不画诊断」骗过上面那条):崩溃当场必须
  findings=1 且摘要含 `gpu.driver_old:critical(92)`。
- `the_hosts_no_rule_matched_placeholder_is_not_a_finding`:host 的 `rule: ""` 占位报告不画;
  换成真规则就必须画(证明过滤不是把诊断一起关了)。

变异实测:把 `report` 的过滤回退成 r2 的 `state.diagnosis.as_ref()` → 上面第一条**红**并点名
`Finding { rule: "gpu.driver_old", … }`(0 passed / 1 failed);放回 → **277 passed / 0 failed**。
2026-11-02 22:27:00 +08:00
AskaEth ee4d82e3d4 refactor(app-iced): M95 r3 把日志页的展示转换从 app/view.rs 拆出来(纯搬移,行为零变化)
`app/view.rs` 已经 392 行(全仓硬上限 400),而日志页的转换还要加判别与测试,先拆。

- `app/view.rs`:392 → **324** 行,只留 `impl App`、`model()` 与实例页的展示转换,
  另加 `mod logs; use logs::logs_model;`(调用点仍是 `logs_model(state).with_status(lang)`,未改)。
- 新增 `app/view/logs.rs`:**80 行**,逐行搬入 `logs_model` / `log_level` / `log_outcome` /
  `diagnosis_of`,实现体一行未动(含 r2 的「先按 `diagnosis.session` 定身份」写法,
  那是下一个 commit 才改的行为)。

等价比证:搬移前后 `cargo test -p unlucky-app-iced -p unlucky-i18n` 都是
**274 + 6 + 23 passed / 0 failed**(lib + no_hardcoded_tokens + i18n),数量与结果一致。
除模块声明与 import 调整外没有任何逻辑改动。
2026-11-02 22:26:59 +08:00
AskaEth 1bccf89e5c fix(app-iced): M95 r1 五条视觉复审(悬挂缩进 / 会话身份 / 摘要可见 / 贴底 / 描边)
- P2-1 **悬挂缩进真做了**(之前文档在骗人,代码里只有 `row![bar, text]`):
  每行拆「前缀 + 正文」两个文本控件(前缀 = 第一个空格及其之前,含空格;
  `split_prefix` 在第一个「词」>20 字符时不拆),正文带 Fill 宽度自己折行 ⇒
  续行落在前缀宽度之后。于是 `at com.mojang…(GLX.java:64)` 不再拆成「`at` 独占一行」。
  文档同步写清代价:续行缩进量 = 该行前缀宽度。
- P2-2 会话身份上页头:`Logs::with_status(lang)` 用 `LogsStatus` 模板拼
  `{instance} · {when} · {outcome}`,结局文案走 `LaunchOutcomeNormal/Crash/Silent`
  三 key(bridge 文档里那条「LogOutcome 三态 → 三 key」的结构化出口,此前 UI 侧零调用点)。
  中文模板差在全角竖线(守卫要求 zh≠en,也确实更符合中文排版)。
- P2-3 摘要钉成诊断卡**固定页脚**(不进滚动区):「One-line summary」+ Copy summary
  紧邻它要复制的那段等宽文本;卡片头不再放复制按钮(避免两处入口)。
- P2-4 日志卡改 `panel::scroll_tail`(`Anchor::End`):偏移按「距底多远」记,内容增长时
  跟随尾部;「用户上滚后是否冻结」的边界写在 `panel::scroll_tail` 文档里(结论:不冻结,
  冻结要 app 层回灌滚动状态,已登记)。
- P2-5 两张主卡补 1px 描边:`panel::card` 改用 `Component::OutlinedCard.spec().shape` +
  `ui.outlined(..)`,与 `frame::card` 同一套令牌。

测试:新增 `a_line_is_split_into_prefix_and_body_for_the_hanging_indent`(含 CJK 行与
「不拆」的两种边界)与 `status_line_names_the_session_with_the_launch_outcome_keys`;
key 清单补 `LogsStatus` 与三个 `LaunchOutcome*`;demo 的 state.build 改为收 `Lang`。

验证:`fmt --check` 通过;`clippy -p unlucky-app-iced --all-targets -D warnings` 干净;
`test -p unlucky-app-iced` 274+6 全绿、`-p unlucky-i18n` 23 全绿。
图:`.screens/logs-{empty,live,crash}-{light,dark}.png`(1180×760)、
`r2-live-tall-light.png`(1180×1080,栈帧可见,P2-1 证据)。
2026-11-02 22:26:59 +08:00
AskaEth 063a1dba39 feat(app-iced): M95 日志页接真组件 + 诊断面板(等宽折行 / 按严重度分组 / 一行摘要)
日志页从 M84 占位换成真组件,数据全部来自 presenter 快照(页面不读盘):

- 日志行列表:`pages/logs/lines.rs`,等宽(`BodySmall` 12/16 + mono 族)+ 按词或字形
  折行 + 等级色条;**选折行不选横滚**的理由写在模块文档(424px × 200 字符的现实 +
  mono 族的 CJK 回退会毁掉横滚唯一的收益「列对齐」)。
- 诊断面板:`pages/logs/diagnostic.rs`,按严重度分组(CRITICAL → WARNING → NOTICE),
  每条给规则 id / 结论 / 说明 / 建议动作 / 置信度;卡片头钉住「Copy summary」,
  摘要是稳定单行的 `ULL-LOGS v1 | …`(崩溃日志换 issue 时直接粘)。
- 空态:真组件 `feedback::EmptyState`(图标块 + 标题 + 说明 + CTA 回实例页)。
- 两块都在 `pages/logs/panel.rs` 里**定高 + 内部独立滚动**(`FillPortion(3):(2)`):
  第一版让诊断卡按内容撑开,三条结论就把日志卡压成 0 高度(实拍复现后改的)。
- 视图模型:`pages/logs/model.rs`(纯展示字段)+ `app/view.rs::logs_model` 做快照转换。
- 接线(只追加):`pages/mod.rs` 的 `pub use logs::…` / `page()` 那行 /
  `Message::CopySummary` / `demo_model` 一行;`pages::Model` 加 `logs` 与 `lang` 两个字段;
  `app/update.rs` 把 `CopySummary` 落到 `iced::clipboard::write`、`ClearLogs` 清 presenter 的
  `log_lines`(呈现层操作,不造命令)。
- i18n:`crates/i18n/src/catalog.rs` 自己锚点下 16 个 key(中英同表);按塔裁决把
  `#[cfg(test)] mod tests` 逐字外置到 `crates/i18n/src/catalog/tests.rs`
  (catalog.rs 415 → 257 行,tests.rs 164 行;7 个用例逐字未动、i18n 23 个用例全绿)。

验证:`fmt --check` / `clippy -p unlucky-app-iced --all-targets -- -D warnings` 干净;
`test -p unlucky-app-iced` 272 + 6 全绿、`-p unlucky-i18n` 23 全绿;截图
`.screens/logs-{empty,crash,live}-{light,dark}.png`(1180×760,本地 gitignore)。

遗留(已报 finding):`dto::Diagnosis` 缺 core 的 `severity` 与规则表 `cause.key`;
`Action::OpenLogs` 没有对应 `AppCommand`(点了只有占位日志)。
2026-11-02 22:26:53 +08:00
AskaEth c0396544c5 Merge branch 'feat/p5-download-page-real-components-and-ins' 2026-11-02 21:59:22 +08:00
tower 5340908882 refactor(download): rebase 到 M93 之后 app/update.rs 破 400 → 下载页四支拆到 update/download.rs
M93(外观三件套接线)合入后 `app/update.rs` 从 348 涨到 410 行,再加上下载页的四个 arm
会顶破 ≤400 硬线。把下载页那几支(SelectVersion / InstallVersion / CancelInstall /
SetCategory / Action::RefreshSources)挪到 `app/update/download.rs`(`app/update/**` 在本
mission scope 内),`on_page` 入口只留一句 `if self.on_download(&message) { return; }`,
match 末尾补一条 `_ => {}` 做穷尽性。

行为零变化:指令、视图模型写法、`refresh_view` 顺序都照搬;M93 的 `Appearance` arm /
`env_start_route` / `on_appearance` 一行没动。

行数:update.rs 410 → 397,download.rs 57。
2026-11-02 21:45:40 +08:00
tower 6453c76750 fix(download): M94 r2 —— 安装终态出口 + 分类真机过滤(复审 P1-1 / P2-1)
P1-1 安装一旦收尾就再也装不了第二个版本(终态面板没有出口)
- 页面侧:`Download::from_snapshot` 把 **Done / Cancelled** 当结束(`InstallView::finished`),
  面板让回列表 —— 装完一个版本可以直接选下一个继续装(图:r2-done / r2-done-next)。
  不清 `Failed`:失败态带着用户要的东西(停在哪个文件 + 去日志页)。
- 面板加真出口:失败态「Open logs」(实心)+「Dismiss」(文本 → `Message::CancelInstall`,
  host 侧对没在跑的任务是安全 no-op),修掉「失败后连重试都不可达」。
- 决策抽成纯函数 `install::panel_state()`(Hint / Ready / Install),`panel()` 只是它的画法,
  于是「能不能再装」可以被单测钉死,不用开图形环境。
- 两条新测试**不走 MockBackend**(它从不发 `InstallProgress`,终态永远跑不到 ⇒ 假绿):
  直接构造 `AppState` + `apply(CoreEvent::InstallProgress(..))`。

P2-1 分类 chip 在真 host 上是空控件
- `from_snapshot(state, category, catalog)` 收分类并按 `Category::matches` **本地过滤**
  (真 host 的 `ListVersions(_)` 丢掉分类参数、回全量清单);
- 分类不再从列表反推(`Category::of` 只留给演示首帧),`app/view.rs::model` 收分类、
  `refresh_view` 把上一份视图模型的分类带回去 ⇒ 事件回来 chip 不会跳回「全部」;
- `update.rs` 的 `SetCategory` 写分类后**立刻重建**(chip 与列表同一个谓词),再派真指令。

p3(顺带)
- 失败态与占位态共存:列表空/加载中时若安装还在跑,面板照样出现在底部(r2-installing-empty);
- 修 `install.rs` 的坏 rustdoc 链接;`install.rs` / `model.rs` 的测试模块外置到子文件守住 400 行。

测试文件:`pages/download/{model,install}/tests.rs`(新),`pages/download/tests.rs` 里的
`from_snapshot` 调用补分类参数。

实证(回退法):删掉 `.filter(|view| !view.finished())` → 两条终态测试红并点名
「Done 之后不许再占着安装面板」;删掉 `.filter(|category.matches(..))` → 两条分类测试红
(「只剩正式版」left=3 right=1)。放回修复 → 全绿。

门禁:fmt --check 干净、clippy -D warnings 干净、app-iced 280 + 集成 6 + i18n 23 全绿(-j 4)。
2026-11-02 21:36:28 +08:00
tower 9d6ee70301 fix(download): M94 r1 —— 五条复审(P2-1/2/3/4/6)
P2-1 占位规则统一:加载态原先贴顶、空态居中 → 两态都走同一套「内容在可用区居中」
  (依据:components::feedback::empty_state 的既有口径 12b §15.4;两态互切不再纵向跳)。
  实测两个状态的内容中心线 y=449 / y=445(差 4px)。

P2-2 文案只说真能做到的事:
  - 删「The list appears after the first successful fetch; this page never goes blank.」
    (工程话术 + 承诺了 UI 做不到的「永空白」)→「如果一直停在这里,点右上角的刷新。」
    (对应页头那个真的 Refresh 按钮)
  - 空态「Switch the category or clear the filter」删掉「清筛选」(页面没有筛选控件)
    →「换一个分类,或刷新一次列表。」
  - 副标题「Version list from the official manifest」去掉 manifest 这种工程词 →「可以安装的版本」

P2-3 去掉静止的 indeterminate 进度条:页面没有帧订阅,indeterminate 只会拿到一个固定相位
  (看起来像 17% 卡住)。加载态改成纯文字;安装面板只在进度已知时画 determinate。
  两帧证据:215/512 → 条填 177px(42%),409/512 → 338px(80%),条随事件走而不是动画。

P2-4 失败态与安装中彻底不同构:失败不再画进度条,改成整块 ErrorContainer 面板
  (ErrorContainer 底 + Error 图标 + Error 色标题 + 停在哪个文件 + 说明 + 实心「打开日志」)。
  实测底色:浅色 (255,218,214) / 深色 (147,0,10),安装中面板底色仍是页面底色。

P2-6 死 key 与假守卫:
  - 删掉没用的 DownloadSelected;
  - 用真的守卫替换原来那条「前缀遍历 + 条数下限」:every_download_key_is_referenced_by_the_page
    用 include_str! 把页面源码编进来,目录里每个 Download* key 必须在源码里出现 Key::Xxx。
    实证:把 DownloadSelected 放回去 → 该测试红并点名 key;删掉 → 绿。
    注释里也写清抓不住的三种情况(反向是编译错误 / 译文完整性归 i18n 自己的测试 / 宏拼 key 能骗过它)。

门禁:fmt --check 干净、clippy -D warnings 干净、app-iced 276 + i18n 23 全绿;
tests.rs 384→383 行(不再贴顶),其余文件均 ≤400。
2026-11-02 21:36:26 +08:00
tower 57e8380b24 feat(download): M94 下载页换成真组件 + 真快照 + 真指令(PAGEDL)
页面(crates/app-iced/src/pages/download*):
- Tabs(分类)/ ListItem(版本行)/ filled 安装按钮 / progress / EmptyState / divider
  全部取自 components/ 的真组件;页头动作换成 buttons::text
- 数据只吃 pages::download::Download(app/view.rs 由 AppState 快照转,installed_only 与
  download_query 照用);页面自己不留业务状态,交互只发意图
- 状态:加载(不确定进度 + 文案)/ 空(EmptyState + 刷新 CTA)/ 有数据 / 安装中 / 安装失败
  (失败给可读文案 + 「打开日志」出口)

接线(追加式):
- pages::Message 加 SelectVersion / InstallVersion / CancelInstall / SetCategory
- app/update.rs 翻成 AppCommand::{InstallVersion, CancelInstall, ListVersions}
  (SelectVersion 只改视图模型;「刷新」= 当前分类重新拉,全部走 RefreshAll)
- pages::Model 加 download 字段

文案:28 个 Download* key 进中英两套 catalog(一行一个 key 的全语言);页面零中文字面量,
  core 的中文阶段串(InstallStage::label / message)一律不进界面

i18n 结构:catalog.rs 的既有测试模块原样外置到 catalog/tests.rs(392 行 → 282 行,
  逐字未改),新 key 才装得下 400 行硬上限

测试:app-iced 276 + i18n 23 全绿;clippy -D warnings 与 fmt --check 干净
2026-11-02 21:36:22 +08:00
AskaEth b3f99652bb Merge branch 'feat/p5-appearance-wire-window-panel-alpha-ba' 2026-11-02 19:13:11 +08:00
AskaEth da2da4cd9b fix(app-iced,presenter): M93 r4 —— Float 键写不回 / prefs.rs 超限 / CPU 档判定 / 平铺退化
复审(代码)r1 的两条 P1 + 两条必修。

P1-1 `SetSetting` 写不回 Float 键(整窗透明度重启回旧值)
- `presenter/src/host/settings.rs::parse_value` 改成**按 schema 的 `ValueKind` 取类型**,
  不再硬编码 Int/Bool 键名。历史:`appearance.window_opacity`(Float)被当 String 送进
  `store.set` → schema 的 `as_f64()` 返回 None → `InvalidValue` → **`save()` 根本没执行**,
  α 只在内存生效、重启回旧值、每次拖动弹一条 Error toast。
- 测试(presenter 层,`host/settings_tests.rs`):
  ① `float_key_round_trips_through_set_setting`:set 0.8 → **新开 store(= 重启)** 读回 0.8;
  ② `every_float_key_in_the_schema_can_be_written_back`:遍历 schema 里**所有**
  `ValueKind::Float` 的键逐个写回并读回(集合级守门,下次新增 Float 键漏接会立刻红)。

P1-2 `presenter/src/prefs.rs` 404 行 → 拆测试
- `#[cfg(test)] mod tests` 整体挪到 `crates/presenter/src/prefs/tests.rs`,结构体与 re-export
  一字未动。`wc -l`:**404 → 315**(tests.rs 90 行)。

P2-2 CPU 档判定只在 `--renderer=cpu` 时成立
- 新增 `app/backend.rs`:`effective_backend()` 把 `probe::apply` 写下的 `ICED_BACKEND`
  候选列表翻回档位(`tiny-skia` / `tiny-skia,wgpu` → CPU;`wgpu,tiny-skia` → GPU;未设 → 请求值)。
  `App::with_presenter` 用它喂 `appearance::State`,于是**默认 Auto 档在没有独显的机器上**
  也会走「面板 α / 玻璃强制 1.0」。
- 测试:`every_iced_backend_list_maps_to_a_tier` + `auto_with_a_software_renderer_forces_the_panel_opaque`。
- 实跑:`ICED_BACKEND=tiny-skia`(= probe 在无独显机器上写下的值)→
  `[appearance] boot … panel_alpha=1.000 (requested 0.550) backend=cpu`。

P2-1 平铺退化成「一条竖条」——根因不是几何,是**渲染层没吃 scale**(r3 的编辑没落到 `tiled()`)
- `picture::tiled()` 原来仍用 `ContentFit::None` + 原图尺寸,所以 `tiles()` 算出的 `scale`
  完全没被消费:小图被按 32dp 画在 129dp 的格子里 ⇒ 到处是缝。
- 现在:每块 `源尺寸 × tiles.scale`、`ContentFit::Fill`、行高显式 `Fixed`、整列 `Fill`。
- 诊断:新增 `background::small_tile_note()`(短边 < 200dp 的图在 tile 档下给一句
  「scaled up so the window stays covered」),已接进设置页诊断块(首屏可见)。
- 测试:`tile_grid_always_covers_the_viewport_within_budget`(6 组尺寸,含 32×32 / 16×16 / 1×1,
  断言覆盖 ≥ 视口且块数 ≤ 预算)、`small_images_get_a_tile_diagnostic_instead_of_silently_degrading`。
- 出图:`r5-tile-tiny32.png`(32×32 铺满全窗、右缘无裸白、提示可见)、`r5-tile-120.png`、
  `r5-tile-big.png`。

顺手(复审 P2-3/P2-4/P2-5)
- P2-4:`MAX_SOURCE_EDGE` 16384 → **8192**,并给解码器加 `image::Limits`(宽/高 + `max_alloc`
  320MB)作第二道闸;补 `an_over_sized_image_reports_too_large_instead_of_trying_to_decode`
  (`TooLarge` 这条分支此前无测试)。
- P2-5:`transparency.rs` 补一段「为什么 `LWA_ALPHA` + `WS_EX_LAYERED` 不影响命中测试与
  自绘标题栏拖动」(穿透要 `WS_EX_TRANSPARENT` 或 `LWA_COLORKEY`,两者都没用;`window::drag`
  走 `WM_NCLBUTTONDOWN(HTCAPTION)`)。
- P2-3:断链 `[background::dimmed]` → `dim_alpha`;`iced/image` → `image-without-codecs`;
  测试文档 204 → 205;马赛克残留说明只保留 r1→r2 的历史一句。
- 解码在 UI 线程同步做:**登记欠账**(挪后台线程超出本 mission)。
2026-11-02 18:46:43 +08:00
AskaEth 5c40c64b5f fix(app-iced): M93 r3 —— 复审 r1 四条返工(Canvas 不吃 α / 平铺补缝 / 诊断上移 / 文档)
先 `git rebase main`(M92 `eae7ae0` 已在主线;`Cargo.lock` 与 `crates/app-iced/Cargo.toml`
自动合并,`image-without-codecs` 与 M92 的 `unlucky-i18n` 两段都在)。

P2-2(口径违约)Canvas 层不许吃面板 α
- `Ui::layer()` 改成按令牌 `MaterialLayer::allows_translucency()` 判定:Canvas 恒不透明,
  只有 Card / Overlay 乘 `panel_alpha`。判据取令牌的不变量,三个调用点(标题栏 / 导航轨 /
  页头带)没有各自特判。
- 守门:`the_canvas_layer_stays_opaque_while_panels_take_the_alpha`(α=0.55 时 Canvas.a==1.0、
  Card/Overlay.a==0.55;CPU 档连卡片都是 1.0)。

P2-1 平铺不许留缝
- `background::tiles()` 超 `MAX_TILES` 时改成**放大图块步长**(`Tiles { cols, rows, scale }`,
  按 1.15 逐级放大到块数进预算),而不是减列/减行;`MAX_TILE_SCALE` 提到 1024 只是安全阀
  (1×1 的图在 1180×760 上需要 ~118 倍才能用 64 块盖满)。
- `picture::tiled()` 用 `ContentFit::Fill` + 格子尺寸 = `源尺寸 × scale`(同比例,不变形):
  `None` 会按图片原生尺寸画、比格子小就留空,正是那道缝。
- 守门:`tile_grid_always_covers_the_viewport_within_budget`(6 组尺寸,含 120×120 与 1×1,
  断言 `covered >= viewport` 且 `count <= MAX_TILES`);背景层底下本来就有不透明画布底色兜底。

P2-3 诊断上移到首屏
- 诊断块(读盘失败红字 / CPU 降级 / 配色回落 / 当前配色层级)从卡片末尾移到「背景图路径」
  紧下方:1180×760 首屏可见(截图 `r4-broken-path.png` 里红字在 y≈594)。

P2-4 文档对齐
- `transparency.rs`:`WS_EX_LAYERED` 的归属改成「本文件的 `ensure_layered` 负责」,
  窗口创建侧只拿句柄;`platform/mod.rs` / `lib.rs` 的模块表同步。
- 删掉「马赛克取样」这类已删实现的现在时描述(`background` / `picture` / `app` 的注释),
  保留一句 r1→r2 的历史说明;`app/tests/appearance.rs` 的测试意图跟着改。

门禁(工作区级,最终树):`cargo fmt --all --check` 干净;
`cargo clippy --workspace --all-targets -- -D warnings` 无输出;
`cargo test --workspace` 全绿(app-iced 299 lib + 6 集成守门、presenter 126、其余 crate 全过)。
出图:`r4-canvas-opaque-panel55{,-dark}.png`(Canvas 不透明 + 面板透底)、
`r4-tile-120.png`(120×120 平铺右缘无裸白)、`r4-broken-path.png`(诊断首屏可见)。
2026-11-02 18:18:37 +08:00
AskaEth 22e4c423fa feat(app-iced): M93 r2 —— 背景图真图直出 + 面板 α 收敛到 Ui::layer
复审前补齐两处(tower 扩 scope:Cargo.toml / pages/frame.* / theme.rs / presenter::prefs)。

1) 背景图**真图直出**
- `iced` 开 `image-without-codecs`(刻意不用 `image`:后者会打开 `image/default` 全套编解码器);
  编解码能力由本 crate 的 `image` features 决定,补了 `jpeg` / `bmp`(用户挑的背景图/壁纸多半是 jpg)。
- `pages/appearance/picture.rs`:`Cover`/`Contain` 走渲染器 `ContentFit`,`Tile` 按 `background::tiles`
  算行列(上限 64 块)真的铺 N 个图块;暗化 = 一层「版面底色 @ α=dim」叠在图上。
- 36dp 马赛克那条路**已删除**(不再是主路径,也不留死代码)。
- 踩坑记录:`Handle::from_bytes` 的纹理分配赶不上截图第 3 帧(实测画面全空),改用
  `Handle::from_path`(渲染器自己解码),已在 `picture::image_handle` 的文档里写明。

2) 面板 α **收敛到唯一来源**
- `Ui::layer()` 直接乘上面板 α(新增 `Ui::panel_alpha` / `with_panel_alpha`,默认 1.0、
  `scale_alpha(1.0)` 逐字节不变),`app::rebuild_ui` 注入生效值。
- 于是外壳(根容器/标题栏/导航轨)、`frame::card`(实例/下载/日志三页卡片)、
  `frame::header_bar`(新增底色)、外观分组卡片、空态图标块**全部同源**,
  调用点不再有任何 `panel_color(..)` 自乘(该 helper 已删)。
- 有背景图时根容器不再盖一层实色画布(否则 panel α=1 时背景图整块被挡)。

3) `presenter::prefs` 补重导出 `PANEL_ALPHA_MIN` / `BG_DIM_MAX`,app-iced 里两份 0.4 / 0.95
   字面量改为转发常量。

验收:11+3 张图(`C:\Temp\ull-shots\wt-93\r2-*.png`)——三档填充肉眼可分、`.jpg` 能加载、
GPU 档 panel55 时卡片/分组卡/页头带都透出背景、CPU 档仍强制 1.0 且背景在面板空隙可见;
另附实例/下载/日志三页 panel55 各一张,证明三页都吃到同一个 α。
2026-11-02 18:07:32 +08:00
AskaEth b8ab46662c feat(app-iced): M93 —— 外观接线:整窗 α / 面板 α / 背景图 / 配色来源
把 appearance.* 那批键接到窗口与像素上(M91 只落了键与判据,零调用方)。

整窗 α(D34/D36)
- `ensure_layered(hwnd)` 落在唯一 unsafe 文件 `platform/win32/transparency.rs`
  (`WS_EX_LAYERED` 只有 Get/SetWindowLongPtrW 能加,且 CI 的 unsafe-scope 是集合相等);
  `app/boot.rs` 只负责开窗,句柄到手后由 `Message::WindowHandle` → `window_alpha::dispatch`
  先补样式再下发。本机实测 `layered=ok outcome=Applied { byte: 205 }`(α=0.8)。
- 三条入口都会重算并下发:启动(句柄到达)、设置页改动、配置文件热切。
- `layered` 日志有三态:`n/a`(α≥1,压根不需要)/ `ok` / `FAILED`,避免把「没用到」写成假红。

面板 α 与 CPU 档(D36)
- 面板 α 独立于整窗 α:只作用到外壳层(根容器 / 标题栏 / 导航轨)与外观卡片;
  CPU 档强制 1.0、玻璃强制关(`pages/appearance/alpha.rs` 的真值表 + 单测)。
- 色卡档继续走令牌 baseline,只有取色档(①②级)才换 `Ui` 的 scheme(`Ui::from_scheme`)。

背景图(D38)
- 读盘解码(png)→ cover/contain/tile 三种填充几何 → `bg_dim` 向底色退 → 36dp 网格马赛克;
  iced 的 `image` widget 需要 `iced/image` feature(Cargo.toml 不在 scope),故为已登记降级。
- 读盘失败静默回落 + 一条可读诊断(设置页错误行 + 日志),配色经 `Provenance` 回落色卡。

设置页「外观」分组
- 整窗透明度滑块(下限 0.08)/ 面板透明度(CPU 档禁用并写明 55%→100% 的降级)/
  背景图开关与路径 / 填充三档 / 暗化 / 配色来源三档;改完即时生效并写回:
  bridge 覆盖的 5 个键走 `SettingsBridge::persist`,`window_opacity` / `palette_source`
  走 `AppCommand::SetSetting`(每个键只有一个写者)。
- `SettingsBridge::load_base()`(presenter)让 app-iced 不需要直接依赖 core 就能读回
  `window_opacity` / `palette_source`。

取证
- `ULL_APPEARANCE_*` / `ULL_START_ROUTE` 只影响本次进程(语义同 ULL_TICK_TRACE),
  截图通道不读用户配置(D30)。
2026-11-02 18:07:31 +08:00
AskaEth eae7ae0ff4 Merge branch 'feat/p5-i18n-crate-language-probe-font-strate' 2026-11-02 17:42:58 +08:00
AskaEth ce2c42032b fix(i18n): M92 r1 —— Windows 真 locale 走注册表子进程(零 FFI)+ 删掉假守卫
复审 p1:`detect_env()` 在 Windows 上恒为 `en`(`LC_ALL/LC_MESSAGES/LANG` 都不存在),
D28 整条中文链(探测 → 扫 CJK → 需下载)在基线平台永不触发。复审在本机(zh-CN)实测
`lang=en raw=None`,这条我认。

**p1 修法(tower 定路线:子进程读注册表,不碰 FFI、不加依赖)**
- 新增 `crates/app-iced/src/platform/locale.rs`:`SystemLocale` 实现
  `unlucky_i18n::lang::LocaleSource`,内部 `reg.exe query "HKCU\Control Panel\International"
  /v LocaleName`(本机实测 `REG_SZ zh-CN`),读不到时退 `/v Locale` 的十六进制 LCID
  (本机 `00000804`),用一张 20 条的短表翻成标签,表外语言返回 `None`(宁可英文,不猜语种)。
  先例同 M73 壁纸探测(`core::platform::wallpaper` 的 `reg.exe` 路线)。
- 子进程走注入的 `RegIo` 接口,单测用 `FakeReg`(不 spawn);夹具是**本机真机抓的原文**
  (含 CRLF / 前导空白),另有一条 `#[cfg(windows)]` 真机测试证明 `reg.exe` 这条路真的通。
- i18n 仍是纯函数:`lang::resolve(setting, platform, env)` 是四级优先级的唯一实现——
  **`ui.language` 显式值 > 平台源 > POSIX 环境变量 > 英文**,`Detection` 新增 `origin`
  (`setting` / 源自报的 `label()` / `env` / `default`),启动日志因此能写清 `source=registry`。
  配置值不认识时**跳过**继续往下走,不当成任何一种语言。
- `fonts::cjk::probe_with(setting, faces)` 把 `ui.language` 那一级的位置先留好(目前传 `None`:
  设置层还没有读取点,写在模块文档的诚实边界里);`probe()` 走全链。
- `platform::mod` 的模块表补一行说明 locale 也是「零 unsafe」那一类。

**本机(zh-CN)实跑证据**(`--software-renderer --screenshot …`,exit 0):
`[font] … | lang=zh-CN locale=zh-CN source=registry cjk=system:"Microsoft YaHei UI"`
——探测出中文、且走的是「扫 CJK 字体」而不是 `not-needed`。

**p2**:删掉 `catalog` 那条 `compile_fail` 守卫(复审两个变异仍全绿 ⇒ 它守的是「这段代码
编译不过」,不是「编译期校验还在」)。改为陈述事实:「`Key` 是枚举,拼错 `E0599`」,
并写明这里**没有**守卫测试及原因。

未动 `ui.language` 默认值(`core` schema 不在本 mission scope,tower 已另记)。
2026-11-02 16:58:09 +08:00
AskaEth e77a5df6e8 feat(i18n): 建国际化地基——语言探测 / 中英消息目录 / 中文字体策略(D28)
M92 只建地基、不接页面(页面接线是后续 mission,避免「先硬编码再全量重写」)。
新建的 crates/i18n 是**零依赖** crate:`cargo tree -p unlucky-i18n --edges normal`
只打印它自己,所以 iced 版、将来的别的壳、presenter 都能依赖它而不锁死渲染框架。

- lang:`Lang::{En, ZhCn}` + 可注入的 `LocaleSource`(`EnvLocale` 按 POSIX 优先级读
  LC_ALL > LC_MESSAGES > LANG)。未知语种一律英文——把 ja/fr 猜成中文会连带触发中文字体
  下载,比英文界面更糟。配置值(`ui.language` 的 zh_cn/en_us)走严格解析,locale 走宽松
  解析,两条路刻意分开。**已知缺口**:Windows 上这三个变量通常都不存在,
  `GetUserDefaultLocaleName` 属于 platform 的 FFI(后续 mission)。
- catalog:`Key` 枚举(拼错的 key = 编译错误,附 compile_fail 文档测试守着)
  + `catalog!` 宏「一行 = 一个 key 的全部语言」⇒ 内置两套目录缺译在结构上不可能发生。
  缺译诊断(`Catalog::diagnose`)留给注入目录与回归守门:`get` 严格返回 None、
  `text` 返回 ⟪missing⟫、`render` 返回 ⟪missing:KeyName⟫——**不回落**到另一种语言。
  带变量的句子走 `{name}` 占位符,缺参时占位符原样留在文案里(可见,不静默变空)。
- font:`decide(lang, installed)` 是纯函数。英文环境恒 `NotNeeded`(D28 的零字体/零网络);
  中文环境按候选族名单命中系统字体,扫不到就发 `NeedsDownload(Noto Sans SC)` 信号,
  **本 crate 不联网**;`Fetcher` 是注入式接口,网络实现留给后续 mission。
  URL/哈希显式留白(`Source::PendingPin`)——不编造 CDN 路径,猜错的地址会被后面所有人当事实用。
- bridge:文案边界规则全文(R1 不许拼句 / R2 core 中文串不进 UI / R3 未知串不许画原文)
  + `classify()` 示例契约:过渡表把已知漏到界面的中文串映射到 key,品牌专名显式放行,
  其余一律 `Untranslated`(UI 只许显示兜底 key)。
- app-iced/fonts:新增 `fonts/cjk.rs` 适配层,把系统字面表喂给 `decide`;读不到字体库时给
  `Unprobeable` 而不是「需下载」(否则会误触发一次 CDN 下载)。结论并入同一行启动日志:
  `[font] … | lang=zh-CN locale=zh_CN.UTF-8 cjk=system:"Microsoft YaHei"`。

测试:26 条(i18n 22 + app-iced fonts/cjk 4),每条断言都写了「抓得住什么 / 抓不住什么」。
2026-11-02 16:13:22 +08:00
AskaEth 1331d90ff4 Merge branch 'feat/p4-iced-app-split-presenter-wire-one-pag' 2026-11-02 15:57:07 +08:00
AskaEth 6b66adff44 fix(app-iced): M90 r3 —— 空闲 1Hz 心跳恢复配置热切 + 导航轨口径订正 + 文档收口
复审 P2-2(空闲态掐时钟 ⇒ config_poll_due 再也不跑):
- 空闲态改用 1 Hz 自续心跳(Task::perform(sleep(1s)) → Message::Heartbeat),忙碌态仍用
  window::frames() + 33ms 闸门;两条时钟不并行(心跳在 needs_frames() 为真时只续排不推进)。
- ticks 改成「33 ms 一拍」记账(1 s 心跳记 30 拍),config 轮询判据从「整除」改成「跨越」——
  实测整除版在 ticks=137 之后每拍 +30 永远不命中,配置轮询静默死掉。
- 顺手修一个真 bug:Message::SystemTheme 原来无条件 set_flavor,会把配置文件里的显式主题盖掉
  (优先级现在是 --theme > 配置文件 > 系统),否则「改 launcher.json 主题 1 秒内生效」验收根本看不到。
- 实测:14 s 交互跑,空闲段 [tick] clock=idle-1hz 每秒一行、frames 冻结在 2947、refreshes 冻结在 6;
  t≈7s / t≈11s 两次改 launcher.json,各自 ≤1 s 内打出 [settings] 配置文件变了 + [theme] 翻转。

复审 P2-1(导航轨口径):
- pages/mod.rs 交付表改成事实(产品路径的 rail 由 shell/nav.rs 画,组件版只给 harness/单测,
  像素等价是复审判的),补偏离表第 10 条(两套 rail 各测各的,漂移没人拦);
- pages/tests.rs 的 the_real_rail_agrees_with_the_layout_system 注释同步订正。

P3 文档五条:shell 的「8px 拖动热区」改成事实(拖动面=整条 48px 标题栏到第一个 caption;
缩放热区 4px)、state/update 的「每拍重建视图模型」改成 view_dirty 门控、message 的 Frame 注释
改成「只在忙碌时订阅」、pages/model 的 From<&AppState> 改成自由函数 model(..)、pages/tests 的守门
目录补 src/app。

r3 浅/深产品图与 r2 逐字节相同(视觉零变化)。
2026-11-02 15:56:59 +08:00
AskaEth 022778a5a7 fix(app-iced): M90 r2 —— 找回窗口装饰 + 按需节拍 + 数据根一致 + 假绿守卫
复审 P1-1(产品页丢窗口装饰):shell 加内容槽位 view_with_content(ui, nav, title, content, forced),
app/view.rs 把 pages::page(...) 塞进真外壳;shell::view 保留给 Route::Agent 回落(删了会让私有
view/nav::rail 变死代码,-D warnings 红)。content() 拆到 shell/fallback.rs 让 mod.rs 回到 365 行。
1180 浅/深新图右上角三个 caption 键实测:ink 中心 y=23.5(栏高 48 的一半)、x 中心 1064.5/1110.5/
1156.5 = 46px 分档的 1065/1111/1157(±0.5px),close 命中区右缘 = 1180 窗角。
偏离 #7 改写成「交互面缺失」并注明本轮已修。

P1-2(harness.rs 402 行):tests 外置到 pages/harness/tests.rs(343 + 64 行);
components/gate.rs 的行数守门从走查 components/ 扩到 src/**(新测试当场抓出 app/tests.rs 416 行,
已按同样办法拆成 app/tests.rs + app/tests/presenter.rs)。

P2-1(常驻重绘 + 每拍重建模型):subscription 只在「截图 / 有 Busy-Running / 有 toast / 3 s 派发
观察窗」时订阅 window::frames();refresh_view 加 view_dirty 门。实测交互模式 9 s:只有 2 行 tick
trace(n=30/60,观察窗内),之后 6.5 s 一条都没有;refreshes 全程停在 7。

P2-2(两个数据根):--data-dir 时 SettingsBridge::with_path(config_path(root)),与后端同根。

P3-2(假绿):新增 RecorderBackend(dispatch 只记账、drain_events 恒空)+ launching_only_dispatches
_and_never_echoes_locally;植入本地回显后该测试实测转红(status 变 Downloading · 0%、can_stop 翻
true),撤回后转绿。
P3-3:尺寸字面量守门目录加 app;P3-1:--force-state 经 pages::Model::forced 作用到产品页控件
(hover 图里页头/列表/动作行/rail/caption 全部可见状态层);视觉 P2-2:加载器名统一走 badge()。

gallery 回归改口径:r2 与「主线 + r1」的 gallery 逐字节相同(cmp IDENTICAL);与 r1 记的
c825e1a 基线不同,差异来自主线 M86/M88 的令牌收口,不是本 mission。
2026-11-02 15:56:58 +08:00
AskaEth b484998fc9 feat(app-iced): M90 —— app.rs 拆成 src/app/ + presenter 真接线 + 实例页端到端
拆分(行为不变,截图四件套逐字节相同):
- 400 行的 app.rs 拆成 src/app/{mod,state,message,update,subscription,view,boot,tests}.rs,
  每个文件 ≤ 300 行;components 的 #[path] 跟着搬到 src/app/mod.rs(逻辑路径仍是 crate::app::components),
  可见性放到 pub(crate) 让页面层能用真组件。
- 拆分前后各出 shell/gallery 浅深四张图,cmp 全部 IDENTICAL。

presenter 接线(crate 文档 §4):
- App 持有 Box<dyn Backend> + SettingsBridge + AppState;启动 build_backend + SettingsBridge::open + AppState::new。
- 截图通道强制 mock(D30 可复现);交互模式走真 host,--data-dir 从 M90 起真的被消费。
- Boot 抽干首屏快照(第 3 帧落盘,等节拍会把空页面拍进图);节拍 = 帧事件 + 33ms 时间闸门
  (iced::time::every 要 tokio/smol,见 finding),tick → drain_events → AppState::apply。
- 用户动作不乐观回显:Launch/Stop 走 Backend::dispatch,状态等事件回来才变。
- 路由:pages::Message::Navigate(Route) 直接进 AppState::navigate,选中项 = 路由栈顶(不再有本地副本);
  首屏自动选中第一行(list-detail 桌面惯例),只做一次。

实例页打通(其余三页保留占位视觉 + Message/update + 路由):
- 列表行换成 components::navigation::ListItem(真五态/选中),页头与详情动作换成 components::buttons
  (Play 是唯一的实心 accent),分隔线换成 components::divider。
- 数据全部来自 presenter 快照:VERSION/LOADER/ISOLATION/VERSION ID/FILES/LAST PLAYED + 运行状态行。
- 导航轨换成真 NavigationRail(88),5 项各自压/替换路由。

版面约束未破(实测):列表列 336、正文列 424(元信息卡 206+12+206,右缘 896)、rail 88(药丸 16..71)、
断点 600/840 走 frame_metrics;新增 pages/tests.rs 的 9 条数值断言钉住它们。
2026-11-02 15:56:57 +08:00
AskaEth 270026338f Merge branch 'feat/p4-iced-components-feedback-progress-sna' 2026-11-02 15:56:52 +08:00
AskaEth d4e73aacdd fix(iced/feedback): 逐环改固定窗判据 + 贴顶边距 + Dialog 下限单测(复审 r3 P2-3/P2-1/P2-2)
D45 封顶轮的最后一改,只动这三条:

- **P2-3 逐环断言(假绿)**:环本来就在固定 x 栅格(24 + 56·i、宽 40),判据从「主色连通簇数
  非空」改成**六个窗逐个测**「窗内墨迹(轨道 ∪ 激活并集)外接成 40(±2)×40(±2)、且不越窗」,
  外加「1..5 号窗必须有激活色弧」。簇数只打印不再当判据。G18-i 从「欠账」改成**已收口**,
  并删掉模块文档里「要么把 6 个环摆成固定 x 栅格」那句错话(它们本来就在栅格上)。
  环带定位也换过两版(「每行墨点 4..20」会把 40 切成 32;「首行..末行」在深色档扩到 274 行),
  最终用「第一个窗里连续有墨、高度≈40 的行组」。
  变异:涂掉 4 个环 → `第 1 个窗里没有任何环墨迹:环丢了` 红;25% 环缩成 20×20 →
  `第 1 个窗里的环应 40×40(±2),实测 20×20` 红。
- **P2-1 贴顶边距**:新增「框顶行 → 条顶行 = 36±1」与「取景框高 = 96±1」两条。
  变异(`HOST_SHORT` 96 → 72,重编重拍):`D:贴底 ⇒ 框顶到条顶应为 36(96 − 48 − 12),实测 12` 红;
  还原后绿。—— 之前只量「条底距框底」,宿高回退成 72 时确实抓不住。
  顺带修了取景框判据本身:条自身(深色)比画布更接近框色,会把条自己认成框(实测差值 1),
  现在要求「框色连续段比条本身更宽」。
- **P2-2 Dialog 下限**:补单测 `effective_width(200.0) == 280.0`(360/800 两点一并钉住),
  并把「不低于官方 280」写进 `Dialog::width` 的文档。
- 欠账登记(只写表、不改代码):G18-j(动作色用全图合计,单卡漂移可能被别卡盖住)、
  G18-k(再 4 处文档残留,不为凑数扩大 diff)、G18-l(取景框判据会把条的抗锯齿底行算成框,
  余量 0.0008,方向是误报;加了「框高 96」后触发概率下降)。
2026-11-02 15:56:48 +08:00
AskaEth 11d7b64a5e fix(iced/feedback): 逐卡宽度/动作色精确判据、贴底边距、Dialog 下限与 icon 走 view_at
复审 r3 的 A/G/D/E 与随之冒出来的 clippy 项:

- **A(p1,假绿)**:`7.6 Dialog 动作色` 原来量的是 `DialogActions::None` 那张卡的 bbox,且判据
  是「离 Primary 更近」——标题/正文字形都被算进去(旧值 1790px)。现在:①判据换**精确色**;
  ②加两条反向证据(无动作那张卡里精确 Primary 必须为 0;有动作那张卡的下半区必须有字形行);
  ③量的是全图的动作字形(实测 362px,与复审独立复算一致)。变异:把 350 个动作字形涂成
  `OnSurfaceVariant` → `7.6 实测只命中 12 px` → 红。
- **G**:Dialog 宽度改**逐卡**量(列剖面,排掉 scrim 色,深色档不会再把遮罩算成 1180 宽的假卡)。
  环的逐环断言**登记为 G18-i**(实测 6 个环只有 3~4 个能被精确色簇干净命中,按簇数断言会把
  「相位换了」误报成「环丢了」,理由写进 `probe/circular.rs` 模块文档)。
- **D**:两个 bottom 宿主 72 → 96,并给窄宿主画 1px 取景框;probe 新增「条底距宿主底 = 12」
  (实测 `底边距 [11, 12]`)。取景框的描边不参与布局,所以宿主宽不变、条宽仍是 344/300。
- **E**:`DialogGeometry::effective_width` = `min(480, 可用宽).max(280)`(尊重官方下限);
  gallery 加一格 `Dialog::width(360)`;probe 逐卡断言实测 `[360, 480, 480, 480, 480]`。
- **C(按 tower 修正版)**:空态图标改用 M86 的 `icons::view_at(.., 32)`(先 rebase 到 main)。
- 顺带:`action.rs` 的 24 图标也走 `view_at(24)`——`icons.rs` 的守门测试按「逐文件调用点计数」
  要求不在白名单里的文件 `icons::view` 为 0,本文件按盒尺寸烘一份(输出逐像素相同)。
- clippy:去掉 `needless_range_loop` 与未用变量,`-D warnings` 已绿。
2026-11-02 15:56:47 +08:00
AskaEth 5d7426b6c9 fix(iced/feedback): 空态图标改用 M86 的 view_at 渲染 32(复审 C 修正版)
tower 修正指令:M86(e377989)把 `icons::view_at(kind, ink, px)` 并进 main 了,所以 C 项
从「登记值改成 24」改为「登记值保持 32 + 用 view_at 真渲 32」:

- `empty_state.rs`:`icons::view(..)` → `icons::view_at(.., geometry.icon)`,盒仍 96、
  图标 32;`view_at` 按 32 **重新烘焙 stroke**,视觉线宽仍是 1.5px(不会把描边拉粗)。
  早先 `view` 把 svg 钉成 `Fixed(24)`,32 的盒子只居中不拉伸 —— 实渲 24、墨迹 20×20。
- `panel.rs`:`EMPTY_ICON = 32.0`(`IconSize::Empty`),删掉 G18-h 欠账说明(已消解)。
- `probe/panels.rs`:图标墨迹判据 16..26 → **22..34**(32 盒)。
- `tests.rs`:`geometry.icon == 32` 保留,并写明它守的是「登记值 = 交给 view_at 的 px」,
  抓不住「盒子 32 而 svg 仍钉 24」那类(渲染层由 probe 的墨迹断言兜)。
- 顺带清掉 `panels.rs` 里两个未用 import(`-D warnings` 会红)。

已 `git rebase main`(1f4bc1b,含 M86),无冲突。
2026-11-02 15:56:47 +08:00
AskaEth a4cdd61fe5 fix(iced/feedback): 复审 r3 的 A/B/D/E/F 项 + C 项待 rebase 后改 view_at
(先落一个中间提交,rebase main 之后再按 tower 的修正把空态图标改成 32/view_at)
2026-11-02 15:56:46 +08:00
AskaEth 5d9e75efd8 test(iced/feedback): 环形旋转周期断言打印实测数值(跨周期跳变 / 每周期圈数)
给复审用的一行 `--nocapture` 输出:`[rotation] turns(0.9999)=… 跳变=… 圈(…°)· 每周期 4 圈 = 1440.0°`,
代码本身不变。
2026-11-02 15:56:46 +08:00
AskaEth 10d4f86fdf fix(iced/feedback): 圆角/描边带来的 probe 阈值订正 + Dialog 宽度逃生口接线
上一轮被 2 小时上限打断前的工作,逐条落地(都已在本地编译 + 单测 17 passed + 8 组 probe 中
6 组通过、empty-light 差 ±1 阈值):

- `keyframes::sample` 的 debug_assert 收窄成「首点不在 0ms **且** 首值不是 0」才炸:
  原先 `debug_assert_eq!(first.ms, 0)` 会把 `SEG_TAIL_1/2`(首点 250/900ms,值为 0)也判红,
  而那一类的「保持首值」与 Compose 的「动画初值 0」等价,是合法的。真正危险的是 ROTATION 那种
  「首值 ≠ 0」(会画出不存在的静止段),现在只拦这一种。
- `empty_state::block_layer()` 改成 `pub(super) const BLOCK_LAYER`:函数版在非 test 构建里
  是 dead_code(`-D warnings` 会红),改成常量后组件与 probe 共用一个来源。
- `probe/panels.rs`:图标块尺寸容差放到 ±2(块上补的 1px `OutlineVariant` 描边各占 1 行,
  浅色实测块色行 94、深色 97);顺带去掉没用到的 `matches` import。
- `tests/progress.rs`:三处期望值按实现对齐 —— 旋转每周期 **4 圈**(不是 1 圈)、
  右段宽 **0.505**(0.355..0.860,手算 0.516 偏了 0.01)、单调扫描只扫 `[0,1)`(phase==1.0
  就是下一周期的 0,回绕正常)。
- `dialog.rs`:`Fill + max_width(480)` **实测不生效**(Row 给 Fill 子项的 limits 是
  min=max=分配额,`Limits::max_width()` 的 `.max(self.min.width)` 会把 480 顶回去,
  两列并排实测渲染成 548)⇒ 改回 `Fixed(min(可用宽, 480))`,并加 `Dialog::width(available)`
  让页面递可用宽;gallery 里接线用 `HOST_WIDE`(=800 ⇒ 仍取 480)。能力缺口登记在注释里
  (与 G17-d 同一堵墙,报告里有 548 的实测)。

未完成(下一轮/合并前补):`cmp/dialog-*` 并排图要按 REF_H≈420 重出(动作行被裁掉)、
empty-light 的 ±2 阈值要复跑确认、clippy/test 门禁要贴真实 `test result:` 行。
2026-11-02 15:56:45 +08:00
AskaEth 38b1e7a4dd docs(iced/feedback): 偏离编号、图注 L3、[估] 口径与深色 scrim 观察项
复审 r1 的文档/证据订正(都是「图注与登记不一致」这类,但视觉 reviewer 正是照图注看图):
- gallery 图注「SurfaceContainerHigh + L3」→ 补「(本项目不画阴影,见 D-1)」;
- `snackbar.rs` 的「登记为偏离 D-3」→ D-4(D-3 是进度条波浪降级,之前混号了);
- `mod.rs`「三条偏离」→ 四条,并把深色档 scrim 的观察项登记成 D-5;
- `empty_state.rs` 开头「本文件数值全部是 [估]」→ 按表逐项区分 `[估]/[规格]/[源]`;
- `action.rs` 模块文档补一句「禁用色的底必须是这两个动作件自己的容器角色」。
2026-11-02 15:56:44 +08:00
AskaEth c723d1faff fix(iced/feedback): 空态间距双算、图标块对比度、Dialog 宽度与动作禁用底色(P2-1/2/4)
- P2-1 空态间距双算:`Column::spacing(8)` 与显式 `gap(16/24)` 叠加 ⇒ 实际渲染 32/40、
  条目总高 264,而登记的是 16/8/24、232。现在 `spacing(0.0)`,间距只有一个来源(`gap()`);
  断言从 `gap > 0.0` 改成**精确等于令牌档位** 16/8/24。
- P2-2 空态图标块几乎没有边界:`SurfaceContainerLow` 与画布 ΔRGB 只有 11/5/0(≈1%),
  浅色下看不出块。换 `SurfaceContainerHigh`(材质层 Overlay)+ 1px `OutlineVariant` 描边,
  并用 `block_layer()` 让 probe 与组件取同一个层(不再各写一份角色)。
- P2-1 Dialog 恒 480 宽:`Length::Fixed(480)` 在窄宿主会溢出,`.max_width(560)` 对 Fixed 是
  空操作。改成 `Fill + max_width(480)` = `min(宿主, 480)`(iced 无 min-width,见 G17-d)。
- P2-4 动作禁用底色取错:`interaction::disabled_content_over_role(..., false)` 把 38% 内容色
  固定叠在 **Surface** 上,而 Snackbar 的底是 `InverseSurface`、Dialog 是 `SurfaceContainerHigh`
  ⇒ 真出现禁用态会是「38% 深字叠深底」。改为 `content_color()` 按真实容器角色合成(同令牌 α)。
- probe:Dialog 上内距判据 `(24..=40)` 收紧到 `(24..=30)`;空态新增「块↔标题 ≈16」与
  「条目总高 ≈232」两条渲染层断言。
2026-11-02 15:56:44 +08:00
AskaEth e9af27c8f1 fix(iced/feedback): 不定态两条 p1 —— 线性先排序合并(P1-A)+ 环形补全局旋转(P1-B)
复审 r1 的两条 p1 都是「报告写了 ✓、图上没有」,逐条修:

P1-A(视觉)线性不定态把两段画成一段:
- 根因:`phase_segments` 的两段**不保证有序**(第一线前缘抢跑 250ms、第二线从 0 起),
  `linear()` 却按返回顺序单调铺 `FillPortion`,且份额之和在 34/100 相位上 >1(最大 1.99),
  iced 按总和归一 ⇒ phase 0.55 的两段被压成一段 100px 台阶。
- 修:新增 `layout_spans`(排序 + 合并重叠 + 显式铺间隙)与 `portion_shares`(累计舍入,
  份额之和**恒为 1000**),`linear()` 只消费这两者。
- 断言:`raw_segments_are_unordered_and_layout_sorts_them`(原始输出不有序、排序后位置对得上
  A1 keyframes)、`portion_shares_always_sum_to_the_full_track`(200 个相位全 = 1000)、
  以及渲染层 `12.4:phase 0.55 必须数出两段`(旧实现会数到 1 段)。

P1-B(代码)环形不定态少一条全局旋转 + keyframes 首点语义:
- 官方是两条旋转相加:`CircularGlobalRotationDegreesTarget = 1080f`(`tween(6000, LinearEasing)`,
  javap 偏移 5125)与 8 关键帧旋转(0→360°),绘制时 `fadd` 后 rotate ⇒ 每周期 1440°(4 圈)。
  我们只做了 360°,转速是官方的 1/4。
- `ROTATION` 首点在 300ms/90°:Compose 会自己补 t=0(值 = 动画初值 0),我们没补 ⇒ `[0,0.05)`
  恒 90°,且周期末尾 360° → 下周期 90° 瞬时倒跳 270°。现在显式补 `KeyPoint{ms:0, value:0}`
  并在 `Keyframes::sample` 加 `debug_assert!(首点 ms == 0)`,同一类坑不再靠自觉。
- `CircularGeometry::indeterminate` 改返回 `(弧长, 圈数)`,圈数 = 3.0×phase + 关键帧(可为 >1),
  `progress.rs` 直接乘 360、不做 rem_euclid。
- 断言:`circular_indeterminate_rotation_is_four_turns_and_continuous`(每周期 4 圈、
  跨周期跳变 ≤ 一帧 = 4/60 圈,修前是 0.75 圈 = 270°、旋转单调)。

probe 拆成 `probe.rs` + `probe/circular.rs`(新断言会把单文件顶过 400 行)。
2026-11-02 15:56:43 +08:00
AskaEth 36f58d7904 feat(iced): 反馈类家族四件(Progress / Snackbar / Dialog / EmptyState)
D39 五家族并行的反馈类线(M78 / COMPFEED)。全部对消息类型泛型、不 import app::Message,
数值来自令牌或登记为 G17(令牌层没有反馈类的 ComponentMetrics 与骨架位)。

- Progress:linear(确定态 FillPortion 占比 + 官方 1750ms 双段不定态 keyframes)与
  circular(40/4 环,弧长 = 数值×360°,不定态走官方 6000ms 弧长+旋转 keyframes)。
  环形用几何 SVG(canvas feature 没开),组件不计时,相位由调用方给。
- Snackbar:48/68 两种高、上限宽 600(A1 ContainerMaxWidth)、12 外边距、贴顶/贴底、
  action(InversePrimary)+ 关闭键(InverseOnSurface);**组件不计时**,超时归调用方。
  iced 的 Length 没有 min-width ⇒ 344 的最小宽由宿主保证(登记 G17-d,与按钮 G15 同墙)。
- Dialog:scrim(黑@0.32 预合成实色,D27)+ R28 + SurfaceContainerHigh + pad 24 +
  标题/正文/动作三区 + 无动作/单动作/双动作;官方 280..560 区间内取 480。
- EmptyState:**官方没有这个组件**,96 图标块/32 图标/HeadlineSmall 全部标 [估]。
- action.rs:Dialog/Snackbar 共用的文字/图标动作(内容角色作参数,状态层仍走 interaction)。
- gallery/feedback.rs:状态矩阵;家族内再切一段 ULL_FEEDBACK_PART(all|progress|snackbar|
  dialog|empty),开关本身住在 feedback/gallery_parts.rs,不动 gallery/mod.rs。
- feedback/probe.rs:对着真截图量像素(渲染层断言),四类几何各逐项钉住。
- feedback/tests.rs:几何/节奏/角色/scrim 的纯函数断言。

gates: fmt --all --check · clippy -p unlucky-app-iced --all-targets -D warnings ·
test -p unlucky-app-iced(144 通过 / 0 失败)
2026-11-02 15:56:42 +08:00
AskaEth 27f0d2a551 Merge branch 'feat/p4-appearance-base-keys-titlebar-mode-wi' 2026-11-02 15:43:13 +08:00
AskaEth 5d58301e2e fix(ci): M91 r4 —— 属性内复用主状态机(终结属性内 raw string / 字符字面量盲区)
根因:属性外的状态机(普通/行注释/块注释/字符串/raw/字符)只覆盖属性外;属性内另写了一套
「遇 " 跳到下一个 "」,于是属性内的 raw string 与字符字面量完全不认识:
- 假绿:`#[doc = r#"a "b"#]` 之后跟一个真 `#[allow(unsafe_code)]` → exit 0(r2 能抓到,是新盲区)
- 误红:`#[doc = r#"x" allow(unsafe_code) is banned "y"#]`(纯文档)→ 被报「多出来的 unsafe 落点」

修法(结构性,不是补分支):把逐字符分派抽成 step(),主循环与属性解析**共用同一个函数**;
属性解析只多保留一层「[ ] 深度决定何时离开属性」。属性内的字符串/raw/字符字面量因此与
属性外同一套识别,两处永不发叉。

- g_plain 标记「该位置是否处于普通态代码」:注释与字符串内容既不参与属性起点判定,
  也不参与属性内的 [ ] 计数与文本匹配(所以纯文档里的 allow 文本不会误报)
- 回归用例写进 yml 注释:四个形态(含 CRLF)+ 反方向纯文档例,改词法器前先跑
- 已验:gawk 5.4.0 / gawk --posix / mawk 1.3.4(debian:bookworm-slim)三种实现对四个
  回归用例与全仓 601 个文件给出完全一致的结果(恰好一个落点)
2026-11-02 15:33:48 +08:00
AskaEth 03ce813c32 fix(ci): M91 r3 —— unsafe-scope 门禁换成词法器(括号字面量/expect/块注释三处假绿根治)
必修 1:不配对括号字面量把 r2 的「全文净深度合并」带偏,文件尾部永久不可见
- r2 用「方括号净深度」决定续行合并,但 ['[', '(']、"[2"、starts_with("[2") 这类字面量
  会让深度永久偏离 0(实测 601 文件里 10 个扫到 EOF 仍非 0),于是该文件后面的一切都被跳过:
  在 gate.rs 尾部追加作用域 allow,r2 门禁 exit 0(假绿)
- 换成逐字符词法器:普通 / 行注释 / 块注释(可嵌套)/ 字符串 / raw 字符串 / 字符字面量,
  只在普通态认 `#` 或 `#!` 紧跟 `[` 的属性起点,只在这一个属性内部累计到 `]` 配平;
  数组字面量跨行不再参与合并

必修 2:`#![expect(unsafe_code)]` 既绕过门禁也绕过 workspace 的 deny
- 判据从 allow 放宽到 (allow|expect):expect 同样放行 unsafe(rustc -D unsafe_code 实测通过)

必修 3:块注释里的属性文本被当成第二个落点(与「注释刻意不算」的承诺相反)
- 词法器带块注释状态机(含嵌套),行注释/块注释/字符串里的属性文本都不算

顺带
- 已知边界对齐事实:宏体里直接写出的属性**会**被扫到;真正漏的是「宏在别处定义、这里只调用」;
  补两条:注释/字符串里的属性文本刻意不算、manifest 侧 [lints.rust] unsafe_code 不在扫描面
- 候选文件用 git grep -l unsafe_code 预筛(标识符不会被空白拆开,不会漏),整个 job 约 1~2 秒
2026-11-02 15:16:14 +08:00
AskaEth fa2c6560fd fix(appearance): M91 r2 —— 真交叉断言 + unsafe 门禁跨行扫描 + α 上取整
P2-1「与 design 码值一致」的守门此前不存在
- 新增 crates/app-iced/src/platform/mod.rs::palette_source_codes_match_design_level_codes:
  三个格子对照 PaletteSource::as_str() 与 unlucky_design::palette::Level::code(),
  并覆盖反方向(from_key_value(level.code()) 必须还原同一档)与「码值在允许值表里」
- core 侧那条测试改名为 value_tables_are_self_consistent,注释改成事实:
  core 不依赖 design,只能钉自己的字面量;一致性由 app-iced 的那条对照断言守
- 实测:把 design 的 Level::Wallpaper 码值改成 "desktop" → 新断言红(design 自己 178 个测试仍全绿)

P2-2 unsafe-scope 门禁:行级正则 → 跨行扫描
- 去注释 + 按方括号闭合把续行并成逻辑行,再逐个属性组判断 allow(...unsafe_code)
- 抓得住原来两种绕过:多行属性 #![allow(\n unsafe_code\n)]、同行两属性 #[inline] #[allow(unsafe_code)]
- 门禁注释补全已知边界(不再声称「依赖 fmt job 兜底」——已不依赖)

O-1 α 量化方向:round → ceil
- 0.08×255=20.4,round 得 20 → 实际下发 20/255=0.0784,低于 D36 名义下限
- ceil 得 21 → 0.0824;新增 applied_alpha_is_never_below_the_floor 断言 下发字节/255 ≥ 0.08

O-7 契约测试补三个新键
- appearance_keys_are_all_ai_writable_and_registered 的 audited 清单加 window_opacity /
  titlebar_mode / palette_source
2026-11-02 14:28:36 +08:00
AskaEth 7406785325 feat(appearance): M91 —— 外观下层三键 + win32 整窗 α FFI + unsafe 落点门禁
core(D36/D37/D38 的键与范围)
- appearance.window_opacity(0.08..=1.0,默认 1.0,越界钳位且不报错;下限 D36 定案)
- appearance.titlebar_mode(auto|always|never,默认 auto;判定逻辑不在本 mission)
- appearance.palette_source(background-image|wallpaper|preset,与 design 的 Level::code() 同名)
- schema 1.3.0 → 1.4.0:纯增量、不加迁移步骤(新键出厂值 = 老文件缺键)
- appearance 拆成目录 mod/modes/tests(单文件 ≤400 行),非法枚举由 diagnostics() 给可读回落说明

presenter(只读语义,不破 M89 零 UI 门禁)
- AppearanceBase { window_opacity, titlebar_mode, palette_source } + from_document()
- 单测:缺键→出厂值、越界→钳位、非法枚举→回落且合法值照读

app-iced(D36 唯一 unsafe 落点)
- platform::effective_window_alpha(backend, requested) 纯函数:≤0/非有限 → None(拒绝整窗穿透),
  其余夹到 0.08..=1.0;CPU 档同样允许窗口级 α(D36 对 D27 的修订),4 组真值表测试
- platform/win32/transparency.rs:SetLayeredWindowAttributes(LWA_ALPHA) 的 FFI +
  alpha_byte 量化 + apply_window_alpha;非 Windows 同签名空实现返回 Unsupported

CI
- guards.yml 新增 unsafe-scope job:全仓 allow(unsafe_code) 文件集合必须恰好等于
  crates/app-iced/src/platform/win32/transparency.rs(集合相等,不是黑名单命中)

版本号绊线
- isolation_keys / java_install_keys:字面量 1.3.0 → 1.4.0(绊线保留字面量)
- store / document 的两个关系夹具改成相对 launcher_version() 构造(「更高 minor 只读」/
  「同 minor 更高 patch 保留 $schema」),下次 bump 不再假绿或假红
2026-11-02 13:58:33 +08:00
AskaEth 006eb6869a fix(app-iced): 图标守门白名单补导航族(M79 进主线后的语义错配)
M86 的 sub_24_boxes_go_through_view_at 写于 M79 之前,白名单「未列出 = 0」把
导航族四处 view 调用判成违规。实测这四处 geometry.icon = IconSize::Toolbar = 24,
view 是正确入口,故补白名单(逐文件精确计数,仍能抓「同文件多一处」),总数 8→13。
icons.rs 399 行。
2026-11-02 13:40:21 +08:00
AskaEth 1f4bc1b80d Merge branch 'feat/p4-card-fixed-cool-neutral-hue-for-prese' 2026-11-02 13:12:47 +08:00
AskaEth 13248ddc9b test(design): 新增走 resolve_source 的接线守护测试(复审 r1 p1-1)+ 两条小修
**p1-1**:新增 `resolved_image_scheme_keeps_tinting_from_the_seed`——走产品公开入口
`resolve_source(Some(ImageInput::pixels(..)), None, Some(CardKey::Sky/Teal), Light)`,断言
`provenance.level == Level::BackgroundImage`、`surface == #FFF8F7`(暖白,跟着图走)、色相
≈22°(远离固定的 239.51°)、两张卡下主色与底色都相同、且与天蓝卡的 surface 不同。

上一版只有 `image_scheme_ignores_the_preset_neutral_hue`,它测的是 `Seed::scheme` 生成器层:
把 `resolve::try_level` 的取色分支改成固定中性时 8 条测试全绿(复审已实证)。那条保留并补了
⚠️ 注明「不覆盖接线」,避免下次又把生成器层当成接线守护。

**小修 1**:`every_card_scheme_meets_wcag_aa_under_the_fixed_neutral` 的 on-surface/surface
量级断言按 `flavor` 分开(原来 `(16.30..=16.40) || (14.28..=14.36)`,浅/深两值互换也能过)。

**小修 2**:测试文件拆分以守住 ≤400 行——`tests/card_neutral.rs`(crate 根,400 行)+
`tests/card_neutral/common.rs`(改前路径复原 / 模式列表 / 色相环距离,27 行)+
`tests/card_neutral/dump.rs`(出图数据导出 + 改前/改后实测表,68 行,无断言)。crate 根的子模块
默认在 `tests/` 下找,所以用 `#[path]` 指明目录。

`cargo test -p unlucky-design --test card_neutral` → 9 passed(8 + 新 1 条)。
2026-11-02 13:12:44 +08:00
AskaEth 4b059201fd fix(design): 中性色相常量改容差断言 + 取色接线注释指向真正的守护测试(复审 r1 p2)
- `palettes.rs::neutral_hue_is_the_default_seed_hue`:`to_bits()` 位相等 → 1e-9° 容差。
  色相来自 `atan2(a).to_degrees()`,`NEUTRAL_HUE_DEG` 的字面值是在 Windows(UCRT) 上取的,
  CI 的 ubuntu(glibc) 理论上可差 1 ulp 就让位相等在另一个 OS 上红;而这条要抓的是「换了
  `DEFAULT_SEED` 却没复核常量」(偏几十度),1e-9° 足够且不受平台末位差影响。
  `tests/card_neutral.rs::sky_card_is_bit_identical_to_the_product_baseline` 是**同平台内**
  两个算出来的值互比,继续用位相等(p1 硬约束的钉子,不动)。
  常量文档同步说明「为什么不用 `to_bits`」。
- `resolve/mod.rs` 取色分支的注释:原来只写「`tests/card_neutral.rs` 有反证」,而那条用例
  测的是 `Seed::scheme` 生成器层、覆盖不到这里(复审 r1 p1-1 的病根)。改成点名
  `resolved_image_scheme_keeps_tinting_from_the_seed`——走的就是本函数的公开入口。

`palettes.rs` 仍是 400 行(本轮改 doc 同时压缩了相邻说明,没突破上限)。
2026-11-02 13:12:43 +08:00
AskaEth b748986aae test(design): 钉住 D43 的固定中性色相(八卡同底色 / 天蓝不动 / 动态路径不退化)
`tests/card_neutral.rs`(新文件,8 条用例):

1. `sky_card_is_bit_identical_to_the_product_baseline`:默认档天蓝的 49 个角色 ==
   `Scheme::baseline`,两套模式逐值相等(M88 的 p1 级硬约束)。
2. `preset_cards_share_one_neutral_hue_and_chroma`:八张卡 surface 的 CAM16 色相
   一致(±1°)、实测彩度落在 ±1.5 带宽内,且更强的一条——八张卡的 surface 是同一个
   hex(实测差 0.00° / 0.00)。
3. `the_old_per_card_tinting_really_was_a_ninefold_gap`:反证,证明上面的带宽量得出
   问题——改前浅色档彩度差 9.3 倍(rose 1.29 / teal 12.03),改后 1.0 倍;深色档改前
   彩度本来就一致(tone 6 装得下 12),问题在色相横跨 285°,改后色相跨度 0。
4. `cards_only_change_the_accent_families`:强调族不靠手抄清单,而是实测「换强调色会
   变的角色」(深色 26 个 / 浅色 23 个——浅色 on-primary/secondary/tertiary 的 tone 是
   100,任何色相都是纯白),其余角色必须逐值留在基线。
5. `card_scheme_matches_from_accent_when_chroma_is_the_default`:色卡方案 ==
   `Scheme::from_accent(卡种子)`,把 D43 接到 `tests/official_accent.rs` 的官方对账上。
6. `image_scheme_ignores_the_preset_neutral_hue`:动态路径的守护测试——同一张图在 sky /
   teal 卡下强调色相同,中性调色板色相来自图像种子(精确口径查 `Palettes::neutral`,
   另加一条观感口径),且远离 `NEUTRAL_HUE_DEG`;谁把取色也改成固定中性就会红。
7. `every_card_scheme_meets_wcag_aa_under_the_fixed_neutral`:8 卡 × 浅/深 × 26 对全部
   ≥ 4.5:1(`palette::wcag::check` 全表),并把 on-surface/surface 钉在实测量级。
8. `dump_card_palette_for_the_color_card`:出图数据(`CARD|…` 行,改前/改后并排),
   渲染脚本只做排版。

实测:8 卡 surface 改前 `#EBFEF8`~`#FFF8F4`(浅)/`#061613`~`#1F0F10`(深)→ 改后统一
`#F6FAFF` / `#09151C`;全表最差比值浅 5.44 / 深 4.98,均远高于 AA 的 4.5。

门禁:`cargo test -p unlucky-design` 231 条全绿(216+5 doc → 226+5 doc,+10 条增量:
本文件 8 条 + `color::palettes` 2 条)。
2026-11-02 13:12:42 +08:00
AskaEth de9d5337ac feat(design): 内置色卡改用固定中性色相,切卡只换强调色(D43)
色卡路径原先走 TonalSpot「中性色也跟种子染色」,八张卡的中性色相 = 种子色相,
浅色档 surface 的实测 CAM16 彩度从 1.29(rose)跨到 12.03(teal)——tone 98 处
暖相色域装不下彩度 12,同一个请求值在暖相上只兑现 1.29。9 倍的底色跳变让用户在
设置页逐张试色时觉得「每张卡都像换了个应用」。

- `color::palettes::NEUTRAL_HUE_DEG`(新常量)= 出厂默认种子 `#3C8DBC` 的 CAM16
  色相 239.51420577630955°,出处与「换默认色时要复核什么」写在常量文档里;
  `neutral_hue_is_the_default_seed_hue` 用位相等钉住它与 `DEFAULT_SEED` 的绑定。
- `Palettes::accent_on_fixed_neutral(accent, neutral_hue, neutral_chroma)`:强调三族
  跟 accent 色相,中性两族固定在给定色相;其余常数(36/16/24/25°/84、tone 表、
  gamut 映射)一行未动。私有 `tonal_spot_with_neutral` 承载实现,`tonal_spot` 只是
  它的「中性色相 = 种子色相」特例。
- `Card::scheme` 改走上述入口(`NEUTRAL_HUE_DEG` + 卡自己的中性彩度)。默认档天蓝的
  49 个角色**逐值不变**(固定在等于它自己中性色相的那个值上,属「沿用」而非「另选」)。
- 动态取色路径**刻意不动**:`resolve::try_level` 仍按图像种子逐色染色、只借色卡的中性
  彩度定浓淡;两处注释都写明「两条路径不同,别统一处理」。
- 订正 `palette/card.rs` 的模块文档:原文把「请求值相同」当成「观感相同」,现在改成
  可直接当契约读的三条(表面与描边八卡逐值相同 / 只有强调族跟卡走 / 天蓝卡 == baseline)。

门禁:fmt 干净、clippy -D warnings 通过、`cargo test -p unlucky-design` 全绿、
`cargo tree -p unlucky-design --edges normal` 仍是单行(零第三方依赖)。
2026-11-02 13:12:41 +08:00
AskaEth e377989327 Merge branch 'feat/p4-token-clean-icon-size-and-disabled-al' 2026-11-02 13:12:22 +08:00
AskaEth 7c274afd7c fix(design,app-iced): 守门改成调用形状+逐调用点计数;filled-tonal 事实纠正(M86 r2 P1)
P1-1(守门漏网):sub_24_boxes_go_through_view_at 原判据只认带 turbofish 的 icons::view::,
且 plain && !sized 让「同文件有 view_at」整文件放行,checked 还数文件不数调用点。现在:
- 先剥注释(注释里写 icons::view 不再误报);
- 按调用形状匹配:icons::view( / view::< / crate::icons::· / super::icons::· / use …view as v
  与 {view} 之后的 v( / view(;
- 逐调用点计数,每文件必须恰好等于白名单期望(未列出 = 0)——所以「同文件改对 3 处漏 1 处」也红。

P1-2(事实错误):FilledTonalButtonTokens.DisabledContainerOpacity = 0.12f 是存在的,
javap -p -constants 可见;它是编译期常量、被内联进 getDefaultFilledTonalButtonColors,所以在
<clinit> 里看不到——我上一轮把「没在 clinit 出现」误写成「字段不存在」。同时把「TonalButtonTokens」
从 0.1f 家族的依据里剔除(它在整个 AAR 里零消费者,正是本 mission 警告的死令牌坑),改为只引用
ButtonDefaults 真的调了 getDisabledContainerOpacity 的 Filled/Elevated/Outlined,并注明
TextButton(容器透明)与 Baseline(基准表、运行时无消费者)。
2026-11-02 13:12:18 +08:00
AskaEth 896edf07fe test(app-iced): 修 sub_24_boxes 白名单与 18px 线宽断言的格式化口径
- 白名单路径补全(shell/nav.rs 不在 components/ 下);扫描跳过 icons.rs 自身;
- SVG 的 stroke-width 用 {} 渲染 f32,2.0 会打印成 "2",所以断言改成
  比数值(stroke_width_for(18,1.5) == 2.0)再比它渲染出的字符串,别写死 "2.0"。
app-iced --lib: 168 passed, 0 failed。
2026-11-02 13:12:16 +08:00
AskaEth 7627462593 test(design): 两族禁用 α 的令牌层钉值(硬编码期望,去自指)
M86 r1 P2:app-iced 那条 surface_family 断言原本从被测常量算期望,改坏常量也绿。除了把它改成
硬编码,令牌层自己也钉一条(cargo test -p unlucky-design 就能红,不必等 app-iced 链接):
DISABLED_CONTAINER_ALPHA == 0.10、DISABLED_SURFACE_ALPHA == 0.12、内容 0.38,且 surface > button。
2026-11-02 13:12:16 +08:00
AskaEth da1fe3aa4f fix(app-iced): 盒 ≠ RENDER_PX 的图标改走 view_at,线宽回到 1.5px(M86 r1 P2)
r1 视觉/代码复审撞上同一件事:本轮把按钮/chip/switch 的盒子改成 18/18/16,但 SVG 仍按 24
烘焙、靠布局盒缩下去 ⇒ 实测线宽 1.04~1.25px(令牌要 1.5px)。label 的对照证据是同屏 caption
(20px,走了 view_at)实测正好 1.50px——差别只在有没有走按盒烘焙。

- buttons/assemble.rs(前置/后置)、selection/chip.rs(前置/后置)、selection/switch.rs 滑块勾
  共 5 处改为 icons::view_at(kind, color, geometry.icon);
- 新增单测 view_at_bakes_the_requested_size:source(18) 必须是 width=18 + stroke-width=2.0
  (视觉 1.5px),并反证「24 烘焙塞进 18 盒只有 1.125px」;
- 新增源码级守门 sub_24_boxes_go_through_view_at:icons::view 只允许出现在图标盒=24 的文件,
  否则红——谁把上面 5 处改回 view 立刻被抓;
- 三处会带偏人的文字订正:icons.rs 模块文档改成「24 盒走 view、其余一律 view_at」;
  selection/mod.rs 的「图标一律 icons::view」同改;token_contract.rs 明说那条只保证烘焙路径。
2026-11-02 13:12:14 +08:00
AskaEth fa40d798e2 docs+test(design,app-iced): filled-tonal 的禁用容器偏离写进代码;禁用色断言去自指(M86 r1 P2)
r1 复审两条:
- 我在报告里说「filled-tonal 的 0.12 偏离已写进代码」,实际一个字都没写,而 state.rs:47 还把
  TonalButtonTokens 列进 0.1f 家族,等于让人误以为已对齐。现在 state.rs 的常量文档与
  disabled.rs 的模块文档都写清:官方 getDefaultFilledTonalButtonColors 硬编码 0.12f
  (FilledTonalButtonTokens 无该字段),我们的入口不区分变体、tonal 实际画 0.10,属已登记偏离。
- surface_family_disabled_colors_are_the_12_percent_class 的期望值原本从被测常量自己算,
  把 DISABLED_SURFACE_ALPHA 改成 0.10 也照样绿(自指断言)。现在期望值硬编码 0.12/0.38 并
  先钉常量本身;按钮族那条同改。
2026-11-02 13:12:13 +08:00
AskaEth b566097107 fix(design,app-iced): 图标尺寸与禁用 α 收敛到官方字节码,两族拆开(M86)
图标:IconSize::Button 16→18(AAR ButtonDefaults.<clinit> 的 bipush 18);新增
IconSize::SwitchThumb=16(SwitchTokens.Selected/UnselectedIconSize)承接开关滑块,按钮与
滑块不再借同一档;IconSize::Nav 的值不动但文档订正(无消费,官方导航/列表图标是 24)。
ButtonSmallTokens.IconSize=20 在整包 AAR 里零消费(javap 搜 getIconSize 无外部调用),
是死令牌,不再作为取值依据。

禁用 α:官方本来就分两族——按钮容器 0.10(*ButtonTokens.DisabledContainerOpacity)与
面状控件 0.12(chip 描边 / slider 轨道 / switch 轨道)。原来的缺陷是一个全局 12% 同时
服务两族(按钮那半边一直错、另半边一直对),现拆成 disabled_button_colors/…_content
与 disabled_colors/disabled_content_over_role 两个入口;interaction 首次超 400 行,
禁用口径与测试搬到 interaction/disabled.rs。

标题栏 caption 三键:官方 SmallIconButtonTokens.IconSize=24,按用户点名缩到 20
(容器 46×48 不动),登记为项目偏离;icons.rs 加按令牌取尺寸的入口 view_at,按
(种类,尺寸) 分别烘焙 SVG,20px 的 stroke 取 1.8 保视觉线宽 1.5px。
2026-11-02 13:12:11 +08:00
AskaEth c825e1aa3d Merge branch 'feat/p4-iced-components-navigation-listitem-t' 2026-11-02 11:56:11 +08:00
AskaEth 111955efae fix(app-iced): M79 r3 —— 分隔线守卫的三处「假绿」根治(众数参考色 / 角色身份校验 / 锚定行无条件打印)
代码复审 r3 = p1-2items,两条都是判据自身的假绿,产品代码零改动。

1. **参考色取「跨度中点那一个像素」→ 缺口落在中点就假绿**
   `line_segments` 改用该行**墨迹众数色**(`modal_color`):242px 的线 vs ≤4px 的缺口,
   众数天然是线自己的颜色。反例 `gap_at_mid`(r2 图在 x=144..145 挖 2px 孔):
   改前 `ok. 1 passed`(假绿),改后 **2 段 red**。

2. **行锚定可被「更靠上的另一条细低对比横线」抢走**
   `find_thin_line` 增加 `expected` 身份色 + `blend_coverage`:线色必须落在
   `Surface → SurfaceVariant` 的插值线上(覆盖率 0.30~1.05,三通道 t 最大差 ≤0.25)。
   这比「单通道差 ≤20」稳得多:实测 `OutlineVariant (200,212,224)` → t=(1.84,2.24,3.1) 出界;
   hover 8% `(228,233,238)` → t=(0.72,1.0,1.7) 散开 0.98;两者都被拒。
   反例 `decoy_outline_gap` / `decoy_hover_gap`:改前 `ok`(假绿,锚到 y=400/401),
   改后打印 `divider skip y=… (不是预期角色色的插值,拒绝)` 并退回真分隔线 → **3 段 red**。

3. **锚定行无条件打印**(不管红绿):
   `[probe] divider anchored at y=471 span=x24..266 color=[221, 233, 245] segments=1 (light, 1180x760)`
   ——「锚错行」从此不再静默。

4. **深色档不再是零覆盖**:`divider::check` 浅色必跑,`tabs-dark.png` 存在就跑深色
   (实测 `y=471 color=[46,60,70] segments=1 (dark)`)。判据/锚定逻辑抽到 `probe/divider.rs`,
   `probe.rs` 393 行仍 <400。

9 张图全跑(退出码即判据):
r2 rc=0(1段) · before rc=101(3段@471) · mutB rc=101(2段) · mutC rc=101(2段) ·
mutE rc=101(3段@471) · mutF rc=101(3段@471) · **gap_at_mid rc=101(2段)** ·
**decoy_outline_gap rc=101(3段@471,y=400 被身份拒)** · **decoy_hover_gap rc=101(3段@471,y=401 被身份拒)**。

已知边界写进 `probe/scan.rs` 模块文档:≤25% 覆盖的淡线 → **loud red**(锚定失败 panic,
不会假绿);身份校验绑定 `SurfaceVariant`(换角色色会 loud red,改 `divider.rs` 一行);
只认横线;深色图缺失时跳过深色断言但不失败。

门禁:fmt ✓ · `cargo test -p unlucky-app-iced` 197 passed · gate 6 passed · probe 5 passed ·
`clippy --all-targets -D warnings` 干净。变异图在 `target/m79/{mutB,mutC,mutE,mutF,gap_at_mid,decoy_outline_gap,decoy_hover_gap}/`,不入库。
2026-11-02 11:56:08 +08:00
AskaEth dffae2c429 fix(app-iced): M79 r2 —— 分隔线守卫换成「锚定细线行 + 整段跨度数段」
代码复审 r2 P1-1:上一条守卫只认 r1 那种「半透明 AA 缝」,四处假通过。
两个结构性原因与对应修法:

1. **窗口被截成 150px**:`longest_h_line` 凑满 150px 就 return,`line_segments` 只在那
   150px 里数段 → 分隔线实长 242px,只看前 62%;缺口在窗口外看不见、在窗口内则被
   「跳到缺口之后重新起跑」滑过。现在 `find_thin_line` 返回的是**该行整段墨迹跨度**,
   `line_segments` 在这个跨度上数段。
2. **行的锚定不稳**:原来「第一条 ≥150px 的连续带」能被五态行的 40px 高底色带、
   `Primary` 指示条顶替(mutE/mutF 会量到完全不同的元素)。现在四条判据一起锚定:
   跨度 ≥200 · 近乎实心(墨迹 ≥ 跨度×4/5)· 薄(跨中点纵向墨迹 ≤3 行)· 低对比
   (线色与底色单通道差 ≤120)。

`TOL=6` 的口径写进注释:**单通道**最大可辨差;40% 覆盖的抗锯齿行实测差 `(9,6,4)`,
6 能看见它又挡住 ±3 渲染噪声;相邻那行是满覆盖(线色差 25/17/10),所以行锚定不会丢。

五次验收(真跑 probe,退出码即判据;变异图在 `target/m79/{mutB,mutC,mutE,mutF}/`
由 r2/r1 图改像素生成,不入库):

| 图 | 结果 | 实测 |
|---|---|---|
| r2 原图 | **rc=0 绿** | 1 段 |
| `before/`(r1 逐 tab + AA 缝) | **rc=101 红** | 3 段,y=471 x24..265 |
| mutB(缺口 x=200..201,旧窗口外) | **rc=101 红** | 2 段,y=471 x24..266 |
| mutC(缺口 x=100..101,旧窗口内) | **rc=101 红** | 2 段,y=471 x24..266 |
| mutE(r1 两个缝位 102/192 挖真缝) | **rc=101 红** | 3 段,**y=471 x24..266**(仍是分隔线那行,不是 y=503 的五态带) |
| mutF(额外:r1 分段线 + 上方 200px Primary 带) | **rc=101 红** | 3 段,y=471(锚定没被 Primary 带顶替) |

门禁:fmt ✓ · `cargo test -p unlucky-app-iced` 197 passed · 全套 probe 对 r2 图 5 passed ·
`clippy --all-targets -D warnings` 干净。未改任何组件实现。
2026-11-02 11:56:06 +08:00
AskaEth 55a775fca7 test(app-iced): M79 r2 —— probe 复跑暴露的两处修正(分隔线判据 / 可见性警告)
真跑 `navigation::probe` 才发现的两件事,都只影响测试代码:

1. **Secondary 分隔线判据错了**:原来按 `SurfaceVariant` 的**精确值**找块,
   但 1px 的线画在分数像素上,真实像素是「线色 ⊗ 页面底色」的混合值
   (实测 `(221,233,245)`,不是令牌原值)→ 找到 **0 段**,断言假红。
   改成:先找「与页面底色不同、横跨 >150px 的那一行」,再用**该行自己的颜色**数连通段
   (`probe/scan.rs` 新增 `longest_h_line` / `line_segments` / `differs`)。
   现在:r1 基线图 → 实测 **2 段**(红);r2 新图 → **1 段**(绿)。
2. `matches` 原来被写成 `differs` 的正向委托(语义反了),以及 `pub(super) fn shot` 暴露了
   更私有的 `Shot`(`private_interfaces` 警告,`-D warnings` 会红):前者改回
   `!differs(..)`,后者把 `shot`/`shot_dir` 收回私有(子模块 `probe::long` 仍可见)。

其他:`probe/long.rs` 的 `filter(..).next()` → `find(..)`(clippy::filter_next)。

复跑(真实输出):
- `ULL_NAV_DIR=target/m79/r2 cargo test -p unlucky-app-iced navigation::probe` → **5 passed**
- `cargo test -p unlucky-app-iced` → **197 passed**(+ gate 6 passed,1 ignored)
- `cargo clippy -p unlucky-app-iced --all-targets -- -D warnings` → 干净
2026-11-02 11:56:04 +08:00
AskaEth a36f542f28 fix(app-iced): M79 r2 —— 四条 review 修复(对齐/单行/可比对象/整行分隔线)
代码 r1 P2-1 = 视觉 r1 P2-1(同一处,必修)
- `app_bar::action`:容器只给了 `align_y` → 24px 图标贴 40×40 命中区左缘(实测前导墨迹
  `38..58`,应 `44..68`;hover 的圆形状态层也露在图标一侧)。补 `align_x(Center)`;
  同时页头内容行补 `align_y(Center)`(`Container` 默认 Top,整行原本贴顶)。
- 把 Q21 升格成规则并全目录复查(结论写进 `navigation/mod.rs` 的表):
  app_bar 命中区/页头行、tabs tab 内容、drawer 药丸 = 两轴显式居中;
  list_item 文本块与 drawer 分组标题 = 左上起排(`Start` 是对的,不需要居中)。
  回归钉子:`navigation::probe` 的 appbar 断言量「左内缩 == 右内缩 ±2」。

代码 r1 P2-2(必修):四件文本都没有单行约束,容器高度却是固定值
- `app_bar`(标题/副标题)、`list_item::line`、`drawer::line`、`tabs` 标签统一
  `.wrapping(Wrapping::None)` + 外层 `clip(true)`;页头/ListiItem/drawer 的文本块改成
  `Fill + clip`,长文本切在文本区里,不折行、不顶穿、不压 trailing 动作。
- iced 没有 ellipsis → 按 `inputs/menu.rs` 的 D19 先例登记 **D-M79-5**
  (官方要单行 ellipsis,我们单行 + 超长直接切割)。
- 取证:gallery 新增 `ULL_NAV_PART=long`(长实例名整块)+ `navigation/probe/long.rs`
  量「行高仍 44/60/76 / 页头仍 56 / 标题墨迹右缘 < trailing 左缘」。取证块按
  `selection::matrix` 的先例收进 `navigation/matrix.rs`(gallery 单文件贴 400 红线)。

视觉 r1 P2-2(必修):ListItem 三列内容不同 → 「选中 trailing = Primary」没有可比对象
- 三列都挂 `trailing(settings)`,只有 selected 列走 `Primary`(其余 `OnSurface` / 38%)。

视觉 r1 P2-2(可选,已做):Secondary 分隔线逐 tab 分段 + 0.5 圆角留了 1px 亮缝
- 改成**整行一条**:`Stack` 把一条 `Fill` 的 1px `SurfaceVariant` 压在 tab 行底下
  (底座是 tab 行本身 → 线长 = tab 行总宽,横向滚动跟着走)。
- `TabsGeometry::bar_radius(bool)` 收成 `indicator_radius()`(只有 3px 的 Primary 条要圆角),
  单测同步;probe 新增「Secondary 分隔线必须 1 段」的断言。

门禁:`cargo fmt --all --check` ✓ · `cargo build -p unlucky-app-iced --lib` 零 warning ✓
(截图/探针/测试的实时输出见完成报告;本轮宿主机被多条并行线的 rustc 打满,
bin 链接排队严重,如未取到会如实标注未完成项。)
2026-11-02 11:56:02 +08:00
AskaEth 81e34d77da feat(app-iced): M79 导航家族 —— ListItem / Tabs / NavigationDrawer / AppBar
四件组件 + gallery 状态矩阵 + 渲染层几何断言,全部只落在
`components/navigation/**` 与 `gallery/navigation.rs`(不改 mod.rs / interaction / layout)。

- ListItem(12b §4):一行/两行/三行 44/60/76(A1 56/72/88,登记偏离)、leading/trailing 槽位、
  hover/focus/pressed 只叠状态层(内容色不变)、禁用是修饰器且内容角色取 A1 的 `OnSurface`@38%。
- Tabs(12b §6):Primary 3px 指示条(用 `Stack` 分层让 Fill 条拿到「该 tab 的宽」)、
  Secondary 1px `SurfaceVariant` 分隔线、带图标时容器 64、横向滚动;选中色按变体分(Primary / OnSurface)。
- NavigationDrawer(15 §21 + D33):**同一个容器两个宽度** —— rail 88 ⇄ drawer 240;
  药丸从容器宽派生(56×32 / 216×56,左右各缩 16 / 12,A1 `ItemPadding`);
  分组标题 TitleSmall、组间 1px OutlineVariant;drawer 项高 56(G24)。
- AppBar small(12b §16):页头 56(A1 64,登记偏离)、TitleLarge emphasized + LabelMedium 副标题、
  leading/trailing 槽位、滚动只换 `SurfaceContainer` + L2(A1 无 OnScroll…Height)。
- gallery:`ULL_NAV_PART=list|tabs|drawer|appbar|all` 分块出图;四块矩阵(ListItem 三行高 × 三态、
  Tabs 三个选中位置 + 五态、rail/drawer 同尺度并排、AppBar 静息/滚动)。
- `navigation/probe.rs`:读真截图量像素的 4 条断言(行高 44/60/76、禁用 OnSurface@38%、
  指示条宽 = 标签墨迹 + 2×16、rail 88 / drawer 240 与药丸内缩 16/12、AppBar 56),
  已用两次变异(行高改 56/72/88;drawer 内缩改 16)验证能被打红。

门禁:fmt ✓ · clippy -p unlucky-app-iced --all-targets -D warnings ✓ · test -p unlucky-app-iced 195 passed ✓
2026-11-02 11:55:59 +08:00
AskaEth 22636668d0 Merge branch 'feat/p5-ci-guards-for-presenter-zero-ui-and-d' 2026-11-02 11:19:20 +08:00
AskaEth e9034a40c8 fix(guards): design 零依赖门禁覆盖 dev/build 依赖表(M89 复审 p2-2)
cargo tree --edges normal 看不到 dev-dependencies / build-dependencies,而 design
对外承诺的是「零第三方依赖」,不分 normal/dev/build。新增一步「依赖表必须为空」:
按表头切出 dependencies / dev-dependencies / build-dependencies / target.*.dependencies
(含 [dependencies.serde] 子表写法,表头携带包名),有任何一个键即失败;原「依赖树只允许
出现自身」那步保留,两道互补。

实测:基线绿;[dev-dependencies] serde 红(同一时刻 tree step 仍绿,正是原来的盲区);
[build-dependencies] digest 红。老 job(core-no-ui / file-size / licenses)解析后仍与 main 相等。
2026-11-02 11:19:16 +08:00
AskaEth 48061c3f31 fix(guards): presenter 依赖表检查覆盖 [dependencies.slint] 子表写法(M89 复审 p2-1)
原判据要求表头是 `[dependencies]`,`[dependencies.slint]` / `[target.'cfg(windows)'.dependencies.slint]`
这类合法子表既不匹配、表头又被 next 吃掉,整段内容完全不查。配合「子表 + optional +
非默认 feature」可让依赖表与 cargo tree 两步同时绿。

- 表头正则加 `(\.[^]]*)?`,并把带子表后缀的表头本身纳入检查(表头携带包名)。
- 实测:基线绿;`[dependencies.slint]` 红;子表+optional+feature 红;target.* 变体红;
  `[dependencies.serde_json]` 不误报(仍绿)。
2026-11-02 11:19:15 +08:00
AskaEth 56bc52756b ci(guards): 新增 presenter 零 UI 与 design 零第三方依赖两道门禁(M89)
- presenter-no-ui:照 core-no-ui 的「依赖表 + cargo tree」双查,名单一致
  (slint/egui/eframe/iced/winit/gtk/javafx/tauri/fltk/druid/relm)。
  依赖表检查按表头切出依赖表再查键名——presenter 的 description 合法地写着
  「Slint 版与 iced 版共用」,core-no-ui 那种整文件 grep 在这里会误报。
- design-zero-dep:cargo tree -p unlucky-design --edges normal 只允许出现自身一行,
  把 README 明写的「零第三方依赖」卖点从人肉守变成 CI 守。
- 两条都 fail-closed:cargo tree 自身失败(crate 改名/解析异常)同样红;
  失败信息给出命中清单与放哪里的建议。
- 未改任何已有 job 的语义(core-no-ui / file-size / licenses 原样)。
2026-11-02 11:19:14 +08:00
AskaEth 1d0ff70908 Merge branch 'feat/p4-palette-source-trio-image-wallpaper-p' 2026-11-02 11:10:59 +08:00
AskaEth 8be91a9cc9 docs(design)+test(palette-extract): 修正「种子一定是原图真实颜色」的过度承诺
自测发现:400×400 红蓝棋盘在 MAX_EDGE=256(适配层 thumbnail 缩放)与 512(design 块平均)
下都取到 #704070,都不在原图上——「≤MAX_SAMPLES ⇒ 真实颜色」只对「传进 analyze_rgba 的
那个缓冲」成立,不是整条链的性质。
- Seed::color 文档写明边界 + 上游缩放同样会破坏该性质(附实测例)
- 结构性断言的注释/断言文案改为「design 不再叠第二层块平均」,不再声称能保住真实颜色
2026-11-02 11:05:06 +08:00
AskaEth 09b716ae79 docs(design)+test(palette-extract): r2 三条口径修正与常数绑定
- LowChroma 文档:删掉「彩度必然 < 候选门槛 5」(人气门槛也会淘汰高彩度小面积,
  实测 0.5% 纯红报 113.36),改为与契约文档 §6 一致的口径;代码臂注释与 score::most_chromatic 同步
- Seed::color / share 改成有边界的说法:≤MAX_SAMPLES 才是图上真实色、share 的分母是有效采样点
- palette-extract 新增 max_edge_stays_within_the_design_sample_budget:
  MAX_EDGE² ≤ MAX_SAMPLES(256² 当前余量为零,调大就红)
- card.rs:把 6.43~6.50 / 7.70~7.76 写成断言(原来只有 ≥4.5 下限,文档却宣称区间)
- LowChroma.color 字段注释改成「最接近可用的候选色」(113 那条并不「能用」)
2026-11-02 11:05:03 +08:00
AskaEth f4c48752c2 fix(design): Outcome::NotReached / 色相环回绕断言 / LowChroma 口径统一 / 文档指向(复审 r1 p2-4 + 色彩 r1 p2-2/3/4)
- Outcome 新增 NotReached:区分「用户没提供」与「更高级成功所以没轮到」,
  UI 不会再对着用户已选的卡说「色卡:未选择」;新增 not_reached_is_distinguishable_from_not_provided
- cards_are_visually_distinct 补色相环回绕间隔(原来只比排序相邻差,350° 的卡会静默通过)
- Failure::LowChroma 的 color 与 chroma 现在永远来自同一个簇(原来一条路径拿 A 簇的色值配 B 簇的彩度);
  两条路径的口径写进文档注释,并提醒 UI 别把数字写成「彩度 X < 门槛 8」
- card.rs:测试名指向修正(card_keys_are_stable_and_unique,测试就在本文件)、
  「对比度 8.4:1」的假示例换成实测区间(light 6.43~6.49 / dark 7.70~7.75)、
  中性彩度那段补上「请求值相同 ≠ 实际表面同冷度」的事实与 D43 去向
2026-11-02 11:05:02 +08:00
AskaEth 948a3685cd test(design): 钉住 D40 的彩度门槛 8(复审 r1 p2-3)
MIN_SEED_CHROMA 8.0 → 6.0 的变异原本全绿(那条断言比的是常量自己)。
新增两条用实测 chroma≈7.23 的灰蓝(#7E8489):
- seed::tests::chroma_just_below_the_gate_is_rejected(门槛之上/之下夹住)
- resolve::tests::faint_colour_falls_back_to_the_selected_card(回落 + 落点是选中卡 + 方案逐值等于该卡)
顺带把 resolve 的单测拆到 resolve/tests.rs(400 行上限)
2026-11-02 11:05:01 +08:00
AskaEth 579f3ae271 feat(palette-extract): 补 bmp 解码器(复审 r1 p2-2,定案加)
M73 的 Windows 壁纸探测只读注册表/.theme 里的路径,不筛扩展名,.bmp 壁纸真会出现;
image 的 bmp 是独立 feature 且无额外传递依赖(bmp = []),代价≈0。
- features 加 "bmp",Cargo.toml 与 lib.rs 注释的口径对齐(原来注释说支持 bmp 但没开)
- 新增 bmp_is_supported(真编一张 BMP 再解码取色)
2026-11-02 11:05:00 +08:00
AskaEth 2ce394fbfc fix(design): 混合透明度不再被误判为「全透明」(复审 r1 p2-1)
先按 alpha 过滤像素、再做块平均(整块全透明则跳过)。原实现先平均再比门槛,
块内 255 与 0 的整数除法得 127 < 128 → 512×512 里 131072 个不透明像素被报
NoOpaquePixels,UI 会告诉用户「背景图几乎全透明」,与事实相反。
- 新增 half_transparent_image_keeps_its_opaque_colour(红)
- 新增 mostly_transparent_image_still_samples_the_visible_part
- 模块文档加一行「混合透明度」的处置与踩坑说明
2026-11-02 11:04:59 +08:00
AskaEth 36c1cce00d test(design): 补「先量化再打分」的端到端反证(复审 r1 p1)
reviewer 实测变异:把 analyze_rgba 改成不透明像素算术平均,150 个测试全绿——
因为反证只测了 quantize/score 中间层,接线没人覆盖。新增:
- seed::tests::end_to_end_picks_the_five_percent_accent(95% 米白 + 5% 珊瑚 → #FF6B5A)
- seed::tests::end_to_end_picks_the_three_percent_accent(97/3 → #FF8800)
- resolve::tests::a_five_percent_accent_drives_level_one(一级成功、种子是珊瑚、≠ 回落色卡)
顺带把 seed 的单测拆到 seed/tests.rs(410 行 → 220 + 190,恢复 ≤400 纪律)
2026-11-02 11:04:58 +08:00
AskaEth 80314f4830 docs(design): hint_key 的文案映射表搬进 provenance.rs 的文档注释
DevDocs/ 是 gitignore 的本地文档,契约里的「键 → 文案」表review 时看不到,
所以在 hint_key 的 rustdoc 里放同样一张表(差异只有措辞建议,结构一致)。
2026-11-02 11:04:57 +08:00
AskaEth 31ee69100a refactor(design): resolve 拆成目录模块(单文件 ≤400 行),同步文档口径
- palette/resolve/{mod.rs,provenance.rs}:编排函数与 Provenance 类型分开,路径不变
- color/mod.rs + palettes.rs:原来的「取色/色卡库不在本 crate、还没实现」改成指向 crate::palette
- 取色 gap 登记改为「单种子已实现,多 source 色仍未实现」
2026-11-02 11:04:55 +08:00
AskaEth 1aac62f6f0 feat(palette-extract): 图片解码适配层(D40 第 2 层,唯一引入 image 的地方)
- 新 crate unlucky-palette-extract:路径/字节 → RGBA → design 的种子
- image 只开 png + jpeg(关 default-features,不拖 rayon/avif/webp)
- 解码前按 Limits 拒超大尺寸(解压炸弹),解码后最长边缩到 256
- input(&Result<Rgba8, _>) 把失败翻成 ImageInput::Undecodable(回落链靠它区分「没设」与「坏了」)
- 11 个单测覆盖纯色/JPEG/抄错扩展名/垃圾字节/截断/缺文件/1px/灰图/大图/超限/零拷贝
2026-11-02 11:04:52 +08:00
AskaEth 18327fb038 feat(design): 配色来源纯数学层(Monet 式量化取色 + 色卡 + 三级回落 + 对比度收敛)
D40 分层:design 保持零第三方依赖,只吃已解码的 RGBA 像素。
- palette/quantize:5bit 直方图 + CIELAB k-means(QuantizerCelebi/Score 的形状)
- palette/score:官方 Score 口径(CUTOFF_CHROMA 5 / TARGET 48 / 权重 0.7+0.3)
- palette/seed:seed_from_rgba + D40 彩度门槛 8,灰白图回落而非凭空上色
- palette/card:8 张内置色卡,key 为持久化键
- palette/wcag:26 对文字对比度门禁 + 沿 tone 轴收敛并记录修正
- palette/resolve:纯函数三级来源编排 + Provenance(原因码/提示键)
2026-11-02 11:04:10 +08:00
AskaEth c1fc5b8086 Merge branch 'feat/p5-presenter-extract-host-layer-for-iced' 2026-11-02 11:02:29 +08:00
AskaEth d03ba13c0f test(presenter): 修正 tile_viewport 单测的期望值(760/128 量化到 640 而非 768)
迁移后首次跑 test 时这条自测断言写错:函数行为与原 ui_bind 的实现逐字一致,
错的是期望值。修完 `cargo test -p unlucky-presenter` = 122 passed。
2026-11-02 10:11:55 +08:00
AskaEth f159529936 feat(presenter): 新建 unlucky-presenter,把 host/状态/DTO/设置桥从 app 迁出(M85 第 1 块)
D26(iced 重写)的关键一次性动作:UI 框架无关的宿主层独立成 crate,
Slint 版(crates/app)与 iced 版(crates/app-iced)共用,Slint 退役后不再重写一遍。

- 新建 crates/presenter(package `unlucky-presenter` / lib `unlucky_presenter`),
  workspace 登记 + crate 文档头写清「这层为什么存在」、对外表面、UI 侧允许/禁止、
  iced 接线骨架、以及哪些东西留在了 crates/app 及为什么。
- `git mv`(保留文件历史)迁入:host/(25 文件)、dto/(11 文件)、state.rs(+tests)、
  prefs.rs、settings_bridge.rs、toast.rs、router.rs、dialogs.rs、wizard.rs、
  mock.rs、mock_data.rs、backend.rs。模块路径与公开 API 名称不变(`host::install` 等),
  内部实现零改动。
- 新增 `theme`:把原本混在 ui_bind/theme_runtime 里的**纯判据**(CONFIG_POLL_TICKS /
  SYSTEM_THEME_POLL_TICKS / TILE_REGENERATE_STEP / BackgroundKey / tile_viewport /
  system_theme_value / 两个 poll 判据)抽出来,附 5 个单测;app 侧改成调用它们。
- crates/app 改为消费者:删掉迁走的文件,`crate::host|state|dto|prefs|settings_bridge|
  toast|router|dialogs|wizard|mock|mock_data|backend` 全部改写为 `unlucky_presenter::*`
  (64 处),backend.rs 的 config_path/build_backend 提为 pub。
- crates/app-iced 只登记 unlucky-presenter 依赖,本 mission 不接线。
- prefs.rs 的 2 处 slint 文档引用改写;`grep -rn slint crates/presenter` 现在无输出。
2026-11-02 09:29:31 +08:00
AskaEth 00f132d8ce Merge branch 'feat/cli-unify-launcher-binary-subcommands-wi' 2026-11-02 08:08:20 +08:00
AskaEth fb3d3f46c9 docs(readme): 未知子命令的提示改成 unlucky-launcher --help(M82 r4 复审 P2)
两版 README 的「未知子命令是错误,不是开窗」一段写的是提示 `ull --help`,与真实输出相反:
GUI 二进制跑出来是 `unlucky-launcher --help`(只有 `ull` 自己跑才是 `ull --help`),
而且同文 87/77 行已经写着「自称的名字跟着你敲的那个二进制走」——正文自相矛盾。

只改这两处;全文其余 `ull` 都是指 `ull` 那个产物本身(对照表、§ 说明、`ull --help` 的对比句),
按语境保留。
2026-11-02 08:08:18 +08:00
AskaEth 8ca1e1e965 fix(cli): 程序名占位符在构造时渲染,两条出口(人类可读 / --json)不再分叉(M82 r3 复审 P2)
r2 只在人类可读通道渲染 `%prog%`,JSON 两条出口(错误信封的 message/hint、Report 的
warnings)直接序列化未渲染文本,于是 `--json lst` 吐出 `"hint":"%prog% --help 查看命令列表"`
——连 `ull` 自己的 JSON 都被这轮改动弄坏了。

改成**构造时渲染**,一个入口管住所有出口:
- `CliError::usage/user/internal/with_hint` 统一过 `render()`(错误只有这一处成形);
- `Report::new` 渲染 human 行,`Outcome::with_warnings` 渲染 warnings;
- `print_report` / `print_error` 不再各自渲染(渲染点只有一个,不会再有第三条出口漏掉)。

回归测试三条:`json_error_envelope_never_leaks_the_placeholder`、
`json_warnings_never_leak_the_placeholder`、`human_lines_render_the_placeholder_too`
(后者同时断言「账户名恰好叫 ull」这种用户数据不被改)。
2026-11-02 08:08:16 +08:00
AskaEth 2f8bbd1746 fix(cli): 程序名只替换模板占位符,不再改写用户数据(M82 r2 复审 P2)
r1 把 `rename` 套在**插值后的整条文本**上,于是用户数据被改坏:账户名恰好是 `ull` 会变成
`unlucky-launcher`,路径 `D:/tmp/ull/.ULL/...` 会变成 `D:/tmp/unlucky-launcher/.ULL/...`,
回显的子命令 `ull` 也会被改名。

改成模板占位符:文案里写 `%prog%`,**只在渲染时**(print_report / print_error / 帮助)替换成
argv[0] 的 basename。占位符只存在于静态模板里,插值进来的账户名 / 路径 / 子命令碰不到它。
用 `%prog%` 而不是 `{prog}`:这些模板要活在 `format!` 里,`{prog}` 会被 format! 当成参数名
编译失败。

同时补上 r1 漏的反方向:**stdout 的正常输出**(Report.human 与 warnings)现在也做替换,
以前只有 stderr 的错误做了,两边不一致。

回归测试:`user_data_that_says_ull_is_never_rewritten` 断言「账户名 `ull`」「路径 `\ull\`」
「未知命令 `ull`」在 fill 之后逐字不变。

占位符替换覆盖 21 个文件的 63 处文案;测试夹具里形如 `ull-mcp-diagnose-<pid>` 的临时目录名
已回滚为字面量(那不是文案)。
2026-11-02 08:08:13 +08:00
AskaEth c9e6236a87 refactor(cli): 判据收敛到 unlucky_cli::cli,banner 按被调用的二进制名自称(M82 复审)
去重:`looks_like_cli(args) -> bool` 落在 `unlucky_cli::cli`,两个 GUI 的 main.rs 只调用它,
判据从两份变一份。cli crate 里**不**出现 GUI 的 flag 表——语义仍是「首个 token 是裸词就接管;
首个 token 是 flag 时只有解析出真命令才接管,解析失败不接管」。原来两个 main.rs 里的 5+5 条
单测合并成 cli crate 的一份(用例集按复审要求原样搬,含 --screenshot/--renderer/--diff/
--mock/--force-state/--scale-factor、--help/-h/--version/-V、10 个已知子命令、--json 前置、
未知子命令),两个 main.rs 的旧测试已删除。

banner:新增 cli::program 模块,从 argv[0] 的 basename 取程序名(剥目录、剥 .exe、空值回落
ull),把帮助/版本/错误提示里独立出现的 `ull` 换成它——`unlucky-launcher --version` 不再自称
`ull`,也不会把人指去敲 `ull --help`。`run()` 签名未改;`ull` 自己跑时输出逐字不变(替换是
恒等),打包脚本的 `ull --version` 冒烟不受影响。
2026-11-02 08:08:12 +08:00
AskaEth fecff989e7 docs: 修掉「把没量的东西标成实测」的四处引用(M82 复审 P2)
- 两个 GUI main.rs:把「不 dlopen 任何图形库」改成「不依赖任何图形库(缺失时不影响)」,
  并把「实测见 §7」换成「§7.4 是依赖取证;耗时是结构性判断,没重量(§10-8)」
- packaging/README §1:§7.6(不存在)→ §7.4
- packaging/README §4:实测引用 §7.1 → §7.4(ELF 取证在 §7.4)
- packaging/README §7.4 标题补 ELF DT_NEEDED,并记下复审在 bookworm-slim 里的真跑结果
- packaging/release-notes:ull 示例前补 chmod +x(Release 资产不带可执行位)
2026-11-02 08:08:11 +08:00
AskaEth 0d4628f85e docs(packaging): 如实记下 M82 的体积/首帧没有重量
本机当时三个 worker 同时在编,全量 release profile(codegen-units=1 + fat LTO)要
重跑 400+ crate,这两个数留给空闲窗口补。写清结构性判断(分发在 MainWindow::new()
与后端探测之前,GUI 路径只多一次 argv 解析)与它的性质:不是实测。
2026-11-02 08:08:10 +08:00
AskaEth 1c7285cea9 feat(cli): GUI 二进制内建 CLI 子命令分发,ull 纳入发布资产(M82)
一个二进制两种用法:两个 GUI 的 main.rs 在进 UI 之前分发子命令,命中就把控制权交给
unlucky_cli::run —— 不建窗口、不探后端、不扫字体、不 dlopen 图形库。

分发判据只有一条,子命令表不重复:crates/cli 的解析器是唯一真相源。
- 首个 token 是裸词 → 子命令位置,交 CLI(未知子命令由 CLI 报错 + 提示 --help,退出 1,
  不静默开窗)
- 首个 token 是 flag → 只有 CLI 解析出真命令才算 CLI 调用;--data-dir X 解析成 Welcome、
  --screenshot / --renderer / --diff 等 GUI 参数解析失败,一律仍走 GUI(CI 脚本零改动)

顺带:unlucky-launcher --version 以前会直接开窗,现在走 CLI 分发报版本。

packaging:ull 作为独立裸资产随每个槽位发布(make_artifacts.py + release.yml 的 assets
数组与 SHA256SUMS glob),矩阵完整性检查改成 GUI/CLI 各查一次。

app-iced 的 x11 / wayland / linux-theme-detection 收进 target 专属的 cfg(target_os) 段。

门禁实跑:cargo fmt --all --check 通过;cargo clippy --workspace --all-targets -D warnings
通过(exit 0);cargo test --workspace 通过(51 个测试二进制 / 1369 个用例,含本次新增的
5 + 5 条分发判据用例)。
2026-11-02 08:08:09 +08:00
AskaEth ef3212021a Merge branch 'feat/p4-iced-components-inputs-textfield-sear' 2026-11-02 07:45:42 +08:00
AskaEth 05fefc8a7b fix(app-iced): M77 r1 —— 菜单项 trailing 槽位靠右(视觉 r5 P1)
`item_row` 的 Row 早先是 `leading → label → trailing`,中间没有撑开的空间,于是
trailing(快捷键 / 子菜单箭头 / 勾)**紧贴 label 右边**,一列菜单里读不出一条竖线,
也与 M3「trailing 落在行右内边距上」不符。

改法(只动 `menu.rs` 的行装配):
- label 与 trailing 之间插一个 `space::horizontal()`(宽度 Fill),**只在有 trailing 时插**——
  没有 trailing 的项仍然左边起排,不被居中/右对齐;
- 把 Row 包进 `container(..).width(Fill).height(Fill).align_y(Center)`:Fill 空间要有宽度可撑
  (Q8 的教训:撑开靠容器,不靠子元素),顺带把内容垂直居中(`button` 只把内容放左上角);
- trailing 的右缘现在落在 item 右内边距上,与 leading 左内边距对称(12 = 4 内缩 + 8 button pad);
- 分组分隔线 / 禁用项的行为未动(同一函数的其它分支没改)。

渲染层断言(`probe/menu.rs`):带 trailing 的项,其 trailing 墨迹右缘 = `menu_right − 17`
(= 4 指示器内缩 + 12 内边距 + `Check` 字形在 24 viewBox 里约 5 的边界)±1。
变异(删掉那行 `space::horizontal()`)后实测 `menu_right=559、墨迹右缘=438 ⇒ 偏 121`,断言红;
修复后偏 **17**。重出 `menu-{light,dark}.png`,勾已贴行右缘成一条竖线。
2026-11-02 07:45:39 +08:00
AskaEth 21f2734981 test(app-iced): M77 渲染层 probe —— 逐行几何断言(5 条,能被变异打红)
M68 的教训:纯函数返回对、图可能是错的。`inputs/probe.rs` 对着**真窗截图**量像素,
没给 `ULL_INPUTS_DIR` 就大声跳过(与 `gallery::probe` 同一口径)。

量什么(每条对应一次真实可能画错的形态):
- 文本框高**精确 56**(320 宽格)、Filled 指示线 静息 1dp / 聚焦 2dp 的墨量比;
- Outlined 描边 1dp / 2dp(描边墨量随亚像素相位在 1.0~1.67 浮动,所以用**比值**判);
- 错误态:框内标签与框下辅助文本都是 `Error` 墨迹;
- 搜索条高 56、前置图标墨迹偏移 20(= pad 12 + offset 4 + viewBox ≈4)、
  docked 面板 宽 360 / 头 56 / 1px `Outline` 分隔 / 结果区 240;
- Slider 竖条 4×44、0% 贴左、50% 分界在正中、100% 无未激活段(thumb 不越界);
- 菜单容器 161(= 8 + 48×3 + 1 + 8)、项高 48、选中指示器内缩 4、分组线 1dp。

像素工具因 `gallery::scan` 是私有模块而自带一份最小读图器(约 60 行),已报 finding。
2026-11-02 07:45:38 +08:00
AskaEth ddd234b3c8 feat(app-iced): M77 输入家族 —— TextField / Search / Slider / Menu(含 gallery 登记)
D39 五家族并行的「输入类」这一条线。四个组件全部对消息类型泛型(`M: Clone`,不碰
`app::Message`),状态一律走 `components::interaction`,精确尺寸走 `components::layout`,
零尺寸字面量(取不到的集中登记 G16~G20 在 `inputs/geometry.rs`)。

- TextField(12b §9):Filled / Outlined;56 高(无标签 40)、4 圆角、内边距 16/16、
  前置图标可视边 12(文本从 48 起)、指示线/描边 静息 1dp → 聚焦与错误 2dp、
  错误态线/标签/辅助文本全 Error、禁用 12%/38%(Filled 容器官方 4%,令牌只有 12% → D10)。
  标签常浮起(无上浮动画,D12);A1 无 Pressed token,press 按 hover(D11)。
- Search(15 §20):SearchBar(收起)+ SearchView(展开 docked)分开实现。bar 消费
  `ComponentMetrics::SEARCH_BAR`,图标可视边 = pad 12 + icon_offset 4 = 16(只做加法,
  不写 16/20),聚焦 1px Secondary;view 头部 56 + 1px **Outline** 分隔 + 结果区 240。
- Slider(12b §11):自绘 `advanced::Widget`——轨道 16 full、竖条 4×44(focus/pressed 收窄到 2)、
  激活 Primary / 未激活 SecondaryContainer、命中 48、离散停靠点 4(未激活轨在点两侧留 6 空隙)。
  位移区间取 handle/2..W−handle/2,所以 0%/100% 竖条不越界。
- Menu / DropdownMenuItem(12b §8):SurfaceContainer + L2 阴影(Elevation 令牌)、项 48、
  水平 12、上下 8、选中 SecondaryContainer(指示器内缩 4、圆角 8)、分组分隔线复用
  `components::divider`。圆角取令牌表 4(12b 说本项目 12 → D18)。
- gallery/inputs.rs:四组件状态矩阵登记(五态 × 两式 + 空/有值/错误、0%/50%/100%、菜单展开态),
  按 `part` 拆成四段(整段比 760 高的窗口还高)。

单文件 ≤400 行:四个组件的单测与 Slider 的纯几何分别拆到 `<name>/tests.rs` 与 `slider/track.rs`。
2026-11-02 07:45:36 +08:00
AskaEth 78667953f5 Merge branch 'feat/p4-iced-components-selection-checkbox-ra' 2026-11-02 07:14:48 +08:00
AskaEth 9519ce8e1b test(selection): 状态层的两种 α 都要查(复审 P2-4 补,第二次变异曾漏网)
原来只查 hover(8%)那一色,于是「把 pressed 列的状态层画成方块」照样绿——实测变异
mut-one-layer-box 当时是 passed。现在 hover(8%) 与 pressed/focus(10%) 两种颜色各查一遍,
每一块的四个角都不许有墨。

变异证据(六个变异全部转红,见报告):
- mut-one-box-22     → 18.3:Primary 块不得超过 21,实测 22×22 @ (541,222)
- mut-one-layer-box  → 18.5:Pressed 状态层必须是圆(四角不该有墨)@ (532,212)
- mut-chip-34        → 13.1:filter chip 应恰好四块,实测 3
2026-11-02 07:03:13 +08:00
AskaEth 4155429f45 test(selection): 像素判据从「存在性」改成「逐处都对」(复审 P2-4)
- 20(±1) 的 Primary 块:**每一块**都不许超过 21(22 = 描边画到框外),且总数恰好 12
- 状态层:**每一块** Ø40 都要四角无墨(原来只看两块角点),数量 ≥5
- switch 轨道 **恰好一块** 52×32、chip **恰好一块** 32 高(原来 any(...) 就算过)
- 新增 P2-1 / P2-3 的像素断言:禁用 chip 4 块完整宽度 + filter·on 仍填充;20×20 禁用块不许是 12%
- 断言与像素扫描搬到 selection/probe.rs + selection/pixel.rs:gallery/selection.rs 只剩「摆矩阵」
  (原文件 458 行,超 400 红线)
2026-11-02 06:55:34 +08:00
AskaEth 0c9d4049b3 fix(selection): Checkbox / Radio 命中区恒 48×48(复审 P2-2)
- 无标签时行宽只有状态层的 40dp(A1 的 minimumInteractiveComponentSize 是 48)
- ControlGeometry::hit_padding() = (48−40)/2 = 4,view 侧左右各留这段透明内边距
- 单测钉住 40 + 2×4 == 48(checkbox 与 radio 各一遍)
2026-11-02 06:55:31 +08:00
AskaEth c5765c57e7 fix(selection): Chip 禁用改成修饰器,只清消息、不删图标与选中态(复审 P2-1)
- 旧 API Chip::disabled(label, variant) 强制 leading/trailing/selected = None/false:实测
  assist 78→54、filter·on 74→50(连填充和勾都没了),禁用列里 filter·on 与 filter 逐像素同形
- 新 API Chip::assist(label, msg).leading(k).disabled():只清 on_press,视觉信息全保留
  (research/12b §13 的禁用态是「容器/描边 12%、文字与图标 38%」——只降不删)
- 矩阵里禁用格改用修饰器,图标与选中态一并保留
2026-11-02 06:55:28 +08:00
AskaEth 00dbb5a5d5 fix(selection): 选中禁用的 Checkbox 容器改用 OnSurface@38%,并补逐格断言(复审 P2-3)
A1:SelectedDisabledContainerColor=OnSurface + SelectedDisabledContainerOpacity=0.38(不是普通禁用容器的
12%)。断言同时钉住三件事:fill == 38% 叠色、fill != 12% 容器色、未选中禁用描边与它是同一个 38%。
(同一提交还带着 P2-2 的命中区单测:两处断言都在 tests.rs,无法按 hunk 切分。)
2026-11-02 06:55:25 +08:00
AskaEth 17394b73ff refactor(app-iced): paint.rs 按组件拆成 paint/ 目录(复审 P2-5)
- paint.rs 原本正好 400 行、零余量:再加一个态就得动它(和 app.rs 同一个问题)
- 拆成 mod.rs(Frame / 状态层 / 禁用色 / 标签色)+ check.rs / radio.rs / switch.rs / chip.rs
- 对外 API 一字不改(selection::paint::* 路径全部保持)
- 顺带把「选中且禁用的 Checkbox 容器」从 12% 改成 A1 的 38%(复审 P2-3):这一行就在被移动的
  check.rs 里,单独切 hunk 只会让历史不完整;断言在下一条提交
2026-11-02 06:52:10 +08:00
AskaEth b69a2cdd7f feat(app-iced): M76 选择类家族 —— Checkbox / Radio / Switch / Chip(15 §18/§19 + 12b §10/§13)
- selection/:geometry(令牌→像素,缺档登记 G16)/ paint(Interaction×选中×错误→角色)/
  shell(48 透明外壳 + 帧内状态通道 + 勾形 SVG)/ checkbox·radio·switch·chip / matrix(12×5 状态矩阵)/ tests
- gallery/selection.rs:小节 + **渲染层像素断言**(20×20 选中与未选中同尺寸 · Radio 内点 10 ·
  Ø40 状态层是圆 · 勾形 14.8 · 短横 · 轨道 52×32 · Chip 32 · 禁用 38%),按颜色+墨迹块定位、不假设行号
- gallery/mod.rs:新增 ULL_GALLERY_SECTION 段落过滤(tower 批准,M77 零成本复用)+ 四个 helper 放宽到 pub(crate)

A1 证据(本机 AAR javap):未选中 hover/focus/pressed 描边是 OnSurface(不是 OnSurfaceVariant)、
Radio 选中态 hover 仍 Primary、Checkbox SelectedDisabledIconColor=Surface、Switch hover 滑块 PrimaryContainer。
2026-11-02 06:11:21 +08:00
AskaEth 35345f3399 Merge branch 'feat/p4-iced-component-base-buttons-gallery-h' 2026-11-02 04:51:10 +08:00
AskaEth 4d7b198822 fix(app-iced): M75 r6 —— probe 删 19 行重复块 + 补第 8 行(icon·tonal) + 三处注释按 visual-r4 P2-2 更正
① 19 行重复块(第 4 次报):probe.rs 218-236 与 198-216 逐字重复(on_primary/left_ink 双层循环/
   icon_pad/assert 各两遍,靠 shadowing 编过),已删;粘在一起的注释头随之消失。
   滑动窗口脚本:改前 'DUP at 199 and 219' → 改后 'no 19-line duplicate'。
② 逐行护栏补到 8 行:(7, OnSurface, true, "icon·tonal");汇总打印 '8 行通过'。
   变异(该行墨迹涂回满档色)→ probe 红。
③ 三处过期注释按 review 的 §P2-2 逐条改:
   - probe 模块文档 药丸顶行宽 ≈9 → 左内缩 ≈14;
   - 圆角推导改成像素中心闭式 inset(k)=r−√(r²−(r−k−0.5)²),r=20,k=1 给 12.40;
   - gallery/mod.rs 列宽注释 132 → 160(cell_width 的实际值)。
2026-11-02 04:51:07 +08:00
AskaEth 4954ba3463 chore(app-iced): M75 r5 —— 删掉被 count_in 取代的 count_pixels(clippy dead_code) 2026-11-02 04:51:06 +08:00
AskaEth b563c6ac44 docs(app-iced): M75 r5 —— 三处过期注释按 A1 更正(会被四条家族线当规格抄)
- buttons/mod.rs 表行 1.8:禁用内容角色「按变体」(Variant::disabled_content),容器 α 注明 A1 是 10%
- buttons/mod.rs 表行 2.7:IconButton 禁用内容 = OnSurface@38%(A1),落点改到 disabled_content_over_role
- interaction α 表 / disabled_colors 文档:容器 0.12 与 A1 的 0.10f 差异点名 D8;内容默认为 OnSurfaceVariant
- overlay_color 的 content 说明从 OnSurface 改成 OnSurfaceVariant(与 A1 一致)
2026-11-02 04:51:04 +08:00
AskaEth eed1c9d3f7 fix(app-iced): M75 r5 —— 禁用内容角色按变体(A1)+ 删 probe 重复块 + 逐行护栏
1) A1 逐变体(AAR javap):Filled/Elevated/Outlined/Text = OnSurfaceVariant@0.38;
   FilledTonal = OnSurface@0.38(FilledTonalButtonTokens.DisabledLabelTextColor);
   Standard/Filled IconButton = OnSurface@0.38(*IconButtonTokens.DisabledColor)。
   新增 Variant::disabled_content() / IconVariant::disabled_content(),
   interaction::disabled_content_over_role(ui, role, on_container) 成唯一入口。
2) probe.rs 删掉被两个 reviewer 点名的重复块(disabled_colors 调用两次、
   disabled_container 绑定两次),并补 grep 证据。
3) 护栏改成**逐行**:矩阵 7 个有内容的变体行各自断言 disabled 列的 38% 墨迹 > 40 px
   (浅/深同一套阈值,单行回退就会红),取代原来按浅色标定的全局 500 阈值。
6) 删掉 interaction/mod.rs 里挂在 mod tests 头上的悬挂文档。
2026-11-02 04:51:03 +08:00
AskaEth b31a758cda test(app-iced): M75 r4b —— 无容器禁用墨迹的护栏收窄到 disabled 列 + 阈值 500(变异实测会红)
整图计数会把别处抗锯齿像素算进来(正常 800/1966 px,涂回满档色后仍剩 348 px),
护栏实际上拦不住 code-r2 说的「text/standard 那两行」。改为只在矩阵最右 1/3 列内计数,
阈值 500(浅 800 / 深 1966 正常,变异后 348)——变异实测:红。
2026-11-02 04:51:02 +08:00
AskaEth a878fc6a58 refactor(app-iced): M75 r4 —— interaction 拆成 mod.rs(287) + tracker.rs(159),恢复被压掉的文档
interaction.rs 连压三笔才回到 400 行(靠删文档腾空间),而它会被四条家族线各扩一次。
- interaction/mod.rs:五态枚举 + overlay_color / disabled_colors / disabled_content_over(态→颜色)
- interaction/tracker.rs:事件→态的状态机 + 它的两条单测;Tracker 用法示例(ignore 代码块)恢复
- 对外路径不变:components::interaction::* 照旧(mod.rs 里再导出 Tracker)
- 两份文件都写了「加东西前先看行数余额」
顺带修两处门禁:注释里去掉 hex 色值(守门连注释一起扫)、再导出显式 allow。
2026-11-02 04:51:01 +08:00
AskaEth ab5f5802a8 test(app-iced): M75 r3f —— 去掉一条与上一行重复的断言,interaction.rs 400 行 2026-11-02 04:50:59 +08:00
AskaEth 95a6f56060 docs(app-iced): M75 r3d —— interaction.rs 再收 2 行,确保 ≤400 2026-11-02 04:50:58 +08:00
AskaEth a3b3c9eba7 docs(app-iced): M75 r3c —— interaction.rs 文档收口(Tracker 用法/α 表压行,回到 400 行内) 2026-11-02 04:50:57 +08:00
AskaEth 86f7a31d9a fix(app-iced): M75 r3b —— IconButton 高度断言按抗锯齿实测改为 39..=40 + interaction.rs 收回到 400 行内
exact-color 的连通块高度会排除底部抗锯齿行,实测 40×39;宽度仍精确 40。
区间收到 39..=40 仍能抓住 ±2px 回归(42→41、38→37 都落区间外)。
2026-11-02 04:50:55 +08:00
AskaEth 5fd1f3ca26 fix(app-iced): M75 r3 —— 禁用内容角色按 A1 改 OnSurfaceVariant + 两项语汇 + probe 双色护栏
A1 定案(本机 gradle 缓存 material3.aar + javap):
  FilledButtonTokens.<clinit>: DisabledContainerColor=OnSurface, DisabledContainerOpacity=0.1f
    DisabledIconColor/DisabledLabelTextColor=OnSurfaceVariant, 两者 Opacity=0.38f
  OutlinedButtonTokens/TextButtonTokens: LabelTextColor=OnSurfaceVariant(12b §1.9 原本就对)

据此:
- interaction::disabled_content_over / disabled_colors 的内容角色从 OnSurface 改成
  OnSurfaceVariant(α 仍取令牌 DISABLED_CONTENT_ALPHA=0.38);设计层 color::state::disabled_content()
  用的是 OnSurface,与 A1 不符 —— 令牌线修好前这里按 A1 直接求值,已登记。
- content_color 的「底」按有无容器分开:有容器叠 12% 容器、无容器叠 surface(视觉 r2 P2-1)。
- probe 加第二条护栏:无容器三式的 38% 墨迹单独计数,避免只护住有容器那四式(code r2 p2-1)。
- gallery 样例图标改描边(Download + ChevronRight),并加单测钉「同控件不混用 Fill/Stroke」(P2-3)。
- 滚动条:透明轨 + OutlineVariant thumb(P2-4;宽度 10px 是 iced 内部常量,只能改颜色)。
- probe 文档修好被 //! 打断的命令、shot_path 注释归位、去掉空转的重复断言(P2-5)。
2026-11-02 04:50:54 +08:00
AskaEth d075feb77d test(app-iced): M75 复审 P1-B —— 渲层断言收紧到 2px 级(变异实测会红)
- 高度断言改 assert_eq!(true_height, 40):变异成 42 实测变红(证据 target/m75/evidence.sh)
- 补「带图标左内边距 16」断言:墨迹 ≈21(16→21 / 24→29 / 8→13 三档互不重叠)
- 分隔线:行数 2..=4 + 单条墨量覆盖率 0.8..=1.3(1.5dp 会到 ~1.5),
  只统计「连续非底色 > 900」的长横线行,避免把文字墨量算进去
- 两条判据都踩过误报:白色与浅色页面底色在容差内(padding 量出 3)、
  圆角透出的底色被当成满档 OnPrimary;已改成「内带 + nearer 判定」并注明原因
2026-11-02 04:50:52 +08:00
AskaEth e905fa3922 fix(app-iced): M75 复审 P1-A/P1-B/P2 —— 内容色跟态走 + 断言收紧 + 矩阵排版
P1-A 禁用内容色没下发(视觉 reviewer:text/standard 的 enabled 与 disabled 逐像素相同):
  assemble 里内容色恒取 ui.color(content) 再被 text().color() 钉死,Style.text_color 的 38%
  永远下发不出去。新增 assemble::content_color(ui, forced, content),文字按钮与 IconButton
  共用;probe 补两条像素断言(禁用 38% 墨迹必须存在、禁用容器内不得有满档 OnPrimary)。

P1-B 渲染层断言抓不住 2px 级回归(reviewer 变异验证:高度 42 仍绿):
  高度改成精确 == 40;补「带图标左内边距 16(墨迹 ≈21)」断言;分隔线从「行数」改成
  「墨量覆盖率 ≈1.0」(1.5dp 会到 ≈1.5)。

P2-1 probe 给了图却读不到时硬失败(只有完全没给 ULL_GALLERY_SHOT 才跳过)。
P2-2 Geometry::stroke 真正接到 Border.width;IconGeometry::icon 改由 side−2×padding 推导。
P2-3 矩阵 cell 宽 192→160(总宽 1040<1180,disabled 列不再被裁);禁用格保留前后图标。
P2-4 补注:snap 仅 wgpu 实现,CPU 验收图不受它影响。
2026-11-02 04:50:51 +08:00
AskaEth 8072f1cc7e test(app-iced): M75 —— probe 分隔线判据改为「非底色长横线」+ 修 forced 改成 Option 后的两处测试
线在 fractional y 上被抗锯齿摊到两行,按精确 OutlineVariant 色匹配抓不到(实测 0 行);
改为「非 Surface 的长横线」计数,断言 2..=6 行(两条线 × 各 1~2 行)。
2026-11-02 04:50:49 +08:00
AskaEth 5f85891e7e fix(app-iced): M75 —— 截图取证必须能强制「静息」态(光标污染修掉)
StyleSpec.forced 从 Interaction 改成 Option<Interaction>:Some(Enabled) 与 None 语义不同——
前者强制画静息,后者才跟随 button::Status。修前光标恰好停在某个格子上时,gallery 的
enabled 列会被画成 hover(表现为 text 行出现不该有的灰底容器,看着像 Text 按钮自带容器)。
2026-11-02 04:50:47 +08:00
AskaEth 5861881073 refactor(app-iced): M75 —— probe 拆出 scan 工具 + clippy 全绿 + 三个字面/生命周期修正
- gallery/{probe,scan}.rs 拆分:像素扫描(连通块/外接框/顶行宽度)与判据分离,
  后续四条家族线可以自带 probe 复用扫描逻辑
- probe 的实心块判据跳过字形割出的小碎片(area < 800),阈值写明原因
- app.rs / gallery::mod 的 clippy 修正(needless_lifetime / field_reassign_with_default / collapsible_if)
- cargo test -p unlucky-app-iced 与 cargo clippy --all-targets -D warnings 已绿(见报告)
2026-11-02 04:50:46 +08:00
AskaEth 4774e7b788 WIP(app-iced): M75 组件基座 checkpoint —— 组件骨架/按钮五式/分隔线/gallery harness
已落地:
- components/{mod,interaction,layout,divider,gate}.rs + buttons/{mod,geometry,assemble,tests}.rs
- gallery/{mod,buttons,probe,scan}.rs + 四个家族桩(selection/inputs/feedback/navigation)
- cli.rs: --screen=shell|gallery + capture_flavor(截图默认 Light,删掉等系统主题的闸门)
- app.rs: gallery 路由 + DemoMessage 接线(400 行)

待办(下一个 commit):编译/测试全绿、浅深矩阵出图、1:1 并排与数值表。
2026-11-02 04:50:44 +08:00
AskaEth 22ddc74784 Merge branch 'feat/p4-iced-page-frame-layout-system-breakpo' 2026-11-02 04:44:53 +08:00
AskaEth 831bd00620 docs(app-iced): SIM 常量口径精确化(字符最多行 402 / 全段最宽行 411)
视觉 r3 的不计入裁决的精度提醒:`SIM_SAFE_MAX_INK = 402` 其实是**字符最多**那一行的 ink,
全段**墨迹最宽**的行是 411px(渲染 410),所以「余量 22px」实际是 13px。

- 拆成两个常量并分别断言:`SIM_SAFE_MAX_CHARS_INK = 402`(67 字符那行)、
  `SIM_SAFE_WIDEST_INK = 411`(墨迹最宽那行,新加断言 `cap >= 411`,并在失败信息里打印余量);
- 文档写明「两者不是同一行:402 那行字符多但字形窄」。

证据:`cargo test -p unlucky-app-iced --no-default-features`
→ `test result: ok. 86 passed; 0 failed` / `test result: ok. 6 passed; 0 failed`。
2026-11-02 04:10:05 +08:00
AskaEth 589f51f4fb docs(app-iced): 订正测试注释(1180 上限是 424,不是 435)
r3 复审:`layout/tests.rs` 的注释说「现在必须是 435」,同一函数的断言却是 424。

证据:`grep -n 435 crates/app-iced/src/pages/layout/tests.rs`
- 第 229 行(旧):`// 1180 —— M68 就是在这条上翻的车(712px),现在必须是 435` → 改成 **424**;
- 剩下两处 435 都是**平均口径上界**的正确引用,不是残留:
  第 176 行注释「不是『平均步进 × 72』的算术值 435」、
  第 180 行 `assert_eq!(measure_average_bound_px(), 435.0)`。
2026-11-02 03:52:28 +08:00
AskaEth 87d5dd8f67 docs(app-iced): 订正 meta_grid 注释(1180 与 900 都是 2 列)
r3 复审:`meta_grid` 的文档还写着「1180 → 3 列,900 → 2 列」,但本轮起两者都是 2 列。

证据:`grep -n "3 列" crates/app-iced/src/pages/instances.rs`
- 旧:`/// 元信息网格:列数由 [...] 决定(1180 → 3 列,900 → 2 列)。`
- 新:写明「1180 与 900 都是 2 列;3 列需要 3×160 + 2×12 = 504px > 行宽上限 424,数学上不可行;
  硬塞回 3 列会让 NeoForge 20.4.237(118px)折行、同排卡底不齐」。
- 现在文件里唯一出现「3 列」的地方就是这条「不可行」的说明(第 275/276 行)。
2026-11-02 03:52:24 +08:00
AskaEth 0de8e63ad6 fix(app-iced): 必修1 跟进 —— 旧断言 435→424 + clippy 常量断言
必修1 改完 cap 之后跑门禁抓到的两处自身的漏改:

1. `settings_pane_keeps_the_drawer_width_and_a_readable_form_column` 里还留着
   `assert_eq!(measure_cap_px(), 435.0)`(P2-1 轮加的锚点),cap 收到 424 后它必红。
   已改 424 —— 这正是「锚点断言」该有的行为:常量一改就红,而不是悄悄跟着漂。
2. clippy `assertions_on_constants`:`assert!(SIM_SAFE_MAX_CHARS <= MAX_MEASURE_CHARS)`
   两边都是 const,改成 `const { assert!(..) }`。

门禁(本机私有 target,CPU 档):86 lib passed + 6 集成 passed;clippy -D warnings 干净。
2026-11-02 02:42:58 +08:00
AskaEth c5c543ba43 docs(app-iced): 必修3 504→435 一轮留下的三处文档漂移
视觉 r2 逐条点出来的三处「偏离登记与对外口径没跟着改」:

1. `pages/mod.rs` 偏离 #1:详情列右缘写 976 / 分歧 180px —— 行宽收到 424 后实测是
   页头右缘 **1156**、详情列右缘 **896**(472 + 424),分歧 **260px**;
2. `pages/mod.rs` 偏离 #3:单列页占比写 48%(435/1044)—— 现在是 **40.6%(424/1044)**;
3. `frame/pieces.rs` 的 `scroll_style` 函数头仍写「滑块 `Outline`、尺寸取 iced 默认」——
   实现早已是 `OutlineVariant` + 6px 轨 + 2px 内缩,文档跟上。

三处都在 D30 要求「逐条写清」的偏离登记/口径里,属于对外承诺,不是内部注释。
2026-11-02 02:04:32 +08:00
AskaEth ddf6937b2b fix(app-iced): 必修1 「72 字符」改成逐行口径,上限 435 → 424
代码 r2 的复核:`435 / 6.0526 = 71.9` 是**整段平均步进**换来的数,不是每行都 ≤72;
它实测交付图里就有 **73 字符**行(`guess. Nothing on this page is wired to real data yet: the layout is, the`,
ink 428px)。上一轮是系数错,这一轮是**把平均当成了逐行上界**。

选 **(a)**:不变式改成逐行可验证,cap 跟着压到能满足它的地方。依据是 FreeType 量宽 + 贪心词折行的模拟表
(同一段 361 字符正文扫列宽):

| 列宽 | 最长一行 | 该行 ink | 判定 |
|---|---|---|---|
| 435(= 平均口径上界) | 73 字符 | 428px | ✗ |
| 428 | 73 字符 | 428px | ✗ |
| 426 | 71 字符 | 426px | ✓ 只剩 1 字符余量 |
| **424**(4px 栅格) | **67 字符** | **402px** | ✓ 取这个 |

- `measure_cap_px()` = `min(逐行安全宽 424, 平均口径上界 435)` = **424**;平均口径另立
  `measure_average_bound_px()`(435)并在文档里标明「只是上界,不是取值」。
- 单测 `line_length_is_capped_at_72_characters` 改成**两侧夹逼**:`402 ≤ cap < 428` ——
  放大到 428 会重新出现 73 字符行(红),压到 402 以下白丢宽度(也红);另断言
  `cap < measure_average_bound_px()`,防止有人把平均口径当上限。
- §3 新增「逐行口径」小节:模拟表 + **作用域**(承诺只覆盖 14px 正文;12px 辅助文案是 1~2 行短句,
  不做逐行承诺;CJK 全角 1em 另行定)。
- 四页文档与 `pages/mod.rs` 的 ≤435 同步改 424。
2026-11-02 02:04:23 +08:00
AskaEth a650f8184b fix(app-iced): 必修2 元信息卡最小宽改由内容推(136 → 160),1180 下网格 2 列
视觉 r2 抓到的回归:上一轮为塞进三列把 `META_CARD_MIN_WIDTH` 收到 136,只算外框、忘了
`card_padding = 20`,卡内只剩 96px —— demo 里 `NeoForge 20.4.237`(实测 118px)与
`NeoForge 21.1.72`(110px)都会按词折成两行,同排另两张卡仍一行 → **卡底不齐**。

- 最小宽改由**卡内最长内容**反推:118(Segoe UI 14px 实测)+ 2×20(令牌 `card_padding`)= 158
  → 取 4px 栅格 **160**(值上等于最初的 160,但这次是内容推出来的,不是凑上限凑出来的)。
- 于是元信息网格在 1180 宽落到 **2 列**(2×160 + 12 = 332;3 列要 504 > 行宽上限),
  卡内可排 **166px** ≥ 118,所有值单行放下;6 格 = 3 行 2 列。
- 新增单测 `meta_card_is_wide_enough_for_the_longest_value`(此前 39 条 pages 测试没有一条覆盖它):
  ① 夹具 `NeoForge 20.4.237` 必须仍在 DEMO 里(数据变了就红,提醒重新量);
  ② `META_CARD_MIN_WIDTH − 2×card_padding ≥ 118`;③ 实际两列布局下每卡内宽也 ≥ 118。
- `meta_grid_drops_columns_as_the_detail_narrows` 跟着改成「上限处 2 列」(1180 与 900 同为 2 列,
  差别落在详情列宽 435 vs 404)。
2026-11-02 02:04:04 +08:00
AskaEth dc67be529d docs(app-iced): P2-2 r2 712 是 ink 不是列宽 + 补偏离登记
代码 reviewer 把 P2-2 说得更准,我上一轮只改了数字没改算式,这里按它的复核改到底:

- **712 是那一行的墨迹宽度,不是列宽**。早期文档写「1180 − 88 − 2×24 = 712」,这个减法得 **1044**;
  M68 的卡片是 x=112..1156(1044px),卡片内文本区 **1004px**,那一行 ink 才是 712px(x=133..845)。
- 交叉验算对上了:137 字符 × 5.19px(12px × 实测 0.4323em)= **711px** ≈ ink 712。
  所以事故是「1004px 的文本区里,一行排了 137 字符」,不是「列宽 712」。
- 订正记录改成两条(① 712 是 ink ② 字符数按实测),并写明早期口径的错法。

顺带把视觉 reviewer 的「请登记」落地:`pages/mod.rs` 新增**偏离登记表** 6 条
(页头右缘与详情右缘 180px 分歧、空态标题比页标题大、单列页只用 48% 内容宽、空态字形 40、
返回入口无箭头、实例空详情态未出图),每条写了「为什么」与「什么时候收口」。
2026-11-02 01:05:27 +08:00
AskaEth c495fccd6c fix(app-iced): 视觉 P2-2/3 滚动条变细变淡 + 图标语汇统一成描边
视觉复审两条:

- **P2-2 滚动条偏重**:原来直接用 iced 默认 10px 轨 + `Role::Outline`(那份灰绿在浅色下压过列表内容)。
  改 **6px 轨 + 2px 外边距 + `Role::OutlineVariant` 滑块**(比 `Outline` 弱一档的容器描边角色),
  圆角由 full 按 6px 折算成 3。功能不变(仍是列表列独立滚动、仍只有这一条 scrollable)。
- **P2-3 图标线宽语汇不一致**:设置页「Java」分组用的是令牌里的 `Play`——它是**填充**字形,
  同屏其它图标都是描边。改成描边的 `IconKind::Folder`(Java 分组讲的是运行时安装位置,语义也对),
  并在 `pieces.rs` 写明规矩:页面骨架**只用描边图标**,将来若要「主操作允许实心图标」,
  由 `components/` 统一定义并只留一处。守门见上一条 commit 的 `pages_use_only_stroked_icons`。
2026-11-02 00:39:58 +08:00
AskaEth a0ccf79b2b test(app-iced): P2-3 尺寸硬编码守门扩到 pages/(并补图标语汇守门)
代码 reviewer 指出:`no_hardcoded_tokens` 的**尺寸**守门只扫 `src/shell`,`pages/` 是新增目录、
**根本没被扫到**,页面里写 `.width(24)` 没人会发现(颜色守门扫全 `src/**`,所以颜色那条是覆盖的)。

- `shell_takes_every_size_from_tokens` → `layout_sources_take_every_size_from_tokens`:
  扫描面改成 `src/shell` + `src/pages`,并把「至少 2 个文件」的哨兵断言提到 **≥10 个**(防扫描逻辑坏掉)。
- 实测结果:**扩了之后 `pages/` 是干净的(0 处)**——所有尺寸都走 `layout::*` 常量 / `LayoutMetrics` /
  `Space` / `TypeStyle`,没有需要修的违规。
- 顺带补 `pages_use_only_stroked_icons`:`pages/` 里不许出现令牌层的填充字形(`Play`/`Stop`/`More`),
  这是视觉 P2-3「图标语汇不一致」的机器化守门(修法见下一条 commit)。
- 文件顶到 403 行 → 压到 399(仓库硬限 400)。
2026-11-02 00:39:48 +08:00
AskaEth 367100848a docs(app-iced): P2-2 M68 事故口径订正(实测 137 字符,不再引用估算值)
代码 reviewer 指出:712px ≈ 「118 字符」的那个数本身不是实测,是拿 0.5em 估的步进换算的,
和 P2-1 是同一个根因(高估了步进)。按实测重写事故描述:

- M68 那张卡里的正文是 `BodySmall` **12px**,步进 = 14px 实测值 6.0526 × 12/14 = **5.19px/字符**,
  所以 712px **实测 137 字符**(不是 118);
- 补一条**订正记录**说明早期数错在哪:既高估了步进、也对错了字号,两条叠加把事故说轻了 16%;
- `pages/mod.rs` 的历史判词同步改成 712px + 137 字符;
- 全 `pages/` 已无「118 字符」这个数(`grep` 为空)。
2026-11-02 00:13:42 +08:00
AskaEth fae1b61d9e fix(app-iced): P2-1 「72 字符」改成实测口径(上限 504 → 435)
两位 reviewer 都量到:`CHAR_ADVANCE_EM = 0.5` 是估的,14 × 0.5 × 72 = 504px 实际能放 83 字符,
「72 字符」只活在名字里。改成实测:

- 口径:`BodyMedium` 14px / Segoe UI Regular / 无 letter-spacing(iced 0.14 不支持字距),
  样本 = 实例页详情那段正文(361 字符,大小写/标点/空格/`→` 混排),FreeType getlength
  实测 **2185.00px / 361 = 6.0526 px/字符 = 0.43233em**(旧 0.5 高估 15.7%);
  交叉验证:同段首行 78 字符渲染墨迹 484px,FreeType 对同一串 479px,差 1%(抗锯齿外扩)。
- `CHAR_ADVANCE_EM` 0.5 → **0.43233**;`measure_cap_px()` 加 **floor** → **435**
  (435.79 四舍五入成 436 就会让最后一两个字符挤不进 72 字符的承诺);下限 315 → 272。
- 版面连带重算:`META_CARD_MIN_WIDTH` 160 → **136**(3×136 + 2×12 = 432 ≤ 435,否则 1180 宽下
  元信息网格会从 3 列掉到 2 列)。**816 / 720 两个两列阈值不受影响**(它们由 LIST/DETAIL/FORM 宽度算,
  与行宽上限无关),单测里的绝对值断言原样成立。
- 单测:`line_length_is_capped_at_72_characters` 里新增 tower 要求的断言 —— 用**实测步进**
  (样本字符数 + 实测宽度,独立于常量)反算「上限列宽能放多少字符」≤ 72(实际 71.87),
  并加一条回归护栏:同一实测步进下旧口径 504px = 83.3 字符,改回 0.5em 立刻红。
- 文档:`layout.rs` §3 补「怎么重新量」(字体、样本、工具、交叉验证、CJK 不适用)与全部数字;
  `mod.rs` / 四页文档里的 ≤504 同步改 435。
2026-11-02 00:13:20 +08:00
AskaEth 2c442585b1 feat(app-iced): M84 页面骨架与布局系统(断点 / 行宽上限 / 三种版面)
视觉复审在 M68 的判词是不能把「全宽单卡」当页面模板(1180 宽下正文单行 712px ≈ 118 字符)。
本 mission 把它落成一套**布局系统**,四个页面只是它的用例:

- `pages/layout.rs`:官方 600 / 840 两个档位 → 三种版面(单列 / supporting-pane / list-detail),
  阈值由列宽和算出(设置 240+24+320 → 720;实例 336+24+320 → 816 ≤ 840),行宽硬上限
  14 × 0.5em × 72 = **504px**,全部是纯函数,`pages/layout/tests.rs` 用绝对值和边界表钉死。
- `pages/frame.rs`:页头(56 / 两行 84)+ 内容列(≤504)+ 两列骨架 + 空态位;
  `pages/frame/pieces.rs` 是占位控件(卡片 / 分隔线 / 文本按钮 / 图标),令牌驱动、无完整状态机,
  待 M75 的 components 家族合入后替换。
- 四页:实例(list-detail,列表列 336 独立滚动,窄窗退化成整页切换)、
  设置(supporting-pane,240 分组导航 + ≤504 表单列)、下载 / 日志(单列,两行页头)。
- `pages/harness.rs` + `examples/pages_demo.rs`:自带 `--screen/--width/--screenshot-theme`,
  不碰 `cli.rs` / `app.rs`(M75 在改)。

偏离与说明:
- 布局用「页边距 16/24」而非 16/24/32,官方 expanded 也是 24(少一条偏离登记);
- 实例空详情态一屏会有 5 档字号(22/16/24/12 + 14 的操作按钮),未截图的状态,已知并登记;
- `lib.rs` 只加一行 `pub mod pages;`。
2026-11-01 23:27:28 +08:00
AskaEth 352dd7937e Merge branch 'feat/p4-core-platform-wallpaper-source-detect' 2026-11-01 21:18:02 +08:00
AskaEth 69d0025d09 fix(core): M73 r1 —— Windows 壁纸来源改为「当前主题优先」,通配主题包降为末档
复审 p2-1(medium):Win10+ 上 `%APPDATA%\Microsoft\Windows\Themes\` 通常只有
`TranscodedWallpaper`,一个 `.theme` 都没有;当前生效的主题记在
`HKCU\Software\Microsoft\Windows\CurrentVersion\Themes` 的 `CurrentTheme` 值里,
指向 `%LOCALAPPDATA%\Microsoft\Windows\Themes\Custom.theme`。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- 在 git ls-files 的 pathspec 末尾加 '*.py' '*.sh',语义不变(>400 行即硬失败)
- 保留 LICENSE*/CHANGELOG* 排除;Cargo.lock 等机器生成文件刻意不入列
- 阈值、失败方式(::error + exit 1)均未改动
2026-11-01 16:22:51 +08:00
AskaEth 0aac307d92 Merge branch 'feat/p3-ci-headless-render-smoke-xvfb-xplatci' 2026-11-01 16:15:49 +08:00
AskaEth 7e08e0ccc9 fix(docs): M62 r1 —— 修正「缺字体→空图+exit 0」的错误论据,拆出运行时冒烟细节文档
review r1(p2-1items/fix-then-merge)只要求改文档与注释,门禁逻辑一行未动。

- P2-1(唯一阻塞项):原稿称「字体库缺失 → 合法 PNG + 退出码 0」,这是错的。review 实测两条失效都是
  **硬失败**:完全没有字体 → fontique panic(退出码 101,不产生 PNG);非软渲染(默认 femtovg)→
  `take_snapshot is not supported by this FemtoVG backend`(退出码 1)。改正口径:`--screenshot` 的 PNG
  是进程自己编码写出的,只要走到 `take_snapshot` 就是「合法 PNG + 退出码 0」,像素断言是**纵深防御**,
  守的是「进程活着、PNG 合法、但场景几乎没画」这一档;其余情形本机暂无样本,属防御性设计、不宣称已复现。
  落点:packaging/ci-runtime-smoke.md §1 与 packaging/check_screenshot.py 模块 docstring。
- P3-1:ci.yml 的 runtime-smoke job 头补一行 —— 本 job 绑死 Slint `Window::take_snapshot`,迁 iced 后必须
  提供等价离屏截图通道,否则静默退化成「能起进程」。
- P3-2:阈值余量写明量级(342/64=5.3×、19.45/3.0=6.5×、0.00761/0.001=7.6×,改「5~8 倍」);并写明
  闸门下限 ≈「至少画出一条标题栏」(review 实测切片 174/35.10/0.02623 与右下 400×300 切片仍 pass),
  即只证明「场景被真实渲染过」,不证明元素齐全、更不证明视觉正确。
- P3-3:README 398/400 已贴红线 —— 借 review 给的逃生口**提前拆**:细节搬进 packaging/ci-runtime-smoke.md
  (78 行),README §11 收缩为 12 行索引(374 行),并写明「再增长继续往细节文档写,两文件都 ≤400」。
- P3-4:本地复跑示例改 Windows 安全路径 `D:/tmp/ull-smoke`;脚本对 repo_root/out_dir 统一 `cygpath -m`
  归一并回显真实路径(Git Bash 下 `/tmp` 会静默落进 Docker VM,宿主什么都收不到 —— review 实测踩到)。
- P3-5:两个 `run:` 里的 `set -o pipefail` 保留但加注释说明「当前无管道,为将来加管道先有失败传播」;
  `cargo clean -p unlucky-app || true` 加内联注释(N7 兜底,CI 里没有对应行,别误抄进 ci.yml)。

验证:`--selftest` 三组仍全部 fail(判红能力未被改坏);真实截图仍 pass(1180x760 / 342 / 19.45 / 0.00761);
0 字节与非 PNG 路径 exit 1;`yaml.safe_load` 通过(6 job,Linux 槽位无 continue-on-error,artifact 步
`if: always()`);`cargo fmt --all --check` 退出 0;cygpath 修复后重跑 Docker 冒烟,宿主机 D:/tmp/ull-m62-rev2
拿到 PNG,md5 `156c382a…` 与前两次 run 及 review 的 artifact 完全一致。
2026-11-01 16:15:47 +08:00
AskaEth ea5f66cf92 feat(ci): M62 —— Linux runtime-smoke(xvfb 真渲染一帧)+ 像素非空白门禁
「编得过」不等于「跑得起来」:CI 里 Linux/macOS 此前只 build + test,从未创建过真实图形窗口。
本 commit 补第一份 Linux 真实图形环境运行证据,并把它做成不可伪造的门禁。

- ci.yml 新增 runtime-smoke(ubuntu-latest):装 xvfb + 软渲染/字体依赖,用 xvfb-run 起 GUI 进程,
  走既有 `--software-renderer --screenshot` 通道渲出一帧 PNG,再断言 ①退出码 0 ②PNG 宽高 > 0
  ③非空白(唯一色数 / 亮度σ / 强边缘占比三项过阈值),并把截图作为 artifact `runtime-smoke-screenshot` 上传。
  既有 fmt/clippy/test/build 四个 job 与触发器语义零改动(纯追加)。
- ci.yml 新增 runtime-smoke-macos 探针:只在 workflow_dispatch 触发且 continue-on-error。官方 macOS
  runner 处于 LaunchDaemon 上下文、无 WindowServer 会话,大概率建不了窗口;本机无 macOS 且 worker 禁
  push,这条路径尚无实测证据,故不拿它卡 PR 门禁(首跑结论决定升常驻或按 D26 归入 iced 重写)。
- packaging/check_screenshot.py:零第三方依赖的 PNG 解码(zlib + 反过滤 0~4)与三项指标判定,
  `--selftest` 打印阈值依据(人为纯色帧 1/0.0/0.0 必红,真实帧 342/19.45/0.00761 全过线)。
- packaging/linux-smoke-docker.sh:在 Docker 里复刻同一 job(TUNA apt/rustup 源),便于无 Linux 桌面的本地复跑。
- packaging/README.md §11:覆盖平台、阈值依据数值、本地复跑、已知跳过项、迁 iced 后需重新验证。

本机实测(Debian 12 容器 + xvfb + 软渲染):退出码 0,PNG 1180x760,唯一色 342 / σ 19.45 / 强边缘 0.00761。
CI(Ubuntu 24.04 runner)首跑仍未验证——本 job 证明「能出图」,不证明视觉正确。
2026-11-01 16:15:47 +08:00
AskaEth a681ccb526 Merge branch 'feat/p4-design-system-tokens-typography-icons' 2026-11-01 16:06:40 +08:00
AskaEth 83e19e58f0 fix(design): M61 r1 收口 —— 官方 emphasized/弹簧/高程 + 冻结测试
reviewer r1 判 p2-2items(fix-then-merge),本轮按条目收口:

P2-1 冻结测试补全(变异实测证明原来可静默漂移):
- tests/type_scale_pins.rs:baseline 15 档 (size,line_height,weight,tracking,family)
  与 Expressive 强调 15 档逐值对拍,期望值独立于实现重写
- tests/token_pins.rs:49 条 tone(light+dark 全表)、7 条 easing 曲线参数、
  16 档时长、9 组弹簧、28 个 IconKind 下标、形状阶梯
- 自证:TitleMedium 16→17 与 EmphasizedDecelerate 换曲线两次变异均 FAILED

P2-2 emphasized 口径事实错误(用户点名):
- 取回 A 级官方值(androidx TypeScaleTokens v0_103 的 *Emphasized* 四元组):
  label-*/title-medium/title-small 强调是 700(此前给 600)、body-large 0.5→0.15、
  title-medium 0.2→0.15、body-medium 0.2→0.25、display-large -0.2→0
- TypeRole::emphasized() 改为官方表(typography/emphasized.rs);
  原「只加一档字重」规则改名 emphasized_simplified() 并标注非官方
- 顺带按官方订正 baseline tracking(display-large -0.2、body-medium 0.2、
  title-medium 0.2)与 TitleLarge 的 Brand 族归属
- crate 文档新增「baseline MD3 vs M3 Expressive 差异清单」+ 未支持能力 gap

弹簧改为两套 token 结构(用户追加令):
- motion::spring(scheme × category × speed) 二维表 12 格:
  standard spatial ζ=0.9 (k 1400/700/300)、expressive spatial ζ=0.6~0.8 (k 800/380/200)、
  effects 两套同值 ζ=1.0 (k 3800/1600/800),均取 A 级 MotionTokens 原值
- Spring 改存阻尼比(与 Compose 令牌同口径),9 个具名常量

中文字重覆盖(用户追加令):
- FontFace 带 WeightCoverage(可变区间/静态表),Platform::resolve_weight 给
  Native/Nearest/Synthetic 三档结论;雅黑无 500、苹方无 700 两处 gap 显式登记

component_map 高程按官方回调(P2 并入 finding):
- FilledCard L2→L0、OutlinedCard L1→L0、Menu L4→L2、Dialog L5→L3(Menu 圆角同时
  按 MenuTokens 改 4dp);新增官方值 pin 测试 + R4「全屏都在飘」守门断言

P3 四条:
- 同族容器撞色:彩度按共享基准等比压缩(Palettes.reference_chroma),
  primary/secondary-container 不再同值,且强调色覆盖时表面逐像素不变
- femtovg 引用改函数名(行号已漂);删掉 contrast_aa 的死代码后门
- 骨架数值跟 M63:nav 56→44、titlebar 40→48,control 40/touch 48 换算依据入文档
- 图标折算口径订正为「居中描边,视觉外扩 <1px」
2026-11-01 15:58:19 +08:00
AskaEth 65ca0ed29a feat(design): 加 unlucky-design 令牌 crate(M3 色彩/排版/形状/动效/图标)
D26 把视觉基线锁到 Material 3 Expressive,本 crate 是「UI 整体性」的地基:
令牌唯一真源,后续 iced 页面只消费不新增。零第三方依赖、零 UI 框架依赖
(cargo tree -p unlucky-design --edges normal 只有它自己一行)。

- 色彩:49 个 M3 语义角色 × light/dark,种子色派生 6 条 tone 调色板;
  tone 轴严格用 CIELAB L*(偏差 ≤1),色相/彩度用 OKLCh 近似 CAM16 并在
  模块文档里写明口径与已知偏差(不宣称是标准 HCT 动态取色)
- 对比度:26 组文字对 × 6 组种子 × 2 模式全部 ≥4.5:1;3 条例外显式登记
  (Outline 非文字 3:1、OutlineVariant 装饰、SurfaceVariant 是容器色)
- 排版:M3 type scale 15 档 + 三平台字体族回退栈(只写族名,不内嵌受限字体)
- 形状/高程/动效:Expressive 形状刻度 + shape morph 数据、elevation 级别与
  surface container 映射、时长/缓动/弹簧(纯函数可采样)
- 图标:24 个从 Slint 版迁移的 SVG path + 4 个新增,IconKind 冻结只可末尾追加;
  写明 Slint 的 stroke-width 是绝对像素、SVG 是 viewBox 用户单位的语义差异,
  换算封装成 Icon::stroke_width
- 组件档位表:20 个组件的形状/高程/字体/动效一次取全

TODO(M63):elevation shadow 的 blur/offset/alpha 与四组弹簧参数是占位值,
接口先定、数值待玻璃视觉规格表(tower 已通告边界)。
2026-11-01 15:34:04 +08:00
AskaEth fef0922073 Merge branch 'feat/p3-release-linux-arm64-slot-linuxarm' 2026-11-01 14:21:12 +08:00
AskaEth 4c1a6c54e5 fix(release): M51 r1 —— linux-arm64 改用 ubuntu-22.04-arm(与 x64 同 glibc 2.35 地板)
P2-1:24.04-arm 会给出 2.39 地板,把 Debian 12 / Raspberry Pi OS Bookworm
(2.36)挡在门外,且「arm64 只有 24.04 档」这句前提是错的(同段注释自己就承认
ubuntu-22.04-arm 有效)。改 os 为 ubuntu-22.04-arm,注释改成「与 x64 同地板
(2.35),覆盖 Debian 12 / Ubuntu 22.04 arm64 / RPi OS Bookworm」;
README §3/§4/§7.5/§9 与 release-notes 的 Downloads/Support matrix 全量订正
2.39 → 2.35+,删掉「arm64 地板高于 x64」的对比措辞。槽位名、产物名、六槽位
齐全性检查与打包脚本均未变。

P2-2:rustup target add 步骤改 env TARGET + `rustup target add "$TARGET"`,
并补 shell: bash(此前是全文件唯一没显式 shell 的 run,在 Windows 槽位会落进
pwsh);Linux 图形栈依赖那步也补了 shell: bash,现在所有 run 都显式 bash、
run 块内零 `${{ }}` 直插。

顺带 nit:host_slot() 报错改回原始 arch token(`riscv64` 不再显示成 None),
README §7.5 宣传的那个用例现在诊断可读。

验证:yaml 结构断言(六槽位 / linux-arm64=ubuntu-22.04-arm / 齐全性循环 6 /
run 块 ${{ }} 零泄漏 / run 步骤全带 shell)、全部 run 块 bash -n、grep 2.39
零残留、host_slot 7 组合注入 + freebsd/riscv64 报错含原名、image_arch 真 exe、
windows-arm64 stub 全流程、架构守卫拦截、linux-arm64 归档两次 sha256 一致
(f949d0f5… 与上一轮相同)、windows-x64 原生回归(冒烟 + sha256sum -c)。
2026-11-01 14:16:29 +08:00
AskaEth 15caa1b61f feat(release): M51 —— 发布矩阵补 Linux arm64 与 Windows arm64 两槽位(六槽位)
- release.yml:加 linux-arm64(原生 ubuntu-24.04-arm runner)与 windows-arm64
  (windows-latest 上交叉编译到 aarch64-pc-windows-msvc);产物根改由
  matrix.build_dir 参数化,dumpbin 自检与打包都吃矩阵路径而非硬编码 target/release;
  交叉槽位多两步(rustup target add + ARM64 库体检),release job 的槽位齐全性
  检查从四扩到六。
- make_artifacts.py:--slot / --build-dir 参数化 + 六槽位映射;打包前回读可执行文件头
  (PE 机器码 / ELF e_machine / Mach-O cputype)校验架构,跨槽位构建自动跳过
  ull --version 冒烟并打印原因(不在 x64 上假装跑得动 arm64 二进制)。
- packaging/README.md + release-notes.md:两张支持矩阵同步加 Windows 11 ARM64 与
  Linux arm64 行;arm64 glibc 地板如实写 2.39(高于 x64 的 2.35),并说明与 Termux
  实验级的区别(这两个是正式发布槽位)。

本地验证:宿主判定注入(七个组合 + 非法宿主 exit 1)、文件头架构识别(真 x64 exe +
合成 PE/ELF/Mach-O 头)、windows-arm64 stub 全流程、架构守卫真拦、linux-arm64 归档
两次 sha256 一致、windows-x64 原生回归(冒烟 + sha256sum -c)。arm64 真机构建由 CI 补。
2026-11-01 13:48:37 +08:00
AskaEth 0bfe22aa6f Merge branch 'feat/p3-release-windows-dumpbin-locator-fix-d'
Release / build (macos-x64) (push) Canceled after 0s
Release / build (macos-arm64) (push) Canceled after 0s
Release / build (linux-x64) (push) Canceled after 0s
Release / build (windows-x64) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
2026-11-01 13:06:12 +08:00
AskaEth a6013c7d22 fix(ci/release): M50 —— Windows 槽位 dumpbin 定位链改三级(vswhere → find → PATH)
v0.1.0 首跑(run 41273841717)windows-x64 在「Windows 单文件自检」挂
`找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)`。两个独立根因:

- `command -v dumpbin` 在 windows-latest 上不成立:VS 环境未预置进 PATH。
- 兜底 glob 深度错了一级:写的是 `<VS 根>/<年>/VC/Tools/...`,真实布局是
  `<VS 根>/<年>/<版次>/VC/Tools/...`(2022/Enterprise、2022/BuildTools),
  所以即便 PATH 失守、glob 也不可能命中。
- 且只扫了 64 位 `Program Files` 根,Build Tools 落在 `Program Files (x86)` 根。

改成三级定位链,全不命中才 fail(门禁语义一字未动:工具缺失仍 exit 1、
导入表出现 VCRUNTIME/MSVCP/MSVCR/ucrtbase 仍 exit 1,文案不变):

1. vswhere —— VS 官方定位器,安装器自身路径固定,与 VS 版本/版次/布局无关:
   `.../Installer/vswhere.exe -latest -products '*' -find <pattern>`。
   模式先试反斜杠(官方文档形式)、再试正斜杠,输出统一折成正斜杠后再执行。
2. find 兜底 —— 两个 VS 根(64 位 + x86)按路径模式扫,`-maxdepth 11`
   留一级余量,不再依赖固定层数的 glob。
3. PATH —— 最后才试,PATH 最不可控。

另加一行 `dumpbin: <路径>(定位自 <层级>)`,下次真机挂掉能直接看出命中哪一级。
同文件其余步骤不依赖 VS 环境(已逐条核对),无需收敛。

验证(本机无 VS):
- 抽出 run 块 `bash -n` 通过;13 条 stub 用例全绿:无工具→fail 文案不变且不写
  dependents.txt、vswhere 反斜杠/正斜杠两个模式、两处 VS 根 find 兜底、PATH 兜底、
  动态 CRT 仍 fail-closed、vswhere 优先于 PATH、vswhere 输出的反斜杠路径归一化后可执行。
- 未改写的原始 run 块在本机实跑:干净 exit 1 + 原文案,不误命中、不崩。
- yaml 解析 + 23 条结构断言全过(矩阵四槽位、触发条件、门禁 grep/文案/exit 原样);
  文件 276 行 ≤ 400。
- git-bash 行为实证:`.exe` 的 `-x` 为真、非可执行文件为假、`find` 接受含空格的
  `C:/Program Files` 根。
真机验证由 merge 后 retag 重跑流水线完成。
2026-11-01 12:58:40 +08:00
AskaEth 37ccaaf23f Merge branch 'feat/p3-fix-game-java-discover-cfg-gating-jav'
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Release / build (macos-x64) (push) Canceled after 0s
Release / build (macos-arm64) (push) Canceled after 0s
Release / build (linux-x64) (push) Canceled after 0s
Release / build (windows-x64) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
2026-11-01 11:00:32 +08:00
AskaEth dc4fac8391 fix(core/game): M49 —— Java 去重键平台无关归一化(Linux CI 三红)
`game::java::discover::path_key` 只在 `cfg!(windows)` 分支折分隔符,POSIX 上
把 `C:\a\b\java.exe` 原样留成带反斜杠的键,与 `C:/a/b/java.exe` 不同键 →
`dedupe` 不去重。三条用例(Windows 语义无 cfg 门)因此在
`test (ubuntu-latest)` 挂:`path_key_normalises_separators`、
`dedupe_keeps_the_higher_priority_source`、
`java_discover::dedupe_collapses_one_install_found_twice`。

- `path_key`:`\?\` 前缀剥离与分隔符折成 `/` 改为**与平台无关**(同一个
  `bin/java[.exe]` 用哪种拼法都是同一个文件,键必须相同);大小写折叠仍只有
  Windows 做——POSIX 上 `Java` 与 `java` 是两个目录,折了会把两份真实安装误并。
  已知取舍写进文档:POSIX 上文件名里的字面反斜杠会一并被当分隔符。
- 三条用例:不靠 `#[cfg(windows)]` 跳过。`path_key` 单测改成平台无关断言 +
  大小写折叠的双侧断言(Windows 折 / POSIX 不折,两侧都钉住);集成用例的
  「同一路径第二种写法」按平台给(Windows 用大小写+分隔符,POSIX 用分隔符)。
- 回归钉子:新增 `one_install_normalises_to_one_key_whatever_the_spelling`
  (普通写法 / 反斜杠写法 / `\?\` 前缀写法 / 混合写法 四种拼法同键)与
  `path_key_folds_case_only_where_the_filesystem_does`。

验证:Linux(rust:1-bookworm 容器,ull-linux-target 热缓存)
`cargo test -p unlucky-core --lib --test game` → 224 + 120 passed / 0 failed
(基线是 lib 220/2 failed、game 119/1 failed);
`cargo test --workspace --all-targets --no-fail-fast` → 34 个测试块
1137 passed / 0 failed(该次跑在 M48 launch 修复叠加的临时副本上,因为 M48
尚未合入 main,见报告)。Windows 本机 `cargo fmt --check`、
`cargo clippy -p unlucky-core --all-targets -D warnings`、
`cargo test -p unlucky-core` 全绿(17 个测试块 0 failed)。
2026-11-01 11:00:25 +08:00
354 changed files with 56340 additions and 695 deletions

No files matched your search

+80
View File
@@ -90,3 +90,83 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo build --workspace --release
# 运行时冒烟(M62):Linux 上**真渲染一帧**,补上「能编译 ≠ 能跑」的空白。
# 上面四个 job 都只证明代码编得过、测得过,从不创建真实图形窗口;本 job 在 Xvfb 虚拟显示里
# 启动 GUI 进程,让 Slint 软件渲染器把当前场景渲进内存像素缓冲并编码成 PNG,再对**像素本身**
# 断言(不是只看退出码:窗口起来了但一帧空白同样是「退出码 0」)。
# 诚实边界:证明「Linux 上真能跑出非空白的一帧」,**不**证明视觉正确——视觉靠人眼看 artifact。
# 本 job 绑死 Slint Window::take_snapshot;迁 iced 后必须提供等价的离屏截图通道,否则本 job 静默退化成「能起进程」。
runtime-smoke:
name: runtime smoke (ubuntu x64, xvfb)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: 无头 X 与图形栈依赖(xvfb + 软渲染 + 字体)
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
xvfb xauth \
libxkbcommon-dev libxkbcommon-x11-0 libfontconfig1-dev \
libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev \
libx11-6 libxcursor1 libxrandr2 libxi6 \
fonts-dejavu-core fonts-liberation2
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# 用 debug 而不是 release:本 job 验的是「运行时渲染通路能通」,不是优化后二进制的行为;
# release 的 lto="fat" + codegen-units=1 会把这一步拖到十几分钟却不带来额外证据。
- run: cargo build --bin unlucky-launcher --locked
- name: 在 Xvfb 里跑一帧并截图(退出码非 0 即失败)
run: |
set -o pipefail # 当前没有管道(死行),保留是为将来加管道时先有失败传播
mkdir -p "$RUNNER_TEMP/smoke/data"
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
target/debug/unlucky-launcher --software-renderer \
--screenshot "$RUNNER_TEMP/smoke/runtime-smoke.png" \
--data-dir "$RUNNER_TEMP/smoke/data"
- name: 断言 PNG 存在且非空白(尺寸 / 唯一色数 / 亮度σ / 强边缘占比)
run: python3 packaging/check_screenshot.py "$RUNNER_TEMP/smoke/runtime-smoke.png" --json
- name: 上传截图与数据目录(失败也传,便于复盘)
if: always()
uses: actions/upload-artifact@v4
with:
name: runtime-smoke-screenshot
path: ${{ runner.temp }}/smoke/
if-no-files-found: warn
# macOS 槽位探针(M62):**默认不在 PR 上跑**,只有手动 dispatch 才触发。
# 之所以是探针而不是常驻槽位:官方 macOS runner 处于 LaunchDaemon 上下文,没有登录的
# WindowServer 会话,Slint 的 winit backend 创建窗口大概率直接失败;而 worker 禁 push,
# 本机也没有 macOS,这条路径**尚无任何实测证据**——不拿未验证的 job 卡住主干门禁。
# 首跑结论决定去留:绿 → 去掉 if/continue-on-error 升为常驻槽位;红 → 按 D26 归入 iced
# 重写后再规划,并把错误原文贴进 progress 文档(不静默删 job、不吞失败原因)。
runtime-smoke-macos:
name: runtime smoke (macos arm64, probe)
if: github.event_name == 'workflow_dispatch'
runs-on: macos-latest
timeout-minutes: 30
# 探针阶段保留证据但不算流水线结论:log/artifact 里失败原因照常可见,
# 解除条件 = 该槽位在真实 runner 上首跑通过并把本行与上面那条 if 一起删掉。
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo build --bin unlucky-launcher --locked
- name: 尝试截图(macOS 没有 X 服务器,直接跑;失败即为结论)
run: |
set -o pipefail # 当前没有管道(死行),保留是为将来加管道时先有失败传播
mkdir -p "$RUNNER_TEMP/smoke/data"
target/debug/unlucky-launcher --software-renderer \
--screenshot "$RUNNER_TEMP/smoke/runtime-smoke.png" \
--data-dir "$RUNNER_TEMP/smoke/data"
- name: 断言 PNG 存在且非空白
run: python3 packaging/check_screenshot.py "$RUNNER_TEMP/smoke/runtime-smoke.png" --json
- name: 上传截图与数据目录(失败也传,便于复盘)
if: always()
uses: actions/upload-artifact@v4
with:
name: runtime-smoke-screenshot-macos
path: ${{ runner.temp }}/smoke/
if-no-files-found: warn
+282 -4
View File
@@ -1,9 +1,12 @@
name: Guards
# 架构与合规守门(与 fmt/clippy 同级,不允许 continue-on-error):
# 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查
# 2. file-size —— 全仓代码/文档单文件 ≤400 行(用户硬约束)
# 3. licenses —— cargo-deny:许可证白名单 + GPL 依赖拦截 + 供应链来源校验
# 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查
# 2. presenter-no-ui —— crates/presenter 零 UI 依赖(D26 / M85):Slint 版与 iced 版共用的宿主层
# 3. design-zero-dep —— crates/design 零第三方依赖(M61 / M87):依赖表(含 dev/build)必须为空 + 依赖树只允许出现自身
# 4. file-size —— 全仓代码/文档单文件 ≤400 行(用户硬约束)
# 5. licenses —— cargo-deny:许可证白名单 + GPL 依赖拦截 + 供应链来源校验
# 6. unsafe-scope —— 全仓 `allow(unsafe_code)` 的文件集合必须恰好只有一个(D36 / M91)
on:
push:
@@ -43,6 +46,279 @@ jobs:
fi
echo "OK: unlucky-core 依赖树零 UI"
# presenter 是 Slint 版(crates/app)与 iced 版(crates/app-iced)共用的唯一业务层(D26)。
# 一旦 slint/iced/winit 被拉进来,分层就废了,而编译与单测仍会全绿 —— 所以必须由 CI 守。
# 与 core-no-ui 同样是「依赖表 + cargo tree」双查,名单保持一致;脚本刻意复制而非抽公共脚本,
# 理由是 CI 守门脚本自身挂掉的排查成本远高于这几行重复。
presenter-no-ui:
name: presenter 零 UI 守门
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: 依赖表直查(crates/presenter/Cargo.toml 的依赖表不得出现 UI 框架)
run: |
set -uo pipefail
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
# 与 core-no-ui 的整文件 grep 不同:本 crate 的 description 合法地写着
# 「Slint 版与 iced 版共用」,整文件 grep 会把它当越线。所以先按表头切出依赖表
# (含 target.*.dependencies 与 dev/build 表),再去掉注释行,只查键名。
# 子表写法 `[dependencies.slint]` / `[target.'cfg(windows)'.dependencies.slint]`
# 是合法的等长写法,且表头本身带着包名,所以表头也要一并落进检查范围——
# 否则「子表 + optional + 非默认 feature」这种组合能同时骗过依赖表与 cargo tree。
awk '
/^[[:space:]]*\[/ {
in_deps = ($0 ~ /^[[:space:]]*\[(target\..*\.)?(dependencies|dev-dependencies|build-dependencies)(\.[^]]*)?\][[:space:]]*$/)
if (in_deps && $0 ~ /\.[^]]*\]/) printf "%d:%s\n", NR, $0
next
}
in_deps && !/^[[:space:]]*#/ && !/^[[:space:]]*$/ { printf "%d:%s\n", NR, $0 }
' crates/presenter/Cargo.toml > /tmp/presenter-deps.txt
cat /tmp/presenter-deps.txt
if grep -iE "$forbidden" /tmp/presenter-deps.txt; then
echo "::error file=crates/presenter/Cargo.toml::presenter 不得依赖 UI 框架(D26)。窗口创建 / 渲染 / 图片解码属于 app 与 app-iced 侧;需要跨前端共用的类型请放进 presenter 的 dto。"
exit 1
fi
echo "OK: crates/presenter/Cargo.toml 依赖表无 UI 依赖"
- name: 依赖树泄漏检查(cargo tree -p unlucky-presenter,排除自身行)
run: |
set -uo pipefail
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
# 注意:cargo tree 命中 UI 框架时**退出码仍是 0**,所以只能抓输出再判断。
if ! tree=$(cargo tree -p unlucky-presenter --edges normal --prefix none); then
echo "::error::cargo tree -p unlucky-presenter 执行失败 —— crate 是否仍叫 unlucky-presenter?"
exit 1
fi
echo "$tree"
hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden")
if [ -n "$hits" ]; then
echo "::error::presenter 的依赖树出现 UI 框架(传递依赖也不允许),本次命中:"
printf '%s\n' "$hits" | sed 's/^/ - /'
echo "::error::presenter 是 Slint 版与 iced 版共用的宿主层:把窗口 / 渲染 / 图片解码留在 app 侧,presenter 只持有与框架无关的 DTO、状态 reducer 与 host 装配。"
exit 1
fi
echo "OK: unlucky-presenter 依赖树零 UI"
# unlucky-design 是「纯数据 + 纯函数、零第三方依赖」的设计令牌层,这是 README 明写的卖点
# (连 serde 都不加),但目前只靠人肉守。零依赖的判据比 UI 名单更硬:依赖树只允许有自己一行。
# 注意 `cargo tree --edges normal` 看不到 dev/build 依赖,所以另设一步按表头切依赖表逐个查空。
design-zero-dep:
name: design 零第三方依赖守门
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: 依赖表必须为空(dependencies / dev / build 表一律不许有键,含子表写法)
run: |
set -uo pipefail
# design 承诺的是「零第三方依赖」,不分 normal / dev / build —— 而 cargo tree
# --edges normal 对 dev/build 是瞎的。所以先按表头切出全部依赖表(含
# `[dependencies.serde]` 这类子表写法,表头本身携带包名),再看是否为空。
awk '
/^[[:space:]]*\[/ {
in_deps = ($0 ~ /^[[:space:]]*\[(target\..*\.)?(dependencies|dev-dependencies|build-dependencies)(\.[^]]*)?\][[:space:]]*$/)
if (in_deps && $0 ~ /\.[^]]*\]/) printf "%d:%s\n", NR, $0
next
}
in_deps && !/^[[:space:]]*#/ && !/^[[:space:]]*$/ { printf "%d:%s\n", NR, $0 }
' crates/design/Cargo.toml > /tmp/design-deps.txt
if [ -s /tmp/design-deps.txt ]; then
cat /tmp/design-deps.txt
echo "::error file=crates/design/Cargo.toml::unlucky-design 的依赖表必须为空(normal / dev / build 都算),本次命中:"
sed 's/^/ - /' /tmp/design-deps.txt
echo "::error::design 是纯数据 + 纯函数的设计令牌,README 对外承诺零第三方依赖(连 serde 都不加):色彩 / 排版 / 形状 / 高程 / 动效请不要引入任何 crate,测试与构建辅助依赖同样不许加。序列化与运行时能力放到消费方(app / app-iced / presenter)。"
exit 1
fi
echo "OK: crates/design/Cargo.toml 依赖表全空(含 dev / build)"
- name: 依赖树只允许出现自身(cargo tree -p unlucky-design --edges normal)
run: |
set -uo pipefail
if ! tree=$(cargo tree -p unlucky-design --edges normal --prefix none); then
echo "::error::cargo tree -p unlucky-design 执行失败 —— crate 是否仍叫 unlucky-design?"
exit 1
fi
echo "$tree"
# 空行不算,剩下的每一行都必须是自己:正常时整个输出只有一行
# `unlucky-design v0.1.0 (/path/to/crates/design)`。
lines=$(printf '%s\n' "$tree" | grep -cE '[^[:space:]]' || true)
others=$(printf '%s\n' "$tree" | grep -vE '^[[:space:]]*$' | grep -vE '^unlucky-design v' || true)
if [ -n "$others" ] || [ "$lines" -ne 1 ]; then
echo "::error file=crates/design/Cargo.toml::unlucky-design 必须零第三方依赖,本应只有自身一行,实际 $lines 行;越线的包:"
printf '%s\n' "$others" | sed 's/^/ - /'
echo "::error::design 是纯数据 + 纯函数的设计令牌:色彩 / 排版 / 形状 / 高程 / 动效请不要引入 serde、rand、once_cell 等任何 crate。序列化与运行时能力放到消费方(app / app-iced / presenter)。"
exit 1
fi
echo "OK: unlucky-design 依赖树只有自身,零第三方依赖"
# D36 定案:整窗 α 是全仓**唯一**需要 `unsafe` 的能力(Win32 的
# `SetLayeredWindowAttributes` 没有安全替代),落点收在一个文件里。
# 判据必须是**集合相等**而不是「黑名单命中」:
# - 新增第二个显式放行 unsafe 的文件 → 红(黑名单写法在这里会假绿);
# - 落点文件把放行删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径);
# - 多行属性、同行合并属性、多行 `cfg_attr`、`expect(unsafe_code)` 都已覆盖
# (r2 前的行级正则对前两种会假绿,当时只能靠 ci.yml 的 fmt job 兜底;r3 起由词法器直接抓)。
unsafe-scope:
name: unsafe 落点守门(集合必须恰好一个文件)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 全仓显式放行 unsafe 的文件集合 == 唯一落点
run: |
set -uo pipefail
EXPECTED='crates/app-iced/src/platform/win32/transparency.rs'
# 扫描口径(M91 r3):一个小词法器,而不是正则。
#
# 为什么必须用词法器:r2 的写法是「按全文方括号净深度合并续行」,但 Rust 里
# `['[', '(']`、`"[2"`、`starts_with("[2")` 这类**不配对括号字面量**会让深度永久跑偏,
# 于是该文件后面的一切(包括一个真的 `#[allow(unsafe_code)]`)都再也不会被扫到——
# 实测本仓 601 个文件里有 10 个扫到 EOF 仍非 0,`gate.rs` 就是其中之一(r3 复审实证:
# 在它尾部加作用域 allow,r2 门禁 exit 0 假绿)。
# 现在的做法:逐字符状态机(普通 / 行注释 / 块注释(可嵌套)/ 字符串 / raw 字符串 /
# 字符字面量),只在「普通态」里认 `#` 或 `#!` 紧跟 `[` 的属性起点,然后只在这一个
# 属性内部累计到它的 `]` 配平——数组字面量跨行不再参与合并,也就不会带偏。
#
# 放行的两种拼法都算:`allow(...)` 与 `expect(...)`。`expect(unsafe_code)` 同样会
# 放行 unsafe 代码(rustc 实测:`#![expect(unsafe_code)]` + `pub unsafe fn f() {}` 在
# `-D unsafe_code` 下编译通过),所以它必须被同一条判据抓住,不能只认 allow。
#
# 仍然抓不住的(已知边界,别当全覆盖):
# - 落点文件**内部**多开别的 unsafe 能力(本门禁只看「有没有放行属性」,不看 unsafe 块个数);
# - **在别处定义、这里只调用**的宏展开出来的 allow:宏体里直接写出的属性在源码文本里,
# 会被扫到;但「宏定义在别的文件、调用点展开出 allow」时,本文件源码里没有那个属性;
# - 注释(行注释/块注释)与字符串(普通/raw)里的属性文本**刻意不算**:它们不构成放行;
# 代价是「真把 allow 写进字符串」这种写法不会被当作落点(它也确实不放行任何代码);
# - manifest 侧 `[lints.rust] unsafe_code = "allow"`:本门禁只扫 `*.rs`,看不见 Cargo.toml;
#
# r4 回归用例(属性内的 raw string / 字符字面量曾经是盲区;现在两处共用 step(),
# 它们不再是边界,但下面五条必须一直保持红/绿正确 —— 改这段词法器前先跑它们):
# 红 #[doc = r#"a "b"#] 后跟真 allow
# 红 #[cfg_attr(docsrs, doc = r##"a " b "##)] 后跟真 allow
# 红 #[cfg_attr(docsrs, my_macro(39 as char))] 一类含字符字面量/转义的属性后跟真 allow
# 具体形态:#![cfg_attr(docsrs, my_macro('\''))] 后跟真 allow
# 红 以上三者的 CRLF 版本
# 绿 #[doc = r#"x" allow(unsafe_code) is banned "y"#](纯文档,不得误报)
scan() {
awk -v path="$1" '
# ---- 唯一的状态机:主循环与属性内**共用**同一个 step() ----
# 为什么必须复用:r2 是「属性外一套状态机、属性内另写一套」,两套各自演进,
# r3 的属性内就只剩「遇 " 跳到下一个 "」,于是 raw string / 字符字面量在属性内
# 完全不认识 —— `#[doc = r#"a "b"#]` 之后的一切对门禁失明(假绿),
# `#[doc = r#"x" allow(unsafe_code) "y"#]` 这种纯文档被误报(误红)。
# 现在两处只认这一个 step(),属性解析只多一层「[ ] 深度决定何时离开属性」。
#
# 状态:0 普通 / 1 行注释 / 2 块注释(可嵌套)/ 3 字符串 / 4 raw 字符串 / 5 字符字面量。
# 返回值 = 本次消费到的位置(`//`、`/*`、`r#"`、raw 收尾会一次吃掉多个字符);
# g_plain=1 表示该位置处在「普通态代码」里(注释/字符串内容一律 0,
# 所以它们既不会被当成属性、也不会参与属性内的 [ ] 计数与文本匹配)。
function step(s, n, i, c, nx, j, h, ok) {
c = substr(s, i, 1)
g_plain = 0
if (g_state == 1) { if (c == "\n") g_state = 0; return i }
if (g_state == 2) {
if (c == "/" && substr(s, i + 1, 1) == "*") { g_depth++; return i + 1 }
if (c == "*" && substr(s, i + 1, 1) == "/") { g_depth--; if (g_depth <= 0) g_state = 0; return i + 1 }
return i
}
if (g_state == 3) {
if (c == "\\") return i + 1
if (c == "\"" || c == "\n") g_state = 0
return i
}
if (g_state == 4) {
if (c == "\"") {
ok = 1
for (j = 1; j <= g_hashes; j++) if (substr(s, i + j, 1) != "#") ok = 0
if (ok) { g_state = 0; return i + g_hashes }
}
return i
}
if (g_state == 5) {
if (c == "\\") return i + 1
if (c == g_q || c == "\n") g_state = 0
return i
}
if (c == "/" && substr(s, i + 1, 1) == "/") { g_state = 1; return i + 1 }
if (c == "/" && substr(s, i + 1, 1) == "*") { g_state = 2; g_depth = 1; return i + 1 }
if (c == "\"") { g_state = 3; return i }
if (c == g_q) {
nx = substr(s, i + 1, 1)
if (nx == "\\" || substr(s, i + 2, 1) == g_q) { g_state = 5; return i }
g_plain = 1
return i
}
if (c == "r" || c == "b") {
j = i + 1
if (c == "b") {
if (substr(s, j, 1) == "\"") { g_state = 3; return j }
if (substr(s, j, 1) == g_q) { g_state = 5; return j }
if (substr(s, j, 1) != "r") { g_plain = 1; return i }
j++
}
h = 0
while (substr(s, j, 1) == "#") { h++; j++ }
if (substr(s, j, 1) == "\"") { g_hashes = h; g_state = 4; return j }
g_plain = 1
return i
}
g_plain = 1
return i
}
function lex( s, line, n, i, j, k, ch, adepth, attr) {
s = ""
while ((getline line < path) > 0) s = s line "\n"
close(path)
n = length(s)
g_state = 0; g_depth = 0; g_hashes = 0; g_found = 0
for (i = 1; i <= n; i++) {
i = step(s, n, i)
if (!g_plain || substr(s, i, 1) != "#") continue
j = i + 1
if (substr(s, j, 1) == "!") j++
if (substr(s, j, 1) != "[") continue
# 属性:**复用 step()**,只多一层「[ ] 深度决定何时离开属性」。
# 属性里的字符串 / raw 字符串 / 字符字面量因此与属性外同一套识别。
attr = ""; adepth = 0; k = j
while (k <= n) {
k = step(s, n, k)
if (g_plain) {
ch = substr(s, k, 1)
if (ch == "[") adepth++
else if (ch == "]") adepth--
attr = attr ch
if (adepth <= 0) break
}
k++
}
if (attr ~ /(allow|expect)\([^]]*unsafe_code/) g_found = 1
i = k
}
return g_found
}
BEGIN { g_q = "\047"; g_found = 0; exit(lex() ? 0 : 1) }
'
}
# 只对「文本里出现过 unsafe_code」的文件跑词法器(标识符不可能被空白拆开,
# 所以这个预筛不会漏)。601 个文件 → 个位数个候选,整个 job 约 2 秒。
actual=$(git grep -l 'unsafe_code' -- '*.rs' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do
if scan "$f"; then printf '%s\n' "$f"; fi
done | LC_ALL=C sort)
echo "期望(恰好这一个):$EXPECTED"
echo "实际扫到的集合:"
if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi
if [ "$actual" != "$EXPECTED" ]; then
extra=$(printf '%s\n' "$actual" | grep -vxF "$EXPECTED" || true)
if [ -n "$extra" ]; then
echo "::error::多出来的 unsafe 落点(D36 只允许一个):"
printf '%s\n' "$extra" | sed 's/^/ - /'
fi
if ! printf '%s\n' "$actual" | grep -qxF "$EXPECTED"; then
echo "::error file=$EXPECTED::这个文件里没有显式放行 unsafe(allow / expect(unsafe_code))—— 落点被搬走/改名/删了放行?守门路径已失效。"
fi
echo "::error::整窗 α 的 FFI 只允许住在 $EXPECTED(D36)。需要新的 unsafe 能力请先立 mission:改这条守门 + 更新 D36 的定案,不要顺手放行。"
exit 1
fi
echo "OK: 全仓显式放行 unsafe 的文件恰好只有 $EXPECTED"
file-size:
name: 文件行数 ≤400 守门
runs-on: ubuntu-latest
@@ -63,7 +339,9 @@ jobs:
echo "::error file=$f::$f 有 $n 行,超过 $LIMIT 行上限,请按模块拆分"
failed=$((failed + 1))
fi
done < <(git ls-files '*.rs' '*.md' '*.slint' '*.yml' '*.yaml' '*.toml' '*.json')
# 覆盖范围含脚本(*.py/*.sh):packaging/ 下的构建辅助脚本同样受 400 行约束;
# Cargo.lock 等机器生成文件刻意不在列(否则一生成就超限)
done < <(git ls-files '*.rs' '*.md' '*.slint' '*.yml' '*.yaml' '*.toml' '*.json' '*.py' '*.sh')
if [ "$failed" -ne 0 ]; then
echo "共 $failed 个文件超限"
exit 1
+139 -15
View File
@@ -1,6 +1,6 @@
name: Release
# 发布流水线:tag `v*` → 三平台(四槽位)矩阵构建 → 单文件产物 + SHA256SUMS
# 发布流水线:tag `v*` → 四平台(六槽位)矩阵构建 → 单文件产物 + SHA256SUMS
# → 上传 artifact + 创建 GitHub Release(pre-release 默认开)。同一 tag 重跑幂等:
# Release 已存在时覆盖资产与说明,不再 `gh release create`(见 release job 末步);
# 重跑**不碰** pre-release 标记,该标记只在 `inputs.prerelease` 显式给出时才被断言。
@@ -42,26 +42,56 @@ jobs:
fail-fast: false
matrix:
include:
# Windows:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包,
# Windows x64:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包,
# 双击即用的单文件形态;UCRT 本身就是 Win10+ 的系统组件。
# 产物根 target/release;自检与打包都从 matrix.build_dir 取路径(不硬编码)。
- slot: windows-x64
os: windows-latest
build_dir: target/release
rustflags: -C target-feature=+crt-static
# Windows arm64:在 windows-latest(x64 宿主)上**交叉编译**到 aarch64-pc-windows-msvc。
# 为什么不用 windows-11-arm 原生 runner(GitHub 文档 2026 已把 `windows-11-arm` 与
# `windows-11-vs2026-arm` 列为公开仓库**免费**的标准 arm64 runner,原生路线可行):
# ① 双 Windows 槽位共用同一套已验证镜像/工具链,tag 重跑行为与既有槽位一致;
# ② M50 的 dumpbin 定位链取的是 `Hostx64/x64/dumpbin.exe`,dumpbin 读 ARM64 PE 的
# 导入表没问题,而原生 ARM 宿主上那条链得再长一条 `Hostarm64/arm64` 分支——为一个
# 槽位放宽刚立的门禁不值得;
# ③ 交叉编译产物路径变成 target/<triple>/release,已用 matrix.build_dir 参数化。
# 若将来改成原生:只需把 os 换成 windows-11-arm、删掉 target/rustflags 依赖并给 dumpbin
# 定位链补 Hostarm64 分支(届时再评估,不在这里预先放宽)。
- slot: windows-arm64
os: windows-latest
target: aarch64-pc-windows-msvc
build_dir: target/aarch64-pc-windows-msvc/release
rustflags: -C target-feature=+crt-static
# Linux:固定 ubuntu-22.04(glibc 2.35 地板)而不是 latest,
# 否则产物的 glibc 底线会跟着 runner 漂移(24.04 = 2.39)。
# 否则产物的 glibc 底线会跟着 runner 漂移(24.04 的 glibc 明显更高)。
- slot: linux-x64
os: ubuntu-22.04
build_dir: target/release
# Linux arm64:公开仓库免费的原生 ARM64 runner(原生构建,不做交叉)。
# 用 `ubuntu-22.04-arm` 而**不是** 24.04:与 x64 同 glibc 地板(2.35),
# 覆盖 Debian 12 / Ubuntu 22.04 arm64 / Raspberry Pi OS Bookworm(2.36)这类实际在跑的
# ARM 发行版;换 24.04 会把地板抬高、把上面前两个挡在门外。该家族标签有
# `ubuntu-22.04-arm` / `ubuntu-24.04-arm` / `ubuntu-26.04-arm`,均 4C/16GB、
# 公开仓库免费不限量(GitHub 文档 Standard runners for public repositories)。
- slot: linux-arm64
os: ubuntu-22.04-arm
build_dir: target/release
# macOS:latest = arm64;Intel x64 用 macos-15-intel(macos-13 已下线)。
- slot: macos-arm64
os: macos-latest
build_dir: target/release
- slot: macos-x64
os: macos-15-intel
build_dir: target/release
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Linux 图形栈构建依赖(winit / femtovg 编译期需要)
if: runner.os == 'Linux'
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
@@ -71,21 +101,103 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# 只有交叉编译槽位需要额外 target(原生槽位留空 → 本步整体跳过)。
# 不把 `${{ }}` 直接拼进 bash:与后面的打包步骤同一纪律,一律经 env 传入再展开。
- name: 追加交叉编译目标 ${{ matrix.target }}
if: matrix.target != ''
shell: bash
env:
TARGET: ${{ matrix.target }}
run: rustup target add "$TARGET"
# 交叉链接的前提是镜像里真有 ARM64 的 MSVC 库(VC `/lib/arm64/libcmt.lib`,+crt-static
# 就靠它)与 Windows SDK 的 ARM64 导入库(`um/arm64/kernel32.lib`)。缺了照样是失败,
# 但先给一句能照做的话,而不是让链接器吐一屏乱码才发现方向:门禁不降级,只把失败说清楚。
- name: Windows ARM64 交叉工具链体检
if: matrix.target == 'aarch64-pc-windows-msvc'
shell: bash
run: |
set -euo pipefail
roots=("C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio")
vc_lib=$(find "${roots[@]}" -maxdepth 11 -type f -path '*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib' 2>/dev/null | head -n1 || true)
sdk_version=$(ls -d "C:/Program Files (x86)/Windows Kits/10/Lib"/* 2>/dev/null | tail -n1 || true)
sdk_lib=""
if [ -n "$sdk_version" ]; then
sdk_lib=$(find "$sdk_version/um/arm64" -maxdepth 1 -name 'kernel32.lib' 2>/dev/null | head -n1 || true)
fi
echo "MSVC ARM64 静态 CRT: ${vc_lib:-<缺失>}"
echo "SDK ARM64 导入库: ${sdk_lib:-<缺失>}"
if [ -z "$vc_lib" ] || [ -z "$sdk_lib" ]; then
echo "::error::交叉链接缺 ARM64 库:给 VS 加上 Microsoft.VisualStudio.Component.VC.Tools.ARM64(含 ARM64 Windows SDK),或把该槽位改用原生 windows-11-arm runner"
exit 1
fi
- name: cargo build --release --locked
shell: bash
env:
RUSTFLAGS: ${{ matrix.rustflags }}
run: cargo build --release --locked --bin unlucky-launcher --bin ull
TARGET: ${{ matrix.target }}
run: |
set -euo pipefail
target_args=()
if [ -n "$TARGET" ]; then
target_args+=(--target "$TARGET")
fi
cargo build --release --locked "${target_args[@]}" --bin unlucky-launcher --bin ull
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
if: runner.os == 'Windows'
shell: bash
env:
# 跨槽位的关键点:产物在 target/<triple>/release 而不是 target/release。
EXE: ${{ matrix.build_dir }}/unlucky-launcher.exe
run: |
set -euo pipefail
# +crt-static 是否真的生效,只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 说明还得装
# 可再发行包,那就不是"单文件"了,直接 fail。
DUMPBIN=$(command -v dumpbin || true)
#
# dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级,只是把"猜路径"换成官方定位):
# ① vswhere:VS 官方定位器,安装器自身路径固定,与 VS 版本/版次/安装布局无关;
# ② find 兜底:Program Files 与 Program Files (x86) 两个 VS 根都扫(Build Tools 落在 x86 根);
# ③ PATH:镜像若已把工具链预置进 PATH 则直接可用。
# PATH 排最后是刻意的:PATH 最不可控,官方定位器优先。
# Hostx64 的 dumpbin 读 ARM64 PE 的导入表没问题,所以 arm64 槽位共用同一条链。
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
DUMPBIN=""
VIA=""
if [ -x "$VSWHERE" ]; then
# -find 的模式优先用反斜杠(vswhere 官方文档即此形式),再试正斜杠以规避分隔符差异。
for pattern in 'VC\Tools\MSVC\*\bin\Hostx64\x64\dumpbin.exe' \
'VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe'; do
hit=$("$VSWHERE" -latest -products '*' -find "$pattern" 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then
DUMPBIN=${hit//\\//}
VIA="vswhere"
break
fi
done
fi
if [ -z "$DUMPBIN" ]; then
DUMPBIN=$(ls -d "C:/Program Files/Microsoft Visual Studio/"*/VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe 2>/dev/null | head -n1 || true)
# 镜像里的 VS 根形如 <年>/<版次>(2022/Enterprise、2022/BuildTools……),
# 固定层数的 glob 会在 <年>/<版次> 这两级踩空,所以按路径模式扫。
for root in "C:/Program Files/Microsoft Visual Studio" \
"C:/Program Files (x86)/Microsoft Visual Studio"; do
[ -d "$root" ] || continue
hit=$(find "$root" -maxdepth 11 -type f \
-path '*/VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe' 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then
DUMPBIN=$hit
VIA="find: $root"
break
fi
done
fi
if [ -z "$DUMPBIN" ]; then
hit=$(command -v dumpbin || true)
if [ -n "$hit" ]; then
DUMPBIN=$hit
VIA="PATH"
fi
fi
# 工具缺失同样 fail:windows-latest 自带 VS 工具链,基本找得到;
# "找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
@@ -93,7 +205,9 @@ jobs:
echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)"
exit 1
fi
"$DUMPBIN" -nologo -dependents target/release/unlucky-launcher.exe | tee dependents.txt
echo "dumpbin: $DUMPBIN(定位自 $VIA)"
ls -l "$EXE"
"$DUMPBIN" -nologo -dependents "$EXE" | tee dependents.txt
if grep -Eiq 'VCRUNTIME|MSVCP|MSVCR|ucrtbase\.dll' dependents.txt; then
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"
exit 1
@@ -105,11 +219,15 @@ jobs:
# 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。
REF_TYPE: ${{ github.ref_type }}
TAG: ${{ github.ref_name }}
SLOT: ${{ matrix.slot }}
BUILD_DIR: ${{ matrix.build_dir }}
run: |
set -euo pipefail
# git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。
PY=$(command -v python3 || command -v python)
args=()
# 槽位与产物根都从矩阵来:交叉槽位的二进制不在 target/release,脚本据此定位
# 并回读可执行文件头校验架构(宿主 x64 ≠ 产物 arm64 时冒烟自动跳过,只等 CI 真机)。
args=(--slot "$SLOT" --build-dir "$BUILD_DIR")
if [ "$REF_TYPE" = "tag" ]; then
args+=(--version "$TAG") # 与 Cargo.toml 版本交叉校验
fi
@@ -162,16 +280,21 @@ jobs:
run: |
set -euo pipefail
ls -l dist
for slot in windows-x64 linux-x64 macos-arm64 macos-x64; do
if ! compgen -G "dist/*-$slot*" >/dev/null; then
echo "::error::矩阵缺产物:$slot"
exit 1
fi
# 六槽位全齐才发版:缺任意一个就 fail(宁可不出 Release,也不出少一档的 Release)。
# 每个槽位都要 GUI 与 CLI 两个资产——只按「槽位名出现过」判会被其中一个掩盖。
for slot in windows-x64 windows-arm64 linux-x64 linux-arm64 macos-arm64 macos-x64; do
for prefix in unlucky-launcher ull; do
if ! compgen -G "dist/$prefix-*-$slot*" >/dev/null; then
echo "::error::矩阵缺产物:$prefix / $slot"
exit 1
fi
done
done
# 各平台任务各写一份 SHA256SUMS.txt,合并后只有一份是真的 —— 在 Release 侧重算。
# GUI 与 CLI 都要进清单:漏了 ull 就是「发了但没有校验和」。
cd dist
rm -f SHA256SUMS.txt
sha256sum unlucky-launcher-* > SHA256SUMS.txt
sha256sum unlucky-launcher-* ull-* > SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
cat SHA256SUMS.txt
@@ -225,7 +348,8 @@ jobs:
esac
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,这三份与产物并列挂到 Release 上,
# 保证每个下载平台的许可条款都随包分发。
assets=(dist/unlucky-launcher-* dist/SHA256SUMS.txt LICENSE NOTICE README.md)
# `ull` 是独立资产(不在 tar.gz 里):headless 用户下一个小文件直接跑,不用解档。
assets=(dist/unlucky-launcher-* dist/ull-* dist/SHA256SUMS.txt LICENSE NOTICE README.md)
if gh release view "$TAG" >/dev/null 2>&1; then
# 同 tag 重跑(补丁 / 重编):Release 已存在 → 覆盖资产 + 刷新说明,
# 不再 `create`(那会因已存在而失败,把整条流水线在最后一步染红)。
Generated
+1321 -97
View File
File diff suppressed because it is too large. Load diff
+68 -1
View File
@@ -1,6 +1,24 @@
[workspace]
resolver = "2"
members = ["crates/core", "crates/agent", "crates/app", "crates/cli"]
members = [
"crates/core",
"crates/agent",
# M85(D26):UI 框架无关的宿主层。Slint 版(crates/app)与 iced 版(crates/app-iced)
# 共用它;Slint 退役后只剩 iced 依赖,本 crate 不受影响。
"crates/presenter",
"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",
# M87(D40 第 2 层):配色取色的解码适配层。**唯一**允许引入 `image` 类依赖的地方,
# 使 `crates/design` 能继续宣称「零第三方依赖」。
"crates/palette-extract",
# M92(D28):国际化地基——语言探测 / 中英消息目录 / 中文字体策略。零第三方依赖、零 UI 依赖,
# 壳层(iced 版现在,别的壳将来)与 presenter 都依赖它,所以它不能认任何一个渲染框架。
"crates/i18n",
]
[workspace.package]
version = "0.1.0"
@@ -49,6 +67,10 @@ zip = { version = "9", default-features = false, features = ["deflate-flate2-zli
flate2 = "1"
tar = "0.4"
# M87(D40):配色取色的解码适配层。版本与 `crates/app-iced` 的截图通道对齐(同一份
# `image` 只编译一次);编解码器由各 crate 自己按需开 feature,这里只钉版本。
image = { version = "0.25", default-features = false }
# UI 层唯一入口(D2:Slint,RF 2.0 轨道)。
# 默认特性已含 backend-default + renderer-femtovg(GL)+ renderer-software(CPU 软渲染兜底,
# Win7 老机无可用 GPU 时的降级路径,见 DevDocs/PlanDoc/01c-native-gui-options.md §6)。
@@ -86,3 +108,48 @@ lto = "fat"
codegen-units = 1
panic = "abort"
strip = "symbols"
# P6(M96):日常迭代 / 本机验收用的「快」档,发布仍走上面的 release。
# `inherits = "release"` 让其余各项(strip / debug / overflow-checks …)照抄 release,
# 只覆盖拖慢构建的四处:
# - lto = "thin":跨 crate 内联仍有,但可以并行;fat LTO 在 codegen-units = 1 下等于
# 全程序串行链接(375 个 crate 排队),一次性吃掉十几分钟。
# - codegen-units = 16:给 codegen 留并行度,不再把整个 crate 挤成一个 CGU。
# - opt-level = 2:介于 "z" 与 3 之间,"z" 会为了体积关掉一批循环/内联优化,代价是运行期。
# - panic = "unwind":release 是 panic = "abort",inherits 会照抄。留着它是为了让 fast 档编出来的
# **二进制**走 unwind(panic 会栈展开,不是直接 abort)。
# ⚠️ 理由不是「不写就跑不了测试」:`cargo test` **不**依赖这一行 —— cargo 对 test 目标自己强制
# unwind。实测 `cargo test --profile release -p unlucky-i18n`(release = abort 档)照跑,23 个
# 测试全过,且 test 单元的 rustc 参数里**没有** `-Cpanic=`。别拿「cargo test 能跑」当这行生效的证据;
# 要看就用 `cargo build --profile {fast,release} -v` 比 rustc 参数:release 有 `panic=abort`,fast 没有。
#
# ⚠️ 定位(实测,别误读):本机(12 核 + 机械盘源码盘 + -j 4)冷编译 fast = 24m27s,
# **比 release 的 16m01s 还慢**,体积是 2.3 倍(36.41 vs 15.72 MiB GUI)。瓶颈在两个大
# UI crate 的编译 + 链接 + 磁盘 IO,LTO/CGU 这几个旋钮被掩盖了。它的价值在 **SSD 机器**和
# **增量重编**(thin 不必每次全程序 LTO);「加了 fast 档」≠「冷编译会变快」。
[profile.fast]
inherits = "release"
opt-level = 2
lto = "thin"
codegen-units = 16
panic = "unwind"
# P6(M96):依赖不需要为体积做极致优化,但**实测结论与直觉相反**——把依赖的 opt-level 从
# release 的 "z" 抬到 3(本档最初就是这么定的)会让冷编译**更慢**、体积还更大:
# 同机冷编译 opt-3 = 22m45s / 19.46 MiB(GUI),opt-"z" = 16m01s / 15.72 MiB(两臂同为 cu16)。
# 所以这里**opt-level 保持 "z",只把并行度放到 16**;workspace 自己的 crate 仍吃 "z" / 单 CGU。
# ⚠️ **未测**:cu1 → cu16 的收益本轮没有同条件对照(两臂都是 cu16,唯一自变量是 opt-level),
# 所以「cu16 才是收益来源」不是实测结论,要归因得单独跑一轮 cu1 对照。另:GUI 体积相对旧基线
# 涨了 12.9%(13.92 → 15.72 MiB),其中「代码增长」与「依赖 cu16」也没分离。
# **只调优化强度**:panic 与 strip 绝不写进这里。那两项改变的是整个二进制的运行时行为
# (panic 策略决定能否 unwind 出栈、strip 决定符号表留不留),不是「给依赖放宽」的范畴。
[profile.release.package."*"]
opt-level = "z"
codegen-units = 16
# `inherits = "release"` 会把 `[profile.release.package."*"]` **一起继承过来**(实测:删掉下面这块,
# fast 档的外部依赖就从 opt-level 2 退回 `"z"`;workspace 成员不受 `*` 覆盖、仍是 fast 自己的 2)。
# 所以这块**必须存在**,它是承重墙不是冗余兜底 —— 删掉会让 fast 档的依赖静默退回 `"z"`。
[profile.fast.package."*"]
opt-level = 2
codegen-units = 16
+27 -2
View File
@@ -18,13 +18,38 @@ Copyright 2026 AskaEth (YeiJ)
Slint 的其余许可分支(GPL-3.0-only / Slint Software License 3.0)本项目不适用。
2. Rust crates 生态
2. Google Material Color Utilities(https://github.com/material-foundation/material-color-utilities)
本产品的色彩令牌(crates/design 的 color 模块)是 Material 3 色彩算法(HCT 色彩空间 +
ColorSpec2021 调色板/角色映射)的 **Rust 重写移植**,依据 Apache License 2.0 使用,未把其任何语言的源码作为依赖
引入;除下面注明的那张生成常数表外,也没有复制其代码文件。
实际移植的部分(均为纯算法,逐函数对照后以 Rust 重写):
- `hct/Cam16.java`、`hct/ViewingConditions.java`、`hct/Hct.java` → `crates/design/src/color/cam16.rs`、
`viewing_conditions.rs`、`hct.rs`(CAM16 色相/彩度 + CIELAB `L*` 亮度)
- `hct/HctSolver.java` → `crates/design/src/color/hct_solver.rs` + `critical_planes.rs`
(色域映射求解器;其中 255 项 `CRITICAL_PLANES` 常数表是原样搬运的生成数据)
- `utils/ColorUtils.java`、`utils/MathUtils.java` → `crates/design/src/color/space.rs`
- `palettes/TonalPalette.java`、`dynamiccolor/ColorSpec2021.java`、`dynamiccolor/DynamicScheme.java`
→ `crates/design/src/color/tonal.rs`、`palettes.rs`、`scheme.rs`(Tonal Spot 变体的彩度常数与
49 个角色的 tone 映射)
对齐验证:`crates/design/tests/official_baseline.rs` 与 `tests/official_accent.rs` 的期望色值,
是把上述官方 Java 实现(Apache-2.0)在本机编译运行后输出的结果抄本——合计 290 项逐通道
比对(baseline 49 角色 × 2 模式 = 98 项;强调色 8 个种子 × 12 个强调族角色 × 2 模式 = 192 项),
误差上限 1(实测为 0)。两文件的表头各写着自己的分项口径。
Apache License 2.0 全文见 https://www.apache.org/licenses/LICENSE-2.0
上游版权声明:Copyright Google LLC。
3. Rust crates 生态
cargo-deny 白名单见 deny.toml:Apache-2.0 / MIT / BSD-2-Clause / BSD-3-Clause / Zlib / BSL-1.0 / ISC /
CC0-1.0 / MIT-0 / Unlicense / Unicode-3.0 / CDLA-Permissive-2.0 / Apache-2.0 WITH LLVM-exception。
GPL 系(含 AGPL、LGPL 静态链接、SSPL)依赖不得进入主干。
3. 参考实现致谢
4. 参考实现致谢
本项目设计参考自 HMCL(https://github.com/HMCL-dev/HMCL)与 PCL2
(https://github.com/Meloong-Git/PCL),两者均为 GPL-3.0,特此致谢。
+174 -67
View File
@@ -2,57 +2,130 @@
**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 in the same binary**: `unlucky-launcher instances list`, `unlucky-launcher launch <instance>`,
`unlucky-launcher logs <sid>` and the rest of the command set run before any UI is built — no
window, no renderer probe, no font scan. Commands: `instances`, `install`, `launch`, `accounts`,
`sessions`, `logs`, `diag`, `config`, `java`, `agent`, plus `--help` / `--version`.
- **A second, lighter artifact `ull`** (`cargo build --release --bin ull`, zero UI dependencies): the
same command set from a binary that links no graphics stack at all — not even fontconfig. Servers,
CI containers and Termux get `ull`; desktops get `unlucky-launcher` and one binary covers both GUI
and CLI. Both are release assets from the next tagged release (the `v0.1.0` assets predate this).
- 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; also runs every CLI subcommand |
| `unlucky-launcher-iced` | `crates/app-iced` (iced 0.14) | under construction; skeleton only, not shipped; same subcommand dispatch |
| `ull` | `crates/cli` | the zero-UI build of the same command set, shipped as its own asset |
`unlucky-launcher` and `ull` are not duplicates. The GUI binary needs the graphics stack when it
opens a window (it `dlopen`s X11 / Wayland / GL, and links `libfontconfig` at load); `ull` needs
nothing beyond glibc, so it runs on a server, a CI container or Termux where no window can exist.
Pick `ull` when there is no desktop, `unlucky-launcher <command>` when there is. Both print the same
CLI text and name themselves after the binary you invoked: `unlucky-launcher --help` starts with
`unlucky-launcher ·`, `ull --help` starts with `ull ·`.
### Subcommand dispatch
`unlucky-launcher <args>` first asks "is this a CLI call?" before any UI code runs:
```bash
unlucky-launcher # GUI
unlucky-launcher --software-renderer # GUI, CPU renderer (unchanged)
unlucky-launcher instances list # CLI, no window
unlucky-launcher --json launch 1.20.1 # CLI, global options before the command
unlucky-launcher --version # CLI, prints version + OS summary
```
The rule is deliberately narrow, so nothing that used to work changed meaning:
- The first argument is a bare word (not `-…`) → it sits in the subcommand position, so the CLI gets
it. GUI flags never appear there: `--screenshot` / `--diff` / `--data-dir` take their values
*after* the flag.
- The first argument is a flag → the line is a CLI call only if `crates/cli`'s parser also produces a
real command (`--help`, `--version`, `--json instances list`). `--data-dir X` alone parses to "no
subcommand", so it still opens the GUI; every GUI-only flag (`--software-renderer`,
`--screenshot*`, `--renderer`, `--theme`, `--diff`, `--force-state`, …) fails CLI parsing, so it
still opens the GUI.
**Unknown subcommands are an error, not a GUI launch**: `unlucky-launcher lst` prints
"未知命令 `lst`", hints `unlucky-launcher --help`, and exits 1. Silently opening a window would tell
a script that its typo took effect. The subcommand table itself lives in exactly one place —
`crates/cli/src/cli/parse.rs` — so both GUI binaries and `ull` accept the same set by construction.
## 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 +136,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 +145,87 @@ 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). It is also the CLI: the same binary answers
every subcommand listed above.
- `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. Same subcommand dispatch.
- `ull` — the CLI as a standalone, UI-free binary. Same command set, no graphics stack, smaller, and
the right artifact for a machine that has no display at all.
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.
+148 -45
View File
@@ -2,41 +2,117 @@
[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**:`unlucky-launcher instances list`、`unlucky-launcher launch <实例>`、
`unlucky-launcher logs <sid>` 等命令在任何 UI 代码之前分流——不开窗、不探渲染后端、不扫字体。
命令集:`instances`、`install`、`launch`、`accounts`、`sessions`、`logs`、`diag`、`config`、
`java`、`agent`,外加 `--help` / `--version`。
- **另有一个更轻的 `ull`**(`cargo build --release --bin ull`,零 UI 依赖):同一套命令,但二进制不
链接任何图形栈——连 fontconfig 都不需要。服务器、CI 容器、Termux 拿 `ull`;有桌面的机器拿
`unlucky-launcher`,一个文件同时管 GUI 与命令行。两者都从下一个 tag 起进发布资产
(`v0.1.0` 的资产早于这个变化)。
- 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 已发布,仍是当前能用的图形界面;同时支持全部 CLI 子命令 |
| `unlucky-launcher-iced` | `crates/app-iced`(iced 0.14) | 施工中,仅骨架,不随发布包分发;子命令分发同款 |
| `ull` | `crates/cli` | 同一套命令的零 UI 构建,作为独立资产随发布分发 |
`unlucky-launcher` 与 `ull` 不是重复品:开窗时 GUI 二进制要用图形栈(X11 / Wayland / GL 走
`dlopen`,启动时直接链接 `libfontconfig`);`ull` 除了 glibc 什么都不需要,所以在没有窗口的服务器、
CI 容器、Termux 上跑得起来。没有桌面就用 `ull`,有桌面就用 `unlucky-launcher <子命令>`。两者打出来的
CLI 文案是同一套,自称的名字跟着**你敲的那个二进制**走:`unlucky-launcher --help` 首行是
`unlucky-launcher ·`,`ull --help` 首行是 `ull ·`。
### 子命令分发
`unlucky-launcher <参数>` 在跑任何 UI 代码之前先问一句「这是不是一次 CLI 调用」:
```bash
unlucky-launcher # GUI
unlucky-launcher --software-renderer # GUI,CPU 渲染(行为不变)
unlucky-launcher instances list # CLI,不开窗
unlucky-launcher --json launch 1.20.1 # CLI,全局选项排在子命令前面
unlucky-launcher --version # CLI,输出版本 + 系统摘要
```
判据刻意收得很窄,所以原本能用的用法一条都没变:
- 第一个参数是裸词(不以 `-` 开头)→ 它就在子命令的位置上,交给 CLI。GUI 的 flag 永远不会出现在
那个位置:`--screenshot` / `--diff` / `--data-dir` 的取值都排在 flag 之后。
- 第一个参数是 flag → 只有当 `crates/cli` 的解析器也解析出一条真命令时才算 CLI 调用
(`--help`、`--version`、`--json instances list`)。单独 `--data-dir X` 解析成「没有子命令」,
仍然开 GUI;GUI 专属参数(`--software-renderer`、`--screenshot*`、`--renderer`、`--theme`、
`--diff`、`--force-state` 等)在 CLI 里一律解析失败,也仍然开 GUI。
**未知子命令是错误,不是开窗**:`unlucky-launcher lst` 打印「未知命令 `lst`」、提示
`unlucky-launcher --help`,以 1 退出。静默开窗会让脚本以为自己那个拼错的参数生效了。子命令表只有
一处定义(`crates/cli/src/cli/parse.rs`),所以两个 GUI 二进制和 `ull` 认的命令集天然一致。
## 模块地图
```
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 +123,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 +132,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 版(已发布的那个)。它同时也是 CLI:同一个二进制认上面列的全部子命令。
- `unlucky-launcher-iced`——iced 版骨架。名字是临时的:只有翻牌时才能改成 `unlucky-launcher`,
因为同一个工作区里两个 crate 不能往同一个 target 目录里产出同名 bin。子命令分发同款。
- `ull`——CLI 的独立无 UI 构建:同一套命令、不链接图形栈、体积更小,给完全没有显示设备的机器用。
渲染开关:
- `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 玩家走了很多年,
本项目在设计取舍与实现细节上都从中受益良多。
+92
View File
@@ -0,0 +1,92 @@
[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" }
# M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**——
# 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。
# 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。
unlucky-presenter = { path = "../presenter", 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",
# M93:背景图要真图直出 ⇒ 需要渲染器的 image 支持。用 `image-without-codecs` 而不是
# `image` 是刻意的:后者会打开 `image/default` 的全套编解码器;前者的编解码能力由本 crate
# 依赖表里那条 `image` 的 features 决定(png / jpeg / bmp),不必把 webp/tiff/exr/gif 拖进来。
"image-without-codecs",
"svg",
"thread-pool",
] }
# 截图落 PNG + 背景图解码(M93):png 是截图通道,jpeg/bmp 是用户真会挑的背景图/壁纸格式
# (M73 的壁纸探测不筛扩展名)。仍不开 default-features——那会拖进 avif/ravif/webp/tiff 一整套。
image = { version = "0.25", default-features = false, features = [
"png",
"jpeg",
"bmp",
] }
# M82:同一个二进制也带 CLI 子命令(`unlucky-launcher-iced instances list` …),
# 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli →
# core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull`
# 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。
unlucky-cli = { path = "../cli", version = "0.1" }
# M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced,
# 所以系统字体扫描留在这里(`src/fonts/cjk.rs`),本 crate 只把族名喂给它的纯函数。
unlucky-i18n = { path = "../i18n", version = "0.1" }
# M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`,
# 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` /
# `wgpu*` 的 logger(见 `src/renderer/log_bridge.rs`),把这行捞出来。
# 已是 iced 传递依赖树里的版本(0.4.34),不新增任何传递依赖;MIT OR Apache-2.0,许可白名单内。
log = { version = "0.4", features = ["std"] }
# X11 / Wayland 后端与 Linux 主题检测只在 Linux 上启用:macOS 没有 X11/Wayland(走 Metal +
# 系统外观),Windows 走 DX12,这三个 feature 在那边没有意义。同一个包在两处写 features 是
# **取并集**(Cargo 的 feature 解析是加法),所以 Linux 上拿到的仍是上面 5 个 + 这 3 个,
# Windows / macOS 只拿上面那 5 个(`cargo tree --target` 可复核)。
# 诚实边界:这**不**解决「iced 版至今没在 macOS 上编过」这件事——`iced_winit` 已把 `mundy`
# 放在 `cfg(target_os = "linux")` 段里,这三个 feature 在非 Linux 上本来就不拉依赖,这里只是
# 让本仓库的依赖图按平台自述清楚,不再把 Linux 专属能力写成全平台事实。
[target.'cfg(target_os = "linux")'.dependencies]
iced = { version = "=0.14.0", default-features = false, features = [
"x11",
"wayland",
"linux-theme-detection",
] }
[features]
default = ["gpu"]
# 硬件渲染后端(wgpu→Vulkan/DX12)。关闭后只剩 tiny-skia(CPU),用于体积基线与无 GPU 机器。
gpu = ["iced/wgpu"]
[lints]
workspace = true
+37
View File
@@ -0,0 +1,37 @@
//! 页面骨架截图入口(M84 `PAGEFRAME` 的验收通道)。
//!
//! ```text
//! cargo run -p unlucky-app-iced --example pages_demo -- \
//! --screen=instances --width=1180 --height=760 \
//! --screenshot=out.png --screenshot-theme=light --software-renderer
//! ```
//!
//! 参数解析与 iced 接线都在 `unlucky_app_iced::pages::harness` 里:**本 example 只是入口**,
//! 它也刻意不接 `cli.rs`(那边归 M75)。详细口径见 `pages::harness` 的模块文档。
use std::process::ExitCode;
use unlucky_app_iced::pages::harness::{self, Request};
fn main() -> ExitCode {
let request = match Request::parse(std::env::args().skip(1)) {
Ok(request) => request,
Err(message) => {
eprintln!("[cli] {message}");
eprintln!(
"用法:pages_demo [--screen=instances|download|settings|logs] [--width <px>] \
[--height <px>] [--theme=light|dark] [--screenshot-theme=light|dark] \
[--screenshot <png>] [--renderer=auto|gpu|cpu] [--software-renderer]"
);
return ExitCode::from(2);
}
};
match harness::run(request) {
Ok(()) => ExitCode::SUCCESS,
Err(message) => {
eprintln!("[run] {message}");
ExitCode::FAILURE
}
}
}
+118
View File
@@ -0,0 +1,118 @@
//! App 侧的外观动作:**下发整窗 α / 写回配置 / 打取证日志**。
//!
//! 三件事各自只有一条路,且都从这里出去(`update` 只负责把它们按消息串起来):
//!
//! | 动作 | 唯一落点 | 为什么 |
//! |---|---|---|
//! | 下发整窗 α | [`App::apply_window_alpha`] → `app::window_alpha::dispatch` | 判据在 `platform`、FFI 在 `platform/win32`,这里只做「取 α → 交给执行侧 → 记日志」 |
//! | 写回配置 | [`App::persist_appearance`](8 个 UI 字段)+ `AppCommand::SetSetting`(另外两个只读键) | 每个键**只有一个写者**:`AppearancePrefs` 里没有 `window_opacity` / `palette_source`,它们只能走 schema 指令 |
//! | 取证日志 | [`App::log_appearance`] | 一行里同时有请求值、生效值、下发字节与 FFI 结局 |
//!
//! ## 为什么「写回」要分两条路
//!
//! `SettingsBridge::persist` 收的是 `AppearancePrefs`(背景图四件 + 面板 α + 玻璃 + 强调色),
//! 它的实现是「先读文件当前值,再覆盖我们改的那几个字段」——所以用它写**它覆盖的键**最省事,
//! 也不会把用户的自定义强调色冲掉。而 `appearance.window_opacity` / `appearance.palette_source`
//! 不在那个结构里(它们是「只读下层键」,见 `presenter::prefs::AppearanceBase`),
//! 写它们只能走 schema 白名单指令 `AppCommand::SetSetting`(真 host 用 `SettingStore::set` 落盘)。
use unlucky_presenter::dto::AppCommand;
use crate::pages::appearance::Change;
use super::state::App;
use super::window_alpha::{self, Dispatch};
impl App {
/// 把当前整窗 α 交给执行侧(补 `WS_EX_LAYERED` + `SetLayeredWindowAttributes`)并记一行日志。
///
/// 没有句柄时**也打日志**(`hwnd=none`):不能让「α 没生效」这件事只体现为「屏幕上没变化」。
pub(super) fn apply_window_alpha(&self, context: &str) {
let Some(hwnd) = self.hwnd else {
self.log_appearance(context, None);
return;
};
let alpha = crate::platform::effective_window_alpha(
self.appearance.backend,
f64::from(self.appearance.window_opacity),
);
let dispatch = window_alpha::dispatch(hwnd, alpha);
self.log_appearance(context, Some(&dispatch));
}
/// 取证日志:`[appearance] <状态行> layered=… outcome=…`。
///
/// 数值来自 [`crate::pages::appearance::State::log_line`](请求值 / 生效值 / 下发字节),
/// 尾巴来自执行侧。**这一行就是「透明度到底设成了多少」的证据**——RDP 上的观感不可信。
pub(super) fn log_appearance(&self, context: &str, dispatch: Option<&Dispatch>) {
let mut line = self.appearance.log_line(context);
if let Some(dispatch) = dispatch {
line.push(' ');
line.push_str(&dispatch.describe());
} else {
line.push_str(" layered=skipped outcome=no-window");
}
eprintln!("{line}");
}
/// 设置页改了一项外观。
pub(super) fn on_appearance(&mut self, change: Change) {
let needs_window = self.appearance.apply(change.clone());
// 配色来源 / 背景图可能变了 ⇒ `Ui` 可能要改用解析出的方案。
self.rebuild_ui();
self.view_dirty = true;
self.refresh_view();
match change.key() {
// 两个只读下层键:走 schema 指令(`SettingsBridge` 的结构里没有它们)。
Some(_) if matches!(change, Change::WindowOpacity(_) | Change::PaletteSource(_)) => {
if let (Some(key), Some(value)) = (change.key(), change.value()) {
self.dispatch(AppCommand::SetSetting {
key: key.to_owned(),
value,
});
}
}
// 其余键:走 bridge(它知道怎么保留强调色这类本 mission 不管的字段)。
Some(_) => self.persist_appearance(),
None => {}
}
if needs_window {
self.apply_window_alpha("change");
} else {
self.log_appearance("change", None);
}
}
/// 重读配置后同步外观(热切:外部 CLI / AI 改完 1 秒内生效)。
pub(super) fn reload_appearance(&mut self) {
let (prefs, _theme) = self.bridge.load();
let base = self.bridge.load_base();
self.appearance.reload(&prefs, &base);
self.appearance_prefs = prefs;
self.rebuild_ui();
self.view_dirty = true;
self.apply_window_alpha("reload");
}
/// 把外观写回 `launcher.json`(只改本 mission 管的字段,强调色/玻璃原样保留)。
fn persist_appearance(&mut self) {
let mut prefs = self.appearance_prefs.clone();
prefs.bg_image_enabled = self.appearance.bg_enabled;
prefs.bg_image = self.appearance.bg_path.clone();
prefs.bg_fill = self.appearance.bg_fill;
prefs.bg_dim = self.appearance.bg_dim;
prefs.panel_alpha = self.appearance.panel_alpha;
prefs.glass = self.appearance.glass;
self.bridge.persist(&prefs, self.theme_mode);
self.appearance_prefs = prefs;
match self.bridge.path() {
Some(path) => eprintln!("[settings] 外观已写回 {}", path.display()),
None => eprintln!("[settings] 外观未落盘(数据根解析不出来,内存态运行)"),
}
}
}
+177
View File
@@ -0,0 +1,177 @@
//! 本进程**实际**用的是哪个渲染后端(D36 的「CPU 档」判据)。
//!
//! ## 为什么不能直接看 `--renderer` 的请求值
//!
//! `Options::backend` 是**请求**(默认 `Auto`)。真正落到哪个渲染器由 `probe::apply` 决定,
//! 它把结论写进进程环境变量 `ICED_BACKEND`(iced 只认这个)。于是默认参数 + 没有独显的机器上:
//! 请求是 `Auto`,实际是 tiny-skia(CPU)——如果面板 α / 玻璃的降级判据只看请求值,
//! D36 明写的「CPU 档面板 α 强制 1.0」**恰好在它最该生效的场景失效**(复审 r1 P2-2)。
//!
//! 所以判据统一走 [`effective_backend`]:它把 `ICED_BACKEND` 翻译回 [`Backend`]。
//! 这里是**唯一**的翻译点,别在别处再读一遍环境变量。
//!
//! ## M97 之后这条链是准的(以前不是)
//!
//! 以前 `ICED_BACKEND` 可能是**候选列表**(`wgpu,tiny-skia`),运行期 wgpu 静默回退到
//! tiny-skia 时这个变量还停在 wgpu ⇒ 本模块按 **GPU 档**判 ⇒ **半透明在 CPU 渲染器上照开**。
//! 也就是说「静默降级」不只让日志说谎,还让 D36 的降级判据一起说谎。
//!
//! 现在 `ICED_BACKEND` **永远是单项**([`crate::renderer::Kind`]),而且 `app::boot` 的重试
//! 在降级重开前会把它改成 `tiny-skia`,于是:**真降级到 CPU 的那一次,面板 α 会正确地强制 1.0**。
//!
//! ## 已知边界(诚实登记)
//!
//! `iced` 0.14 没有公开的「当前后端」查询,所以本模块读的仍是**我们请求的那一项**。
//! 单项 + 重试([`crate::renderer`] 判据表第 8 行)把「请求 ≠ 实际」的窗口压到只剩一种:
//! 重试那次自己也失败——那时候进程会带着具名错误退出,不会带着错的档位继续画。
use crate::renderer::Kind;
use crate::Backend;
/// `ICED_BACKEND` 的取值 → 后端(纯函数,好测)。
///
/// 逗号列表按**首项**解释:兼容 M97 之前写下的值(以及外部手设的实验值),
/// 但本 crate 不再产出那种值(守门测试见 `probe::tests::apply_writes_exactly_one_backend`)。
#[must_use]
pub(super) fn parse_kind(env: &str) -> Option<Kind> {
match env.trim().split(',').next()?.trim() {
"tiny-skia" => Some(Kind::TinySkia),
"wgpu" => Some(Kind::Wgpu),
_ => None,
}
}
/// `ICED_BACKEND` → 实际档位(纯函数,好测)。
///
/// | `ICED_BACKEND` | 含义(`probe::apply` 的产物) | 结论 |
/// |---|---|---|
/// | `tiny-skia` | 只有 CPU(`--renderer=cpu` / 未编 wgpu / 探测无硬件 / wgpu 失败后重开) | [`Backend::Cpu`] |
/// | `wgpu` | 判据表第 3、6 行:起步就是 GPU(起不来会打印具名错误并重开成 `tiny-skia`) | [`Backend::Gpu`] |
/// | 未设 / 别的值 | 不是本 crate 写的(外部手设 / M97 之前的旧进程) | 请求值(不猜) |
#[must_use]
pub(super) fn backend_from_env(request: Backend, env: Option<&str>) -> Backend {
env.and_then(parse_kind).map_or(request, Kind::tier)
}
/// 本进程实际生效的后端(判据 = `probe::apply` 写下的 `ICED_BACKEND`)。
#[must_use]
pub(super) fn effective_backend(request: Backend) -> Backend {
backend_from_env(request, std::env::var("ICED_BACKEND").ok().as_deref())
}
/// 本进程实际生效的**后端名**(`None` = 变量没写 / 不认识)。
///
/// 与 [`effective_backend`] 的区别:那个给「档位」,这个给「名字」,用于**取证日志**
/// (`[shot] … renderer=wgpu`)。两张图分辨不出 GPU/CPU 的时候,靠的就是这一行。
#[must_use]
pub(super) fn effective_kind() -> Option<Kind> {
std::env::var("ICED_BACKEND")
.ok()
.as_deref()
.and_then(parse_kind)
}
#[cfg(test)]
mod tests {
use super::backend_from_env;
use crate::Backend;
use unlucky_presenter::prefs::{PANEL_ALPHA_MIN, WINDOW_ALPHA_MIN};
/// 抓得住:`probe::apply` 现在产出的**每一种单项值**都被翻译成正确的档位,
/// 且 M97 之前写下的逗号列表仍按首项解释(向后兼容)。
/// 抓不住:运行期重试降级那一刻的时序(那由 `app::boot` 的重试保证,见 `renderer` 判据表第 8 行)。
#[test]
fn every_iced_backend_value_maps_to_a_tier() {
// 本 crate 现在只产单项(`probe::tests::apply_writes_exactly_one_backend` 守这一条)。
assert_eq!(
backend_from_env(Backend::Auto, Some("tiny-skia")),
Backend::Cpu,
"M97 判据表第 5 行:探测无硬件 ⇒ CPU 档"
);
assert_eq!(
backend_from_env(Backend::Auto, Some("wgpu")),
Backend::Gpu,
"M97 判据表第 6 行:有硬件 ⇒ GPU 档"
);
assert_eq!(
backend_from_env(Backend::Cpu, Some("tiny-skia")),
Backend::Cpu,
"强制 CPU 也是 CPU 档"
);
// 兼容 M97 之前的逗号列表(外部手设 / 旧进程留下):按首项解释。
assert_eq!(
backend_from_env(Backend::Auto, Some("tiny-skia,wgpu")),
Backend::Cpu
);
assert_eq!(
backend_from_env(Backend::Auto, Some("wgpu,tiny-skia")),
Backend::Gpu
);
assert_eq!(
backend_from_env(Backend::Gpu, Some("wgpu,tiny-skia")),
Backend::Gpu
);
// 未设 / 空白:保持请求值(不是本 crate 写的,不猜)。
assert_eq!(backend_from_env(Backend::Auto, None), Backend::Auto);
assert_eq!(backend_from_env(Backend::Auto, Some(" ")), Backend::Auto);
// 不认识的取值不猜:保留请求值。
assert_eq!(
backend_from_env(Backend::Auto, Some("metal")),
Backend::Auto
);
}
/// **复审 r1 P2-2 的守门**:默认档(`Auto`)在无独显机器上必须落进 CPU 档,
/// 从而让「面板 α / 玻璃强制 1.0」在默认路径上真的生效。
///
/// 抓得住:判据走 `effective_backend()` 这条链(谁把 `State::new` 改回只吃请求值,这条会红)。
/// 抓不住:整窗 α 在 CPU 档仍要生效(那是另一条判据,见 `alpha` 模块真值表)。
#[test]
fn auto_with_a_software_renderer_forces_the_panel_opaque() {
use crate::pages::appearance::alpha::{effective_glass, effective_panel_alpha};
let backend = backend_from_env(Backend::Auto, Some("tiny-skia"));
assert_eq!(backend, Backend::Cpu);
assert_eq!(
effective_panel_alpha(backend, 0.55),
1.0,
"D36:CPU 档面板 1.0"
);
assert!(!effective_glass(backend, true), "D36:CPU 档玻璃强制关");
// 但整窗 α 不受限(D36 对 D27 的修订)。
assert!(crate::platform::effective_window_alpha(backend, 0.8).is_some());
// 反方向:GPU 档不做这个降级(否则「强制」就没意义了)。
let gpu = backend_from_env(Backend::Auto, Some("wgpu"));
assert_eq!(effective_panel_alpha(gpu, 0.55), 0.55);
assert!((PANEL_ALPHA_MIN - 0.4).abs() < 1e-9 && (WINDOW_ALPHA_MIN - 0.08).abs() < 1e-9);
}
/// **M97 的新守门**:wgpu 失败重开成 `tiny-skia` 之后,档位必须跟着变成 CPU 档。
///
/// 这是修之前做不到的一条:那时变量停在 `wgpu,tiny-skia`,静默回退到 tiny-skia 之后
/// 判据仍然读 `Gpu` ⇒ 半透明在 CPU 渲染器上照开。现在 `app::boot` 重开前会改写这个值,
/// 这条测试钉的是「改写成 tiny-skia 之后判据确实翻转」。
/// **两个后端取值各自对应哪个档位、面板 α 各是多少。**
///
/// ⚠️ 这条**不**守「降级后档位翻转」——它只是把两个字面量喂给纯函数,`respawn` 一行都没执行。
/// 复审 M97 r1 p2-1 就是靠这一点把 `respawn` 里的 `.env("ICED_BACKEND", …)` 删掉而测试全绿的。
/// 真正的翻转由 `app::boot::tests::fallback_env_carries_the_backend_and_the_gate`
/// (respawn 到底传了什么)钉住——那条红了才是「翻转坏了」。
#[test]
fn each_backend_value_maps_to_its_tier_and_alpha() {
use crate::pages::appearance::alpha::effective_panel_alpha;
let gpu = backend_from_env(Backend::Auto, Some("wgpu"));
assert_eq!(gpu, Backend::Gpu);
assert_eq!(effective_panel_alpha(gpu, 0.55), 0.55);
let cpu = backend_from_env(Backend::Auto, Some("tiny-skia"));
assert_eq!(cpu, Backend::Cpu);
assert_eq!(
effective_panel_alpha(cpu, 0.55),
1.0,
"D36:CPU 档面板 α 强制 1.0"
);
}
}
+311
View File
@@ -0,0 +1,311 @@
//! 启动:后端探测 → 字体求值 → `iced::application(..).run()`。
//!
//! ## M97:起步后端起不来时怎么办(两条路,策略不同,别合成一条)
//!
//! `ICED_BACKEND` 现在永远是**单项**(见 [`crate::renderer`] 的判据表)。单项的代价是
//! iced 不再自己做回退,于是「wgpu 起不来」会变成 `iced::Error::GraphicsCreationFailed`
//! ——**这正是我们要的**:那里面装着 wgpu 的具名错误(哪个 backend、卡在 adapter 还是 device)。
//!
//! 拿到具名错误之后分两条路(判据表第 8 行 / 第 9 行):
//!
//! - **交互启动**:换一个**进程**用 `tiny-skia` 重开([`OnFailure::Respawn`])——安全网,用户还能用。
//! 代价是子进程把启动流程**重跑一遍**(重新探测?不:`ULL_GPU_FALLBACK` 让它跳过探测;
//! 但**字体栈、配置、presenter 都会重算**,实测首帧 531 ms)。这条代价只落在失败路径上。
//! - **取证启动**(带 `--screenshot`,含 `--screen=gallery`):**拒绝出图**、退出码 1
//! ([`OnFailure::Refuse`])——换了后端出的图会让报告撒谎。
//!
//! 为什么必须是「换进程」而不是「在本进程里再 `run()` 一次」:Windows 实测直接 panic
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)——
//! winit 一个进程只能建一次事件循环。猜测式重试别再加回来。
use std::time::Instant;
use iced::{window, Task};
use unlucky_design::color::SchemeFlavor;
use crate::cli::Options;
use crate::fonts;
use crate::probe;
use crate::renderer::{self, Decision, Kind};
use crate::theme::Families;
use super::message::Message;
use super::state::{App, STARTED};
use super::{MIN_SIZE, WINDOW_SIZE};
/// 启动 iced(阻塞到退出)。`--diff` 不走这里,见 `main.rs`。
pub(crate) fn run(options: Options) -> iced::Result {
let _ = STARTED.set(Instant::now());
let decision = boot_probe(&options);
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` > **Light**(唯一取值口在 `Options::capture_flavor`)。
// 这里**不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。
if options.screenshot.is_some() && options.shot_flavor().is_none() {
eprintln!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)");
}
let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light);
// 起步后端起不来时按判据表第 8/9 行分流(交互降级 vs 取证拒绝)。具名原因在分流之前
// 就打到同一个 stderr 上了。
match launch(&options, families, flavor, decision.primary) {
Err(iced::Error::GraphicsCreationFailed(error)) => {
renderer::report_failure(decision.primary, &error);
match on_failure(decision, options.screenshot.as_ref()) {
Some(OnFailure::Respawn(fallback)) => respawn(decision.primary, fallback),
Some(OnFailure::Refuse) => {
// 取证启动(`--screenshot`):**拒绝降级出图**。图是要拿去下结论的,
// 换了后端出的图会撒谎——以前就是这么把 CPU 截图当 GPU 结论用的。
eprintln!(
"[gpu] 拒绝降级出图:本次是取证启动(--screenshot {}),换后端出的图会撒谎。",
options
.screenshot
.as_ref()
.map_or_else(String::new, |path| path.display().to_string())
);
eprintln!(
"[gpu] 想用 CPU 出图请显式加 --renderer=cpu / --software-renderer(CI 就是这么跑的)。"
);
Err(iced::Error::GraphicsCreationFailed(error))
}
None => Err(iced::Error::GraphicsCreationFailed(error)),
}
}
other => other,
}
}
/// 起步后端失败后的去向(判据表第 8 行 vs 第 9 行——**两条路策略不同,别合成一条**)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OnFailure {
/// 交互启动:安全网优先,换进程用这个后端重开。
Respawn(Kind),
/// 取证启动:红着退出,**不出图**。
Refuse,
}
/// 纯判据:起步后端失败时走哪条路。
///
/// `evidence` 非空 = 本次是取证启动(带 `--screenshot`,含 `--screen=gallery`),路径只用于日志。
/// 返回 `None` = 没有兜底可用(`--renderer=cpu` / 未编 wgpu),原样把错误交回去。
///
/// 抽成纯函数只为一个理由:这条分支以前**没有任何测试**,`respawn` / `Refuse` 两条路可以被
/// 静默改反(复审 M97 r1 p2-1 就是这么测出来的)。
fn on_failure(decision: Decision, evidence: Option<&std::path::PathBuf>) -> Option<OnFailure> {
let fallback = decision.fallback?;
Some(match evidence {
Some(_) => OnFailure::Refuse,
None => OnFailure::Respawn(fallback),
})
}
/// 降级重开要传给子进程的环境变量(**两条缺一不可**)。
///
/// ① `ICED_BACKEND`:让子进程直接用 `fallback` 跑。
/// ② [`renderer::FALLBACK_ENV`]:**防无限重开的闸门**——子进程见它就必须跳过探测
/// (`boot_probe` 的第一个分支),否则会算出同一个结论、再失败、再 respawn。
///
/// 抽成纯函数是为了让「漏传其中之一」变成**测试能抓到**的错误:这两条都是静默失效型
/// (漏 ① 会让降级重开又跑一次 wgpu、漏 ② 会无限重开),线上都极难看出来。
fn fallback_env(fallback: Kind) -> [(&'static str, &'static str); 2] {
[
("ICED_BACKEND", fallback.env_value()),
(renderer::FALLBACK_ENV, fallback.env_value()),
]
}
/// 安全网:起一个**新进程**用 `fallback` 后端重开,然后带着它的退出码退出。
///
/// 为什么必须是新进程:winit 一个进程只能建一次事件循环
/// (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。
///
/// 只在**交互启动**时走到这里;取证启动(`--screenshot`)宁可红着退出也不换后端出图
/// (判据表第 8 行 vs 第 9 行——**这两条路的策略不同,别合成一条**)。
///
/// ## `ULL_GPU_FALLBACK` 是防自杀循环的唯一闸门
///
/// 子进程看到 [`renderer::FALLBACK_ENV`] **必须跳过探测**(`boot_probe` 的第一段),换成
/// `tiny-skia` 直接跑;否则子进程会算出和父进程一样的结论(auto + 有硬件 → wgpu)、再失败、
/// 再 respawn —— **无限递归重开**。这种循环极难复现也极难诊断(每次重开都像是「刚刚启动」),
/// 所以闸门放在探测入口的第一个分支,不放在 respawn 那一侧。
fn respawn(failed: Kind, fallback: Kind) -> ! {
let exe = std::env::current_exe().unwrap_or_else(|err| {
eprintln!("[gpu] 降级重开失败:拿不到自身路径({err})");
std::process::exit(exit_code(None));
});
eprintln!(
"[gpu] 降级 → {}({} 已失败,原因见上两行;换一个进程重开:winit 不允许同进程建两次事件循环)。\
下一次的日志从 `[probe] 本次是降级重开` 开始。",
fallback.name(),
failed.name()
);
let status = fallback_command(&exe, std::env::args_os().skip(1), fallback).status();
// 退出码**透传**子进程的:上层脚本/CI 靠它判断这次到底是成功还是失败。
// 写错这一段的失效方式是「安静地给出一个错结论」——不崩、不静默降级、不无限重开,
// 只是让 CI 把失败当成功。所以它拆成 [`exit_code`] 这个纯函数,由测试钉住。
let code = match status {
Ok(status) => exit_code(status.code()),
Err(err) => {
eprintln!("[gpu] 降级重开失败:{err}");
exit_code(None)
}
};
std::process::exit(code)
}
/// 子进程退出码 → 本进程退出码。
///
/// `None` = 子进程被信号杀死(只有 Unix 会这样,`ExitStatus::code` 返回 `None`)。
/// 这种情况走兜底码而**不是** panic,也不是 0:上层的判据是「非零 = 失败」,
/// 「拿不到退出码」和「退出码是某个非零值」对脚本是一回事。
fn exit_code(code: Option<i32>) -> i32 {
/// 重开失败 / 拿不到退出码时的兜底码(与 `main` 的「参数或运行错误」一致)。
const RESPAWN_FAILED: i32 = 2;
code.unwrap_or(RESPAWN_FAILED)
}
/// 构造降级重开的子进程命令(**可测**:program / argv / env 都能在测试里断言)。
///
/// `program` / `args` 是参数而不是在函数里读 `std::env`,唯一目的就是让测试能喂一个假命令
/// (`cmd /c exit 7` / `sh -c "exit 7"`)来验退出码透传——真用 `current_exe()` 就没法测了。
fn fallback_command(
program: &std::path::Path,
args: impl IntoIterator<Item = std::ffi::OsString>,
fallback: Kind,
) -> std::process::Command {
let mut command = std::process::Command::new(program);
command.args(args).envs(fallback_env(fallback));
// 三路 stdio **显式**继承。`Command` 默认就是继承,但显式写出来有两个作用:
// ① 将来为了「安静」加上 `.stderr(Stdio::null())` 时,diff 里看得见;
// ② 降级那次的子进程日志(`[probe] 本次是降级重开`)**必须和父进程同一路输出**,
// 否则验收脚本只抓到父进程那两行具名错误,看不到降级后到底用什么跑起来了。
// 注:`Command` 没有 stdio 的 getter(stable 没有),所以这条**只能靠真跑一次**证明
// (见 §Windows 实测的 `interactive.log`:父子两段日志在同一个 stderr 文件里)。
command
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
command
}
/// 按一次后端启动 iced。`backend` 决定这次写进 `ICED_BACKEND` 的唯一一项。
fn launch(
options: &Options,
families: Families,
flavor: SchemeFlavor,
backend: Kind,
) -> iced::Result {
std::env::set_var("ICED_BACKEND", backend.env_value());
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(default_font(families))
.window(window::Settings {
size: WINDOW_SIZE,
min_size: Some(MIN_SIZE),
decorations: false,
resizable: true,
// M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但**这里给不了**——
// ① iced 0.14 的 `window::Settings` 没有扩展样式字段;
// ② winit 0.30 只在鼠标穿透(`IGNORE_CURSOR_EVENT`)时置这一位,`transparent: true`
// 走的是 DWM 那条路,与 `LWA_ALPHA` 无关;
// ③ 加扩展样式只有 `Get/SetWindowLongPtrW`,是 `unsafe`,而全仓 `unsafe` 只允许出现在
// `platform/win32/transparency.rs`(CI 的 `unsafe-scope` 是集合相等)。
// 所以窗口创建侧只负责开窗,样式补齐与 α 下发都在拿到句柄之后:
// `app::update` 的 `Message::WindowHandle` → `window_alpha::dispatch`(先 ensure_layered)。
transparent: false,
..window::Settings::default()
});
match options.scale_factor {
Some(scale) => application.scale_factor(move |_| scale).run(),
None => application.run(),
}
}
/// 渲染后端探测 + 写 `ICED_BACKEND`,返回判据产物(供 `run` 决定要不要换进程重开)。
fn boot_probe(options: &Options) -> Decision {
// 本进程是「上一步失败后重开」的那一次:别再探一次(会得到同一个结论,然后无限重开)。
// 这一步的决策**兜底必须是 None**——由 `decision::fallback_round` 单点保证。
if let Some(kind) = renderer::forced_fallback() {
let decision = renderer::fallback_round(kind);
std::env::set_var("ICED_BACKEND", kind.env_value());
eprintln!(
"[probe] 本次是降级重开({}={}):跳过探测 -> ICED_BACKEND={}",
renderer::FALLBACK_ENV,
kind.env_value(),
kind.env_value()
);
eprintln!("[probe] {}", decision.describe());
return decision;
}
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;
// `ULL_PREFLIGHT=1` 时先自己验一遍 wgpu(判据表第 7 行);默认不跑,见 preflight 模块文档。
let preflight = renderer::preflight::run_if_requested();
let decision = probe::apply_with_preflight(options.backend, &probe, preflight.as_ref());
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()
);
}
}
decision
}
/// 默认字体(正文档解析出来的族)。
fn default_font(families: Families) -> iced::Font {
iced::Font {
family: iced::font::Family::Name(families.text),
..iced::Font::DEFAULT
}
}
#[cfg(test)]
#[path = "boot/tests.rs"]
mod tests;
+187
View File
@@ -0,0 +1,187 @@
//! `app/boot.rs` 的单元测试 + **变异验证说明**(M97 r1)。
//!
//! 放独立文件(同 `pages/harness/tests.rs`)是为了让 `boot.rs` 留在 400 行内。
//! ## 变异验证怎么做(M97 r1 的教训,改测试前先读这段)
//!
//! 这批测试的价值不是「跑绿了」,而是「把实现改坏会红」。做变异时**只在目标函数体内替换**,
//! 并且把测试里的同一字面量排除在外:
//!
//! ```text
//! # // 示意(真实脚本是一次性的,没入库)
//! def in_body(path, marker, changes):
//! """按函数体范围替换。**不能**全局 sed:那会连测试期望里的同一字面量一起改掉,
//! 于是实现和断言被换成同一个错值 ⇒ 测试照样绿 ⇒ 验证的是变异脚本自己。"""
//! ```
//!
//! M97 r1 第一次跑变异时,M1/M2/M3 三个变异「照样绿」——不是测试弱,是脚本用全局 `sed`
//! 把断言一起改了。这类假绿最阴:变异验证本来是我们判断「测试够不够」的终极手段,
//! 手段本身假绿时,它还会附赠「已验证」的强信心。
use super::*;
use crate::renderer::decision;
use std::ffi::{OsStr, OsString};
use std::path::Path;
fn probe(device_type: &'static str) -> crate::Probe {
crate::Probe {
adapters: vec![crate::probe::AdapterSummary {
name: "adapter".to_owned(),
device_type,
}],
hardware: !matches!(device_type, "Cpu" | "Other"),
}
}
/// **降级重开到底传了什么**(M97 r1 p2-1 的守门)。
///
/// 抓得住:`fallback_env` 少写/写错任一条——两条都是静默失效型:
/// 漏 `ICED_BACKEND` ⇒ 子进程又去试 wgpu(白开一次窗);漏 `ULL_GPU_FALLBACK` ⇒ **无限重开**。
/// 抓不住:`Command` 本身没被建起来(那要真起进程,见 §「Windows 实测」)。
#[test]
fn fallback_env_carries_the_backend_and_the_gate() {
for fallback in [Kind::TinySkia, Kind::Wgpu] {
let env = fallback_env(fallback);
assert_eq!(
env.len(),
2,
"就这两条,多一条少一条都要在这里改文档:{env:?}"
);
assert!(
env.contains(&("ICED_BACKEND", fallback.env_value())),
"子进程必须直接用 {fallback:?} 跑,实际 {env:?}"
);
assert!(
env.contains(&(renderer::FALLBACK_ENV, fallback.env_value())),
"缺防无限重开的闸门 {FALLBACK_ENV:?},实际 {env:?}",
FALLBACK_ENV = renderer::FALLBACK_ENV
);
}
}
/// 闸门的另一半:子进程那一轮的决策**不能再有兜底**,否则失败后继续 respawn ⇒ 无限递归。
///
/// 抓得住:`fallback_round` 的 `fallback` 被写成 `Some(..)`(自杀式循环)。
/// 抓不住:`forced_fallback()` 没被 `boot_probe` 调用(那是环境变量的读,见下一条的说明)。
#[test]
fn fallback_round_never_degrades_again() {
for kind in [Kind::TinySkia, Kind::Wgpu] {
let decision = decision::fallback_round(kind);
assert_eq!(decision.primary, kind);
assert_eq!(
decision.fallback, None,
"降级重开那一次不许再有兜底,否则会无限递归重开"
);
assert_eq!(decision.reason, "fallback-after-failure");
}
}
/// 判据表第 8 行 vs 第 9 行:**交互降级 / 取证拒绝**,两条路的策略不同。
///
/// 抓得住:`--screenshot` 分支被改回「也降级」(那正是 M97 要杀的「取证工具自己换后端」)。
/// 抓不住:`screenshot` 传进来的时机(那是 `run()` 的接线,见 Windows 实测记录)。
#[test]
fn evidence_start_refuses_instead_of_degrading() {
let auto = decision::decide(crate::Backend::Auto, &probe("IntegratedGpu"), true);
let evidence = std::path::PathBuf::from("out.png");
assert_eq!(
on_failure(auto, Some(&evidence)),
Some(OnFailure::Refuse),
"取证启动 + wgpu 起不来 ⇒ 拒绝出图"
);
assert_eq!(
on_failure(auto, None),
Some(OnFailure::Respawn(Kind::TinySkia)),
"交互启动 + wgpu 起不来 ⇒ 换进程降级"
);
}
/// **构造侧**:argv 原样透传、两组 env 都在、program 就是传进来的那个。
///
/// 抓得住:重开时丢掉用户参数(`--screenshot` / `--screen` / `--data-dir` 全没了,
/// 子进程会按默认参数跑,那是最难查的一种「重开后行为变了」)、env 少传(M97 r1 的 p2-1)。
/// 抓不住:stdio 继承(`Command` 没有 stdio getter,只能真跑一次,见 `fallback_command` 的注释)。
#[test]
fn fallback_command_passes_program_argv_and_envs() {
let args = ["--renderer=auto", "--screenshot", "out.png"];
let command = fallback_command(
Path::new("unlucky-launcher-iced"),
args.iter().map(OsString::from),
Kind::TinySkia,
);
assert_eq!(
command.get_program(),
OsStr::new("unlucky-launcher-iced"),
"program 必须是传进来的那个(生产里是 current_exe)"
);
assert_eq!(
command.get_args().collect::<Vec<_>>(),
args.iter().map(OsStr::new).collect::<Vec<_>>(),
"argv 必须原样透传,一条不漏、顺序不变"
);
let envs: Vec<_> = command.get_envs().collect();
for (key, value) in fallback_env(Kind::TinySkia) {
assert!(
envs.contains(&(OsStr::new(key), Some(OsStr::new(value)))),
"重开命令缺 {key}={value},实际 {envs:?}"
);
}
}
/// **执行侧**:子进程退出 7 → 父进程拿到 7(不是 0、不是自己造的值)。
///
/// 抓得住:`exit_code` 被改成恒 0 / 恒 1 / 写成 `status.success().into()`。
/// 抓不住:生产里 `current_exe()` 那个 program(测试用假命令,见 `fallback_command` 注释)。
#[test]
fn fallback_command_really_passes_the_child_exit_code() {
#[cfg(windows)]
let (program, args) = ("cmd", vec!["/c", "exit", "7"]);
#[cfg(unix)]
let (program, args) = ("sh", vec!["-c", "exit 7"]);
let status = fallback_command(
Path::new(program),
args.iter().map(OsString::from),
Kind::TinySkia,
)
.status()
.expect("假子进程必须能起来");
assert_eq!(
exit_code(status.code()),
7,
"子进程退出 7,父进程必须透传 7;{status:?}"
);
}
/// 子进程**被信号杀死**(`code()` 为 `None`):走兜底码,不 panic、不当成功。
///
/// 这是纯函数测试,所以在 Windows 上也能跑(真造一个信号死亡的 `ExitStatus`
/// 需要 `ExitStatusExt`,只有 Unix 有;而这里测的正是「拿不到码」那一刻的行为)。
#[test]
fn signal_death_falls_back_to_a_nonzero_code() {
assert_eq!(exit_code(Some(0)), 0, "成功必须透传 0");
assert_eq!(exit_code(Some(7)), 7);
let fallback = exit_code(None);
assert_ne!(fallback, 0, "拿不到退出码绝不能当成功");
assert_eq!(fallback, 2, "兜底码与 main 的「参数或运行错误」一致");
}
/// 没有兜底可用的两行(强制 CPU / 未编 wgpu):失败就原样把错误交回去,不许 respawn。
#[test]
fn no_fallback_means_no_respawn() {
let cpu = decision::decide(crate::Backend::Cpu, &probe("IntegratedGpu"), true);
let evidence = std::path::PathBuf::from("out.png");
assert_eq!(cpu.fallback, None);
assert_eq!(on_failure(cpu, None), None);
assert_eq!(
on_failure(cpu, Some(&evidence)),
None,
"强制 CPU 也不该走到「拒绝降级」那条提示上(它压根没想上 GPU)"
);
let not_compiled = decision::decide(crate::Backend::Auto, &probe("IntegratedGpu"), false);
assert_eq!(on_failure(not_compiled, None), None);
}
+91
View File
@@ -0,0 +1,91 @@
//! 应用消息:单一大枚举。
//!
//! 纪律:本枚举是**唯一**的消息类型,`view` 产出的所有回调都必须落到这里;组件库与页面层
//! 各自用自己的 `Message`(组件契约 §2 的泛型纪律),由 `view` 做一次 `.map` 接上。
//!
//! ## 三类输入(M90)
//!
//! | 来源 | 变体 | 落点 |
//! |---|---|---|
//! | 窗口/框架 | `Boot` / `WindowReady` / `Window` / `Frame` / `Captured` / 三个 caption | [`super::update`] 直接处理 |
//! | **时间** | `Frame`(忙碌,33 ms 闸门)+ `Heartbeat`(空闲,1 Hz) | presenter 的 TICK 节拍:`backend.tick` + `drain_events` + `apply` |
//! | **页面** | `Page(pages::Message)` | 导航 → `AppState::navigate`;启动/停止 → `AppCommand` |
use iced::{window, window::Direction};
use unlucky_presenter::router::Route;
use crate::pages;
use crate::shell::NavItem;
use super::components;
/// 消息(单一大枚举)。
#[derive(Debug, Clone)]
pub enum Message {
/// 启动即发一次(`iced::application` 的 boot 闭包)。
Boot,
/// `window::latest()` 的结果。
WindowReady(Option<window::Id>),
/// 系统主题探测结果(只可能在**交互模式**出现,截图模式必有主题)。
SystemTheme(iced::theme::Mode),
/// 一帧画完。**只在需要 33 ms 节拍时订阅**:截图通道 / 有 Busy-Running 实例 / 有未过期
/// toast / 派发后的 3 s 观察窗(见 `subscription::needs_frames`)。
Frame,
/// **空闲态**的 1 Hz 心跳:`Task::perform(sleep(1s))` 的自续链,用来替代空闲时的帧循环
/// (M90 r3 / P2-2)。它保证 `theme::config_poll_due` 仍按「30 × 33 ms ≈ 1 秒」的
/// presenter 语义跑,外部 CLI/AI 改配置后主题 1 秒内生效。
Heartbeat,
/// 窗口事件(M90:`Resized` 决定布局档位,600 / 840 两条断点靠它生效)。
Window(window::Event),
/// 窗口句柄(M93:`window::run` 探测出的 raw handle;`None` = 拿不到,整窗 α 无法下发)。
WindowHandle(Option<isize>),
/// 页面消息(导航 / 启动 / 停止 / 占位动作)。
Page(pages::Message),
/// 外壳导航轨点选(`src/shell/nav.rs` 的回调仍然发这一条;收到就翻成同一个路由跳转)。
///
/// M90 起产品界面用 `pages` 里的真 `NavigationRail`,这条变体是给**外壳回退路径**
/// (`Route::Agent`)留的——`shell` 不在本 mission 的 scope 里,不能删它的消息。
Select(NavItem),
/// 按下标题栏 → 拖窗。
DragWindow,
/// 边缘热区:开始缩放(方向由坐标决定)。
ResizeStart(Direction),
/// 最小化。
Minimize,
/// 最大化/还原。
ToggleMaximize,
/// 关窗退出。
Close,
/// 截图回来了。
Captured(window::Screenshot),
/// gallery(组件库 harness)里的演示交互。
Demo(components::gallery::DemoMessage),
}
/// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。
///
/// 这是 `NavItem → Route` 的**唯一**转换点:外壳(`src/shell/nav.rs`)与页面导航轨
/// (`pages::NAV`)最终都落到同一个 `Route`,所以两条入口不会各走一套页面语义。
pub(super) fn route_of(nav: NavItem) -> Route {
match nav {
NavItem::Instances => Route::Instances,
NavItem::Download => Route::Download,
NavItem::Logs => Route::Logs,
NavItem::Settings => Route::Settings,
NavItem::Ai => Route::Agent,
}
}
/// 路由 → 外壳导航项([`route_of`] 的逆;外壳 rail 与 caption 键都按它高亮)。
///
/// `Route::Detail(_)` 高亮「实例」(与 `Route::nav()` 同源)。
pub(super) fn nav_item_of(route: &Route) -> NavItem {
match route {
Route::Instances | Route::Detail(_) => NavItem::Instances,
Route::Download => NavItem::Download,
Route::Logs => NavItem::Logs,
Route::Settings => NavItem::Settings,
Route::Agent => NavItem::Ai,
}
}
+71
View File
@@ -0,0 +1,71 @@
//! Elm 三件套:`State` / `Message` / `update` + `view` 接线,以及启动序列。
//!
//! 启动序列:①探测渲染后端并写 `ICED_BACKEND`(必须在 `run()` 之前,M60 Q1)→ ②求值字体栈
//! (D28 前半)→ ③`iced::application(..).run()`(1180×760、无边框自绘标题栏)→ ④首个 `view()`
//! 打耗时;截图模式第 3 帧落盘后退出(无边框窗首帧可能是旧尺寸,M60 坑 #5)。
//!
//! ## 为什么拆成目录(M90)
//!
//! 本文件原来是**单个 400 行的 `app.rs`**:正好卡在「单文件 ≤400 行、动它前必须先拆」的硬门槛上
//! (AGENTS 的文件上限 + 本 mission 的第一条任务)。拆法与职责:
//!
//! | 文件 | 职责(**只做这一件事**) |
//! |---|---|
//! | [`state`] | [`App`] 字段 + 构造(presenter 三件在此持有)+ 主题切换 |
//! | [`message`] | [`Message`] 枚举与「导航项 → 路由」的翻译 |
//! | [`update`] | 全部 `Message` 分支 + 「用户动作 → `AppCommand`」的派发与事件回流 |
//! | [`subscription`] | 帧事件(截图)与 presenter 的 TICK 节拍 |
//! | [`view`] | `view` / `theme` / `title` |
//! | [`boot`] | [`run`]:后端探测 → 字体 → `iced::application(..)` |
//!
//! 拆分的每一步都保持**可观察行为不变**(截图四件套 `--screenshot` / `--screenshot-theme` /
//! `--screen=gallery` / `--diff` 出图逐字节相同),presenter 接线是在拆分**之后**另一步做的。
//!
//! ## 组件库为什么还在本模块下声明
//!
//! `src/lib.rs` 是五条组件线共用的文件,改它必然冲突;`#[path]` 保证物理路径仍是
//! `src/components/`(组件目录自己的归属规则不变),逻辑路径仍是 `crate::app::components`
//! (组件源码里的文档与测试都按这个路径写)。
mod appearance;
mod backend;
mod boot;
mod message;
mod state;
mod subscription;
mod update;
mod view;
mod window_alpha;
#[cfg(test)]
mod tests;
pub(crate) use boot::run;
pub use message::Message;
// 组件库(D39)在本文件声明:`src/lib.rs` 是五条组件线共用的文件,改它必然冲突;
// `#[path]` 保证物理路径仍是 `src/components/`(组件目录自己的归属规则不变)。
//
// M90:可见性从 `mod` 放到 `pub(crate) mod` —— 页面层(`src/pages/**`)要**用**真组件
// (`components::buttons` / `navigation` / `divider`),而 `app` 不是它的父模块。
// 只放开 crate 内可见,组件目录的归属规则一字不改。
#[path = "../components/mod.rs"]
pub(crate) mod components;
/// 验收窗口尺寸(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;
+316
View File
@@ -0,0 +1,316 @@
//! 应用状态:**只放字段与构造**,不放消息分支(那是 [`super::update`] 的事)。
//!
//! ## presenter 三件(`crates/presenter/src/lib.rs` crate 文档 §4)
//!
//! | 字段 | 谁给 | 干什么 |
//! |---|---|---|
//! | `backend: Box<dyn Backend>` | [`build_backend`](unlucky_presenter::backend::build_backend) | 派发 [`AppCommand`](unlucky_presenter::dto::AppCommand)、收 [`AppEvent`](unlucky_presenter::dto::AppEvent) |
//! | `bridge: SettingsBridge` | [`SettingsBridge::open`](unlucky_presenter::settings_bridge::SettingsBridge::open) | 读一次外观/主题,之后每 N 拍问「文件变了吗」 |
//! | `state: AppState` | [`AppState::new`](unlucky_presenter::state::AppState::new) | **唯一**的状态写入口(`apply` / `navigate`) |
//!
//! `view: pages::Model` 是**展示转换的缓存**:presenter 快照 → 页面要画的东西。
//! 它在**状态真的变了**时重建([`App::view_dirty`] 门控,M90 r2/P2-1 起),不是每拍重建——
//! 重建要把 N 个实例的名字/版本串克隆一遍,30 Hz 空转没有意义。缓存成字段是为了让
//! `view(&self)` 能只借 `&self`:iced 的 `view` 签名不允许返回一棵借用局部变量的树。
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::OnceLock;
use std::time::Instant;
use iced::window;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::backend::{build_backend, config_path, AppOptions};
use unlucky_presenter::dto::Backend;
use unlucky_presenter::prefs::ThemeMode;
use unlucky_presenter::settings_bridge::SettingsBridge;
use unlucky_presenter::state::AppState;
use crate::cli::Options;
use crate::pages;
use crate::theme::{Families, Ui};
use super::WINDOW_SIZE;
/// presenter 的 TICK 节拍(33 ms ≈ 30 fps)——**忙碌态**的节拍。
///
/// 与 Slint 版的 `TICK_MS` 同值:`backend.tick(..)` 的时间推进、toast 过期、
/// `theme::config_poll_due` 的计数都按这个节拍走,两个 UI 版的时间语义因此一致。
pub(super) const TICK_MS: u64 = 33;
/// [`TICK_MS`] 的 `Duration` 形态。
pub(super) const TICK: std::time::Duration = std::time::Duration::from_millis(TICK_MS);
/// **空闲态**的低频心跳(1 Hz,M90 r3 / P2-2)。
///
/// 空闲时不能订阅 `window::frames()`:那条订阅会自持重绘循环(实测软件渲染约 500 帧/秒),
/// 而空闲时唯一需要的时钟是「偶发一次」——外部 CLI / AI 工具改 `launcher.json` 后的热切
/// (presenter 的 `theme::config_poll_due` 语义是 30 × 33 ms ≈ 1 秒一拍)。
///
/// 实现用的是 `Task::perform(async { sleep(1s) })` 的自续链:[`App::heartbeat`]。它每秒只唤醒
/// 一次、只触发**一帧**重绘,而 `frames()` 是每秒几百帧。为什么不用 `iced::time::every`:
/// 它挂在 tokio/smol 后端下,本 crate 的执行器是 thread-pool(Cargo.toml 不在 scope)。
pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs(1);
/// 派发指令后保持帧订阅的观察窗(见 [`App::watch_until`])。
///
/// 3 秒够真 host 把「命令已受理」的第一条事件交回来;之后由「有 Busy/Running 实例」这个条件续订。
pub(super) const WATCH_WINDOW: std::time::Duration = std::time::Duration::from_secs(3);
/// 进程内「run() 开始」的时刻(首帧耗时基线)。
pub(super) static STARTED: OnceLock<Instant> = OnceLock::new();
/// 首帧日志只打一次。
pub(super) static FIRST_FRAME_LOGGED: AtomicBool = AtomicBool::new(false);
/// 应用状态:UI 状态 + presenter 三件 + 展示转换缓存。
pub struct App {
/// 启动参数(截图/主题/画面开关)。
pub(super) options: Options,
/// 字体族(切主题时重建 [`Ui`] 要用)。
pub(super) families: Families,
/// 令牌求值器(浅/深两套)。
pub(super) ui: Ui,
/// 后端通道(真 host 或 mock)。
pub(super) backend: Box<dyn Backend>,
/// 设置持久化桥。
pub(super) bridge: SettingsBridge,
/// presenter 的全局状态(唯一写入口是 `apply`)。
pub(super) state: AppState,
/// 已推进的拍数(`theme::config_poll_due` 的输入)。单位是 **33 ms 一拍**,不是调用次数。
pub(super) ticks: u64,
/// 下一次「比对配置文件 mtime」的 tick 号(见 `update::advance` 的注释)。
pub(super) config_due_at: u64,
/// 设置文件里的主题模式(`auto` / `light` / `dark`)。
///
/// 用来定优先级:`--theme` > 配置文件 > 系统主题。没有它,`Message::SystemTheme` 会无条件
/// 盖掉配置文件的显式主题(M90 r3 修)。
pub(super) theme_mode: ThemeMode,
/// 上一次推进节拍的时刻(节拍由帧事件驱动,按真实流逝时间累加)。
pub(super) last_tick: Instant,
/// 窗口宽度(布局档位的判据;交互模式跟着 resize 事件走)。
pub(super) width: f32,
/// 展示转换缓存(presenter 快照 → 页面视图模型)。
pub(super) view: pages::Model,
/// 视图模型是否过期(P2-1):只有 `apply` / `navigate` / resize / 主题这类**真的改了状态**
/// 的事情才置位,`advance()` 每拍只在置位时重建模型(否则 30 Hz × N 实例的白克隆)。
pub(super) view_dirty: bool,
/// 「刚派发过指令,等后端回事件」的观察窗(P2-1)。
///
/// 空闲态**不订阅帧事件**(只有 1 Hz 的 `heartbeat`),所以派发之后必须有这段短窗口让
/// 33 ms 节拍跑起来,否则真 host 的异步事件永远抽不出来。
///
/// 空闲态不订阅帧事件(不点亮窗口重绘),但派发之后必须有一段短窗口保持节拍,
/// 否则真 host 的异步事件永远抽不出来(`LaunchInstance` 之后状态还是 Idle ⇒ 帧订阅永不开启)。
pub(super) watch_until: Option<Instant>,
/// 窗口句柄(`window::latest()` 之后才有)。
pub(super) window: Option<window::Id>,
/// 已画的帧数(截图闸门用)。
pub(super) frames: u32,
/// 视图模型重建次数(P2-1 的可观测计数:空闲态 100 拍里必须是 0 增长)。
pub(super) refreshes: u64,
/// 首屏快照到位后是否已经自动选中过第一行(list-detail 的桌面惯例,见 `update::auto_select`)。
pub(super) auto_selected: bool,
/// 窗口高度(背景图平铺网格的视口判据;宽度在 `width`)。
pub(super) height: f32,
/// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。
pub(super) appearance: pages::appearance::State,
/// 设置文件里那 8 个「UI 写入」外观字段的最近快照(写回时只改我们真改了的字段,
/// 强调色这类本 mission 不管的字段原样保留)。
pub(super) appearance_prefs: unlucky_presenter::prefs::AppearancePrefs,
/// 窗口句柄(`Message::WindowHandle` 之后才有;非 Windows 平台恒 `None`)。
pub(super) hwnd: Option<isize>,
}
impl App {
/// 生产构造:按启动参数选后端、打开设置桥。
///
/// **截图通道强制 mock**:出图要可复现(D30),真 host 会按本机盘上的实例清单画面,
/// 同一份代码在两台机器上出不了同一张图。交互模式走真 host(数据根解析不出来时
/// [`build_backend`] 自己退回 mock)。
pub(super) fn new(options: Options, families: Families, flavor: SchemeFlavor) -> Self {
let backend = build_backend(&app_options(&options));
// P2-2:设置桥必须与后端指向**同一个数据根**。早先这里无条件 `SettingsBridge::open()`
// (按便携配置解析),于是 `--data-dir X` 会出现「实例来自 X、主题/设置读写默认根」的一进程
// 两个数据根。Slint 版是同一条口径:`app/src/ui_bind.rs` 的
// `Some(root) => SettingsBridge::with_path(config_path(root))`。
let bridge = match &options.data_dir {
Some(root) => SettingsBridge::with_path(config_path(root)),
None => SettingsBridge::open(),
};
Self::with_presenter(options, families, flavor, backend, bridge)
}
/// 注入式构造(单测用:后端与桥都由调用方给,**不碰磁盘**)。
pub(super) fn with_presenter(
options: Options,
families: Families,
flavor: SchemeFlavor,
backend: Box<dyn Backend>,
bridge: SettingsBridge,
) -> Self {
let width = WINDOW_SIZE.width;
let height = WINDOW_SIZE.height;
let theme_mode = bridge.load().1;
// 外观两半分开读:`load()` 给 8 个 UI 写入字段,`load_base()` 给 3 个只读下层键
// (整窗 α / 标题栏三态 / 配色来源)。M93 之前这两个键在 app 侧**根本读不到**。
//
// 截图通道**不读用户配置**(D30:同一份代码在两台机器上要出同一张图):外观一律取出厂值,
// 矩阵需要的值由 `ULL_APPEARANCE_*` 显式灌进来。
let (appearance_prefs, appearance_base) = if options.screenshot.is_some() {
(
unlucky_presenter::prefs::AppearancePrefs::factory(),
unlucky_presenter::prefs::AppearanceBase::factory(),
)
} else {
(bridge.load().0, bridge.load_base())
};
// 面板 α / 玻璃的降级判据要的是**实际**后端,不是 `--renderer` 的请求值(复审 r1 P2-2):
// 默认 `Auto` + 无独显时请求是 Auto、实际是 tiny-skia,只看请求值就会漏掉 D36 的强制项。
let backend_tier = super::backend::effective_backend(options.backend);
let appearance = pages::appearance::State::new(
&appearance_prefs,
&appearance_base,
backend_tier,
flavor,
);
let view = pages::Model::new(
pages::Screen::Instances,
width,
Some(pages::Screen::Instances.nav()),
);
let mut app = Self {
ui: Ui::new(flavor, families),
families,
options,
backend,
bridge,
state: AppState::new(),
ticks: 0,
config_due_at: unlucky_presenter::theme::CONFIG_POLL_TICKS,
theme_mode,
last_tick: Instant::now(),
width,
view,
view_dirty: false,
watch_until: None,
window: None,
frames: 0,
refreshes: 0,
auto_selected: false,
height,
appearance,
appearance_prefs,
hwnd: None,
};
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
app.rebuild_ui();
app
}
/// 本次进程是否走截图通道。
pub(super) fn has_screenshot(&self) -> bool {
self.options.screenshot.is_some()
}
/// 切主题:重建 [`Ui`](= 重建 Style),**不动任何 UI 状态**。
///
/// M93 起 `Ui` 有两个来源:配色来源取到了图(①②级)就用解析出的方案,否则用令牌 baseline。
/// 所以这里改叫「重算 `Ui`」:先让外观状态记住新深浅(配色解析要它),再重建。
pub(super) fn set_flavor(&mut self, flavor: SchemeFlavor) {
if self.ui.flavor() == flavor && self.appearance.flavor() == flavor {
return;
}
self.appearance.set_flavor(flavor);
let previous = self.ui.name();
self.rebuild_ui();
eprintln!(
"[theme] {} -> {} (Theme::name 变化触发缓存重算)",
previous,
self.ui.name()
);
}
/// 按「配色来源是否取色成功」重建 [`Ui`]。
///
/// 色卡档(③)刻意继续走 `Scheme::baseline`:`resolve_source` 的色卡档返回的是
/// `DEFAULT_CARD`(天蓝卡)方案,与令牌 baseline 逐色不同,应用它会把**全部既有验收图的
/// 像素**改掉。这条偏离登记在 `pages::appearance` 的偏离表 #4。
pub(super) fn rebuild_ui(&mut self) {
// 面板 α(D36)在这里注入 `Ui`:全仓所有表面色都从 `Ui::layer` 出去,所以这是**唯一**
// 生效点(外壳 / 三页卡片 / 外观分组卡片 / 空态图标块自动同源,调用点没有机会各处自乘)。
let panel_alpha = self.appearance.effective_panel_alpha();
self.ui = match self.appearance.derived_scheme() {
Some(scheme) => {
eprintln!(
"[appearance] palette={} → Ui::from_scheme(取色方案接管 49 个角色)",
self.appearance.palette_level()
);
Ui::from_scheme(*scheme, self.families)
}
None => Ui::new(self.appearance.flavor(), self.families),
}
.with_panel_alpha(panel_alpha);
}
/// 把 presenter 的快照转换成页面视图模型(**展示转换在 UI 侧做**)。
///
/// 代价是「克隆 N 个实例的名字/版本串」,所以只在 [`App::view_dirty`] 置位时调用(P2-1)。
pub(super) fn refresh_view(&mut self) {
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
let category = self.view.download.category;
self.view = super::view::model(&self.state, self.width, self.options.force_state, category);
self.view_dirty = false;
self.refreshes += 1;
}
/// 按设置文件里的主题模式切一次外观;`--theme` 显式给了就不动。
///
/// **截图通道直接跳过**:`--screenshot` 的主题唯一取值口是 `Options::capture_flavor`
/// (D30 可复现),读本机 `launcher.json` 会让同一份代码在两台机器上出不同的图。
///
/// `ThemeMode::Auto` 不在这里处理:那要看系统主题(`iced::system::theme()` 的结果在
/// `Message::SystemTheme` 里落地)。
pub(super) fn apply_config_theme(&mut self) {
if self.has_screenshot() || self.options.theme.is_some() {
return;
}
let mode = self.bridge.load().1;
self.theme_mode = mode;
match mode {
ThemeMode::Light => self.set_flavor(SchemeFlavor::Light),
ThemeMode::Dark => self.set_flavor(SchemeFlavor::Dark),
ThemeMode::Auto => {}
}
}
/// 当前令牌求值器(测试与截图日志用)。
pub(super) fn ui(&self) -> &Ui {
&self.ui
}
}
/// 启动参数 → presenter 的后端选项。
fn app_options(options: &Options) -> AppOptions {
AppOptions {
// `--data-dir`:M90 起**真的消费**它(此前是「接受并忽略」的翻牌兼容参数)。
// 截图通道强制 mock,所以 CI 冒烟那条 `--data-dir <tmp>` 不会往那个目录写东西。
data_root: options.data_dir.clone(),
mock: options.screenshot.is_some(),
}
}
/// 首帧耗时日志(只在第一次 `view()` 打一行)。
pub(super) fn log_first_frame() {
if FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) {
return;
}
if let Some(started) = STARTED.get() {
eprintln!(
"[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)",
started.elapsed().as_secs_f64() * 1000.0
);
}
}
+82
View File
@@ -0,0 +1,82 @@
//! 订阅:**按需**帧节拍(presenter 的 TICK)+ 窗口尺寸事件。
//!
//! ## 为什么要「按需」
//!
//! iced_winit 在处理完任何一条消息后都会 `request_redraw()`,而 `window::frames()` 监听的是
//! `RedrawRequested` —— 于是「订阅帧 → 收 Frame → update → 再触发重绘」是一条**自持循环**:
//! 只要订阅着,窗口就永远在重绘(M90 r1 就是这样,CPU 空转)。
//!
//! 现在只在**真的需要 33 ms 节拍**的时候订阅([`App::needs_frames`]):
//!
//! | 条件 | 为什么需要 33 ms 节拍 |
//! |---|---|
//! | 截图通道 | 第 3 帧要落盘 |
//! | 有 `Busy` / `Running` 实例 | 启动进度、日志尾随都靠 `backend.tick` 推进 |
//! | 有未过期的 toast | toast 按毫秒倒计时 |
//! | 刚派发过指令(3 s 观察窗) | 真 host 的异步事件要靠节拍抽出来 |
//!
//! 空闲态**一条帧消息都不来** ⇒ 窗口不重绘、`refresh_view` 不跑(配合 `view_dirty`)。
//!
//! ## 空闲态的时钟从哪来(M90 r3 / P2-2)
//!
//! r2 把空闲的帧订阅掐掉之后,`config_poll_due` 就再也没被调用过 —— 外部 CLI / AI 工具改
//! `launcher.json` 后主题**永不生效**(不是「等下次交互」,是「等下一次 dispatch」)。这与
//! D4「AI 能读改 ULL 配置」直接冲突。
//!
//! 所以空闲态换成一个 **1 Hz 的自续心跳**([`crate::app::message::Message::Heartbeat`],
//! 由 `Task::perform(sleep(1s))` 驱动,见 `update::heartbeat`):每秒醒一次、推进一拍
//! presenter(内部按 33 ms 折算 30 拍,于是 `config_poll_due` 仍是「约 1 秒比对一次 mtime」)。
//!
//! 为什么不用 `window::frames()` + 1 s 闸门来实现这个低频时钟:帧订阅**本身就是**自持重绘循环
//! (iced_winit 处理完任何消息都 `request_redraw()`),闸门只能省掉 `advance()` 里的活,省不掉
//! 整窗重绘——实测软件渲染约 **500 帧/秒**。心跳是「每秒一帧」,差三个数量级。
//!
//! ## 已知代价(都登记,不藏)
//!
//! 1. **忙碌态仍是自持帧循环**:`Running`/`Busy` 一天不结束,就一天按帧刷(软件渲染实测约
//! 970 帧/秒,refreshes 不增长 = 纯像素空转)。功能上需要 tick 来发现游戏退出,但「整段
//! 运行期近千帧/秒重绘」是真实开销;要收口得让 `Running` 走更慢的时钟(follow-up)。
//! 2. 心跳用 `thread::sleep` 占线程池一个线程 1 秒;1 Hz 下可接受,但不是优雅解法(正解仍是
//! `iced::time::every` + tokio/smol,Cargo.toml 在 scope 外,已报 finding)。
use std::time::Instant;
use iced::{window, Subscription};
use super::message::Message;
use super::state::App;
impl App {
/// 本次进程的订阅表。
pub(super) fn subscription(&self) -> Subscription<Message> {
let frames = if self.needs_frames() {
window::frames().map(|_| Message::Frame)
} else {
Subscription::none()
};
// 断点 600 / 840 要跟着真窗口宽度走;截图模式的窗口尺寸由参数钉死,不吃 resize。
let events = if self.has_screenshot() {
Subscription::none()
} else {
window::events().map(|(_, event)| Message::Window(event))
};
Subscription::batch([frames, events])
}
/// 是否订阅帧事件(= 截图模式要帧数闸门)。
pub(super) fn wants_frames(&self) -> bool {
self.has_screenshot()
}
/// 现在要不要**时钟**(见模块文档的表)。
pub(super) fn needs_frames(&self) -> bool {
self.has_screenshot()
|| !self.state.toasts.is_empty()
|| !self.state.running().is_empty()
|| self
.watch_until
.is_some_and(|deadline| Instant::now() < deadline)
}
}
+274
View File
@@ -0,0 +1,274 @@
//! `app` 的单测:主题切换、窗口尺寸、截图闸门、gallery 能构图,以及**presenter 接线的闭环**。
//!
//! 闭环测试用的是 `MockBackend`(presenter 自带的假后端):它走的是与真 host 完全相同的
//! `dispatch → drain_events → AppState::apply → 视图模型` 通道,所以「点 Play → 状态真的变」
//! 这件事在这里就能判死,不必等图形环境。
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::router::Route;
use unlucky_presenter::settings_bridge::SettingsBridge;
#[cfg(test)]
mod appearance;
#[cfg(test)]
mod presenter;
use super::message::Message;
use super::state::App;
use super::{APP_TITLE, WINDOW_SIZE};
use crate::cli::{Options, Screen};
use crate::pages;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 不碰磁盘的 App(mock 后端 + 内存设置桥)。
fn headless(options: Options, flavor: SchemeFlavor) -> App {
App::with_presenter(
options,
FAMILIES,
flavor,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
fn screenshot_options() -> Options {
Options {
screenshot: Some(std::path::PathBuf::from("x.png")),
..Options::default()
}
}
#[test]
fn switching_flavor_rebuilds_styles_but_keeps_ui_state() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
app.state.navigate(Route::Logs);
app.refresh_view();
assert_eq!(app.ui().name(), "ull-m3-light");
app.set_flavor(SchemeFlavor::Dark);
assert_eq!(app.ui().name(), "ull-m3-dark", "切主题要换主题名");
assert_eq!(app.state.route(), &Route::Logs, "切主题不许重置 UI 状态");
assert_eq!(app.view.screen, pages::Screen::Logs, "视图模型跟着路由");
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));
assert_eq!(APP_TITLE, "Unlucky Launcher");
}
#[test]
fn screenshot_mode_never_waits_for_the_system_theme() {
// 门禁依赖:CI 的 runtime-smoke 不带主题,xvfb 里也没有主题会话。
let options = screenshot_options();
assert!(!options.wants_system_theme(), "截图模式不许等系统主题");
assert_eq!(options.capture_flavor(), Some(SchemeFlavor::Light));
let app = headless(options, SchemeFlavor::Light);
assert!(app.has_screenshot() && app.ui().name() == "ull-m3-light");
}
#[test]
fn gallery_screen_builds_a_tree() {
assert_eq!(Options::default().screen, Screen::Shell, "默认仍是产品骨架");
let options = Options {
screen: Screen::Gallery,
..Options::default()
};
let app = headless(options, SchemeFlavor::Light);
// 真实像素由 `--screen=gallery --screenshot` 出图 + `gallery::probe` 量
let _ = app.view();
}
#[test]
fn frames_are_only_requested_in_screenshot_mode() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
assert!(!app.wants_frames(), "普通运行不订阅帧,避免每帧重绘");
app.options.screenshot = Some(std::path::PathBuf::from("x.png"));
assert!(
app.wants_frames(),
"截图模式必须订阅帧事件才能在第 3 帧出图"
);
}
// --------------------------------------------------------------- P2-1 按需节拍
/// 空闲态**不要每帧节拍**(但要有 1 Hz 心跳)。
///
/// 意图与 r2 那条 `an_idle_app_asks_for_no_clock` 一致:空闲时不许按帧循环跑(那会以每秒几百帧
/// 的速度重绘整窗),改成「低频心跳」。断言拆成两半:
///
/// 1. `needs_frames()` 为假 ⇒ **不订阅** `window::frames()`;
/// 2. 空闲时钟的周期 = [`IDLE_TICK`] = 1 s,且心跳每拍推进的 tick 预算 = 30(= 1 s / 33 ms),
/// 这样 `theme::config_poll_due`(30 拍)仍是「约 1 秒比对一次 mtime」。
///
/// 抓得住:有人把「无条件订阅帧」改回来(第 1 半红);有人把心跳周期改快/改慢(第 2 半红)。
/// 抓不住:真实窗口的重绘次数(那要 `ULL_TICK_TRACE=1` 跑一次交互模式看日志,见报告)。
#[test]
fn an_idle_app_uses_a_slow_heartbeat_instead_of_frames() {
let app = headless(screenshot_options(), SchemeFlavor::Light);
assert!(app.needs_frames(), "截图通道永远要帧(第 3 帧落盘)");
let mut idle = headless(Options::default(), SchemeFlavor::Light);
let _ = idle.update(Message::Boot);
assert!(
idle.needs_frames(),
"Boot 刚派发过 RefreshAll:3 秒观察窗内要节拍(等真 host 的首批事件)"
);
// 观察窗过期、也没有忙碌实例 ⇒ 空闲态不订阅帧事件(改走 1 Hz 心跳)
idle.watch_until = None;
assert!(!idle.needs_frames(), "空闲交互态:不订阅帧事件(只有心跳)");
// 派发之后观察窗重新打开
let _ = idle.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
let _ = idle.update(Message::Page(pages::Message::Launch(
"survival".to_string(),
)));
assert!(idle.needs_frames(), "派发后观察窗内要有节拍");
// 进入 Busy 之后由「忙碌实例」这个条件自己续订(不靠观察窗)
idle.watch_until = None;
assert!(
idle.needs_frames(),
"有 Busy 实例时即便观察窗过期也要继续 tick"
);
}
/// 心跳的**节奏**:1 Hz,且每拍折算 30 个 33 ms 单位 ⇒ `config_poll_due` ≈ 每秒一次。
///
/// 抓得住:心跳周期常量、以及 `advance()` 里「按流逝时间折算 ticks」这条记账(改回
/// `ticks += 1` 会让空闲态的配置轮询变成 30 秒一次,这条就红)。
/// 抓不住:`Task::perform` 那条自续链真的每秒触发一次(那是运行期 trace 的事,见报告)。
#[test]
fn the_idle_heartbeat_budgets_one_config_poll_per_second() {
use unlucky_presenter::theme::config_poll_due;
assert_eq!(
super::state::IDLE_TICK,
std::time::Duration::from_secs(1),
"空闲心跳周期必须是 1 s"
);
let mut app = headless(Options::default(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
app.watch_until = None;
// 一次空闲心跳(1 s)之后:ticks 增加 30,且正好落在 config 轮询点上
let before = app.ticks;
app.advance(super::state::IDLE_TICK);
assert_eq!(app.ticks - before, 30, "1 s 应折算 30 个 33 ms 拍");
assert!(
config_poll_due(app.ticks),
"空闲心跳每拍都要落在 config 轮询点上(ticks={})",
app.ticks
);
}
/// 空闲态每拍**不再重建视图模型**(P2-1 的另一半:`refresh_view` 的 30 Hz × N 实例白克隆)。
///
/// 抓得住:把 `advance()` 里的 `if self.view_dirty` 去掉(改回无条件 `refresh_view()`)时,
/// 这条断言里的 `refreshes` 会从「不增长」变成「每拍 +1」。
/// 抓不住:「重建一次花了多少微秒」(没有基准测试;量级判断见报告)。
#[test]
fn idle_ticks_do_not_rebuild_the_view_model() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
let before = app.refreshes;
for _ in 0..100 {
app.advance(super::state::TICK);
}
assert_eq!(
app.refreshes,
before,
"空闲 100 拍:视图模型一次都不该重建(实际 +{})",
app.refreshes - before
);
// 有事件进来时仍然要重建(否则页面不刷新)
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Logs)));
assert!(app.refreshes > before, "导航之后必须重建一次");
}
/// 视图模型跟着窗口宽度走(交互模式下 600 / 840 两条断点靠 resize 事件生效)。
#[test]
fn resizing_the_window_rebuilds_the_view_model() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
let _ = app.update(Message::Window(iced::window::Event::Resized(
iced::Size::new(1180.0, 760.0),
)));
assert!((app.view.window_width - 1180.0).abs() < f32::EPSILON);
let _ = app.update(Message::Window(iced::window::Event::Resized(
iced::Size::new(700.0, 600.0),
)));
assert!((app.view.window_width - 700.0).abs() < f32::EPSILON);
// 页面照旧能构图(窄窗退化到单列,不 panic)
let _ = app.view();
}
/// 截图模式不读设置文件里的主题(D30 可复现:两台机器出同一张图)。
#[test]
fn screenshot_mode_ignores_the_settings_file_theme() {
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
app.apply_config_theme();
assert_eq!(app.ui().name(), "ull-m3-light", "截图模式的主题只认命令行");
}
/// 主题优先级:`--theme` > 配置文件 `appearance.theme_mode` > 系统主题。
///
/// r2 之前 `Message::SystemTheme` 无条件 `set_flavor`,于是「配置文件写 dark、系统是浅色」会
/// 出现 `light -> dark -> light` 的一次抖动、配置永远不生效(r3 做 P2-2 的配置热切实测时暴露
/// 出来,一并修)。
///
/// 抓得住:系统主题不许盖掉显式配置/命令行给出的主题(三条分支各一条断言)。
/// 抓不住:真实桌面切换系统主题时的事件时序(只有消息级验证)。
#[test]
fn the_system_theme_never_overrides_an_explicit_one() {
use unlucky_presenter::prefs::ThemeMode;
// ① auto(内存桥默认):系统主题说了算
let mut auto = headless(Options::default(), SchemeFlavor::Light);
assert_eq!(auto.theme_mode, ThemeMode::Auto);
let _ = auto.update(Message::SystemTheme(iced::theme::Mode::Dark));
assert_eq!(auto.ui().name(), "ull-m3-dark", "auto 模式要跟随系统");
// ② 配置文件显式 dark:系统给浅色也不许改
let mut pinned = headless(Options::default(), SchemeFlavor::Dark);
pinned.theme_mode = ThemeMode::Dark;
let _ = pinned.update(Message::SystemTheme(iced::theme::Mode::Light));
assert_eq!(
pinned.ui().name(),
"ull-m3-dark",
"配置文件说 dark,系统说 light:不许改"
);
// ③ 命令行 `--theme` 优先级最高
let mut explicit = headless(
Options {
theme: Some(SchemeFlavor::Dark),
..Options::default()
},
SchemeFlavor::Dark,
);
let _ = explicit.update(Message::SystemTheme(iced::theme::Mode::Light));
assert_eq!(
explicit.ui().name(),
"ull-m3-dark",
"--theme 优先级最高,系统主题不许盖"
);
}
+331
View File
@@ -0,0 +1,331 @@
//! 外观接线的单测:**配置 → 状态 → 下发字节 / 像素 / 落盘**。
//!
//! 每条都按本 mission 的验收纪律写「抓得住 / 抓不住」。真机才有的东西(`WS_EX_LAYERED` 是否补上、
//! `LWA_ALPHA` 的观感)不在这里假装——那是 `[appearance]` 日志与报告里「没验的部分」的事。
use std::path::PathBuf;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::prefs::{BgFill, PaletteSource};
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::cli::{Backend, Options};
use crate::pages::appearance::{self, Change, State};
use crate::pages::{self, Screen};
use super::{headless, FAMILIES};
use super::super::state::App;
/// 一个把配置写在临时文件里的 App(测「读回 / 写回」必须真的过文件)。
fn with_config(json: &str, backend: Backend, label: &str) -> (App, PathBuf) {
let dir = std::env::temp_dir().join(format!(
"ull-appear-{label}-{}-{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
let path = dir.join("config/launcher.json");
std::fs::write(&path, json).expect("写配置文件");
let app = App::with_presenter(
Options {
backend,
..Options::default()
},
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::with_path(&path),
);
(app, dir)
}
fn cleanup(dir: &PathBuf) {
std::fs::remove_dir_all(dir).ok();
}
/// 抓得住:配置里写 `window_opacity=0.8` → 启动读回 0.8 → 下发字节 205(钳位链的入口那一段)。
/// 抓不住:Win32 是否真的收下 204(`transparency` 的单测与本机日志各自举证)。
#[test]
fn the_configured_window_alpha_is_read_back_and_turned_into_a_byte() {
let (app, dir) = with_config(
r#"{"appearance":{"window_opacity":0.8,"palette_source":"preset"}}"#,
Backend::Cpu,
"read",
);
assert_eq!(app.appearance.window_opacity, 0.8);
assert_eq!(
app.appearance.window_alpha_byte(),
Some(205),
"f32 0.8 → f64 略大于 0.8 → ceil 205(不向下取整)"
);
// CPU 档:窗口 α 允许,面板 α 强制 1.0。
assert_eq!(app.appearance.effective_panel_alpha(), 1.0);
assert!(app.appearance.panel_is_forced());
cleanup(&dir);
}
/// 抓得住:①面板 α 滑到 0.8 → 状态是 0.8、生效值 GPU 档也是 0.8;
/// ②CPU 档下滑到 0.5 → 生效值仍是 1.0,而**请求值**仍是 0.5(界面上的「requested → forced」靠这条)。
/// 抓不住:这层半透明在屏幕上到底多透(RDP 上不可信,见报告)。
#[test]
fn panel_alpha_is_independent_of_the_window_alpha_and_forced_on_cpu() {
let mut gpu = headless(Options::default(), SchemeFlavor::Light);
gpu.appearance.apply(Change::PanelAlpha(0.8));
assert_eq!(gpu.appearance.panel_alpha, 0.8);
assert_eq!(gpu.appearance.effective_panel_alpha(), 0.8);
assert_eq!(gpu.appearance.window_opacity, 1.0, "面板 α 不许带动整窗 α");
let mut cpu = headless(
Options {
backend: Backend::Cpu,
..Options::default()
},
SchemeFlavor::Light,
);
assert!(
!cpu.appearance.apply(Change::PanelAlpha(0.5)),
"只有整窗 α 需要立刻下发(面板 α 只重绘)"
);
assert_eq!(cpu.appearance.panel_alpha, 0.5, "请求值保留");
assert_eq!(
cpu.appearance.effective_panel_alpha(),
1.0,
"CPU 档强制 1.0"
);
assert_eq!(cpu.appearance.window_alpha_byte(), None, "整窗 α 仍是 1.0");
}
/// 抓得住:一次滑块改动 → 落盘 → **新开一个 bridge** 能读回(= 重启后还在)。
/// 抓不住:真 host 是否也写了同一份文件(`AppCommand::SetSetting` 走的是同一份 schema)。
#[test]
fn a_panel_alpha_change_is_written_back_to_the_config_file() {
let (mut app, dir) = with_config("{}", Backend::Gpu, "write");
app.on_appearance(Change::PanelAlpha(0.8));
app.on_appearance(Change::BgFill(BgFill::Tile));
app.on_appearance(Change::BgDim(0.5));
let path = dir.join("config/launcher.json");
let reopened = SettingsBridge::with_path(&path).load().0;
assert_eq!(reopened.panel_alpha, 0.8);
assert_eq!(reopened.bg_fill, BgFill::Tile);
assert!((reopened.bg_dim - 0.5).abs() < 1e-6);
assert_eq!(
app.appearance_prefs.panel_alpha, 0.8,
"内存里的「已落盘快照」也要跟上,否则下一次写回会拿旧基底覆盖"
);
cleanup(&dir);
}
/// 抓得住:路径读不出来时 ①不 panic ②`background_active()` 为假(静默回落)
/// ③诊断里有一句人话(不是空串)④配色回落到色卡档(不换 scheme)。
/// 抓不住:这张图在别的机器上路径存在时会怎样(那是成功路径,另一条测试管)。
#[test]
fn a_broken_background_path_falls_back_silently_but_says_why() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
app.appearance
.apply(Change::BgImage("D:/definitely/not/here.png".to_owned()));
assert!(!app.appearance.background_active());
assert!(app.appearance.image().is_none(), "解码失败 ⇒ 没有可用图");
assert_eq!(app.appearance.diagnostics().len(), 1, "恰好一条诊断");
assert!(
app.appearance.diagnostics()[0].contains("could not be read"),
"诊断要是人话:{:?}",
app.appearance.diagnostics()
);
assert_eq!(app.appearance.palette_level(), "preset", "回落色卡");
assert!(
app.appearance.derived_scheme().is_none(),
"色卡档不换 scheme"
);
}
/// 抓得住:真 PNG → 背景真的生效(解出原图尺寸、路径留给渲染器、配色走「背景图取色」这一级)。
/// 抓不住:渲染出来的像素(由截图举证),以及三种填充的覆盖范围(由 `background::tiles` 的
/// 「覆盖 ≥ 视口」断言守)。
#[test]
fn a_decodable_png_makes_the_background_and_the_palette_active() {
let dir = std::env::temp_dir().join(format!("ull-appear-png-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("临时目录");
let path = dir.join("bg.png");
let image = image::RgbImage::from_fn(64, 32, |x, _y| {
if x < 32 {
image::Rgb([200, 30, 30])
} else {
image::Rgb([20, 60, 200])
}
});
image.save(&path).expect("写 png");
let mut app = headless(Options::default(), SchemeFlavor::Light);
app.appearance
.apply(Change::PaletteSource(PaletteSource::BackgroundImage));
app.appearance
.apply(Change::BgImage(path.to_string_lossy().to_string()));
app.rebuild_ui();
assert!(app.appearance.background_active());
assert!(app.appearance.image().is_some(), "解码成功 ⇒ 有可用图");
assert!(
!app.appearance.bg_path.trim().is_empty(),
"渲染器按路径解码(见 picture::image_handle)"
);
assert_eq!(app.appearance.diagnostics().len(), 0, "成功时没有诊断");
assert_eq!(app.appearance.palette_level(), "background-image");
assert!(
app.appearance.derived_scheme().is_some(),
"取色成功后 Ui 要换成解析出的方案"
);
// 换 scheme 之后主题名仍是同一个(深浅没变),但 primary 必须与 baseline 不同。
assert_eq!(app.ui().name(), "ull-m3-light");
let baseline = crate::theme::Ui::new(SchemeFlavor::Light, FAMILIES);
assert_ne!(
app.ui().color(unlucky_design::color::Role::Primary),
baseline.color(unlucky_design::color::Role::Primary)
);
std::fs::remove_dir_all(&dir).ok();
}
/// 抓得住:设置页在有外观状态时能真的构图(真控件树,含文本框/滑块/互斥按钮),并且
/// 「外观」分组的下标与 `appearance::APPEARANCE_GROUP` 一致(点左列能切过去)。
/// 抓不住:这些控件点下去之后的交互(`Message` 的落点由 `on_appearance` 的其它测试管)。
#[test]
fn the_settings_page_renders_the_real_appearance_group() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
app.state
.navigate(unlucky_presenter::router::Route::Settings);
app.refresh_view();
assert_eq!(app.view.screen, Screen::Settings);
assert_eq!(app.appearance.selected_group, appearance::APPEARANCE_GROUP);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance);
drop(_tree);
// 切分组:只改视图状态,不落盘(`Change::SelectGroup` 的 key() == None)。
app.appearance.apply(Change::SelectGroup(0));
assert_eq!(app.appearance.selected_group, 0);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance);
}
/// **P2-2 的守门(复审 r1)**:面板 α 只作用到允许半透明的层。
///
/// 抓得住:`Ui::layer(Canvas)` 在 α=0.55 时仍是不透明(标题栏/导航轨/页头带都取 Canvas,
/// 它们一旦透底,三个窗口键与导航文字就压在背景照片上);`Card` / `Overlay` 必须真的吃到 α。
/// 抓不住:真实合成后的观感(那由截图看)。
#[test]
fn the_canvas_layer_stays_opaque_while_panels_take_the_alpha() {
use unlucky_design::elevation::MaterialLayer;
let mut app = headless(Options::default(), SchemeFlavor::Light);
app.appearance.apply(Change::PanelAlpha(0.55));
app.rebuild_ui();
let ui = app.ui();
assert_eq!(ui.panel_alpha(), 0.55, "生效值进了 Ui");
assert_eq!(
ui.layer(MaterialLayer::Canvas).a,
1.0,
"窗口底(标题栏/导航轨/页头带)永远不透明——令牌的不变量"
);
for layer in [MaterialLayer::Card, MaterialLayer::Overlay] {
assert!(
(ui.layer(layer).a - 0.55).abs() < 1e-6,
"{layer:?} 必须吃面板 α(实际 {})",
ui.layer(layer).a
);
}
// CPU 档:α 被强制 1.0 ⇒ 连卡片也不透明。
let mut cpu = headless(
Options {
backend: Backend::Cpu,
..Options::default()
},
SchemeFlavor::Light,
);
cpu.appearance.apply(Change::PanelAlpha(0.55));
cpu.rebuild_ui();
assert_eq!(cpu.ui().layer(MaterialLayer::Card).a, 1.0, "CPU 档强制 1.0");
}
/// 抓得住:**把滑块从头拖到尾**(0.00 → 1.00 的 11 个档)时,状态里的整窗 α 与下发字节逐档单调
/// 上升、且最低一档就是下限字节 21(= 0.0824 ≥ 0.08)、最后一档不透明(不给字节)。
/// 这就是「拖动 → 实际 α」的链条在数值上的等价物;真机拖动的那一次由二进制日志举证。
/// 抓不住:拖动的手感与真实观感(RDP 上不可信)。
#[test]
fn a_slider_sweep_moves_the_alpha_and_the_byte_monotonically() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
let mut bytes = Vec::new();
for step in 0..=10 {
let fraction = step as f32 / 10.0;
let alpha = crate::pages::appearance::alpha::window_alpha_from_fraction(fraction);
app.appearance.apply(Change::WindowOpacity(alpha));
let byte = app.appearance.window_alpha_byte();
bytes.push(byte);
// 每一档都要能打出一行(数值而不是观感)。
assert!(app.appearance.log_line("sweep").contains("window_alpha="));
}
assert_eq!(bytes[0], Some(21), "0% 一档 = 下限 0.08 → 21");
assert_eq!(bytes[10], None, "100% 一档 = 不透明 → 不下发");
let sent: Vec<u8> = bytes.iter().flatten().copied().collect();
assert!(
sent.windows(2).all(|pair| pair[0] <= pair[1]),
"字节必须单调不减:{sent:?}"
);
assert_eq!(sent.len(), 10, "除最后一档(不透明)之外每档都有字节");
assert!(
sent.iter().all(|byte| *byte >= 21),
"任何一档都不许低于下限字节:{sent:?}"
);
}
/// 抓得住:日志行里有请求值、生效值与下发字节(RDP 上唯一可信的证据形态)。
/// 抓不住:这行日志是否真的被写进了 stderr(本测试只钉格式,真机日志由跑起来的二进制举证)。
#[test]
fn the_log_line_carries_requests_effective_values_and_the_byte() {
let mut cpu = headless(
Options {
backend: Backend::Cpu,
..Options::default()
},
SchemeFlavor::Light,
);
cpu.appearance.apply(Change::WindowOpacity(0.5));
cpu.appearance.apply(Change::PanelAlpha(0.4));
let line = cpu.appearance.log_line("change");
assert!(line.starts_with("[appearance] change "), "{line}");
assert!(line.contains("window_alpha=0.500"), "{line}");
assert!(line.contains("panel_alpha=1.000"), "CPU 档生效值:{line}");
assert!(line.contains("(requested 0.400)"), "请求值也要在:{line}");
assert!(
line.contains("byte=128"),
"0.5×255=127.5 → 向上取整 128:{line}"
);
// 不透明时不给字节(而不是给 255)。
let opaque = headless(Options::default(), SchemeFlavor::Light);
assert!(opaque.appearance.log_line("boot").contains("byte=none"));
}
/// 抓得住:`State::new` 与 `apply_env` 之外,`State` 的构造不会偷偷读环境(测试之间不串味)。
/// 抓不住:`ULL_APPEARANCE_*` 在真机上的解析(`apply_env` 由截图矩阵覆盖,见报告)。
#[test]
fn a_fresh_state_is_the_factory_appearance() {
let state = State::new(
&unlucky_presenter::prefs::AppearancePrefs::factory(),
&unlucky_presenter::prefs::AppearanceBase::factory(),
Backend::Gpu,
SchemeFlavor::Light,
);
assert_eq!(state.window_opacity, 1.0);
assert_eq!(state.effective_panel_alpha(), 1.0);
assert!(!state.background_active());
assert!(state.diagnostics().is_empty());
assert!(state.image().is_none());
}
+238
View File
@@ -0,0 +1,238 @@
//! presenter 闭环与节拍的断言(M90 r2 从 `app/tests.rs` 外置:那边 416 行会被 file-size 判红)。
//!
//! 这里的每条都依赖一个可注入的 `Backend`:
//! - `MockBackend`(presenter 自带)验「命令 → 事件 → 状态」的闭环;
//! - `RecorderBackend`(本文件,只记账不回事件)验「UI 侧不做乐观回显」——用 MockBackend 验这条
//! 会得到**假绿**,因为它是同步发事件的,本地回显与事件回流终态相同(P3-2 的教训)。
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_presenter::dto::{AppCommand, AppEvent, Backend, CoreEvent};
use unlucky_presenter::mock_data::mock_instances;
use super::*;
// --------------------------------------------------------------- presenter 闭环
/// **首屏快照在 Boot 就到位**(截图在第 3 帧落盘,等 30 fps 的 tick 会拍进空页面)。
#[test]
fn boot_drains_the_first_snapshot_before_the_first_frame() {
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
assert!(app.view.instances.rows.is_empty(), "还没 Boot");
let _ = app.update(Message::Boot);
assert_eq!(
app.view.instances.rows.len(),
4,
"mock 的四个实例已进视图模型"
);
assert_eq!(app.view.screen, pages::Screen::Instances);
}
/// 首屏自动选中第一行(右栏不该开局空白),且**只做一次**:返回列表后再刷新不弹回详情。
#[test]
fn the_first_snapshot_selects_the_first_instance_once() {
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
assert_eq!(
app.state.route(),
&Route::Detail("survival".to_string()),
"首屏快照到位后自动选中第一行"
);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
assert_eq!(app.state.route(), &Route::Instances);
// 再推进一拍(会 drain_events + auto_select):不该把他弹回详情页
app.advance(super::super::state::TICK);
assert_eq!(app.state.route(), &Route::Instances, "自动选中只做一次");
}
/// 只记账、**不回任何事件**的后端(P3-2)。
///
/// 为什么需要它:`MockBackend::dispatch` 是**同步**发事件的,于是「UI 本地回显」与「事件回流」
/// 在 dispatch 之后终态相同——拿 MockBackend 测「不乐观回显」是假绿(r1 就是栽在这条上)。
/// 这个后端把两件事分开:命令只进 `dispatched`,`drain_events` 永远空 ⇒ 状态**不可能**变;
/// 一旦有人在 `update` 里偷偷改本地状态,下面那条断言立刻红(实测见报告「P3-2 取证」)。
#[derive(Debug, Default)]
struct RecorderBackend {
dispatched: Rc<RefCell<Vec<AppCommand>>>,
seeded: Vec<AppEvent>,
}
impl Backend for RecorderBackend {
fn drain_events(&mut self) -> Vec<AppEvent> {
std::mem::take(&mut self.seeded)
}
fn dispatch(&mut self, command: AppCommand) {
self.dispatched.borrow_mut().push(command);
}
}
/// 用 recorder 后端造一个 App(首屏快照靠 `seeded` 一次性喂进去)。
fn recording(options: Options, flavor: SchemeFlavor) -> (App, Rc<RefCell<Vec<AppCommand>>>) {
let recorded = Rc::new(RefCell::new(Vec::new()));
// 注意:`InstancesChanged` 是**整体替换**,所以四条必须合成一条事件;
// 分开发的话最后只会剩一个实例(r1 在这条上踩过)。
let backend = RecorderBackend {
dispatched: Rc::clone(&recorded),
seeded: vec![AppEvent::Core(
CoreEvent::InstancesChanged(mock_instances()),
)],
};
let app = App::with_presenter(
options,
FAMILIES,
flavor,
Box::new(backend),
SettingsBridge::memory_only(),
);
(app, recorded)
}
/// **P3-2:不做乐观回显**——命令必须真的出 UI 层,而状态在后端回事件之前**一个字节都不许变**。
///
/// 抓得住:`update` 里出现「本地先改状态」的写法(recorder 永远不回事件,本地一改这里就红)。
/// 抓不住:「命令有没有真的到达 host」——那由 `MockBackend` 的闭环测试与真机验证负责。
#[test]
fn launching_only_dispatches_and_never_echoes_locally() {
let mut options = screenshot_options();
options.screenshot = None; // recorder 不依赖截图通道
let (mut app, recorded) = recording(options, SchemeFlavor::Light);
let _ = app.update(Message::Boot);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
// Boot 自己发过一条 RefreshAll,先把账本清掉,下面只看「点 Play」这一个动作
recorded.borrow_mut().clear();
let before = app.view.instances.detail.clone().expect("有选中项");
assert!(before.can_launch && !before.can_stop && before.status.is_none());
let _ = app.update(Message::Page(pages::Message::Launch(
"survival".to_string(),
)));
// ① 命令真的出 UI 层了
assert_eq!(
*recorded.borrow(),
vec![AppCommand::LaunchInstance("survival".to_string())],
"Launch 必须翻成一条 AppCommand::LaunchInstance 并交给 Backend::dispatch"
);
// ② 后端一条事件都没回 ⇒ 视图模型必须**原样不动**(任何本地回显都会让这条红)
let after = app.view.instances.detail.clone().expect("有选中项");
assert_eq!(
after, before,
"没有事件回流时页面不许自己变(乐观回显);现在的差异是 {after:?}"
);
assert!(!after.can_stop, "不许因为点了 Play 就本地切成「可停止」");
assert!(after.status.is_none(), "不许本地造一条进度文案");
}
/// **点 Play → 命令真的派发出去 → 事件回流 → 页面真的变**(真后端的闭环)。
#[test]
fn launching_goes_through_the_backend_and_comes_back_as_events() {
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
let detail = app.view.instances.detail.as_ref().expect("有选中项");
assert!(
detail.can_launch && !detail.can_stop,
"空闲:可启动不可停止"
);
let _ = app.update(Message::Page(pages::Message::Launch(
"survival".to_string(),
)));
let detail = app.view.instances.detail.as_ref().expect("有选中项");
assert!(
!detail.can_launch && detail.can_stop,
"派发 LaunchInstance 后后端发回了 Busy 事件:按钮组合必须已经翻转"
);
assert_eq!(
detail.status.as_deref(),
Some("Downloading · 0%"),
"状态行来自事件里的进度,不是本地假造"
);
}
/// 时间推进真的把状态往前推(后端的脚本按 tick 的累计时长走)。
#[test]
fn ticks_advance_the_backend_script() {
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
let _ = app.update(Message::Page(pages::Message::Launch(
"survival".to_string(),
)));
// 33 ms × 50 ≈ 1.65 s:mock 的脚本在 1.5 s 从 Download 切到 Verify
for _ in 0..50 {
app.advance(super::super::state::TICK);
}
let detail = app.view.instances.detail.as_ref().expect("有选中项");
let status = detail.status.as_deref().unwrap_or_default();
assert!(
status.starts_with("Verifying") || status.starts_with("Starting"),
"50 拍之后应已过下载阶段,实际 {status:?}"
);
}
/// 停止也是真命令:`StopInstance` 让 mock 直接回一条 Idle 事件。
#[test]
fn stopping_returns_to_idle_through_an_event() {
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
let _ = app.update(Message::Page(pages::Message::Launch(
"survival".to_string(),
)));
let _ = app.update(Message::Page(pages::Message::Stop("survival".to_string())));
let detail = app.view.instances.detail.as_ref().expect("有选中项");
assert!(detail.can_launch && !detail.can_stop, "停下来回到空闲");
assert!(detail.status.is_none(), "空闲没有状态行");
}
/// 导航:一级页替换、详情压栈、返回出栈——页面换的是 `Route`,不是页面自己的选中项。
#[test]
fn navigation_follows_the_presenter_router() {
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
assert_eq!(app.view.screen, pages::Screen::Instances, "详情仍是实例页");
assert!(app.state.router.can_go_back());
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Settings)));
assert_eq!(app.view.screen, pages::Screen::Settings);
assert_eq!(
app.view.active,
Some(pages::Screen::Settings.nav()),
"导航轨高亮跟着路由走"
);
assert!(
!app.state.router.can_go_back(),
"一级页互相替换,不进栈(presenter::router 的语义)"
);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
assert_eq!(app.view.screen, pages::Screen::Instances);
}
+372
View File
@@ -0,0 +1,372 @@
//! `update`:消息分支的**唯一**落点(含「用户动作 → 后端指令」的派发与事件回流)。
//!
//! ## 数据流的三个入口(presenter crate 文档 §2)
//!
//! ```text
//! 用户动作 ──dispatch(AppCommand)──► backend ──► drain_events() ──► AppState::apply ──► 视图模型
//! ▲ ▲
//! └── pages::Message(导航/启动/停止)──────────────────┘ 忙碌 33 ms / 空闲 1 Hz
//! ```
//!
//! 三条纪律:
//!
//! 1. **不做乐观回显**:点了「Play」不改本地状态,等 `InstanceRuntimeChanged` 事件回来才变
//! (否则事件与本地状态打架时,磁贴与进度条会各说一套);
//! 2. **状态只有一个写入口**:`AppState::apply`(事件)与 `AppState::navigate`(路由);
//! 3. **只在状态变了才重建视图模型**:`refresh_view()` 是快照 → 页面的唯一转换点,
//! 由 `view_dirty` 门控(M90 r2/P2-1 起;此前是每拍无条件重建)。
//!
//! ## 两个时钟(M90 r3 / P2-2)
//!
//! | 场景 | 时钟 | 周期 | 谁触发 |
//! |---|---|---|---|
//! | 截图 / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | [`App::needs_frames`] |
//! | 其余(空闲) | `Heartbeat` 自续链 | 1 s | [`App::heartbeat`] |
//!
//! 两条时钟不会同时干活:心跳在 `needs_frames()` 为真时**只续排、不推进**,忙碌结束自动接回。
use std::time::{Duration, Instant};
use iced::{window, Task};
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::AppCommand;
use unlucky_presenter::prefs::ThemeMode;
use unlucky_presenter::router::Route;
use unlucky_presenter::theme::CONFIG_POLL_TICKS;
use crate::pages;
use crate::renderer::Kind;
use super::message::{route_of, Message};
use super::state::{App, IDLE_TICK, TICK, TICK_MS, WATCH_WINDOW};
use super::CAPTURE_AT_FRAME;
mod download;
mod page_messages;
impl App {
/// 一条消息进来,改状态并返回后续任务。
pub(super) fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Boot => self.on_boot(),
Message::WindowReady(id) => {
self.window = id;
// M93:整窗 α 要 Win32 句柄,而 iced 只给 `window::Id`。`window::run` 是唯一能把
// `&dyn Window`(实现了 `HasWindowHandle`)拿出来的入口。
match id {
Some(id) => {
window::run(id, super::window_alpha::probe_hwnd).map(Message::WindowHandle)
}
None => Task::none(),
}
}
Message::WindowHandle(hwnd) => {
self.hwnd = hwnd;
match hwnd {
Some(_) => self.apply_window_alpha("boot"),
None => {
eprintln!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)");
self.log_appearance("boot", None);
}
}
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:?}");
// 优先级:`--theme` > 配置文件里的 `appearance.theme_mode` > 系统。
// 早先这里无条件盖掉,于是「配置文件写 dark、系统是浅色」会出现
// `light -> dark -> light` 的一次抖动,配置永远不生效(M90 r3 顺手修)。
if self.options.theme.is_none() && self.theme_mode == ThemeMode::Auto {
self.set_flavor(flavor);
}
Task::none()
}
Message::Frame => self.on_frame(),
Message::Heartbeat => self.on_heartbeat(),
Message::Window(event) => {
if let window::Event::Resized(size) = event {
self.width = size.width;
// 高度是背景平铺网格的视口判据之一(宽度早就有),以前只记宽是因为没有背景层。
self.height = size.height;
self.view_dirty = true;
self.refresh_view();
}
Task::none()
}
Message::Page(message) => self.on_page(message),
// 外壳导航轨(`src/shell/nav.rs`)发来的点选:翻成同一次路由跳转
Message::Select(nav) => {
self.state.navigate(route_of(nav));
self.view_dirty = true;
self.refresh_view();
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),
Message::Demo(message) => {
// gallery 只是 harness:按下只打日志,不接业务(组件库要能脱离业务验收)
eprintln!("[gallery] {} pressed", message.id());
Task::none()
}
}
}
/// 启动:问窗口句柄 → 让后端发布首屏快照 → 决定主题。
///
/// **首屏快照在 `Boot` 就抽干**(不是等第一拍):截图通道在第 3 帧落盘,33 ms 的节拍
/// 未必来得及到达,等节拍会把空页面拍进图里。
fn on_boot(&mut self) -> Task<Message> {
let mut tasks = vec![window::latest().map(Message::WindowReady)];
// 主题优先级:显式 `--theme` / `--screenshot-theme` > 设置文件 > 系统。
// 截图通道**不读设置文件**(D30 可复现:同一份代码在两台机器上要出同一张图)。
self.apply_config_theme();
// M93 外观取证开关(`ULL_APPEARANCE_*`)+ 启动那一行数值日志。
// 必须在 `refresh_view` 之前:设置页要画的就是这份状态。
self.appearance.apply_env();
self.rebuild_ui();
self.log_appearance("boot", None);
// 交互模式且没显式指定主题 → 探系统;截图模式永不等(见 cli 模块文档)
if self.options.wants_system_theme() {
tasks.push(iced::system::theme().map(Message::SystemTheme));
}
// 「重新扫描实例目录 + 全部页面快照」:真 host 在这里发首批事件,mock 在构造时已预置
self.dispatch(AppCommand::RefreshAll);
// 取证开关 `ULL_START_ROUTE`:截图/冒烟直接落到某一页(`cli.rs` 不在本 mission 的 scope,
// 而「外观」分组只在设置页上,出矩阵必须能直达)。未知值忽略。
if let Some(route) = env_start_route() {
eprintln!("[boot] ULL_START_ROUTE → {route:?}");
self.state.navigate(route);
// 直达某一页时不要再被「自动选中第一条实例」拉回详情页。
self.auto_selected = true;
}
self.auto_select();
self.view_dirty = true;
self.refresh_view();
// 空闲心跳从这里开始自续(它是唯一能在「没有帧事件」时戳醒 app 的时钟)
tasks.push(heartbeat());
Task::batch(tasks)
}
/// 空闲心跳:只有「不需要帧节拍」时才推进一拍,然后**无条件续排**下一拍。
///
/// 为什么无条件续排:忙碌结束(游戏退出、toast 过期、观察窗关闭)之后没有别的东西会叫醒
/// app,心跳链必须一直在,才能把低频时钟接回去。忙碌期间它只是每秒醒一次、什么都不做。
fn on_heartbeat(&mut self) -> Task<Message> {
if !self.needs_frames() {
let now = Instant::now();
let elapsed = now.saturating_duration_since(self.last_tick).max(IDLE_TICK);
self.last_tick = now;
self.advance(elapsed);
}
heartbeat()
}
/// 一帧画完:①够 33 ms 就推进一拍 presenter ②截图模式在第 [`CAPTURE_AT_FRAME`] 帧出图。
///
/// 时间闸门按**真实流逝时间**判(不是数帧):掉帧不会把游戏启动进度拖慢,
/// 也不会在慢机器上把 1.5 s 的下载阶段拉成 4 s。
fn on_frame(&mut self) -> Task<Message> {
self.frames += 1;
let now = Instant::now();
let elapsed = now.saturating_duration_since(self.last_tick);
if elapsed >= TICK {
self.last_tick = now;
self.advance(elapsed);
}
// 主题在启动时就已确定(截图模式必有主题,见 cli 模块文档),这里只剩帧数闸门
if !self.wants_frames() || self.frames < CAPTURE_AT_FRAME {
return Task::none();
}
match self.window {
Some(id) => window::screenshot(id).map(Message::Captured),
None => Task::none(),
}
}
/// 推进一拍:后端消化时间 → 抽干事件 → 过期 toast → 偶发重读配置文件。
///
/// `elapsed` 是真实流逝时间(毫秒级抖动是正常的:`MockBackend` 的脚本按累计时长走)。
pub(super) fn advance(&mut self, elapsed: Duration) {
self.backend.tick(elapsed);
self.pump();
self.state
.toasts
.expire(u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX));
// `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s,
// 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。
let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS));
let ticks = u64::try_from(ticks).unwrap_or(u64::MAX).max(1);
let bucket_before = self.ticks / 30;
self.ticks += ticks;
let bucket_after = self.ticks / 30;
// 外部(AI / CLI)改了配置文件:主题跟着换,其余外观项等玻璃/背景 mission。
//
// 判据用**跨越**而不是 presenter 的 `config_poll_due(ticks)`(整除):两个时钟的相位不同
// (帧节拍按 33 ms 累加、空闲心跳按 30 拍累加),整除判定一旦错过一次就**再也命中不了**
// ——实测 ticks=137 之后每拍 +30 永远不整除,配置轮询静默死掉。间隔真源仍是
// `CONFIG_POLL_TICKS`(30 拍 × 33 ms ≈ 1 秒)。截图通道不轮询(D30 可复现)。
let poll_due = self.ticks >= self.config_due_at;
if poll_due {
// 追到「当前 ticks 之后的第一个间隔点」,避免心跳把相位拖飞
self.config_due_at = self.ticks + CONFIG_POLL_TICKS;
}
let reloaded =
!self.has_screenshot() && poll_due && self.bridge.reload_if_changed().is_some();
if reloaded {
eprintln!("[settings] 配置文件变了,重读主题 + 外观");
self.apply_config_theme();
// M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。
self.reload_appearance();
}
if self.view_dirty {
self.refresh_view();
}
// 取证开关(`ULL_TICK_TRACE=1`):每跨过 30 拍(≈1 秒)打一行。
// 忙碌态由帧节拍跨桶(≈1 行/秒),空闲态由心跳一次 +30 跨桶(**也是 1 行/秒**)——
// 两种时钟都用同一把尺子,所以 trace 的密度本身就是「时钟在跑」的证据。
if bucket_after != bucket_before && std::env::var_os("ULL_TICK_TRACE").is_some() {
eprintln!(
"[tick] n={} frames={} refreshes={} clock={}",
self.ticks,
self.frames,
self.refreshes,
if self.needs_frames() {
"frames"
} else {
"idle-1hz"
}
);
}
}
/// 派发一条指令并**立刻**抽干它带出来的事件(不本地回显,等价于 Slint 版的 `dispatch`)。
pub(super) fn dispatch(&mut self, command: AppCommand) {
self.backend.dispatch(command);
// 观察窗:命令发出后 3 秒内保持帧订阅,等后端把第一条事件交回来
// (真 host 是异步的,派发瞬间状态还没变;不设这个窗口,空闲态的帧订阅永远不会打开)。
self.watch_until = Some(Instant::now() + WATCH_WINDOW);
self.pump();
}
/// 抽干事件队列并逐条 `apply`(**唯一**的状态写入口)。
pub(super) fn pump(&mut self) {
let events = self.backend.drain_events();
if events.is_empty() {
return;
}
for event in events {
self.state.apply(event);
}
self.view_dirty = true;
self.auto_select();
self.refresh_view();
}
/// 首屏快照到位后**自动选中第一行**(桌面 list-detail 的惯例:右栏不该开局就是空的)。
///
/// 只做一次:用户点「返回」回到列表后,再刷新实例列表也不会把他弹回详情页。
/// 选中这件事走的是 `AppState::navigate`(路由栈),不是本地选中字段——于是「当前选中」
/// 永远等于「路由栈顶」,用户点返回时栈语义自然成立。
fn auto_select(&mut self) {
if self.auto_selected || self.state.route() != &Route::Instances {
return;
}
let Some(first) = self
.state
.tiles()
.first()
.map(|cell| cell.instance.id.clone())
else {
return;
};
self.auto_selected = true;
self.state.navigate(Route::Detail(first));
self.view_dirty = true;
}
/// 截图回来了:落盘 + 打一行取证日志,然后退出。
fn on_captured(&mut self, screenshot: &window::Screenshot) -> Task<Message> {
let Some(path) = self.options.screenshot.clone() else {
return Task::none();
};
match crate::shot::save(screenshot, &path) {
Ok(summary) => {
eprintln!("{}", summary.describe());
// M97:打**实际**后端名(`wgpu`/`tiny-skia`),不是请求值——`renderer=auto` 曾经出现在每张验收图的日志里。
eprintln!(
"[shot] screen={} theme={} renderer={} route={:?}",
self.options.screen.name(),
crate::shot::flavor_slug(&self.ui),
super::backend::effective_kind().map_or("?", Kind::name),
self.state.route()
);
}
Err(error) => eprintln!("[shot] 失败:{error}"),
}
iced::exit()
}
}
/// 空闲心跳的一拍:睡 1 秒再发 [`Message::Heartbeat`]。
///
/// 用 `Task::perform` + `std::thread::sleep` 而不是 `iced::time::every`:后者只在 tokio/smol
/// 后端提供(本 crate 的执行器是 thread-pool,Cargo.toml 不在 scope)。代价是每拍让线程池里
/// 一个线程阻塞 1 秒——按 1 Hz 算,对线程池(默认 = CPU 数)是可接受的占用。
fn heartbeat() -> Task<Message> {
Task::perform(async { std::thread::sleep(IDLE_TICK) }, |()| {
Message::Heartbeat
})
}
/// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `logs`)。
///
/// 为什么是环境变量:`crates/app-iced/src/cli.rs` 不在 M93 的 scope,而外观矩阵必须在**设置页**上
/// 出图。语义与 `ULL_APPEARANCE_*` / `ULL_TICK_TRACE` 一致:只影响这一次进程。
fn env_start_route() -> Option<Route> {
match std::env::var("ULL_START_ROUTE").ok()?.trim() {
"instances" => Some(Route::Instances),
"download" => Some(Route::Download),
"logs" => Some(Route::Logs),
"settings" => Some(Route::Settings),
_ => None,
}
}
#[cfg(test)]
mod download_wiring;
@@ -0,0 +1,57 @@
//! 下载页在 app 层的那几支(M94)。
//!
//! 为什么单独一个文件:M93(外观三件套接线)合入后 `update.rs` 已经贴到 400 行,下载页再往里塞
//! 四个 arm 就顶破硬线;拆出来是既不动 M93 的代码、又能守住 ≤400 的办法(`app/update/**` 属本
//! mission scope)。
//!
//! ## 四支各自的纪律
//!
//! | 消息 | 落点 | 为什么 |
//! |---|---|---|
//! | [`pages::Message::SelectVersion`] | **只改视图模型** | 选中是页面内状态,不是业务命令 |
//! | [`pages::Message::InstallVersion`] | `AppCommand::InstallVersion` | 真指令,回事件后才画进度 |
//! | [`pages::Message::CancelInstall`] | `AppCommand::CancelInstall` | 同上(host 侧对没在跑的任务是 no-op) |
//! | [`pages::Message::SetCategory`] | 写分类 → 重建 → `AppCommand::ListVersions` | 见下 |
//!
//! [「刷新下载源」](pages::Action::RefreshSources) 也在这里:它不是占位动作,具体分类 → 重拉该分类、
//! 「全部」→ 让 host 重扫一遍(`RefreshAll`)。
//!
//! ## 分类为什么既写视图模型、又派指令
//!
//! 真 host 的 `AppCommand::ListVersions(_)` 会丢掉分类参数、回全量清单(复审 P2-1),所以**过滤在
//! 本页做**:写进视图模型 → 立刻 `refresh_view()`(它会把 category 带回去、按同一谓词过滤行)→
//! 再派指令。这不是「乐观回显」:写的是**过滤条件本身**,chip 与列表用同一个
//! [`Category::matches`](crate::pages::Category),于是不可能出现「chip 说正式版、列表是全量」的两份真相。
use super::*;
impl App {
/// 下载页的消息。**处理了返回 `true`**(`on_page` 里就不用再写一遍这些 arm)。
pub(super) fn on_download(&mut self, message: &pages::Message) -> bool {
match message {
pages::Message::SelectVersion(id) => {
self.view.download.selected = Some(id.clone());
}
pages::Message::InstallVersion(id) => {
self.dispatch(AppCommand::InstallVersion(id.clone()));
}
pages::Message::CancelInstall => self.dispatch(AppCommand::CancelInstall),
pages::Message::SetCategory(category) => {
self.view.download.category = *category;
self.view_dirty = true;
self.refresh_view();
if let Some(kind) = category.command() {
self.dispatch(AppCommand::ListVersions(kind));
}
}
pages::Message::Action(pages::Action::RefreshSources) => {
match self.view.download.category.command() {
Some(kind) => self.dispatch(AppCommand::ListVersions(kind)),
None => self.dispatch(AppCommand::RefreshAll),
}
}
_ => return false,
}
true
}
}
@@ -0,0 +1,76 @@
//! 下载页(M94)在 app 层的接线测试:**命令出得去、状态不自己变**。
//!
//! 从 `app/update.rs` 外置(那边 348 行 + 这个模块会顶破 400 行硬上限;同一句话的理由见
//! `crates/i18n/src/catalog/tests.rs`)。`use super::*` 依然指向 `crate::app::update`。
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_presenter::dto::{AppEvent, Backend, VersionCategory};
use unlucky_presenter::settings_bridge::SettingsBridge;
use super::*;
/// 只记账、**不回事件**的后端(同 `app/tests/presenter.rs`):命令出了 UI 层,状态一个字节都不许变。
/// 不用 `MockBackend`:它同步回事件,「本地乐观回显」与「事件回流」终态相同,断言会**假绿**。
struct Recorder(Rc<RefCell<Vec<AppCommand>>>);
impl Backend for Recorder {
fn drain_events(&mut self) -> Vec<AppEvent> {
Vec::new()
}
fn dispatch(&mut self, command: AppCommand) {
self.0.borrow_mut().push(command);
}
}
/// 四条下载页消息:三条派真指令,`SelectVersion` 只改视图模型(P3-2 的不乐观回显)。
/// 抓得住:意图被写成「本地改状态就算完」、或派错指令;抓不住:host 收到后真的开始下载。
#[test]
fn download_intents_dispatch_real_commands() {
let log: Rc<RefCell<Vec<AppCommand>>> = Rc::new(RefCell::new(Vec::new()));
let families = crate::theme::Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
let mut app = App::with_presenter(
crate::cli::Options::default(),
families,
SchemeFlavor::Light,
Box::new(Recorder(Rc::clone(&log))),
SettingsBridge::memory_only(),
);
// M95 起 `on_page` 返回 `Task`(剪贴板命令要走返回的 Task):这里只关心派发与视图模型,
// 任务本身没有需要断言的后效,显式丢弃。
let _ = app.on_page(pages::Message::InstallVersion("1.21.9".to_string()));
let _ = app.on_page(pages::Message::CancelInstall);
let _ = app.on_page(pages::Message::SetCategory(pages::Category::Release));
let _ = app.on_page(pages::Message::SelectVersion("1.21.8".to_string()));
let expected = vec![
AppCommand::InstallVersion("1.21.9".to_string()),
AppCommand::CancelInstall,
AppCommand::ListVersions(VersionCategory::Release),
];
assert_eq!(
*log.borrow(),
expected,
"三条各派一条;`SelectVersion` 不派"
);
assert_eq!(app.view.download.selected.as_deref(), Some("1.21.8"));
assert_eq!(app.view.download.category, pages::Category::Release);
assert_eq!(
app.view.download.rows.len(),
0,
"recorder 不回事件 ⇒ 列表不该冒出来"
);
// 分类是本地过滤条件:**任何一次视图重建都不许把它打回 `All`**(复审 P2-1 的回归点)。
// 抓得住:`refresh_view` 忘记把分类带回去(那一行删掉这条就红)。
// 抓不住:真 host 是否按分类拉清单(那是 presenter 的 `spawn_list`,已报 finding)。
app.refresh_view();
assert_eq!(
app.view.download.category,
pages::Category::Release,
"重建视图模型之后分类必须还在"
);
}
@@ -0,0 +1,91 @@
//! 页面消息的落点(`on_page`):解 M94 冲突时从 `update.rs` 拆出来。
//!
//! 为什么拆:`main` 上 `update.rs` 已经 **397 行**(M94 自己也是因此把下载页四支拆到
//! `update/download.rs`),日志页再往里塞「剪贴板 + 清日志」两支就顶破 400 硬线。
//! 与 M94 同一种做法:`app/update/**` 属本 mission scope。
//!
//! ## 穷尽性(M94 复审的 p3,本轮收掉)
//!
//! 这里是全 crate **唯一**对 [`pages::Message`] 的穷尽匹配点,所以**没有** `_ => {}` 兜底:
//! 谁往枚举里加变体却忘了接线,编译器立刻报 E0004。原来是 `_ => {}` + 注释「做穷尽性」——
//! 方向正好写反了,它**取消**穷尽性,把「漏接线」变成「点了没反应」。
//!
//! 代价:由前置分支(`on_download` / 本文件里的剪贴板分支)先消费的变体,在 match 里**仍要
//! 显式列出**。那是保住穷尽性的必要代价,也是这条守卫抓得住东西的原因。
//!
//! ## 边界(抓得住什么 / 抓不住什么)
//!
//! - 抓得住:**加一个 `pages::Message` 变体却没接线** → 编译失败(E0004),不会静默;
//! - 抓不住:加一个 `pages::Action` 变体 —— 它落进 `Action(action)` 的占位分支(打印一行
//! `[page] 占位动作 …`),编译期不会报。这是占位期的既有设计,不是本文件能守的。
use super::*;
impl App {
/// 页面消息:导航走路由栈,真业务命令派发,纯视图态改本地呈现。
pub(super) fn on_page(&mut self, message: pages::Message) -> Task<Message> {
// 下载页的四支(含「刷新下载源」)在 `update/download.rs`:`update.rs` 早已贴 400 行
if self.on_download(&message) {
return Task::none();
}
match message {
pages::Message::Navigate(route) => {
self.state.navigate(route);
self.view_dirty = true;
self.refresh_view();
}
pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)),
pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)),
pages::Message::Appearance(change) => self.on_appearance(change),
// M95:摘要落剪贴板(文本由页面造,这里只搬)
pages::Message::CopySummary(text) => return iced::clipboard::write(text),
// 下面四支已由 `on_download` 提前消化;显式列出而不是 `_ => {}`,见模块文档
pages::Message::SelectVersion(_)
| pages::Message::InstallVersion(_)
| pages::Message::CancelInstall
| pages::Message::SetCategory(_) => debug_assert!(false, "应由 on_download 消费"),
// M95:清空日志是**呈现层**操作(`log_lines` 是 presenter 的公开快照字段,
// 不派业务命令、不写持久状态)
pages::Message::Action(pages::Action::ClearLogs) => {
self.state.log_lines.clear();
self.view_dirty = true;
self.refresh_view();
}
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
pages::Message::Action(action) => {
eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
}
}
Task::none()
}
}
#[cfg(test)]
mod tests {
/// 抓得住:有人给 `on_page` 的 match 重新加 `_ => {}` 兜底 —— 那正是 M94 复审的 p3:
/// 兜底让「加了变体忘了接线」从**编译错误**退化成「点了没反应」,这条测试把它拽回来。
/// 抓不住:别的兜底写法(把 match 拆成 if-else 链、给子枚举加通配之后再漏变体),
/// 也抓不住「加了变体但接错分支」——那两条只有编译器和人读得出来。
#[test]
fn the_page_match_has_no_wildcard_fallback() {
let source = include_str!("page_messages.rs");
let wildcards: Vec<(usize, &str)> = source
.lines()
.enumerate()
.filter(|(_, line)| {
let trimmed = line.trim();
trimmed.starts_with("_ =>") || trimmed.starts_with("_=>")
})
.map(|(index, line)| (index + 1, line))
.collect();
assert!(
wildcards.is_empty(),
"on_page 里出现了 `_ =>` 兜底(行 {:?}):它会取消对 pages::Message 的穷尽性,\
新变体漏接线时编译器不再报 E0004",
wildcards
);
}
}
+337
View File
@@ -0,0 +1,337 @@
//! `view`:presenter 快照 → 页面视图模型 → 控件树。
//!
//! 展示转换(快照字段 → 显示文本/网格)**全部在这里**,页面层只认 [`pages::Model`]。
use iced::{Element, Theme};
use unlucky_i18n::Lang;
use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage};
use unlucky_presenter::router::Route;
use unlucky_presenter::state::AppState;
use crate::app::components::interaction::Interaction;
use crate::cli::Screen;
use crate::pages::{self, Detail, Meta, Row};
use crate::shell::{self, NavItem};
use super::message::{nav_item_of, Message};
use super::state::{log_first_frame, App};
use super::APP_TITLE;
mod logs;
use logs::logs_model;
impl App {
/// 主题名(iced 只在 `Theme::name()` 变化时重算受主题影响的缓存)。
pub(super) fn theme(&self) -> Theme {
self.ui().theme()
}
/// 窗口标题(自绘标题栏另画一份,这里是给系统/截图工具看的)。
pub(super) fn title(&self) -> String {
APP_TITLE.to_owned()
}
/// 整棵树。
pub(super) fn view(&self) -> Element<'_, Message> {
log_first_frame();
// `--screen=gallery`:组件库 harness(D39 §5)。它用自己的 DemoMessage,
// 这里只做一次 map,组件库与业务 Message 依然解耦。
if self.options.screen == Screen::Gallery {
return super::components::gallery::view(&self.ui).map(Message::Demo);
}
// 产品页走**内容槽位**:外壳(标题栏 / 拖动与缩放热区 / 三个 caption 键 / rail)由
// `shell` 画,中间那块才是页面。M90 r2 之前这里用的是 `pages::app`(自带一条**没有任何
// 控件**的标题栏留白带),而 `DragWindow` / `ResizeStart` / `Minimize` / `ToggleMaximize` /
// `Close` 五个消息只在外壳里构造 ⇒ 产品页「拖不动、缩不了、点不到关闭」。
//
// M93:三样东西从外观状态进来——①背景层(垫在最底下)②面板 α(外壳层的底色)③设置页要读
// 的那份状态本身(`page_with_appearance`)。
match pages::Screen::of(self.state.route()) {
Some(_) => {
let background =
pages::appearance::layer(&self.ui, &self.appearance, (self.width, self.height))
.map(|element| element.map(Message::Page));
shell::view_with_background(
&self.ui,
nav_item_of(self.state.route()),
APP_TITLE,
background,
pages::page_with_appearance(&self.ui, &self.view, &self.appearance)
.map(Message::Page),
self.options.force_state,
)
}
// `Route::Agent`:AI 独立页还没有页面实现(P1),回退到 M68 的外壳卡(同一套装饰)。
None => shell::view(
&self.ui,
NavItem::Ai,
APP_TITLE,
NavItem::Ai.label(),
"The assistant page is not built yet (P1). Chat lives in the docked panel once \
the AI mission lands; this screen is the M68 skeleton fallback.",
self.options.force_state,
),
}
}
}
// --------------------------------------------------------------- 快照 → 视图模型
/// 把 presenter 的快照转成页面视图模型(**唯一**的展示转换点)。
///
/// `forced` 是 `--force-state` 的落点(P3-1):它只影响**画出来的**状态层,不改任何状态机。
pub(super) fn model(
state: &AppState,
width: f32,
forced: Option<crate::cli::ForcedState>,
category: pages::Category,
) -> pages::Model {
let route = state.route().clone();
let screen = pages::Screen::of(&route).unwrap_or(pages::Screen::Instances);
let rows: Vec<Row> = state
.tiles()
.into_iter()
.map(|cell| Row {
id: cell.instance.id.clone(),
name: cell.instance.name.clone(),
supporting: supporting_line(cell.instance),
status: runtime_status(&cell.instance.runtime),
})
.collect();
let detail = match &route {
Route::Detail(id) => state
.instances
.iter()
.find(|instance| &instance.id == id)
.map(detail_of),
_ => None,
};
// 语言入口还没接到 `Ui`(`theme.rs` 归 M93),产品路径先英文。
let lang = Lang::En;
pages::Model {
screen,
window_width: width,
active: route.nav(),
instances: pages::Instances { rows, detail },
download: pages::Download::from_snapshot(state, category, unlucky_i18n::Lang::En.catalog()),
logs: logs_model(state).with_status(lang),
lang,
forced: forced.map(|state| match state {
crate::cli::ForcedState::Hover => Interaction::Hovered,
crate::cli::ForcedState::Pressed => Interaction::Pressed,
}),
}
}
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版」,见 `LoaderInfo::badge`)。
fn supporting_line(instance: &InstanceSummary) -> String {
format!("{} · {}", instance.mc_version, instance.loader.badge())
}
/// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。
fn runtime_status(runtime: &InstanceRuntime) -> Option<String> {
match runtime {
InstanceRuntime::Idle => None,
InstanceRuntime::Busy { stage, progress } => Some(format!(
"{} · {:.0}%",
stage_label(*stage),
progress.ratio() * 100.0
)),
InstanceRuntime::Running { pid } => Some(format!("Running · pid {pid}")),
InstanceRuntime::Failed { reason } => Some(format!("Launch failed · {reason}")),
}
}
/// 阶段文案(英文基线;i18n mission 接手后统一走词表)。
///
/// 不复用 `LaunchStage::label()`:那是中文,而本 crate 的界面文案在 i18n 之前是英文基线
/// (见 `pages/instances/data.rs` 的同一条理由:干净 CI 上没有中文字体)。
fn stage_label(stage: LaunchStage) -> &'static str {
match stage {
LaunchStage::Download => "Downloading",
LaunchStage::Verify => "Verifying",
LaunchStage::Launch => "Starting",
LaunchStage::Window => "Waiting for window",
}
}
/// 详情列的内容(字段全部来自 [`InstanceSummary`])。
fn detail_of(instance: &InstanceSummary) -> Detail {
// 视觉 P2-2(M90 r2):列表行与详情曾经「同物两名」(列表 `badge()` 出「原版」、详情硬编码
// 「Vanilla」)。现在两边都走 `LoaderInfo::badge()` 这一个入口;等 i18n 落地后由词表统一,
// 届时两处会同时变——这正是要的。
let loader = instance.loader.badge();
Detail {
id: instance.id.clone(),
name: instance.name.clone(),
subtitle: supporting_line(instance),
meta: vec![
meta("VERSION", &instance.mc_version),
meta("LOADER", &loader),
meta("ISOLATION", &instance.isolation),
meta("VERSION ID", &instance.version_id),
meta(
"FILES",
if instance.version_present {
"Present on disk"
} else {
"Missing"
},
),
meta("LAST PLAYED", &last_played(instance.last_played.as_deref())),
],
// 真快照没有「正文描述」这种字段(偏离 #9):演示数据才有正文段
note: None,
status: runtime_status(&instance.runtime),
can_launch: matches!(
instance.runtime,
InstanceRuntime::Idle | InstanceRuntime::Failed { .. }
),
can_stop: matches!(
instance.runtime,
InstanceRuntime::Busy { .. } | InstanceRuntime::Running { .. }
),
}
}
fn meta(label: &str, value: &str) -> Meta {
Meta {
label: label.to_string(),
value: value.to_string(),
}
}
/// ISO-8601(`2026-10-29T19:22:00Z`)→ `2026-10-29 19:22 UTC`;`None` = 从未启动。
///
/// 只做切片不做时区换算:core 给的是 UTC,`Z` 就是它的时区;本地时区换算等 i18n/时间格式 mission。
/// 非 ASCII 或长度不足时原样返回(不 panic、不猜)。
pub(super) fn last_played(raw: Option<&str>) -> String {
let Some(text) = raw else {
return "Never".to_string();
};
match (text.get(0..10), text.get(11..16)) {
(Some(date), Some(time)) if date.starts_with(|c: char| c.is_ascii_digit()) => {
format!("{date} {time} UTC")
}
_ => text.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_presenter::dto::{InstanceSummary, LoaderInfo, LoaderKind, Progress};
use unlucky_presenter::mock_data::mock_instances;
fn instance(runtime: InstanceRuntime) -> InstanceSummary {
InstanceSummary {
id: "survival".to_string(),
name: "Survival".to_string(),
mc_version: "1.20.4".to_string(),
loader: LoaderInfo {
kind: LoaderKind::Fabric,
version: "0.16.5".to_string(),
},
last_played: Some("2026-10-29T19:22:00Z".to_string()),
runtime,
icon: None,
isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(),
version_present: true,
}
}
#[test]
fn the_snapshot_becomes_the_page_model() {
let mut state = AppState::new();
state.apply(unlucky_presenter::dto::AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstancesChanged(mock_instances()),
));
state.navigate(Route::Detail("survival".to_string()));
let model = model(&state, 1180.0, None, pages::Category::All);
assert_eq!(model.screen, pages::Screen::Instances);
assert_eq!(model.active, Some(pages::Screen::Instances.nav()));
assert_eq!(model.window_width, 1180.0);
assert_eq!(model.instances.rows.len(), 4, "mock 的四个实例");
assert_eq!(model.instances.rows[0].id, "survival");
let detail = model.instances.detail.expect("路由是 Detail,要有详情");
assert_eq!(detail.id, "survival");
assert_eq!(detail.meta.len(), 6);
assert!(detail.can_launch, "空闲实例可启动");
assert!(!detail.can_stop);
assert!(detail.note.is_none(), "真快照没有正文段");
}
#[test]
fn a_busy_instance_offers_stop_instead_of_play() {
let mut state = AppState::new();
state.apply(unlucky_presenter::dto::AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![instance(
InstanceRuntime::Busy {
stage: LaunchStage::Download,
progress: Progress {
done: 750,
total: 1_500,
},
},
)]),
));
state.navigate(Route::Detail("survival".to_string()));
let model = model(&state, 1180.0, None, pages::Category::All);
assert_eq!(
model.instances.rows[0].status.as_deref(),
Some("Downloading · 50%")
);
let detail = model.instances.detail.expect("有详情");
assert!(!detail.can_launch, "启动中不许再点 Play(不做乐观回显)");
assert!(detail.can_stop, "启动中要能 Stop");
}
#[test]
fn failed_instances_can_be_retried() {
let failed = instance(InstanceRuntime::Failed {
reason: "exit 1".to_string(),
});
assert_eq!(
runtime_status(&failed.runtime).as_deref(),
Some("Launch failed · exit 1")
);
assert!(detail_of(&failed).can_launch, "失败后要能重试");
}
#[test]
fn timestamps_are_sliced_not_guessed() {
assert_eq!(
last_played(Some("2026-10-29T19:22:00Z")),
"2026-10-29 19:22 UTC"
);
assert_eq!(last_played(None), "Never");
// 短串 / 非 ASCII 原样返回(不 panic)
assert_eq!(last_played(Some("2026-10")), "2026-10");
assert_eq!(last_played(Some("昨天")), "昨天");
}
#[test]
fn the_agent_route_has_no_page() {
let mut state = AppState::new();
state.navigate(Route::Agent);
let model = model(&state, 1180.0, None, pages::Category::All);
// Agent 没有页面:模型退回实例页的空壳,view 会走外壳回退(`None` 分支)
assert_eq!(pages::Screen::of(state.route()), None);
assert!(model.instances.detail.is_none());
assert_eq!(model.active, None, "AI 页不高亮任何导航项");
}
}
+144
View File
@@ -0,0 +1,144 @@
//! 日志页的展示转换:presenter 快照 → [`pages::Logs`](从 `app/view.rs` 拆出来,M95 r3)。
//!
//! ## 跨会话串场(M95 r3 的 p2,代码复审抓的)
//!
//! r2 的写法先按 `state.diagnosis` 定「这是哪一场」:
//!
//! ```ignore
//! let session = report.and_then(|r| log_sessions.find(|s| s.id == r.session)).or_else(|| …selected…);
//! ```
//!
//! 而 `state.diagnosis` **全仓只写不清**(`presenter::state::apply_core` 里
//! `CoreEvent::DiagnosisReady` 是唯一赋值点,没有任何清零点),host 也只在崩溃 / 打开会话 /
//! 导入崩溃文件时发它。于是「崩溃一场 → 再启动一场正常跑」这个最普通的路径下:
//! 页头把**新**会话标成上一场的 `The game crashed`、诊断卡挂着上一场的结论,而用户复制去贴
//! issue 的摘要里 `lines=` 是新的那一场的行数——**结论与行数不同源**,用户会把错诊断递出去。
//!
//! 现在的口径(不动 presenter 的写入口,只在页面侧判别):
//!
//! | 概念 | 取值 |
//! |---|---|
//! | **身份**(这一场是谁) | `live_session`(正在跑)→ `selected_session`(打开/刚结束的),**不从 `diagnosis` 反推** |
//! | 诊断结论 | 见下表的三个分支(r4 修正了 r3 的一条**回归**:r3 只认身份,把「导入崩溃文件」与「打开会话」两条路径的诊断一并关掉了) |
//! | 实例/时间/结局(给页头描述) | 身份优先;身份为空才退到报告的 session |
//!
//! 归属判据(`report.session` 与身份的关系):
//!
//! | `live_session` | `selected_session` | 报告算数的条件 |
//! |---|---|---|
//! | `Some(live)` | 任意 | `report.session == live` —— 只有它在线时日志行是它自己在追加,别人的报告挂上来就是串场 |
//! | `None` | `Some(sel)` | `report.session == sel` **或** 报告不属于任何已知会话(`ImportCrashLog` 用 `file:<文件名>` 当会话 id,永远不在索引里) |
//! | `None` | `None` | 全部算数 —— 此时日志行只可能来自 `open_session` 或导入,两者都与报告同一次调用发出 |
//!
//! 已知残留(今天 iced 侧不可达,登记在报告里):`open_session(A)` 在 `selected_session` 仍停在上
//! 一场 B 时会被第三行判据挡掉(A 是已知会话且 ≠ B)——真正要修得让 presenter 在
//! `LogLinesChanged` 时把「日志行属于哪一场」记下来(`AppState` 今天没有这个字段)。
//!
//! 「没有命中规则」的**占位报告**也不画:host 的 `open_session` 在规则引擎无结论时会发一份
//! `rule: ""` / `confidence: 0` / `title: "该会话没有命中已知规则"` 的假报告(`CoreEvent::DiagnosisReady`
//! 是非 Option,host 无法表达「没有结论」)——它是标记不是结论,画出来等于给用户看一条「严重度提示」。
//!
//! 已知缺口(已报 finding,不在本 mission 修):`clear_logs` 之后 `selected_session` 不清,
//! 身份会继续指向那一场;导入崩溃文件只发 `DiagnosisReady`(不带日志行),所以页面上会出现
//! 「结论 + 上一场的日志正文」——行由 `open_session` 负责,那是 presenter 的缺口。
use unlucky_presenter::dto::{Diagnosis, LogLevel, LogOutcome};
use unlucky_presenter::state::AppState;
use crate::pages;
/// 日志页视图模型:presenter 快照 → 要画的东西。
///
/// 两处 DTO 侧的**信息丢失**(已报 finding):`dto::Diagnosis` 没有 core 的 `severity`
/// (所以页面按置信度档分组,不冒称严重度)与 `cause.key`(`title`/`hint` 只能当数据画)。
pub(super) fn logs_model(state: &AppState) -> pages::Logs {
let live = state.live_session.as_deref();
let selected = state.selected_session.as_deref();
let identity = live.or(selected);
// 归属判据(r4 修正 r3 的**回归**:r3 的「有选中就只认选中」把导入/打开这两条路径的诊断
// 一起关掉了)。三条命中任一即认:
//
// 1. 报告属于**正在跑**的那一场 —— 这一条必须单独成立:只有它在线时日志行是它自己在追加,
// 别的会话的报告挂上来就是串场(r3 修的正是这个);
// 2. 报告属于**当前选中**的那一场(没有 live 会话时);
// 3. 报告的会话**不在会话索引里** —— `ImportCrashLog` 用 `file:<文件名>` 当会话 id,
// `open_session` 也不一定把 id 写进 `selected_session`;这两条路径与报告是同一次调用发出的,
// 把它们一并关掉就是把「导入崩溃日志」这条功能画成空(r3 的实测回归)。
let known = |id: &str| state.log_sessions.iter().any(|session| session.id == id);
let report = state.diagnosis.as_ref().filter(|report| {
let id = report.session.as_str();
match (live, selected) {
// 有会话在跑:日志行是它自己在追加,别的会话的报告挂上来就是串场(r3 修的 P1)。
(Some(live), _) => id == live,
// 没有在跑、但有选中项:报告要么是它的,要么是「会话索引里没有的那一份」
// (`ImportCrashLog` 用 `file:<文件名>`;`open_session` 不写 `selected_session`,
// 所以只能靠「不在索引里」认它)。
(None, Some(selected)) => id == selected || !known(id),
// 什么身份都没有:日志行只可能来自 `open_session` 或导入,两者与报告同一次发出。
(None, None) => true,
}
});
// 身份为空时,描述的来源只能退到报告(见模块文档的表格)。
let described = identity.or_else(|| report.map(|report| report.session.as_str()));
let session =
described.and_then(|id| state.log_sessions.iter().find(|session| session.id == id));
pages::Logs {
instance: session.map(|session| session.instance.clone()),
when: session.map(|session| session.when.clone()),
outcome: session.map(|session| log_outcome(session.outcome)),
// 运行中的会话不在 `log_sessions` 里(索引只在结束时追加),所以页头要靠这个标记
// 走「正在运行」那条文案,而不是「当前没有运行中的实例」。
live: live.is_some(),
lines: state
.log_lines
.iter()
.map(|line| pages::Line {
text: line.text.clone(),
level: log_level(line.level),
})
.collect(),
findings: report
.filter(|report| !report.diagnosis.rule.is_empty())
.map(|report| diagnosis_of(&report.diagnosis))
.into_iter()
.collect(),
..pages::Logs::default()
}
}
/// 日志等级:presenter 枚举 → 页面等级(**结构化出口**,不是认字符串)。
fn log_level(level: LogLevel) -> pages::Level {
match level {
LogLevel::Debug => pages::Level::Debug,
LogLevel::Info => pages::Level::Info,
LogLevel::Warn => pages::Level::Warn,
LogLevel::Error => pages::Level::Error,
}
}
/// 会话结局:presenter 枚举 → 页面结局(文案走 `LaunchOutcome*` key,页面不画 core 的中文串)。
fn log_outcome(outcome: LogOutcome) -> pages::Outcome {
match outcome {
LogOutcome::Ok => pages::Outcome::Ok,
LogOutcome::Crashed => pages::Outcome::Crashed,
LogOutcome::SilentFail => pages::Outcome::SilentFail,
}
}
/// 一条诊断结论 → 页面结论(置信度档见 [`pages::Confidence`])。
fn diagnosis_of(diagnosis: &Diagnosis) -> pages::Finding {
pages::Finding {
rule: diagnosis.rule.clone(),
confidence_band: pages::Confidence::of(diagnosis.confidence),
title: diagnosis.title.clone(),
hint: diagnosis.hint.clone(),
confidence: diagnosis.confidence,
action: diagnosis.fix.clone(),
}
}
#[cfg(test)]
mod tests;
+242
View File
@@ -0,0 +1,242 @@
//! 日志页展示转换的守门测试(从 `app/view/logs.rs` 拆出:那边已经 382 行,贴着 400 硬上限)。
//!
//! 全部走**真实 reducer 路径**(`AppState::apply_core`),不造注入源——r3 的教训是
//! 「测试全走注入源而真实路径零覆盖」,所以这里的夹具就是线上那串事件本身。
use unlucky_i18n::Lang;
use unlucky_presenter::dto::{
CoreEvent, DiagnosisReport, LaunchFinished, LiveLogBatch, LogLevel, LogLine, LogOutcome,
LogSession,
};
use unlucky_presenter::state::AppState;
use crate::pages;
use super::logs_model;
/// 崩溃的那一场。
const CRASH: &str = "s-1029-1840";
/// 崩溃之后又启动的、正常跑的一场。
const OK_RUN: &str = "s-1029-1922";
fn session(id: &str, when: &str, outcome: LogOutcome) -> LogSession {
LogSession {
id: id.to_string(),
instance: "开发测试".to_string(),
when: when.to_string(),
outcome,
duration_secs: 62,
}
}
fn line(text: &str, level: LogLevel) -> LogLine {
LogLine {
text: text.to_string(),
level,
}
}
fn report(session: &str, rule: &str) -> DiagnosisReport {
DiagnosisReport {
session: session.to_string(),
diagnosis: pages_dto(rule),
}
}
fn pages_dto(rule: &str) -> unlucky_presenter::dto::Diagnosis {
unlucky_presenter::dto::Diagnosis {
rule: rule.to_string(),
title: "显卡驱动过旧(OpenGL 版本不足)".to_string(),
confidence: 92,
hint: "升级显卡驱动".to_string(),
fix: Some("change_graphics.driver".to_string()),
}
}
/// **真实数据路径**(复审要求:不许造注入源绕过去):
/// 崩溃一场跑完 → 再启动一场正常跑。`state.diagnosis` 在这里**故意不清**,
/// 因为 presenter 里根本没有清零点——这就是线上发生串场的条件。
fn crashed_only() -> AppState {
let mut state = AppState::new();
state.apply_core(CoreEvent::LogSessionsChanged(vec![
session(CRASH, "10-29 18:40", LogOutcome::Crashed),
session(OK_RUN, "10-29 19:22", LogOutcome::Ok),
]));
// 第一场跑起来(live_session = CRASH)→ 日志 → 规则结论 → 收尾(selected_session = CRASH)
state.apply_core(CoreEvent::LogAppended(LiveLogBatch {
session: CRASH.to_string(),
lines: vec![line("[18:40] boom: GLFW error", LogLevel::Error)],
running: true,
}));
state.apply_core(CoreEvent::DiagnosisReady(report(CRASH, "gpu.driver_old")));
state.apply_core(CoreEvent::LaunchFinished(LaunchFinished {
id: "dev".to_string(),
session: CRASH.to_string(),
outcome: LogOutcome::Crashed,
summary: "崩溃(exit 1)".to_string(),
}));
state
}
/// 崩溃之后又启动一场正常跑:`live_session` 前进,`diagnosis` 仍是上一场的。
fn after_a_new_run() -> AppState {
let mut state = crashed_only();
state.apply_core(CoreEvent::LogAppended(LiveLogBatch {
session: OK_RUN.to_string(),
lines: vec![line("[19:22] launching 1.20.4", LogLevel::Info)],
running: true,
}));
state
}
#[test]
fn a_new_run_does_not_inherit_the_previous_crash() {
let state = after_a_new_run();
let logs = logs_model(&state).with_status(Lang::En);
// 抓得住:身份被上一场的 `diagnosis.session` 顶掉(r2 的 bug)——那时 outcome 会是
// Crashed、findings 挂着 gpu.driver_old、摘要里 `lines=` 与结论不同源。
// 抓不住:presenter 自己 `clear_logs` 后 `selected_session` 不清的缺口(已报 finding),
// 以及多场并行会话(本仓一次只跑一场)。
assert_eq!(
logs.outcome,
Some(pages::Outcome::Ok),
"身份应该是正在跑的这一场"
);
let status = logs.status.clone().expect("有会话身份就该有副标题");
assert!(status.contains("Finished normally"), "{status}");
assert!(
!status.contains("The game crashed"),
"页头把新会话标成了上一场的崩溃:{status}"
);
assert!(
logs.findings.is_empty(),
"上一场的结论不能挂到这一场:{:?}",
logs.findings
);
// 摘要必须与身份同源:outcome 是 ok、不含旧规则、行数就是当前这些行。
let summary = logs.summary();
assert!(summary.contains("outcome=ok"), "{summary}");
assert!(!summary.contains("gpu.driver_old"), "{summary}");
assert!(
summary.contains(&format!("lines={}", logs.lines.len())),
"{summary}"
);
assert!(!summary.contains("findings="), "{summary}");
}
#[test]
fn the_crash_itself_is_still_reported() {
// 上一条测试的**对照**:崩溃当场必须画结论,否则那条断言「findings 为空」是空的。
// 抓得住:把判别写成「永远不画诊断」(用空断言骗过 P2)。
// 抓不住:结论排序/去重(那是 core 的规则引擎的事)。
let state = crashed_only();
let logs = logs_model(&state).with_status(Lang::En);
assert_eq!(logs.outcome, Some(pages::Outcome::Crashed));
assert_eq!(logs.findings.len(), 1, "{:?}", logs.findings);
assert_eq!(logs.findings[0].rule, "gpu.driver_old");
let status = logs.status.clone().unwrap_or_default();
assert!(status.contains("The game crashed"), "{status}");
assert!(logs.summary().contains("gpu.driver_old:high(92)"));
}
#[test]
fn an_imported_crash_file_still_shows_its_findings() {
// r3 的**回归**:r3 把归属判据写成「有选中就只认选中」,于是导入崩溃日志
// (`ImportCrashLog` → `DiagnosisReady` 带 `file:<文件名>` 的会话 id,且**不发**
// `LogLinesChanged`)被整条丢掉。今天 iced 侧还没有 dispatch 站点(那些还在遗留的
// Slint shell 里),所以复审没判 p1;这条测试是为了**将来接线的人**不再踩。
// 抓得住:只有「属于当前选中/live 会话」才画的窄判据(导入的 `file:` 会话不等于任何
// 索引里的会话,会被误杀)。
// 抓不住:导入后日志正文该显示什么(`ImportCrashLog` 不载入日志行,那是 presenter 的事)。
let mut state = AppState::new();
state.apply_core(CoreEvent::LogSessionsChanged(vec![session(
CRASH,
"10-29 18:40",
LogOutcome::Crashed,
)]));
// 上一场刚结束 ⇒ selected_session = CRASH(过期选中项),随后用户导入一份崩溃文件
state.apply_core(CoreEvent::LaunchFinished(LaunchFinished {
id: "dev".to_string(),
session: CRASH.to_string(),
outcome: LogOutcome::Crashed,
summary: "崩溃(exit 1)".to_string(),
}));
state.apply_core(CoreEvent::DiagnosisReady(report(
"file:crash-2026-11-02_19.40.15-client.txt",
"integrity.unsatisfied_link",
)));
let logs = logs_model(&state).with_status(Lang::En);
assert_eq!(
logs.findings.len(),
1,
"导入的崩溃日志必须画出它的结论:{:?}",
logs.findings
);
assert_eq!(logs.findings[0].rule, "integrity.unsatisfied_link");
}
#[test]
fn a_live_run_says_it_is_running_instead_of_nothing_runs() {
// 视觉复审 r3 的 P2-3:session 索引只在**结束**时追加(`host::logs::sessions`),
// 所以运行中的那一场永远不在 `log_sessions` 里 ⇒ 拿不到实例名与结局 ⇒ r3 的页头在
// 每次实时运行都说「当前没有运行中的实例」。demo 伪造了身份,截图上根本照不出来。
// 抓得住:页头退回 Idle 文案(这次走真实 reducer:只有 `LogAppended(running=true)`)。
// 抓不住:文案长度在某尺寸下会不会撞右侧动作区(那要看截图)。
let mut state = AppState::new();
state.apply_core(CoreEvent::LogSessionsChanged(vec![session(
CRASH,
"10-29 18:40",
LogOutcome::Crashed,
)]));
state.apply_core(CoreEvent::LogAppended(LiveLogBatch {
session: OK_RUN.to_string(),
lines: vec![line("[19:22] launching", LogLevel::Info)],
running: true,
}));
let logs = logs_model(&state).with_status(Lang::En);
assert!(logs.live, "live_session 非空就该标记为运行中");
let status = logs.status.clone().expect("运行中必须有副标题");
let idle = unlucky_i18n::catalog::Catalog::of(Lang::En)
.text(unlucky_i18n::catalog::Key::LogsSubtitleIdle);
assert_ne!(status, idle, "运行中的页头不能说「没有运行」");
assert!(status.contains("Running now"), "{status}");
}
#[test]
fn the_hosts_no_rule_matched_placeholder_is_not_a_finding() {
// host 的 `open_session` 在规则引擎无结论时发一份 `rule: ""` 的假报告(非 Option 逼的)。
// 抓得住:把「没命中」画成一条严重度结论(用户会去修一个不存在的毛病)。
// 抓不住:host 该不该改发 `None`(那是 presenter 的 finding)。
let mut state = AppState::new();
state.apply_core(CoreEvent::LogSessionsChanged(vec![session(
CRASH,
"10-29 18:40",
LogOutcome::Crashed,
)]));
state.apply_core(CoreEvent::LogLinesChanged(vec![line(
"[18:40] nothing to see",
LogLevel::Info,
)]));
state.apply_core(CoreEvent::DiagnosisReady(report(CRASH, "")));
assert!(
logs_model(&state).findings.is_empty(),
"空 rule 是『没命中』的占位,不是结论"
);
state.apply_core(CoreEvent::DiagnosisReady(report(CRASH, "gpu.driver_old")));
assert_eq!(
logs_model(&state).findings.len(),
1,
"换成真规则就得画出来(证明上面的过滤不是把诊断一起关了)"
);
}
+134
View File
@@ -0,0 +1,134 @@
//! 整窗 α 的**执行侧**:句柄探测 → `WS_EX_LAYERED` → `SetLayeredWindowAttributes` → 一行日志。
//!
//! ## 为什么句柄要绕一圈 iced
//!
//! iced 0.14 只给 `window::Id`(它自己的编号),没有「给我 HWND」的公开 API;唯一的入口是
//! `window::run(id, callback)`——回调里拿到的 `&dyn Window` 实现了 `HasWindowHandle`
//! (raw-window-handle 0.6),于是能取到 `RawWindowHandle::Win32(hwnd)`。
//!
//! ## 三层责任(顺序不能换)
//!
//! 1. **探测句柄**:[`probe_hwnd`],纯取;拿不到就整条链跳过(并如实打日志,不许假装成功);
//! 2. **补分层样式**:`ensure_layered`(唯一 `unsafe` 落点,`platform/win32/transparency.rs`);
//! `SetLayeredWindowAttributes` 在没有 `WS_EX_LAYERED` 的窗口上**必然失败**(Win32 返回 0,
//! 本机 `GetLastError` 常见 87),所以这一步是硬前提;
//! 3. **下发 α**:`apply_window_alpha`。`α ≥ 1` 时它返回 `Opaque` 并且**不调 FFI**,那时也就不必
//! 改扩展样式(1、2 步都是白改),[`dispatch`] 里按这个条件短路。
//!
//! ## 为什么日志要记「下发字节」而不是「看起来多透明」
//!
//! 本机是 RDP + 4 个虚拟显示适配器:`LWA_ALPHA` 的**观感**在这里不可信。所以证据链是
//! `配置值 → 判据 → 字节 → Win32 返回值`,全部经 [`super::App::log_appearance`] 落成一行文本。
use iced::window;
use crate::platform::win32::transparency::{apply_window_alpha, ensure_layered, AlphaOutcome};
/// 一次「补样式 + 下发」的结果(日志与单测共用)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct Dispatch {
/// `WS_EX_LAYERED` 是否就位(`α ≥ 1` 或 `α = None` 时为 `false`:那次根本没动扩展样式)。
pub layered: bool,
/// 下发的结局。
pub alpha: AlphaOutcome,
}
impl Dispatch {
/// 日志尾巴:`layered=ok outcome=Applied { byte: 205 }`。
///
/// `layered` 有三个值,刻意区分「没用到」与「失败」:
/// - `n/a`:α ≥ 1 或被拒绝 —— 那次**根本不需要**分层窗口,不是错误;
/// - `ok`:这一位在(原本就有或本条补上);
/// - `FAILED`:需要补但补不上(无效句柄等)—— 只有这一种是坏消息。
pub(super) fn describe(&self) -> String {
let layered = match self.alpha {
AlphaOutcome::Opaque | AlphaOutcome::Rejected(_) => "n/a",
AlphaOutcome::Applied { .. } | AlphaOutcome::Failed { .. } if !self.layered => "FAILED",
_ => "ok",
};
format!("layered={layered} outcome={:?}", self.alpha)
}
}
/// 把 iced 的窗口句柄翻成 Win32 的 `HWND`(`isize`)。非 Windows 平台返回 `None`。
pub(super) fn probe_hwnd(handle: &dyn window::Window) -> Option<isize> {
use window::raw_window_handle::RawWindowHandle;
let handle = handle.window_handle().ok()?;
match handle.as_raw() {
#[cfg(target_os = "windows")]
RawWindowHandle::Win32(win32) => Some(win32.hwnd.get()),
#[cfg(not(target_os = "windows"))]
_ => None,
#[cfg(target_os = "windows")]
_ => None,
}
}
/// 补样式 + 下发(**幂等**:`α ≥ 1` 时什么都不做,返回 `Opaque`)。
pub(super) fn dispatch(hwnd: isize, alpha: Option<f64>) -> Dispatch {
// α 不透明 / 被拒绝时不需要分层窗口:`SetLayeredWindowAttributes` 那次调用也不会发生,
// 改扩展样式纯属副作用(还会让 DWM 换一条合成路径),所以短路掉。
let needs_layered = alpha.is_some_and(|alpha| alpha < 1.0);
let layered = needs_layered && ensure_layered(hwnd);
Dispatch {
layered,
alpha: apply_window_alpha(hwnd, alpha),
}
}
#[cfg(test)]
mod tests {
use super::dispatch;
use crate::platform::win32::transparency::{AlphaOutcome, AlphaRejection};
/// 抓得住:①`α = None` / `α ≥ 1` / `α = 0` 时**不去碰扩展样式**(短路条件写反会在这里红);
/// ②拒绝路径的返回值与 `transparency` 的判据一致。
/// 抓不住:真实窗口(有效句柄)上 `WS_EX_LAYERED` 是否真的加上了——那要靠本机日志的 `layered=ok`。
#[test]
fn opaque_or_refused_alpha_never_touches_the_window_style() {
for none in [None, Some(1.0), Some(1.5), Some(0.0), Some(-1.0)] {
let result = dispatch(1234, none);
assert!(!result.layered, "{none:?} 不该改扩展样式");
}
assert_eq!(
dispatch(1234, None).alpha,
AlphaOutcome::Rejected(AlphaRejection::NoAlpha)
);
assert_eq!(dispatch(1234, Some(1.0)).alpha, AlphaOutcome::Opaque);
// 无效句柄 + 合法 α:样式补不上(layered=false),FFI 也不会成功——两层都如实报告。
let invalid = dispatch(1, Some(0.5));
assert!(!invalid.layered, "无效句柄补不上 WS_EX_LAYERED");
assert!(
matches!(invalid.alpha, AlphaOutcome::Failed { .. }),
"{:?}",
invalid.alpha
);
}
/// 本机真窗口的取证由 `App` 启动日志给(见 `app::appearance::log_appearance`),
/// 这里只钉日志格式:`Applied { byte: 205 }` 必须能一眼读出字节,且「没用到样式」不等于失败。
#[test]
fn the_log_tail_carries_the_byte_and_distinguishes_skipped_from_failed() {
let applied = super::Dispatch {
layered: true,
alpha: AlphaOutcome::Applied { byte: 205 },
};
assert!(applied.describe().starts_with("layered=ok outcome=Applied"));
assert!(applied.describe().contains("205"));
// α = 1.0:没补样式是**正常**的,不许写成 FAILED(否则报告里会出现假红)。
let opaque = super::Dispatch {
layered: false,
alpha: AlphaOutcome::Opaque,
};
assert!(opaque.describe().starts_with("layered=n/a"));
// 真的要补却没补上:这才是 FAILED。
let failed = super::Dispatch {
layered: false,
alpha: AlphaOutcome::Failed { code: 87 },
};
assert!(failed.describe().starts_with("layered=FAILED"));
}
}
+394
View File
@@ -0,0 +1,394 @@
//! 命令行参数(骨架期 + 组件库 harness 需要的那些;业务开关等页面 mission 再加)。
//!
//! 取值语法与现役 Slint 版一致:`--key value` 与 `--key=value` 两种都认
//! (`crates/app/src/screenshot.rs` 的 `value_of` 是同一口径),这样将来 CI 换 bin 不用改脚本。
//!
//! ## 参数总表
//!
//! | 参数 | 取值 | 语义 |
//! |---|---|---|
//! | `--software-renderer` | — | 等价 `--renderer=cpu`(D27 的 CPU 档) |
//! | `--renderer` | `auto`/`gpu`/`cpu` | 渲染后端请求 |
//! | `--theme` | `light`/`dark` | 主题;不给则跟随系统(仅交互模式) |
//! | `--screenshot` / `--screenshot-theme` | 路径 / `light`·`dark` | 截图通道(出图后退出)与截图专用主题 |
//! | `--screen` | `shell`/`gallery` | 启动画面;**`gallery` = 组件库视觉验收 harness**(D39 §5) |
//! | `--scale-factor` | `>0` | 强制缩放因子(验收要 100%,即 `1`) |
//! | `--diff` / `--diff-scan` | 两个路径 / 非负整数 | 逐像素对比(不开窗)与 ±n px 平移扫描 |
//! | `--force-state` | `hover`/`pressed` | 把整屏可交互控件强制成某态(取证) |
//! | `--data-dir` | 路径 | 翻牌兼容:**接受并忽略**(CI 冒烟在用) |
//!
//! **截图主题默认 Light(门禁依赖,不许改回 None)**,理由与不变量见
//! [`Options::capture_flavor`] 与 `app.rs` 的截图路径。
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",
}
}
/// 截图日志用的后端说明(`cfg!(feature = "gpu")` 决定有没有 wgpu 这一档)。
pub fn note(self) -> &'static str {
if !cfg!(feature = "gpu") {
return "cpu(build without wgpu)";
}
match self {
Backend::Cpu => "cpu(forced)",
Backend::Gpu => "gpu(preferred)",
Backend::Auto => "auto",
}
}
}
/// 启动画面(D39 §5:`gallery` 是视觉验收 harness,不是产品页面)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Screen {
/// 产品骨架:自绘标题栏 + 导航轨 + 内容区。
#[default]
Shell,
/// 组件库 harness:整窗只放状态矩阵。
Gallery,
}
impl Screen {
/// 稳定名字(日志用)。
pub const fn name(self) -> &'static str {
match self {
Screen::Shell => "shell",
Screen::Gallery => "gallery",
}
}
}
/// 解析后的启动参数。
#[derive(Debug, Clone, PartialEq, Default)]
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,即 `1`;`None` = 用系统 DPI)。
pub scale_factor: Option<f32>,
/// `--diff <left> <right>` 与 `--diff-scan <n>`:逐像素对比(不开窗)与 ±n px 平移扫描。
pub diff: Option<(PathBuf, PathBuf)>,
/// 见上(默认 0 = 不扫)。
pub diff_scan: u32,
/// `--force-state=hover|pressed`:把按钮强制成某个状态便于截图取证(D30 的状态图)。
pub force_state: Option<ForcedState>,
/// `--screen=shell|gallery`:启动画面(`gallery` = 组件库视觉验收 harness)。
pub screen: Screen,
/// `--data-dir <dir>`:当前**接受并忽略**(现役 Slint 版 CI 冒烟在用;
/// 真正的消费由后续页面 mission 接 `host`/`state` 时做)。
pub data_dir: Option<PathBuf>,
}
/// 截图取证用的强制交互状态(只影响观感,不改变任何状态机)。
///
/// 五态完整矩阵由 `--screen=gallery` 的 `Button::state(..)` 出,本开关只给整屏取证。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ForcedState {
/// 所有可交互控件按 hover 画。
Hover,
/// 所有可交互控件按 pressed 画。
Pressed,
}
impl Options {
/// 从进程参数解析(错误信息是人话,不 panic、不 `exit`)。
///
/// # Errors
/// 取值不合法(未知 `--renderer`/`--theme`/`--screen`、非法 `--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:?}"
));
}
});
}
"--screen" => {
let raw = take(&mut args, inline, "--screen")?;
options.screen = match raw.as_str() {
"shell" => Screen::Shell,
"gallery" => Screen::Gallery,
other => {
return Err(format!("--screen 只认 shell|gallery,收到 {other:?}"));
}
};
}
// 翻牌前的兼容参数:接受并忽略(见字段文档)
"--data-dir" => {
options.data_dir = Some(PathBuf::from(take(&mut args, inline, "--data-dir")?));
}
other => return Err(format!("未知参数 {other:?}")),
}
}
Ok(options)
}
/// 截图时实际使用的主题(**只表达用户显式给了什么**;默认值见 [`Options::capture_flavor`])。
pub fn shot_flavor(&self) -> Option<SchemeFlavor> {
self.screenshot_theme.or(self.theme)
}
/// 本次进程真正要画的主题(**唯一取值口**)。
///
/// 截图模式(给了 `--screenshot`)**永远返回 `Some`**:`--screenshot-theme` > `--theme` > Light。
/// 交互模式只有显式指定才有值(`None` = 等系统主题)。
///
/// ⚠️ **门禁依赖,不许把截图分支改回 `None`**:CI 的 runtime-smoke 打
/// `--software-renderer --screenshot <png> --data-dir <dir>`(不带主题),xvfb 里没有桌面主题
/// 会话,M68 的「等系统主题到达再出图」闸门在这条路径上会把 runner 挂到超时(不是红,更难查)。
pub fn capture_flavor(&self) -> Option<SchemeFlavor> {
match self.screenshot {
Some(_) => Some(self.shot_flavor().unwrap_or(SchemeFlavor::Light)),
None => self.shot_flavor(),
}
}
/// 是否要问系统主题。截图模式**永远不问**——这就是「无主题环境不会卡住」的不变量。
pub fn wants_system_theme(&self) -> bool {
self.capture_flavor().is_none()
}
}
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_system_theme_and_shell() {
let options = parse(&[]).expect("空参数要能解析");
assert_eq!(options.backend, Backend::Auto);
assert_eq!((options.theme, options.screenshot), (None, None));
assert_eq!(options.screen, Screen::Shell);
}
#[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 both_argv_forms_are_accepted() {
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);
assert_eq!(
parse(&["--diff", "a.png", "b.png"]).unwrap().diff,
Some((PathBuf::from("a.png"), PathBuf::from("b.png")))
);
assert_eq!(
parse(&["--screen", "gallery"]).unwrap().screen,
Screen::Gallery
);
assert_eq!(parse(&["--diff-scan=2"]).unwrap().diff_scan, 2);
assert_eq!(
parse(&["--force-state", "hover"]).unwrap().force_state,
Some(ForcedState::Hover)
);
}
#[test]
fn screenshot_theme_wins_over_theme() {
let o = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap();
assert_eq!(
(o.theme, o.shot_flavor()),
(Some(SchemeFlavor::Dark), Some(SchemeFlavor::Light))
);
}
#[test]
fn bad_values_are_reported_not_panicked() {
for args in [
&["--renderer=metal"][..],
&["--theme=blue"],
&["--scale-factor=0"],
&["--scale-factor=abc"],
&["--screenshot"],
&["--diff", "only-one.png"],
&["--screen=settings"],
&["--force-state=focus"],
&["--diff-scan=-1"],
&["--nope"],
] {
assert!(parse(args).is_err(), "{args:?} 应该报错");
}
}
#[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")));
}
/// **门禁依赖**:CI 的 runtime-smoke 不带主题,截图模式必须有确定主题。
/// 退化成 `None` 的话,xvfb 里的 runner 会挂到超时(不是红,更难查)。
#[test]
fn screenshot_without_theme_defaults_to_light() {
let options = parse(&["--screenshot", "out.png"]).unwrap();
assert_eq!(options.shot_flavor(), None, "用户没给主题");
assert_eq!(
options.capture_flavor(),
Some(SchemeFlavor::Light),
"截图模式必须给确定主题(CI 冒烟依赖这条)"
);
assert!(!options.wants_system_theme(), "截图模式不许等系统主题");
}
#[test]
fn explicit_themes_still_win_in_screenshot_mode() {
let dark = parse(&["--screenshot", "a.png", "--theme=dark"]).unwrap();
assert_eq!(dark.capture_flavor(), Some(SchemeFlavor::Dark));
let both = parse(&[
"--screenshot",
"a.png",
"--theme=dark",
"--screenshot-theme=light",
]);
assert_eq!(both.unwrap().capture_flavor(), Some(SchemeFlavor::Light));
}
#[test]
fn interactive_mode_still_follows_the_system_theme() {
let interactive = parse(&[]).unwrap();
assert_eq!(interactive.capture_flavor(), None);
assert!(interactive.wants_system_theme(), "交互模式等系统主题");
let pinned = parse(&["--theme=dark"]).unwrap();
assert_eq!(pinned.capture_flavor(), Some(SchemeFlavor::Dark));
assert!(!pinned.wants_system_theme());
}
}
@@ -0,0 +1,230 @@
//! 装配与样式:五式共用的一条产线(尺寸 / 内边距 / 图标盒 / 状态层 / 描边)。
//!
//! 单独成文件的原因:`mod.rs` 已经是「对外契约 + 变体角色表」,把绘制细节放一起会顶到 400 行红线;
//! 而且这条产线是**唯一**会把「令牌 → 像素」翻一次的地方,独立出来才好审。
use iced::widget::text::LineHeight;
use iced::widget::{button, text, Row};
use iced::{Alignment, Background, Border, Element, Length, Pixels, Shadow};
use unlucky_design::color::Role;
use unlucky_design::elevation::Elevation;
use unlucky_design::shape::ShapeScale;
use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::{self, Interaction};
use super::super::layout;
use super::{Geometry, Variant};
/// 一个按钮样式要的全部输入(避免 `style_closure` 长出七个位置参数)。
#[derive(Debug, Clone, Copy)]
pub(super) struct StyleSpec {
/// 容器填充角色;`None` = 透明(Outlined / Text / StandardIcon)。
pub fill: Option<Role>,
/// 内容(文字 + 图标)角色。
pub content: Role,
/// 描边角色;`None` = 无描边。
pub border: Option<Role>,
/// 圆角档位(来自组件表,五式统一 `Full`)。
pub shape: ShapeScale,
/// 描边宽度(`Geometry::stroke`:有描边的变体 = `stroke::HAIRLINE`,其余 0)。
pub stroke: f32,
/// 圆角折算用的边长(药丸 = 可见高)。
pub radius_side: f32,
/// 强制态(gallery 取证);`None` = 跟随运行期的 `button::Status`。
///
/// **`Some(Enabled)` 与 `None` 不是一回事**:截图取证必须能强制「静息」,否则光标恰好停在
/// 某个按钮上时那一格会被画成 hover(实测踩过:text 行 enabled 列出现不该有的灰底容器,
/// 看起来像 Text 按钮自带容器)。
pub forced: Option<Interaction>,
}
/// 五式共用的装配:**只画一处**,五个变体函数都走这里。
pub(super) fn assemble<'a, M: Clone + 'a>(
ui: &'a Ui,
variant: Variant,
spec: super::Button<'a, M>,
) -> Element<'a, M> {
let geometry = Geometry::of(ui, variant, spec.leading.is_some());
let (fill, content, border) = variant.roles();
let forced = spec.state.or(if spec.on_press.is_none() {
Some(Interaction::Disabled)
} else {
None
});
// 按钮家族没有浮层级高程:L1(Elevated)只用容器色调差表达(rubric §7 不画阴影)
debug_assert!(
!matches!(
variant.elevation(),
Elevation::Level3 | Elevation::Level4 | Elevation::Level5
),
"按钮家族不许有浮层高程"
);
// **内容色必须跟着态走**:禁用时文字/图标是 on-surface@38%,不是满档内容色。
// 早先这里恒取 `ui.color(content)`,再被 `text(...).color(..)` 钉死,`Style.text_color`
// 里的 38% 永远下发不出去 —— 表现为「text / standard icon 的 enabled 格与 disabled 格
// 逐像素相同」(视觉 reviewer 实测 changed_px = 0;filled 禁用标签 1.23:1)。
let ink = content_color(ui, forced, content, fill, variant.disabled_content());
let label = text(spec.label)
.size(ui.style(TypeRole::LabelLarge).size_px)
.line_height(LineHeight::Absolute(Pixels(
ui.style(TypeRole::LabelLarge).line_height_px,
)))
.font(ui.font_of(ui.emphasized(TypeRole::LabelLarge)))
.color(ink);
let mut row = Row::new().align_y(Alignment::Center);
if let Some(kind) = spec.leading {
// Q9:图标一律经固定尺寸容器(按钮图标 18、IconButton 24 都不例外)。
// **必须走 `view_at`**:盒 18 ≠ `RENDER_PX`(24),用 24 烘焙再缩到 18 会把视觉线宽从 1.5 压到 1.1
// (复审 r1 P2-2 实测 1.04~1.25px)。
row = row.push(layout::fixed(
geometry.icon,
geometry.icon,
icons::view_at::<M>(kind, ink, geometry.icon),
));
}
row = row.push(label);
if let Some(kind) = spec.trailing {
row = row.push(layout::fixed(
geometry.icon,
geometry.icon,
icons::view_at::<M>(kind, ink, geometry.icon),
));
}
let row = row.spacing(geometry.gap);
// **垂直居中必须在内容侧做**:iced 的 `button::layout` 把内容放在 padding-box 的左上角
// (`layout::padded` 只 `move_to(padding.top)`,不做居中),所以内容自己撑满高度再居中。
// 少了这一层,40 高的按钮里文字会贴上沿 10px(实测首版就是这样)。
let content_box = iced::widget::container(row)
.height(Length::Fill)
.align_y(iced::Alignment::Center);
let style = style_closure(
ui,
StyleSpec {
fill,
content,
border,
shape: variant.shape(),
stroke: geometry.stroke,
radius_side: geometry.height,
forced,
},
);
let element = button(content_box)
.height(Length::Fixed(geometry.height))
.padding(geometry.padding)
.style(style);
match spec.on_press {
Some(message) => element.on_press(message).into(),
None => element.into(),
}
}
/// 内容色(文字与图标共用):禁用态走令牌的 38%,其余态就是角色色。
///
/// 单独成函数是为了让「内容色跟态走」这件事只有一处实现——`assemble`(文字按钮)与 `icon`
/// (图标按钮)都调它,两条路径不会再各自钉死满档色。
/// 内容色:**禁用时的角色由调用方按变体给**([`super::Variant::disabled_content`] /
/// [`super::IconVariant::disabled_content`])——A1 里 FilledTonal 与 IconButton 是 `OnSurface`,
/// 其余是 `OnSurfaceVariant`,不能在这里统一写死。
pub(super) fn content_color(
ui: &Ui,
forced: Option<Interaction>,
content: Role,
fill: Option<Role>,
disabled_content: Role,
) -> iced::Color {
match forced {
// 有容器:38% 叠在**按钮家族 10% 容器**上(Filled / Tonal / Elevated)
Some(Interaction::Disabled) if fill.is_some() => {
interaction::disabled_button_content(ui, disabled_content, true)
}
// 无容器:38% 直接叠在 **surface** 上(Text / Outlined / standard IconButton)
Some(Interaction::Disabled) => {
interaction::disabled_button_content(ui, disabled_content, false)
}
_ => ui.color(content),
}
}
/// 生成 `button::style` 闭包:状态层 / 禁用 / 描边全在这里落地。
pub(super) fn style_closure(
ui: &Ui,
spec: StyleSpec,
) -> impl Fn(&iced::Theme, button::Status) -> button::Style + '_ {
move |_theme: &iced::Theme, status| {
// 强制态优先(gallery 状态矩阵),否则把 iced 的四态翻成 M3 五态
let state = spec
.forced
.unwrap_or_else(|| Interaction::from_status(status));
let (background, text_color) = if state.is_disabled() {
let (container, content) = interaction::disabled_button_colors(ui, Role::Surface);
// 透明容器在禁用态依然透明(官方只改内容透明度)
(spec.fill.map(|_| container), content)
} else {
// 有填充:状态层叠在**容器角色**上;透明容器:叠在表面色上(否则 hover 看不见)
let background = match spec.fill {
Some(role) => match state.layer() {
Some(_) => interaction::overlay_color(ui, state, spec.content, role)
.or(Some(ui.color(role))),
None => Some(ui.color(role)),
},
None => transparent_state_color(ui, state, spec.content),
};
(background, ui.color(spec.content))
};
let border_color = if state.is_disabled() {
interaction::disabled_button_colors(ui, Role::Surface).0
} else {
spec.border
.map_or_else(|| ui.color(Role::OutlineVariant), |role| ui.color(role))
};
button::Style {
background: background.map(Background::Color),
text_color,
border: Border {
color: if spec.border.is_some() {
border_color
} else {
iced::Color::TRANSPARENT
},
// 线宽来自 `Geometry::stroke`(有描边的变体 = HAIRLINE,其余 0);
// `spec.border.is_none()` 时颜色是透明的,宽度也就无所谓了。
width: spec.stroke,
radius: ui.radius(spec.shape, spec.radius_side),
},
// rubric §7:L0/L1 不用阴影(Elevated 的 L1 由容器色调差表达)
shadow: Shadow::default(),
// ⚠️ `snap` 目前**只有 wgpu 后端实现**;我们的验收图走 tiny-skia(CPU),
// 所以这个 `true` 在验收图上不起作用——分隔线的「1dp 摊成 2 行」正是这个原因,
// 不是「iced 没有 snap」(reviewer P2-4 纠正的正是我上一版报告里的说法)。
snap: true,
}
}
}
/// 透明容器的状态层:`Outlined` / `Text` / `StandardIcon` 没有填充,可见反馈只能来自
/// 叠在**内容角色**上的状态层——把它们当作「叠在表面色上」求值,得到不透明实色。
///
/// 之所以单独一个函数:这是 M3 里唯一「容器不存在但状态层存在」的情形,
/// 三个变体共用一条口径,免得各自发挥。
pub(super) fn transparent_state_color(
ui: &Ui,
state: Interaction,
content: Role,
) -> Option<iced::Color> {
interaction::overlay_color(ui, state, content, Role::Surface)
}
@@ -0,0 +1,97 @@
//! 按钮家族的**内部几何**:一个数值都不写,全部由 `unlucky_design` 的令牌推导。
//!
//! | 量 | 令牌 | 值(DESKTOP / 100% DPI) |
//! |---|---|---|
//! | 可见高 | `LayoutMetrics::control_height` | 40 `[源]` 12b 1.1 |
//! | 圆角 | `ShapeScale::Full.radius_px(高)` | 20 `[源]` 12b 1.2 |
//! | 左右内边距(无图标) | `Space::X6` | 24 / 24 `[源]` 12b 1.3 |
//! | 左内边距(带前置图标) | `Space::X4` | 16 `[源]` 12b 1.4 |
//! | 图标边长 | `IconSize::Button` | 18 `[源]` AAR `ButtonDefaults.IconSize = 18.dp`(12b 1.6 同值) |
//! | 图标↔文字 | `Space::X2` | 8 `[源]` 12b 1.6 |
//! | 描边 | `unlucky_design::stroke::HAIRLINE`(`crate::theme::STROKE_PX` 同一真源) | 1 `[源]` rubric G7 |
//! | IconButton 边长 | `LayoutMetrics::control_height` | 40 `[源]` 12b 2.1 |
//! | IconButton 图标 / 边距 | `IconSize::Toolbar` / `Space::X2` | 24 / 8 `[源]` 12b 2.2 |
//!
//! **没有落点的一个参考值**(登记在报告里,不在此处写数):
//!
//! - 12b 1.7 最小宽 **58** → `G15`:iced 的 `Length` 没有 min-width 语义;按「24 + 24 内边距 +
//! LabelLarge 二字」自然宽度 ≈ 76,实际不触发,故本 mission 不施加该约束。
//!
//! **G14 已收口(M86)**:这里曾登记「12b 1.6 图标 18 → 令牌只有 16,先用 16 并偏离 2px」。
//! 令牌层现在有 18 档([`IconSize::Button`],出处在 `crates/design/src/icons/mod.rs`),
//! 所以本文件取 18 就是**回到规范值**,不再是偏离。**别按 `ButtonSmallTokens.IconSize = 20` 改**:
//! 那个 token 在 AAR 里零消费(没有任何类读它的 `getIconSize`),是死令牌。
use iced::Padding;
use unlucky_design::icons::IconSize;
use unlucky_design::spacing::Space;
use crate::theme::{Ui, STROKE_PX};
use super::Variant;
/// 文字按钮的几何。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Geometry {
/// 可见高(药丸圆角按它折算)。
pub height: f32,
/// 内边距(上下 0;左右按有无前置图标取 16 或 24)。
pub padding: Padding,
/// 图标盒边长。
pub icon: f32,
/// 图标 ↔ 文字间距。
pub gap: f32,
/// 描边宽度;`0` = 该式无描边。
pub stroke: f32,
}
impl Geometry {
/// 按变体 + 有没有前置图标求值(带图标时左内边距收到 16,右仍 24)。
pub fn of(ui: &Ui, variant: Variant, leading_icon: bool) -> Self {
let (_, _, border) = variant.roles();
let start = if leading_icon {
ui.space(Space::X4)
} else {
ui.space(Space::X6)
};
Self {
height: ui.metrics().control_height,
padding: Padding {
top: 0.0,
right: ui.space(Space::X6),
bottom: 0.0,
left: start,
},
icon: IconSize::Button.px(),
gap: ui.space(Space::X2),
stroke: if border.is_some() { STROKE_PX } else { 0.0 },
}
}
}
/// IconButton 的几何(正方形,图标 24、边距 8)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct IconGeometry {
/// 容器边长(40,命中区 48 由父级留白补足)。
pub side: f32,
/// 图标盒边长(24)——**必须**经 `layout::centered_fixed` 锁住,否则 Q9 会把字形撑满。
pub icon: f32,
/// 图标到容器边的默认边距(8;narrow 4 / wide 14 属变体参数,本 mission 不用)。
pub padding: f32,
}
impl IconGeometry {
/// 从令牌求值。图标边长**由 `side − 2×padding` 推导**(40 − 16 = 24),
/// 与 `IconSize::Toolbar`(24)互为校验:两者不等说明令牌层动了,单测会先红。
pub fn of(ui: &Ui) -> Self {
let side = ui.metrics().control_height;
let padding = ui.space(Space::X2);
Self {
side,
icon: side - 2.0 * padding,
padding,
}
}
}
@@ -0,0 +1,348 @@
//! 按钮家族(`research/12b` §1 五式 + §2 IconButton)。
//!
//! ## 参考值(全部 `[源]` = A1,`material3-android:1.4.0` 的 `*Tokens.kt` / `Button.kt`)
//!
//! | # | 检查项 | 参考值 | 本模块落点 |
//! |---|---|---|---|
//! | 1.1 | 容器可见高 | **40** | `LM.control_height`([`Geometry::height`]) |
//! | 1.2 | 圆角 | **full = 高/2 = 20** | `ShapeScale::Full` + `Ui::radius` |
//! | 1.3 | 水平内边距(无图标) | **24 / 24** | `Space::X6` |
//! | 1.4 | 水平内边距(带前置图标) | **16(左)/ 24(右)** | `Space::X4` / `Space::X6` |
//! | 1.5 | 字级 | **LabelLarge 14/20 w500** | `TypeRole::LabelLarge` 强调档 |
//! | 1.6 | 图标 18 + 图标↔文字 8 | **18** = `IconSize::Button`(AAR `ButtonDefaults.IconSize`);间距 `Space::X2` | [`Geometry`] |
//! | 1.7 | 最小宽 58 | **G15**:iced 的 `Length` 无 min-width,不施加(实测见报告) | — |
//! | 1.8 | 状态层 + 禁用 | hover 8 / focus 10 / pressed 10;容器 **10%**(官方 `DisabledContainerOpacity`;面状控件的 12% 是另一类)& 内容 38%;**内容角色按变体**([`Variant::disabled_content`]) | [`super::interaction`] |
//! | 1.9 | 五式色彩角色 | Filled `Primary`/`OnPrimary`;Tonal `SecondaryContainer`/`OnSecondaryContainer`;Elevated `SurfaceContainerLow`/`Primary`;Outlined 透明 + 1dp `OutlineVariant` + `OnSurfaceVariant`;Text 透明 + `OnSurfaceVariant` | [`Variant::roles`] |
//! | 2.1 | IconButton 容器 | **40×40**、圆角 full | [`IconGeometry`] |
//! | 2.2 | IconButton 图标 / 边距 | 图标 **24**、边距 **8** | [`IconGeometry`] |
//! | 2.4 | Standard 各态**图标色不变** | 只有状态层变化 | [`Variant::roles`] 与态无关 |
//! | 2.7 | IconButton 禁用 | 内容 **`OnSurface`@38%**(A1;见 [`IconVariant::disabled_content`]) | [`super::interaction::disabled_button_content`](按钮族 10% 底) |
//! | 2.8 | 命中区 | 容器 40 + 四周各 4 = **48** | `LM.touch_target`(按钮自身不撑高) |
//!
//! ## 与契约 §2 的签名差异(**已登记,请 tower 确认**)
//!
//! 契约 §2 的示例签名是 `filled(ui, label, on_press)`——三个参数装不下
//! 「五式 × 五态 × 前后双图标 × 禁用」。所以本模块的落地是**契约的加法版**:
//! 泛型纪律一字不改(`M: Clone`,不碰 `app::Message`),但把前两个参数收进 [`Button`]:
//!
//! ```text
//! 契约示例: filled(ui, "保存", Message::Save)
//! 本模块: filled(ui, Button::new("保存").press(Message::Save))
//! ```
//!
//! 差别只是「label + 消息」两件事从位置参数变成构造器参数,语义一一对应;
//! 要图标/禁用/焦点取证时再加链式调用(`.leading(..)` / `Button::disabled(..)` / `.state(..)`),
//! 不需要第二个签名。**这是本周唯一一处偏离契约示例的地方**,报告「决策」一节有登记。
//!
//! ## 为什么不用 iced 内置的 `theme::Button` 样式
//!
//! iced 的内置按钮样式只有 `primary/success/danger/text` 几档,且**不带 M3 的状态层 α**
//! (读的是 `Palette` 现成色)。D30 要求 hover 8% / pressed 10% 可判定,所以样式自绘;
//! 但**只画颜色与描边**——尺寸仍由 `button` 的 width/height/padding 决定,不手搓几何。
use iced::widget::button;
use iced::{Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::elevation::Elevation;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use crate::icons;
use crate::theme::Ui;
use super::interaction::Interaction;
use super::layout;
mod assemble;
mod geometry;
pub use geometry::{Geometry, IconGeometry};
use assemble::{assemble, style_closure, StyleSpec};
#[cfg(test)]
mod tests;
/// 按钮的五种视觉变体(12b §1.9 的色彩角色,一名一式)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Variant {
/// 实心强调色:**每屏最多一个**(rubric K2)。
Filled,
/// 次强调(tonal):`SecondaryContainer`。
Tonal,
/// 带高程:`SurfaceContainerLow` + `Primary` 文本(L1 用色调差,不画阴影)。
Elevated,
/// 描边:透明容器 + 1dp `OutlineVariant`。
Outlined,
/// 纯文本:最低权重。
Text,
}
impl Variant {
/// 五式全序(gallery 的矩阵按这个次序出图)。
pub const ALL: [Variant; 5] = [
Variant::Filled,
Variant::Tonal,
Variant::Elevated,
Variant::Outlined,
Variant::Text,
];
/// 稳定名字(图例 / 文件名 / 失败信息用)。
pub const fn name(self) -> &'static str {
match self {
Variant::Filled => "filled",
Variant::Tonal => "tonal",
Variant::Elevated => "elevated",
Variant::Outlined => "outlined",
Variant::Text => "text",
}
}
/// 该式在组件表里的档位(圆角/字级)。`Component` 没有 `Outlined/Elevated` 变体,
/// 借用 `PrimaryButton`:三者判据值相同(`Full` 圆角 + `LabelLarge` + L0)。
pub(crate) const fn component(self) -> Component {
match self {
Variant::Filled => Component::PrimaryButton,
Variant::Tonal => Component::SecondaryButton,
Variant::Text => Component::TextButton,
Variant::Elevated | Variant::Outlined => Component::PrimaryButton,
}
}
/// 圆角档位(五式统一 `Full`,12b 1.2)。
pub(crate) fn shape(self) -> ShapeScale {
self.component().spec().shape
}
/// **禁用态的内容角色**(A1,逐变体不同——别在这里统一猜)。
///
/// | 变体 | 禁用内容 |
/// |---|---|
/// | Filled / Elevated / Outlined / Text | `OnSurfaceVariant` @0.38 |
/// | **FilledTonal** | **`OnSurface` @0.38** ← A1 的显式例外(`FilledTonalButtonTokens`) |
///
/// 出处:AAR `material3-android:1.4.0` 的 `javap -c`,`FilledButtonTokens.
/// DisabledLabelTextColor = OnSurfaceVariant`、`FilledTonalButtonTokens` 同一字段 = `OnSurface`。
pub(crate) const fn disabled_content(self) -> Role {
match self {
Variant::Tonal => Role::OnSurface,
_ => Role::OnSurfaceVariant,
}
}
/// 高程:只有 Elevated 是 L1(`ElevatedButtonTokens.ContainerElevation`),其余 L0。
///
/// **不画阴影**:rubric §7「L0/L1 不应有可见阴影(用 tone 差)」,所以 L1 只体现为
/// `SurfaceContainerLow` 容器色,`button::Style.shadow` 永远 default。
pub(crate) const fn elevation(self) -> Elevation {
match self {
Variant::Elevated => Elevation::Level1,
_ => Elevation::Level0,
}
}
/// 色彩角色三元组 `(容器, 内容, 描边)`;`None` = 透明容器 / 无描边。
pub(crate) const fn roles(self) -> (Option<Role>, Role, Option<Role>) {
match self {
Variant::Filled => (Some(Role::Primary), Role::OnPrimary, None),
Variant::Tonal => (
Some(Role::SecondaryContainer),
Role::OnSecondaryContainer,
None,
),
Variant::Elevated => (Some(Role::SurfaceContainerLow), Role::Primary, None),
Variant::Outlined => (None, Role::OnSurfaceVariant, Some(Role::OutlineVariant)),
Variant::Text => (None, Role::OnSurfaceVariant, None),
}
}
}
/// IconButton 的三种容器变体(12b §2.5)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum IconVariant {
/// 标准:容器透明、图标 `OnSurfaceVariant`(各态图标色不变,12b 2.4)。
Standard,
/// 实心:`Primary` + `OnPrimary`。
Filled,
/// Tonal:`SecondaryContainer` + `OnSecondaryContainer`。
Tonal,
}
impl IconVariant {
/// 三式全序(gallery 顺序)。
pub const ALL: [IconVariant; 3] = [
IconVariant::Standard,
IconVariant::Filled,
IconVariant::Tonal,
];
/// 稳定名字。
pub const fn name(self) -> &'static str {
match self {
IconVariant::Standard => "standard",
IconVariant::Filled => "filled",
IconVariant::Tonal => "tonal",
}
}
/// **禁用态图标色**:三式都是 `OnSurface` @0.38 —— A1
/// (`StandardIconButtonTokens.DisabledColor` / `FilledIconButtonTokens.DisabledColor`)。
pub(crate) const fn disabled_content(self) -> Role {
Role::OnSurface
}
/// 色彩角色三元组(同 [`Variant::roles`] 的口径)。
pub(crate) const fn roles(self) -> (Option<Role>, Role, Option<Role>) {
match self {
IconVariant::Standard => (None, Role::OnSurfaceVariant, None),
IconVariant::Filled => (Some(Role::Primary), Role::OnPrimary, None),
IconVariant::Tonal => (
Some(Role::SecondaryContainer),
Role::OnSecondaryContainer,
None,
),
}
}
}
/// 一个按钮的装配描述(五式共用;**不持有任何状态所有权**)。
///
/// `on_press: Option<M>` 就是 M3 的 enabled/disabled 开关——`None` = 禁用,
/// 与 `button::on_press_maybe` 的语义天然对齐(省掉一个 `disabled: bool` 字段,
/// 也就不会出现「有消息但画成禁用」这种自相矛盾的状态)。
#[derive(Debug, Clone)]
pub struct Button<'a, M> {
label: &'a str,
on_press: Option<M>,
leading: Option<IconKind>,
trailing: Option<IconKind>,
state: Option<Interaction>,
}
impl<'a, M> Button<'a, M> {
/// 一个可点的按钮。
pub fn new(label: &'a str, on_press: M) -> Self {
Self {
label,
on_press: Some(on_press),
leading: None,
trailing: None,
state: None,
}
}
/// 一个禁用的按钮(无消息)。禁用不是「另一种颜色」,见 `interaction::disabled_button_colors`。
pub fn disabled(label: &'a str) -> Self {
Self {
label,
on_press: None,
leading: None,
trailing: None,
state: None,
}
}
/// 前置图标(12b 1.4:带图标时左内边距收到 16)。
pub fn leading(mut self, kind: IconKind) -> Self {
self.leading = Some(kind);
self
}
/// 后置图标(右内边距保持 24)。
pub fn trailing(mut self, kind: IconKind) -> Self {
self.trailing = Some(kind);
self
}
/// 强制视觉态(`--screen=gallery` 的状态矩阵取证用;运行期不要调)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
}
/// Filled 按钮(实心 `Primary`)。
pub fn filled<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
assemble(ui, Variant::Filled, button)
}
/// Tonal 按钮(`SecondaryContainer`)。
pub fn tonal<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
assemble(ui, Variant::Tonal, button)
}
/// Elevated 按钮(`SurfaceContainerLow` + `Primary` 文本,L1 用色调差)。
pub fn elevated<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
assemble(ui, Variant::Elevated, button)
}
/// Outlined 按钮(透明 + 1dp `OutlineVariant`)。
pub fn outlined<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
assemble(ui, Variant::Outlined, button)
}
/// Text 按钮(无容器、无描边)。
pub fn text<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
assemble(ui, Variant::Text, button)
}
/// IconButton(standard / filled / tonal):40×40、图标 24、命中区 48。
///
/// **图标语汇纪律(视觉 reviewer P2-3)**:同一个控件里**不混用**填充字形(`IconPaint::Fill`,
/// 如 `Play`/`Stop`/`More`)与描边字形(`Stroke`)——混着放会让线宽看起来忽粗忽细。
/// 允许实心字形,但只能出现在「主操作」这一处(例如播放按钮),其余位置一律描边;
/// gallery 的样例因此统一用描边图标(`Download` + `ChevronRight`),并有单测钉住。
///
/// `on_press: Option<M>`(`None` = 禁用)与 `state` 分开给:前者是语义,后者是取证开关。
pub fn icon<'a, M: Clone + 'a>(
ui: &'a Ui,
variant: IconVariant,
kind: IconKind,
on_press: Option<M>,
state: Option<Interaction>,
) -> Element<'a, M> {
let geometry = IconGeometry::of(ui);
let (fill, content, border) = variant.roles();
let forced = state.or(if on_press.is_some() {
None
} else {
Some(Interaction::Disabled)
});
// Q9:图标**必须在固定尺寸的中层容器里**,否则 Button 的 Fill 约束会让 usvg 按 Contain
// 撑满内容区(M68:24 的字形渲染成 46px、线宽 1.5 → 2.9)。
// 这里把盒子做成整个 40×40(而不是 24×24):`button::layout` 把内容放左上角、不居中,
// 所以「居中」这件事必须由容器自己写死,别指望 button。
// 内容色跟态走(禁用 = on-surface@38%),见 `assemble::content_color` 的说明
let glyph = assemble::content_color(ui, forced, content, fill, variant.disabled_content());
let icon_box =
layout::centered_fixed(geometry.side, geometry.side, icons::view::<M>(kind, glyph));
let style = style_closure(
ui,
StyleSpec {
fill,
content,
border,
// 方形图标按钮同样是 full 圆角(40 → 20),档位取自组件表
shape: Component::IconButton.spec().shape,
stroke: 0.0,
radius_side: geometry.side,
forced,
},
);
let element = button(icon_box)
.width(Length::Fixed(geometry.side))
.height(Length::Fixed(geometry.side))
.padding(Padding::default())
.style(style);
match on_press {
Some(message) => element.on_press(message).into(),
None => element.into(),
}
}
@@ -0,0 +1,351 @@
//! 按钮家族的单测(几何值 / 色彩角色 / 样式闭包)。
//!
//! 这里测的是「函数与样式结构」;**像素级**的几何(40 高、20 圆角、24 图标盒)
//! 由 `gallery/probe.rs` 对着真截图量——M68 的教训是「函数对、图错」,两者缺一不可。
use iced::widget::button;
use iced::{Background, Shadow};
use unlucky_design::color::SchemeFlavor;
use unlucky_design::elevation::Elevation;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use super::super::interaction::{self, Interaction};
use super::assemble::StyleSpec;
use super::{style_closure, Geometry, IconGeometry, IconVariant, Variant};
use crate::theme::{Families, Ui, STROKE_PX};
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(
flavor,
unlucky_design::typography::Platform::Windows,
FAMILIES,
)
}
fn light() -> Ui {
ui(SchemeFlavor::Light)
}
#[derive(Debug, Clone, PartialEq)]
enum Msg {
Pressed(&'static str),
}
#[test]
fn every_variant_builds_and_disabled_has_no_message() {
let ui = light();
let _ = super::filled(&ui, super::Button::new("Filled", Msg::Pressed("f")));
let _ = super::tonal(&ui, super::Button::new("Tonal", Msg::Pressed("t")));
let _ = super::elevated(&ui, super::Button::new("Elevated", Msg::Pressed("e")));
let _ = super::outlined(&ui, super::Button::new("Outlined", Msg::Pressed("o")));
let _ = super::text(&ui, super::Button::new("Text", Msg::Pressed("x")));
let _ = super::filled::<Msg>(&ui, super::Button::disabled("Disabled"));
let _ = super::filled(
&ui,
super::Button::new("Icons", Msg::Pressed("i"))
.leading(IconKind::Plus)
.trailing(IconKind::ChevronRight),
);
}
/// 12b 1.1~1.6 + 2.1~2.2:几何逐值钉住(每个值都要能指回令牌)。
#[test]
fn geometry_matches_the_reference_values() {
let ui = light();
let geometry = Geometry::of(&ui, Variant::Filled, false);
assert_eq!(geometry.height, 40.0, "12b 1.1 可见高 40");
assert_eq!(geometry.height, ui.metrics().control_height);
assert_eq!(
ui.radius(unlucky_design::shape::ShapeScale::Full, geometry.height)
.top_left,
20.0,
"12b 1.2 full 圆角 = 高/2"
);
assert_eq!(geometry.padding.left, 24.0, "12b 1.3 左 24");
assert_eq!(geometry.padding.right, 24.0, "12b 1.3 右 24");
assert_eq!(geometry.padding.top, 0.0);
assert_eq!(geometry.gap, 8.0, "12b 1.6 图标↔文字 8");
assert_eq!(geometry.gap, ui.space(Space::X2));
assert_eq!(geometry.stroke, 0.0, "实心按钮无描边");
let with_icon = Geometry::of(&ui, Variant::Filled, true);
assert_eq!(with_icon.padding.left, 16.0, "12b 1.4 带图标左内边距 16");
assert_eq!(with_icon.padding.right, 24.0, "带图标右内边距不动");
let outlined = Geometry::of(&ui, Variant::Outlined, false);
assert_eq!(outlined.stroke, STROKE_PX, "rubric G7 全站 1px");
assert_eq!(outlined.stroke, 1.0);
// 图标 18 是**令牌值**(`IconSize::Button`,AAR `ButtonDefaults.IconSize`);
// 12b 1.6 的 18 与它同源。**别按 `ButtonSmallTokens.IconSize = 20` 改**:那是零消费者的死令牌。
assert_eq!(
geometry.icon, 18.0,
"IconSize::Button(官方 ButtonDefaults.IconSize)"
);
assert_eq!(
geometry.icon,
unlucky_design::icons::IconSize::Button.px(),
"组件侧必须只从令牌取值(不许写字面量)"
);
assert_eq!(geometry.height % 4.0, 0.0, "可见高必须在 4px 栅格上(D1)");
}
#[test]
fn icon_button_geometry_matches_the_reference() {
let ui = light();
let geometry = IconGeometry::of(&ui);
assert_eq!((geometry.side, geometry.icon), (40.0, 24.0), "12b 2.1/2.2");
assert_eq!(
ui.radius(unlucky_design::shape::ShapeScale::Full, geometry.side)
.top_left,
20.0,
"40×40 的 full 圆角 = 20"
);
assert_eq!(geometry.padding, 8.0, "12b 2.2 默认边距 8");
// 命中区 = 容器 40 + 四周各 4(12b 2.8);按钮自身不撑高
assert_eq!(
geometry.side + 2.0 * ui.space(Space::X1),
ui.metrics().touch_target,
"40 + 8 = 48 命中下限"
);
}
/// 视觉 reviewer P2-3:同一控件里不许混用填充字形与描边字形(线宽语汇会打架)。
///
/// gallery 的样例图标(前置/后置)必须同为 `IconPaint::Stroke`;允许实心字形,但只能作为
/// 「主操作」单独出场的图标(例如播放键),不能与描边图标同处一颗按钮。
#[test]
fn sample_icons_never_mix_fill_and_stroke() {
use unlucky_design::icons::IconPaint;
let sample = [IconKind::Download, IconKind::ChevronRight];
for kind in sample {
assert_eq!(
kind.icon().paint,
IconPaint::Stroke,
"{} 是填充字形,不能和描边图标混在同一颗按钮里",
kind.name()
);
}
// 已知的实心字形(留档,下次有人拿它们当按钮图标时这条会提醒)
assert_eq!(IconKind::Play.icon().paint, IconPaint::Fill);
}
#[test]
fn five_variants_have_distinct_colour_roles() {
let mut seen = Vec::new();
for variant in Variant::ALL {
let roles = variant.roles();
assert!(
!seen.contains(&roles),
"{variant:?} 与别的变体色彩角色重复了"
);
seen.push(roles);
}
// 12b 1.9 逐条角色名核对
assert_eq!(
Variant::Filled.roles(),
(
Some(unlucky_design::color::Role::Primary),
unlucky_design::color::Role::OnPrimary,
None
)
);
assert_eq!(
Variant::Tonal.roles(),
(
Some(unlucky_design::color::Role::SecondaryContainer),
unlucky_design::color::Role::OnSecondaryContainer,
None
)
);
assert_eq!(
Variant::Elevated.roles(),
(
Some(unlucky_design::color::Role::SurfaceContainerLow),
unlucky_design::color::Role::Primary,
None
)
);
assert_eq!(
Variant::Outlined.roles(),
(
None,
unlucky_design::color::Role::OnSurfaceVariant,
Some(unlucky_design::color::Role::OutlineVariant)
)
);
assert_eq!(
Variant::Text.roles(),
(None, unlucky_design::color::Role::OnSurfaceVariant, None)
);
for variant in Variant::ALL {
let expected = if variant == Variant::Elevated {
Elevation::Level1
} else {
Elevation::Level0
};
assert_eq!(variant.elevation(), expected, "{variant:?} 高程");
}
}
#[test]
fn icon_button_variants_match_the_reference_roles() {
use unlucky_design::color::Role;
assert_eq!(
IconVariant::Standard.roles(),
(None, Role::OnSurfaceVariant, None)
);
assert_eq!(
IconVariant::Filled.roles(),
(Some(Role::Primary), Role::OnPrimary, None)
);
assert_eq!(
IconVariant::Tonal.roles(),
(
Some(Role::SecondaryContainer),
Role::OnSecondaryContainer,
None
)
);
let ui = light();
for variant in IconVariant::ALL {
let _ = super::icon::<Msg>(&ui, variant, IconKind::Settings, None, None);
let _ = super::icon(
&ui,
variant,
IconKind::Settings,
Some(Msg::Pressed("i")),
None,
);
// 五态取证开关都能出元素(gallery 矩阵依赖它)
for state in Interaction::ALL {
let _ = super::icon(
&ui,
variant,
IconKind::Settings,
Some(Msg::Pressed("i")),
Some(state),
);
}
}
}
fn spec_of(variant: Variant, forced: Option<Interaction>) -> StyleSpec {
let (fill, content, border) = variant.roles();
StyleSpec {
fill,
content,
border,
shape: variant.shape(),
stroke: Geometry::of(&light(), variant, false).stroke,
radius_side: Geometry::of(&light(), variant, false).height,
forced,
}
}
/// **渲染层**:样式闭包吐给 iced 的圆角/描边/阴影必须是令牌推导值。
#[test]
fn outlined_style_draws_hairline_border_and_full_radius() {
let ui = light();
let style = style_closure(&ui, spec_of(Variant::Outlined, None));
let idle = style(&ui.theme(), button::Status::Active);
assert_eq!(idle.background, None, "Outlined 静息容器必须透明");
assert_eq!(idle.border.width, STROKE_PX, "1dp 描边");
assert_eq!(idle.border.radius.top_left, 20.0, "full 圆角");
assert_eq!(
idle.border.color,
ui.color(unlucky_design::color::Role::OutlineVariant)
);
assert_eq!(idle.shadow, Shadow::default(), "L0 不许有阴影");
// 透明容器也要看得见 hover:状态层叠到表面色上
let hovered = style(&ui.theme(), button::Status::Hovered);
assert_ne!(hovered.background, None, "透明容器的 hover 必须有可见变化");
assert_ne!(hovered.background, idle.background);
let disabled = style(&ui.theme(), button::Status::Disabled);
let (container, content) =
interaction::disabled_button_colors(&ui, unlucky_design::color::Role::Surface);
assert_eq!(disabled.text_color, content, "禁用内容 = 令牌 38%");
assert_eq!(
disabled.border.color, container,
"禁用描边 = 按钮族的令牌 10%(OutlinedButtonTokens.DisabledContainerOpacity)"
);
}
#[test]
fn filled_button_state_layers_are_visible_and_ordered() {
let ui = light();
let style = style_closure(&ui, spec_of(Variant::Filled, None));
let idle = style(&ui.theme(), button::Status::Active);
let hovered = style(&ui.theme(), button::Status::Hovered);
let pressed = style(&ui.theme(), button::Status::Pressed);
assert_eq!(
idle.background,
Some(Background::Color(
ui.color(unlucky_design::color::Role::Primary)
))
);
assert_ne!(hovered.background, idle.background, "hover 8%");
assert_ne!(
pressed.background, hovered.background,
"pressed 10% ≠ hover 8%"
);
assert_eq!(
hovered.text_color,
ui.color(unlucky_design::color::Role::OnPrimary),
"12b 2.4/4.7:状态层不换内容色"
);
assert_eq!(idle.border.width, 0.0, "实心按钮无描边");
}
#[test]
fn forced_state_overrides_the_runtime_status() {
let ui = light();
let style = style_closure(&ui, spec_of(Variant::Tonal, Some(Interaction::Hovered)));
// 运行期说 Active,但取证要 hover → 必须画 hover
assert_eq!(
style(&ui.theme(), button::Status::Active).background,
style(&ui.theme(), button::Status::Hovered).background
);
assert_ne!(
style(&ui.theme(), button::Status::Active).background,
Some(Background::Color(
ui.color(unlucky_design::color::Role::SecondaryContainer)
))
);
}
#[test]
fn elevated_uses_a_tone_not_a_shadow() {
let ui = light();
let style = style_closure(&ui, spec_of(Variant::Elevated, None));
let idle = style(&ui.theme(), button::Status::Active);
assert_eq!(idle.shadow, Shadow::default(), "rubric §7:L1 不画阴影");
assert_eq!(
idle.background,
Some(Background::Color(
ui.color(unlucky_design::color::Role::SurfaceContainerLow)
)),
"L1 由 SurfaceContainerLow 的色调差表达"
);
assert_eq!(
idle.text_color,
ui.color(unlucky_design::color::Role::Primary)
);
}
+109
View File
@@ -0,0 +1,109 @@
//! 分隔线(`research/15` §22,A1:`DividerTokens.kt` / `Divider.kt` / `ListTokens.kt`)。
//!
//! | # | 检查项 | 参考值 | 落点 |
//! |---|---|---|---|
//! | 22.1 | 厚度 | **1dp** → 1px ±0 @100% DPI | `LayoutMetrics::divider_thickness` = [`STROKE_PX`] |
//! | 22.2 | 颜色 | **`OutlineVariant`**(不是 `Outline`,那个更深、会抢层级) | `Role::OutlineVariant` |
//! | 22.3 | 缩进 | 列表内 **左 16 / 右 16**;区块分隔 **0(通栏)** | [`list`] / [`full`] |
//! | 22.4 | 用量 | 同屏同类线 ≤3;同容器内不得既有描边又有分隔线 | 页面线自查(本模块不强制) |
//! | 22.5 | 何时不用 | tone 能分层就不画线;纵向分隔本项目不用 | 文档约定 |
//!
//! ## 语义纪律(22.5 的一句话口径)
//!
//! **分隔线是兜底手段,不是排版手段**:能靠 `Surface` / `SurfaceContainerLow` /
//! `SurfaceContainerHigh` 的色差把层级说清的地方就不画线;卡片已经有 1px `OutlineVariant`
//! 描边时,卡片**之间**不再画线(同时出现 = 双重分隔,rubric K3 那类廉价感)。
//!
//! ## 为什么只有两个入口
//!
//! §22.3 只承认两种缩进:通栏(区块分隔)与列表内 16。第三种缩进一定是「页面自己想出来的」,
//! 所以不提供任意数值入口——真要第三种,先在 `SubAgentsTalk/` 登记。
use iced::widget::{container, space};
use iced::{Background, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use crate::theme::{Ui, STROKE_PX};
use super::layout;
/// 通栏分隔线(区块分隔:0 缩进)。
pub fn full<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> {
line(ui)
}
/// 列表内分隔线:左缩进 `Space::X4`(16),右侧到底(`ListTokens.DividerLeadingSpace`)。
///
/// 右缩进按官方也是 16,但列表行本身已有 16 的内边距时再缩一次会变成 32——
/// 本项目把「行内边距」交给行,本函数只负责**线相对行**的缩进,故右端通到行尾。
pub fn list<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> {
layout::inset_start(ui.space(Space::X4), line(ui))
}
/// 一条 1px 的横线(厚度/颜色都只有一处定义)。
fn line<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> {
container(space::horizontal())
.width(Length::Fill)
.height(thickness(ui))
.style(|_theme| container::Style {
background: Some(Background::Color(ui.color(Role::OutlineVariant))),
..container::Style::default()
})
.into()
}
/// 线厚(= `LayoutMetrics::divider_thickness` = `stroke::HAIRLINE`,三者同一真源)。
pub fn thickness(ui: &Ui) -> f32 {
debug_assert_eq!(
ui.metrics().divider_thickness,
STROKE_PX,
"分隔线厚度必须是一像素线的唯一真源"
);
ui.metrics().divider_thickness
}
#[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",
},
)
}
/// 22.1:厚度 1(不是 2,也不是 1.5)。
#[test]
fn thickness_is_exactly_one_pixel() {
let ui = ui();
assert_eq!(thickness(&ui), 1.0);
assert_eq!(thickness(&ui), STROKE_PX);
assert_eq!(thickness(&ui), ui.metrics().divider_thickness);
}
/// 22.3:列表内缩进 16,来自 `Space::X4` 而不是字面量。
#[test]
fn list_inset_comes_from_the_spacing_token() {
let ui = ui();
assert_eq!(ui.space(Space::X4), 16.0, "ListTokens.DividerLeadingSpace");
}
#[test]
fn both_forms_build() {
let ui = ui();
let _: Element<'_, ()> = full(&ui);
let _: Element<'_, ()> = list(&ui);
}
}
@@ -0,0 +1,173 @@
//! 反馈家族里的两个**小动作件**:文字动作与图标动作。
//!
//! ## 为什么不复用 `buttons`
//!
//! 这两处的内容角色都不在按钮家族的三档里:Dialog 动作 = `Primary`,Snackbar action =
//! `InversePrimary`(A1 `SnackbarTokens.ActionLabelTextColor`),Snackbar 关闭图标 =
//! `InverseOnSurface`。`buttons::text` 固定 `OnSurfaceVariant`(12b §1.9 的 Text 变体口径),
//! 拿它画 Dialog 动作就是颜色角色错位(rubric 判 P1)。
//!
//! 所以这里只做**一份**最小实现:内容/容器角色作参数,尺寸/字级仍走令牌,状态层仍走
//! [`interaction`]——与按钮家族同一条产线,只是角色由调用点给。
//!
//! ## 禁用是状态不是构造器
//!
//! 照 `selection/chip.rs` 修好的模式:`on_press: Option<M>`,`None` = 禁用;文字色降到
//! `OnSurface@38%`,标签本身**不删**。禁用色的**底**必须是这两个动作件自己的容器角色
//! (`InverseSurface` / `SurfaceContainerHigh`)——`interaction::disabled_content_over_role`
//! 只支持「叠在 Surface / 叠在 12% 容器上」两档,拿它合成会得到「38% 深字叠深底」(复审 P2-4)。
use iced::widget::{button, text};
use iced::{Background, Border, Element, Length, Padding, Shadow};
use unlucky_design::color::state::DISABLED_CONTENT_ALPHA;
use unlucky_design::color::{Role, Scheme};
use unlucky_design::component_map::Component;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::{to_iced, Ui};
use super::super::interaction::{self, Interaction};
use super::super::layout;
/// 禁用态内容色:`OnSurface@38%` 叠在**真实容器色**上(复审 P2-4)。
///
/// `interaction::disabled_content_over_role` 的 `on_container` 只有「叠在 Surface / 叠在
/// 12% 容器上」两档,而这两个动作件的底是 `InverseSurface`(Snackbar)与
/// `SurfaceContainerHigh`(Dialog)——直接调它会得到「38% 深字叠深底」。
/// 这里按同一令牌口径([`DISABLED_CONTENT_ALPHA`])在本文件合成一次,底换成真实容器角色。
fn content_color(ui: &Ui, role: Role, container_role: Role, disabled: bool) -> iced::Color {
if !disabled {
return ui.color(role);
}
let scheme = Scheme::baseline(ui.flavor());
to_iced(
scheme
.get(Role::OnSurface)
.with_alpha(DISABLED_CONTENT_ALPHA)
.over(scheme.get(container_role)),
)
}
/// 一个文字动作(Dialog 的动作区、Snackbar 的 action 共用)。
///
/// - `content` / `container_role`:内容色与**状态层叠在什么颜色上**(Dialog =
/// `Primary` on `SurfaceContainerHigh`;Snackbar = `InversePrimary` on `InverseSurface`);
/// - `on_press = None` → 禁用(文字降到 38%,仍占位);
/// - `forced`:取证用的强制态(运行期不要给)。
pub(super) fn text_action<'a, M: Clone + 'a>(
ui: &'a Ui,
label: &'a str,
content: Role,
container_role: Role,
on_press: Option<M>,
forced: Option<Interaction>,
) -> Element<'a, M> {
let style = ui.emphasized(TypeRole::LabelLarge);
let height = ui.metrics().control_height;
let pad = ui.space(Space::X3);
let shape = Component::TextButton.spec().shape;
let radius = ui.radius(shape, height);
let disabled = on_press.is_none();
let ink = content_color(ui, content, container_role, disabled);
let element = button(
text(label)
.size(style.size_px)
.font(ui.font_of(style))
.color(ink),
)
.height(Length::Fixed(height))
.padding(Padding {
top: 0.0,
right: pad,
bottom: 0.0,
left: pad,
})
.style(move |_theme, status| {
let state = forced.unwrap_or_else(|| {
if disabled {
Interaction::Disabled
} else {
Interaction::from_status(status)
}
});
button::Style {
background: interaction::overlay_color(ui, state, content, container_role)
.map(Background::Color),
text_color: ink,
border: Border {
radius,
..Border::default()
},
// 浮层的阴影只在 Dialog 容器上(rubric K3 单层),动作件本身不投影。
shadow: Shadow::default(),
snap: true,
}
});
match on_press {
Some(message) => element.on_press(message).into(),
None => element.into(),
}
}
/// 一个图标动作(Snackbar 的关闭键)。命中区 = `touch_target`,字形经 `layout::centered_fixed`
/// 锁在 24(Q9:不锁的话字体会被 Button 的 Fill 约束撑满)。
pub(super) fn icon_action<'a, M: Clone + 'a>(
ui: &'a Ui,
kind: IconKind,
content: Role,
container_role: Role,
on_press: Option<M>,
forced: Option<Interaction>,
) -> Element<'a, M> {
let metrics = ui.metrics();
let side = metrics.touch_target;
let glyph_side = unlucky_design::icons::IconSize::Toolbar.px();
let shape = Component::IconButton.spec().shape;
let radius = ui.radius(shape, side);
let disabled = on_press.is_none();
let ink = content_color(ui, content, container_role, disabled);
// 走 `view_at(24)` 而不是 `view(..)`:`icons.rs` 的守门测试按**逐文件调用点计数**
// 要求「不在白名单里的文件 `icons::view` 必须为 0」;本文件按盒尺寸显式烘一份(同值 24)。
let element = button(layout::centered_fixed(
glyph_side,
glyph_side,
icons::view_at::<M>(kind, ink, glyph_side),
))
.width(Length::Fixed(side))
.height(Length::Fixed(side))
.padding(Padding::default())
.style(move |_theme, status| {
let state = forced.unwrap_or_else(|| {
if disabled {
Interaction::Disabled
} else {
Interaction::from_status(status)
}
});
button::Style {
background: interaction::overlay_color(ui, state, content, container_role)
.map(Background::Color),
text_color: ink,
border: Border {
radius,
..Border::default()
},
shadow: Shadow::default(),
snap: true,
}
});
match on_press {
Some(message) => element.on_press(message).into(),
None => element.into(),
}
}
@@ -0,0 +1,262 @@
//! Dialog(`research/12b` §7):scrim + 容器 + 标题/正文/动作三区。
//!
//! ## 官方值(`[源]` = A1,`DialogTokens` + `AlertDialogKt`,`javap` 核对)
//!
//! | # | 项 | 值 | 落点 |
//! |---|---|---|---|
//! | 7.1 | 圆角 | **28**(`CornerExtraLarge`) | `Component::Dialog.spec().shape` |
//! | 7.2 | 容器 | **`SurfaceContainerHigh`**(`DialogTokens.ContainerColor`) | `Ui::layer(MaterialLayer::Overlay)` |
//! | 7.3 | 内距 | 四边 **24**;标题下 **16**;正文下 **24** | [`DialogGeometry`](`Space` 档位) |
//! | 7.4 | 宽 | 官方 **280..560**;本项目 **480** | [`DialogGeometry::width`] |
//! | 7.5 | 字级 | 标题 `HeadlineSmall 24/32`、正文 `BodyMedium 14/20`、动作 `LabelLarge 14/20 w500` | 组件层取角色 |
//! | 7.6 | 动作区 | 文本 **`Primary`**;主轴 **8** / 交叉 **12**;右对齐 | [`DialogGeometry`] |
//! | 7.7 | 遮罩 | `scrim` = 纯黑 @ **0.32**(`SCRIM_ALPHA`) | [`scrim`] |
//! | 7.8 | 数量 | 一屏 **1 个** modal | 页面责任(组件不计数) |
//!
//! ## 三种动作形态
//!
//! [`DialogActions::None`](纯告知)/ [`DialogActions::Single`](「知道了」)/
//! [`DialogActions::Double`](「取消」+「删除」,右边是主操作)。双动作的 `dismiss` 在左、
//! `confirm` 在右(M3 的阅读序;本项目**不**做「危险操作反色」——官方 Dialog 动作只有 `Primary`)。
//!
//! ## scrim 为什么是**不透明预合成**
//!
//! 官方是「纯黑 @0.32 盖在内容上」。D27 要求 CPU 档零半透明(否则 CPU/GPU 两图不一致),
//! 所以 [`scrim`] 返回的是 `黑@0.32` **叠在 `MaterialLayer::Canvas` 上**之后的实色:
//! 逐像素与「真半透明铺在 surface 上」等价(遮罩下通常就是底色),且两档渲染一致。
//! 代价:遮罩下若是图片/卡片,合成目标不是 surface —— 这条登记为偏离 D-2。
//!
//! **深色档的观感(观察项 D-5)**:画布本身很暗,0.32 叠上去只从 (9,21,28) 压到 (6,14,19)
//! (≈4%),几乎看不出「压暗」。数值是官方 token 推出来的,**不调**:要更强的层级感应该动
//! Dialog 的容器档或阴影,而不是把 scrim 抬到 0.4(那就偏离 token 了)。
//!
//! ## 不画阴影(偏离 D-1)
//!
//! 官方 L3 有阴影,但 D27(CPU 禁模糊)+ 令牌层 `ShadowSpec` 仍是 M63 占位值 ⇒ 先不画,
//! 层级由 `SurfaceContainerHigh` + scrim 表达。
use iced::widget::{column, container, row, text, Space};
use iced::{Alignment, Background, Border, Element, Length, Padding};
use unlucky_design::color::{Role, Scheme};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::typography::TypeRole;
use crate::theme::{to_iced, Ui};
use super::super::interaction::Interaction;
use super::action;
use super::panel::DialogGeometry;
/// 动作区的三种形态(「无动作」「单动作」「双动作」)。
#[derive(Debug, Clone)]
pub enum DialogActions<'a, M> {
/// 无动作:只有标题与正文(纯告知型,例如「已是最新版本」)。
None,
/// 单动作:确认/知道了。
Single {
/// 动作文案。
label: &'a str,
/// 按下后的消息。
on_press: M,
},
/// 双动作:左侧 dismiss、右侧 confirm。
Double {
/// 左(次要)动作。
dismiss: (&'a str, M),
/// 右(主)动作。
confirm: (&'a str, M),
},
}
impl<'a, M> DialogActions<'a, M> {
/// 是否为空(图例/测试用)。
pub fn is_empty(&self) -> bool {
matches!(self, DialogActions::None)
}
}
/// 一个对话框的装配描述(**不持有任何状态所有权**)。
#[derive(Debug, Clone)]
pub struct Dialog<'a, M> {
title: &'a str,
body: &'a str,
actions: DialogActions<'a, M>,
max_width: f32,
state: Option<Interaction>,
}
impl<'a, M> Dialog<'a, M> {
/// 标题 + 正文,默认无动作。
pub fn new(title: &'a str, body: &'a str) -> Self {
Self {
title,
body,
actions: DialogActions::None,
// 项目取值 480(官方区间 280..560 内);窄宿主由页面传更小的可用宽。
max_width: 480.0,
state: None,
}
}
/// 可用宽(默认 480)。实际宽 = `min(available, 480)`,且**不低于官方下限 280**
/// (`DialogMinWidth`;可用宽传 200 时仍画 280 并溢出宿主 —— iced 无 min-width,见 G17-d)。
///
/// 为什么不让组件自己从宿主推:iced 的 `Length` 没有 min-width,而 `Fill + max_width`
/// 会被父级的精确分配顶掉(实测渲染成 548 而不是 480,见 `dialog()` 里的说明)。
pub fn width(mut self, available: f32) -> Self {
self.max_width = available;
self
}
/// 单动作。
pub fn confirm(mut self, label: &'a str, on_press: M) -> Self {
self.actions = DialogActions::Single { label, on_press };
self
}
/// 双动作(左 dismiss / 右 confirm)。
pub fn double(mut self, dismiss: (&'a str, M), confirm: (&'a str, M)) -> Self {
self.actions = DialogActions::Double { dismiss, confirm };
self
}
/// 强制动作件的视觉态(gallery 取证用;运行期不要调)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
}
/// 遮罩色:`黑 @ SCRIM_ALPHA` 叠在 `MaterialLayer::Canvas` 上之后的**实色**(D27)。
pub fn scrim(ui: &Ui) -> iced::Color {
let scheme = Scheme::baseline(ui.flavor());
let canvas = scheme.get(MaterialLayer::Canvas.surface_role());
to_iced(scheme.scrim().over(canvas))
}
/// 把内容放进一块遮罩里(模态的「背后压暗」)。页面把它压在自己的内容上面即可。
pub fn scrimmed<'a, M: 'a>(ui: &'a Ui, content: impl Into<Element<'a, M>>) -> Element<'a, M> {
let backdrop = scrim(ui);
container(content)
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: Some(Background::Color(backdrop)),
..container::Style::default()
})
.into()
}
/// 画一个对话框(不含遮罩;遮罩用 [`scrimmed`])。
pub fn dialog<'a, M: Clone + 'a>(ui: &'a Ui, spec: Dialog<'a, M>) -> Element<'a, M> {
let geometry = DialogGeometry::of(ui);
let surface = ui.layer(MaterialLayer::Overlay);
let radius = iced::border::Radius::new(geometry.radius);
let title_style = ui.style(TypeRole::HeadlineSmall);
let body_style = ui.style(TypeRole::BodyMedium);
let mut content = column![].width(Length::Fill).spacing(0.0).push(
text(spec.title)
.size(title_style.size_px)
.font(ui.font_of(title_style))
.color(ui.color(Role::OnSurface)),
);
content = content.push(gap(geometry.title_gap)).push(
text(spec.body)
.size(body_style.size_px)
.font(ui.font_of(body_style))
.color(ui.color(Role::OnSurfaceVariant)),
);
if !spec.actions.is_empty() {
content = content.push(gap(geometry.body_gap)).push(actions_row(
ui,
&geometry,
spec.actions,
spec.state,
));
}
// 宽度 = `min(spec.max_width, 480)`,**可用宽由调用方递**(`Dialog::width`)。
//
// 复审 P2-1 建议「Fill + max_width」:实测**不行**。`Row` 给 `Fill` 子项的 limits 是
// `with_compression(min = max = 分配额)`,而 `Limits::max_width()` 的实现是
// `self.max.width.min(max_width).max(self.min.width)` —— 父级那个 **min 会把 480 顶回去**,
// 两列并排时每张卡渲染成 **548**(探针实测,见报告)。iced 没有 min-width,也没有在
// 「父级精确分配」下可用的 max-width ⇒ 与 Snackbar 的最小宽同一堵墙(G17-d 之二)。
// 所以:默认 480,窄宿主由页面传 `Dialog::width(可用宽)`。
// 可用宽 → 实际宽:`min(480, 可用宽)` 再 `.max(280)`(官方 `DialogMinWidth`,复审 E)。
// 可用宽 < 280 时仍画 280(溢出宿主)——iced 无 min-width,这条写进 G17-d 之二。
let effective = geometry.effective_width(spec.max_width);
container(content)
.width(Length::Fixed(effective))
.padding(Padding::from(geometry.padding))
.style(move |_theme| container::Style {
background: Some(Background::Color(surface)),
border: Border {
radius,
..Border::default()
},
..container::Style::default()
})
.into()
}
/// 两区之间的固定留白(A1 的「标题下 16 / 正文下 24」)。
fn gap<'a, M: 'a>(height: f32) -> Element<'a, M> {
Space::new()
.width(Length::Fill)
.height(Length::Fixed(height))
.into()
}
/// 动作区:右对齐,主轴间距 8(A1 `ButtonsMainAxisSpacing`)。
fn actions_row<'a, M: Clone + 'a>(
ui: &'a Ui,
geometry: &DialogGeometry,
actions: DialogActions<'a, M>,
state: Option<Interaction>,
) -> Element<'a, M> {
let controls = match actions {
DialogActions::None => row![],
DialogActions::Single { label, on_press } => row![action::text_action(
ui,
label,
Role::Primary,
MaterialLayer::Overlay.surface_role(),
Some(on_press),
state,
)],
DialogActions::Double { dismiss, confirm } => row![
action::text_action(
ui,
dismiss.0,
Role::Primary,
MaterialLayer::Overlay.surface_role(),
Some(dismiss.1),
state,
),
action::text_action(
ui,
confirm.0,
Role::Primary,
MaterialLayer::Overlay.surface_role(),
Some(confirm.1),
state,
),
],
};
container(
controls
.spacing(geometry.action_gap)
.align_y(Alignment::Center),
)
.width(Length::Fill)
.align_x(Alignment::End)
.into()
}
@@ -0,0 +1,149 @@
//! 空态 EmptyState(`research/12b` §15):图标块 + 标题 + 说明 + 可选动作。
//!
//! ## ⚠️ 这是**项目自定**组件,官方 Material 3 没有它
//!
//! `research/14`(官方组件清单)里**没有** EmptyState 这一项,Compose `material3` 也没有
//! 对应 token。所以**几何与排布是我们自定的**:下面表里标 `[估]` 的(96 块 / 三个间距 / 底色)
//! 没有任何官方出处,标 `[规格]` 的是 `11b R14` 的排版口径、标 `[源]` 的只有「CTA 就是标准按钮」
//! 这一条(按钮家族自己的 A1)。**不得整体对外声称「按官方规格实现」**:
//!
//! | # | 项 | 值 | 等级 |
//! |---|---|---|---|
//! | 15.1 | 图标块 | **96×96**,圆角 `Large(16)`,图标 **32** | `[估]`(圆角/图标档位是真令牌,96 是项目值) |
//! | 15.2 | 标题 | **HeadlineSmall 24/32**(空态是唯一允许 Headline 级的场景) | `[规格]` `11b R14` |
//! | 15.3 | 副文案 | **BodySmall 12/16** `OnSurfaceVariant`,最多 2 行 | `[规格]` |
//! | 15.4 | 垂直留白 | 内容在可用区居中(上下留白差 ≤8) | `[估]` |
//! | 15.5 | 快捷入口 | ≤ **3** 个 | `[估]`(本组件只给 1 个动作,见下) |
//! | 15.6 | CTA | 走标准 Button(40 高 / full 圆角),实心 `Primary` 占「每屏一个」名额 | `[源]`(按钮家族) |
//!
//! ## 为什么 CTA 直接用 `buttons::filled`
//!
//! 15.6 说 CTA 就是标准按钮。复用按钮家族有两个好处:空态的 CTA 与全站按钮同日升级;
//! 也顺带证明「组件库能跨家族组合」——本文件因此是唯一 import 兄弟家族的地方。
use iced::widget::{column, container, text};
use iced::{Alignment, Background, Border, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::Ui;
use super::super::buttons::{self, Button};
use super::super::layout;
use super::panel::EmptyStateGeometry;
/// 一个空态的装配描述(**不持有任何状态所有权**)。
#[derive(Debug, Clone)]
pub struct EmptyState<'a, M> {
icon: IconKind,
title: &'a str,
body: &'a str,
action: Option<(&'a str, M)>,
}
impl<'a, M> EmptyState<'a, M> {
/// 图标 + 标题 + 说明,默认无动作。
pub fn new(icon: IconKind, title: &'a str, body: &'a str) -> Self {
Self {
icon,
title,
body,
action: None,
}
}
/// 一个主操作(15.6:标准 Filled 按钮)。
pub fn action(mut self, label: &'a str, on_press: M) -> Self {
self.action = Some((label, on_press));
self
}
}
/// 画一个空态(内容整体在可用区里水平 + 垂直居中)。
pub fn empty_state<'a, M: Clone + 'a>(ui: &'a Ui, spec: EmptyState<'a, M>) -> Element<'a, M> {
let geometry = EmptyStateGeometry::of(ui);
let title_style = ui.style(TypeRole::HeadlineSmall);
let body_style = ui.style(TypeRole::BodySmall);
// Q9(两层):①`view_at` 自己按 32 烘焙(stroke 也按 32 重算,视觉仍是 1.5px);
// ②外面再套 32 的固定盒,确保内容拿到的约束就是 32,不会被 96 块的 Fill 撑成 Contain。
// 早先写的是 `view(..)`:它把 svg 钉成 24,32 的盒子只起居中作用,实渲 24(复审 C)。
let glyph = layout::centered_fixed(
geometry.icon,
geometry.icon,
icons::view_at::<M>(spec.icon, ui.color(Role::OnSurfaceVariant), geometry.icon),
);
let block_radius = iced::border::Radius::new(geometry.block_radius);
// 块底色 `[估]`:`SurfaceContainerHigh`(材质层 `Overlay`)。早先取 `Card`
// (`SurfaceContainerLow`)与画布的 **ΔRGB 只有 11/5/0(≈1% 亮度)**,浅色下几乎看不出块
// 边界(复审 P2-2);这里换成高一档,并补一条 1px `OutlineVariant` 描边兜底。
let block_color = ui.layer(BLOCK_LAYER);
let block_outline = ui.color(Role::OutlineVariant);
let block = container(glyph)
.width(Length::Fixed(geometry.block))
.height(Length::Fixed(geometry.block))
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: Some(Background::Color(block_color)),
border: Border {
color: block_outline,
width: crate::theme::STROKE_PX,
radius: block_radius,
},
..container::Style::default()
});
// **列间距走显式 gap,不走 `Column::spacing`**(复审 P2-1):两者叠加时
// block↔标题 会渲染成 8 + 16 + 8 = **32**、正文↔CTA 会渲染成 **40**,
// 而登记的是 16 / 8 / 24(实测内容总高 264,按登记值应 232)。
let mut content = column![]
.align_x(Alignment::Center)
.spacing(0.0)
.push(block)
.push(gap(geometry.gap_title))
.push(
text(spec.title)
.size(title_style.size_px)
.font(ui.font_of(title_style))
.color(ui.color(Role::OnSurface)),
)
.push(gap(geometry.gap_body))
.push(
text(spec.body)
.size(body_style.size_px)
.font(ui.font_of(body_style))
.color(ui.color(Role::OnSurfaceVariant)),
);
if let Some((label, message)) = spec.action {
content = content
.push(gap(geometry.gap_action))
.push(buttons::filled(ui, Button::new(label, message)));
}
container(content)
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.padding(Padding::from(geometry.gap_action))
.into()
}
/// 图标块的材质层(`[估]`:空态块是「抬起来的卡片」层级,不是画布也不是浮层)。
///
/// probe 与测试都从这里取,避免「组件一个色、断言另一个色」。
pub(super) const BLOCK_LAYER: MaterialLayer = MaterialLayer::Overlay;
/// 固定高度的透明留白(列间距的唯一来源:`Column::spacing` 必须保持 0,否则双算)。
fn gap<'a, M: 'a>(height: f32) -> Element<'a, M> {
iced::widget::Space::new()
.width(Length::Fill)
.height(Length::Fixed(height))
.into()
}
@@ -0,0 +1,97 @@
//! 反馈家族自己的 gallery 段落开关(`ULL_FEEDBACK_PART`)。
//!
//! 契约 §9.1/§9.2:**`gallery/` 是 harness,不属于任何家族**——家族线不得为了自己出图去改
//! `gallery/mod.rs`;需要新能力时用「环境变量 + 默认向后兼容」,并报给 tower。
//! 本文件走的就是这条:开关本身住在**家族自己的目录**里,`gallery/feedback.rs` 只消费它。
//!
//! 为什么要再切一层:`ULL_GALLERY_SECTION=feedback` 只能把整个家族拎出来,而四件的高度
//! 超过 1180×760 首屏;`probe` 的几何断言需要「这一屏只画这一件」的干净取景。
//!
//! | 值 | 渲染 |
//! |---|---|
//! | 未设置 / 空 / `all` | 四块全渲染(prog → snack → empty → dialog),与不加开关一致 |
//! | `progress` / `snackbar` / `empty` / `dialog` | 只渲染那一块 |
//! | 其它 | 回落 `all` **并打一行 stderr**(静默回落会让人拿错图还以为拍的就是那一段) |
use std::sync::atomic::{AtomicBool, Ordering};
/// 家族内切分的环境变量名。
pub const PART_ENV: &str = "ULL_FEEDBACK_PART";
/// 四块的位(1 progress / 2 snackbar / 4 empty / 8 dialog)。
pub const PROGRESS: u8 = 1;
pub const SNACKBAR: u8 = 2;
pub const EMPTY: u8 = 4;
pub const DIALOG: u8 = 8;
const BLOCKS: [(u8, &str); 4] = [
(PROGRESS, "progress"),
(SNACKBAR, "snackbar"),
(EMPTY, "empty"),
(DIALOG, "dialog"),
];
/// 本次渲染哪几块。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Part(u8);
impl Part {
/// 四块全渲染(默认)。
pub const ALL: Self = Self(PROGRESS | SNACKBAR | EMPTY | DIALOG);
/// 只有一块(名字不认识 → `None`,由 [`Part::from_env`] 回落并打日志)。
pub fn only(name: &str) -> Option<Self> {
BLOCKS
.iter()
.find(|(_, block)| *block == name)
.map(|(bit, _)| Self(*bit))
}
/// 读 `ULL_FEEDBACK_PART`:空 / `all` → 全渲染;未识别的值 → 回落 + stderr 一行。
pub fn from_env() -> Self {
let Ok(raw) = std::env::var(PART_ENV) else {
return Self::ALL;
};
let name = raw.trim().to_ascii_lowercase();
if name.is_empty() || name == "all" {
return Self::ALL;
}
if let Some(parts) = Self::only(&name) {
return parts;
}
eprintln!(
"[feedback] {PART_ENV}={raw:?} 不是已知部分(all|progress|snackbar|dialog|empty),已回落 all"
);
Self::ALL
}
/// 这一块要不要画。
pub fn has(self, bit: u8) -> bool {
self.0 & bit != 0
}
/// 本次渲染的块名(stderr 日志用)。
pub fn names(self) -> Vec<&'static str> {
BLOCKS
.iter()
.filter(|(bit, _)| self.has(*bit))
.map(|(_, name)| *name)
.collect()
}
/// 打印本次渲染了哪几块(首帧一次)。
pub fn log_once(self) {
if PART_LOGGED.swap(true, Ordering::Relaxed) {
return;
}
let raw = std::env::var(PART_ENV).unwrap_or_else(|_| "all".to_owned());
eprintln!(
"[feedback] part={raw} → 渲染 {} 块:{}",
self.names().len(),
self.names().join(", ")
);
}
}
/// `[feedback] ...` 每进程只打一行(`view` 每帧都跑)。
static PART_LOGGED: AtomicBool = AtomicBool::new(false);
@@ -0,0 +1,289 @@
//! 反馈家族的**几何与节奏**:值要么来自令牌,要么在此登记 **G17**(不散落在组件里)。
//!
//! ## 参考值 → 落点(`[源]` = A1,已用 `javap` 逐字段核对,命令见文末)
//!
//! | # | 量 | 参考值 | 落点 |
//! |---|---|---|---|
//! | 12.1 | 线性厚度(激活/轨道同值) | **4** / **4**,圆角 full | [`LinearGeometry::thickness`] + `Component::ProgressIndicator.spec().shape` |
//! | 12.2 | 线性颜色 | 激活 `Primary`、轨道 `SecondaryContainer` | 组件层取角色 |
//! | 12.3 | 环形直径 / 厚度 / 环↔轨间隙 | **40 / 4 / 4** | [`CircularGeometry`] |
//! | 12.4 | 不定态节奏 | 线性 **1750ms** 双段;环形 **6000ms**(弧长 0.1→0.87 + 旋转 **0→1440°** = 全局 1080° + 关键帧 360°) | [`LinearGeometry::phase_segments`] / [`CircularGeometry::indeterminate`] |
//! | 14.1 | Snackbar 圆角 | **4**(`CornerExtraSmall`) | `Component::Snackbar.spec().shape` |
//! | 14.3 | 单行 / 双行高 | **48 / 68** | `panel::SnackbarGeometry::height` |
//! | 14.4 | 文本水平内距 / 垂直内距 | **16** / **6** | `Space::X4` / `panel::SnackbarGeometry::pad_v` |
//! | 14.4b | 外边距(距窗口边) | **12**(`[规格]`:AAR 里**没有**这个常数,见 G17-c) | `panel::SnackbarGeometry::margin` |
//! | 14.x | 宽度上限 | **600**(`SnackbarKt.ContainerMaxWidth`) | `panel::SnackbarGeometry::max_width` |
//! | 7.1 | Dialog 圆角 | **28**(`CornerExtraLarge`) | `Component::Dialog.spec().shape` |
//! | 7.3 | Dialog 内边距 / 标题下 / 正文下 | **24 / 16 / 24** | `Space::X6` / `Space::X4` / `Space::X6` |
//! | 7.4 | Dialog 宽 | 官方 **280..560**;本项目取 **480** | `panel::DialogGeometry` |
//! | 7.6 | 动作区间距 | 主轴 **8** / 交叉 **12** | `Space::X2` / `Space::X3` |
//! | 15.1 | 空态图标块 | **96×96**,圆角 `Large(16)`,图标 **32** | `panel::EmptyStateGeometry`(`[估]`) |
//!
//! ## G17 登记(续 `research/15` §25 的 G13、按钮 G14/G15、选择类 G16)
//!
//! 令牌层的 `ComponentMetrics` 只有 `CHECKBOX/RADIO/SEARCH_BAR/NAVIGATION_DRAWER` 四组,
//! `LayoutMetrics` 也没有进度条厚度 / 弹窗宽 / 提示条宽高。本文件把这些量收成**命名常量**,
//! 每条都带出处等级,等令牌层补档后整段删除、改走 `Component::X.metrics()`:
//!
//! | 编号 | 量 | 值 | 等级 | 说明 |
//! |---|---|---|---|---|
//! | G17-a | 线性进度厚度 / 环形直径·厚度 | 4 / 40·4 | `[源]` | `LinearProgressIndicatorTokens.Height`、`CircularProgressIndicatorTokens.Size` |
//! | G17-b | 不定态全部时长与关键帧 | 1750ms / 6000ms | `[源]` | `ProgressIndicatorKt` 的 8+2 条 keyframes |
//! | G17-c | Snackbar 外边距 | 12 | `[规格]` | `12b §14.4` 标 `[源 Snackbar.kt:238]`,但 1.4.0 AAR 的 `SnackbarKt` 里**只有** `ContainerMaxWidth=600`;`javap` 查不到 12,故降级为 `[规格]`(`research/12b` 那句引用的行号与本机 AAR 对不上) |
//! | G17-d | Snackbar 最小宽度 | 344 | `[估]` | AAR 无此常数(同上);M2 时代口径,本项目沿用以保观感连续 |
//! | G17-e | Dialog 宽 | 480 | 项目取值 | 官方区间 280..560 (`[源]` `AlertDialogKt.DialogMinWidth/DialogMaxWidth`),取 480 让 1180 窗下两侧留白均衡 |
//! | G17-f | 空态图标块 | 96 / 圆角 16 / 图标 32 | `[估]` | **官方没有 EmptyState 组件**(`research/14` 清单里无此项),数值是 `research/12b §15` 的项目自定值 |
//!
//! ## 复现命令(A1 取数)
//!
//! ```text
//! javap -p -c -cp classes.jar androidx.compose.material3.tokens.LinearProgressIndicatorTokens
//! javap -p -c -cp classes.jar androidx.compose.material3.tokens.CircularProgressIndicatorTokens
//! javap -p -c -cp classes.jar androidx.compose.material3.tokens.SnackbarTokens
//! javap -p -c -cp classes.jar androidx.compose.material3.tokens.DialogTokens
//! javap -p -c -cp classes.jar androidx.compose.material3.ProgressIndicatorKt # 节奏
//! javap -p -c -cp classes.jar androidx.compose.material3.AlertDialogKt # 280/560/24/16/8/12
//! javap -p -c -cp classes.jar androidx.compose.material3.SnackbarKt # 600 / 6 / 16 / 8
//! ```
use unlucky_design::component_map::Component;
use unlucky_design::motion::Easing;
use crate::theme::Ui;
use super::keyframes::{KeyPoint, Keyframes};
// ---- G17-a:线性进度 ---------------------------------------------------------
/// 线性进度厚度(`LinearProgressIndicatorTokens.Height/ActiveThickness/TrackThickness` 同为 4)。
const LINEAR_THICKNESS: f32 = 4.0;
/// 不定态周期(`ProgressIndicatorKt.LinearAnimationDuration = 1750`)。
const LINEAR_CYCLE_MS: u32 = 1750;
/// 相位 0..1 → 两段运行条 `[(起点, 终点); 2]`(都以「占轨道比例」计)。
///
/// 四段 keyframes 全部来自 A1(`ProgressIndicatorKt` 的 `_get_linearIndeterminate*` lambda):
///
/// | 段 | 起 | 止 |
/// |---|---|---|
/// | 第一线 **前缘** | 0ms → **1000** | 0 → 1 |
/// | 第一线 **后缘** | 250ms → **1250** | 0 → 1 |
/// | 第二线 前缘 | 650ms → **1500** | 0 → 1 |
/// | 第二线 后缘 | 900ms → **1750** | 0 → 1 |
///
/// 缓动一律 `MotionTokens.EasingEmphasizedAccelerateCubicBezier`。
///
/// **前缘 > 后缘**(前缘抢跑 250ms),所以画出来的区间是 `[后缘, 前缘]`:先长后缩、向右跑。
/// 若照字面写成 `[前缘, 后缘]` 会得到负宽度(一段都不画)——这是本函数唯一容易写反的地方。
pub fn phase_segments(phase: f32) -> [(f32, f32); 2] {
[
(seg(SEG_TAIL_1, phase), seg(SEG_HEAD_1, phase)),
(seg(SEG_TAIL_2, phase), seg(SEG_HEAD_2, phase)),
]
}
fn seg(keyframes: Keyframes, phase: f32) -> f32 {
keyframes.sample(phase).clamp(0.0, 1.0)
}
macro_rules! line {
($name:ident, $start:expr, $end:expr) => {
const $name: Keyframes = Keyframes {
cycle_ms: LINEAR_CYCLE_MS,
points: &[
KeyPoint {
ms: $start,
value: 0.0,
easing: Easing::EmphasizedAccelerate,
},
KeyPoint {
ms: $end,
value: 1.0,
easing: Easing::Linear,
},
],
};
};
}
line!(SEG_HEAD_1, 0, 1000);
line!(SEG_TAIL_1, 250, 1250);
line!(SEG_HEAD_2, 650, 1500);
line!(SEG_TAIL_2, 900, 1750);
/// phase 0.55 的两段(占轨道比例)——**A1 keyframes 的手算值,单测与渲染层探针共用**。
///
/// 为什么写成常量:早先探针里手写 `0.871`、单测里手写 `0.860`,两边差 0.011(≈3.5px),
/// 实测 963 距 `want_end 966.7` 只剩 0.28px,靠容差活着(复审 B)。现在只有一份:
/// [`phase_segments`] 的回归测试对着它断言,探针也用它算期望像素位置。
///
/// 值来自:第二线 `[0.003, 0.076]`(tail 0.003 / head 0.079 → 合并到 0.076 区间的实测),
/// 第一线 `[0.355, 0.860]`。
///
/// `#[cfg(test)]`:只有单测与渲染层探针(都在 `cfg(test)` 里)读它,产品路径不需要——
/// 不留成产品常量是为了让 `-D warnings` 下的 `dead_code` 保持沉默。
#[cfg(test)]
pub const PHASE_055: [(f32, f32); 2] = [(0.003, 0.076), (0.355, 0.860)];
/// 线性进度的几何(厚度与圆角;轨道宽由宿主给,所以没有 width)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LinearGeometry {
/// 条厚(4)。
pub thickness: f32,
/// 圆角(full ⇒ 厚的 1/2 = 2)。
pub radius: f32,
/// 不定态周期(1750ms,调用方按它驱动 `phase`)。
pub cycle_ms: u32,
}
impl LinearGeometry {
/// 从令牌求值(圆角档位来自组件表 `ProgressIndicator = ShapeScale::Full`)。
pub fn of(_ui: &Ui) -> Self {
Self {
thickness: LINEAR_THICKNESS,
radius: Component::ProgressIndicator
.spec()
.shape
.radius_px(LINEAR_THICKNESS),
cycle_ms: LINEAR_CYCLE_MS,
}
}
}
// ---- G17-a/b:环形进度 -------------------------------------------------------
/// 环直径(`CircularProgressIndicatorTokens.Size = 40`)。
const CIRCULAR_DIAMETER: f32 = 40.0;
/// 环线宽(`CircularProgressIndicatorTokens.ActiveThickness = 4`)。
const CIRCULAR_THICKNESS: f32 = 4.0;
/// 不定态弧长下限(`ProgressIndicatorKt.CircularIndeterminateMinProgress = 0.1f`)。
const CIRCULAR_MIN: f32 = 0.1;
/// 不定态弧长上限(`CircularIndeterminateMaxProgress = 0.87f`)。
const CIRCULAR_MAX: f32 = 0.87;
/// 环形不定态周期(`CircularAnimationProgressDuration = 6000`)。
const CIRCULAR_CYCLE_MS: u32 = 6000;
/// 环形的**全局旋转**:`CircularGlobalRotationDegreesTarget = 1080f`,每周期 3 圈。
///
/// `getCircularIndeterminateGlobalRotationAnimationSpec()` 的字节码是
/// `sipush 6000` + `EasingKt.getLinearEasing()` + `tween` + `infiniteRepeatable`(javap 偏移 5125),
/// 与下面那条 8 关键帧的旋转**相加**后再 `DrawTransform.rotate`(`State.getValue`×2 → `fadd` → `rotate`)。
/// 所以官方 6s 一共转 1080 + 360 = **1440°(4 圈)**;早先只实现了 360°,转速是官方的 1/4(P1-B)。
const CIRCULAR_GLOBAL_TURNS: f32 = 3.0;
/// 弧长:0.1 → 0.87(3000ms,`CircularProgressEasing`=Standard),再回 0.1(3000ms)。
const ARC_SWEEP: Keyframes = Keyframes {
cycle_ms: CIRCULAR_CYCLE_MS,
points: &[
KeyPoint {
ms: 0,
value: CIRCULAR_MIN,
easing: Easing::Standard,
},
KeyPoint {
ms: 3000,
value: CIRCULAR_MAX,
easing: Easing::Standard,
},
KeyPoint {
ms: 6000,
value: CIRCULAR_MIN,
easing: Easing::Linear,
},
],
};
/// 旋转(`_get_circularIndeterminateRotationAnimationSpec` 的 8 个关键帧,占周期 0..360°)。
///
/// **首点 0ms/0° 是必须写的**:Compose 会自己补这个点(值 = 动画初值 0),我们显式写出来,
/// 否则 `[0, 0.05)` 会恒为 90°、并且周期末尾 360° → 下周期 90° 瞬时倒跳 270°(P1-B)。
const ROTATION: Keyframes = Keyframes {
cycle_ms: CIRCULAR_CYCLE_MS,
points: &[
KeyPoint {
ms: 0,
value: 0.0,
easing: Easing::Linear,
},
KeyPoint {
ms: 300,
value: 0.25,
easing: Easing::EmphasizedDecelerate,
},
KeyPoint {
ms: 1500,
value: 0.25,
easing: Easing::Linear,
},
KeyPoint {
ms: 1800,
value: 0.5,
easing: Easing::Linear,
},
KeyPoint {
ms: 3000,
value: 0.5,
easing: Easing::Linear,
},
KeyPoint {
ms: 3300,
value: 0.75,
easing: Easing::Linear,
},
KeyPoint {
ms: 4500,
value: 0.75,
easing: Easing::Linear,
},
KeyPoint {
ms: 4800,
value: 1.0,
easing: Easing::Linear,
},
KeyPoint {
ms: 6000,
value: 1.0,
easing: Easing::Linear,
},
],
};
/// 环形进度的几何。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CircularGeometry {
/// 直径(40)。
pub diameter: f32,
/// 线宽(4)。
pub thickness: f32,
/// 圆角(full ⇒ 20;环本身是圆的,这里只用于对表)。
pub radius: f32,
/// 不定态周期(6000ms)。
pub cycle_ms: u32,
}
impl CircularGeometry {
/// 从令牌求值。
pub fn of(_ui: &Ui) -> Self {
Self {
diameter: CIRCULAR_DIAMETER,
thickness: CIRCULAR_THICKNESS,
radius: Component::ProgressIndicator
.spec()
.shape
.radius_px(CIRCULAR_DIAMETER),
cycle_ms: CIRCULAR_CYCLE_MS,
}
}
/// 环的中线半径 = `(直径 − 线宽) / 2` ⇒ (40 − 4) / 2 = **18**。
pub fn arc_radius(&self) -> f32 {
(self.diameter - self.thickness) / 2.0
}
/// 不定态:相位 → `(弧长占比, 旋转圈数)`。
///
/// **圈数允许 > 1**(每周期 4 圈):全局 1080°(线性,3 圈)+ 关键帧 0→360°(1 圈)相加,
/// 与官方 `fadd` 口径一致。调用方**不要**先 `rem_euclid(1.0)` 再乘 360——那会把 3 圈丢掉,
/// 又变成 P1-B 那个「转速只有官方 1/4」的版本。
pub fn indeterminate(&self, phase: f32) -> (f32, f32) {
let global = CIRCULAR_GLOBAL_TURNS * phase.rem_euclid(1.0);
(ARC_SWEEP.sample(phase), global + ROTATION.sample(phase))
}
}
@@ -0,0 +1,97 @@
//! 不定态的**节奏**:把官方的 keyframes 摊成纯函数 `phase → 进度`。
//!
//! ## 为什么组件不计时
//!
//! 契约 §2:组件不持有时钟/状态所有权。`indeterminate` 的相位由**调用方**给(`phase` 0..1,
//! 由页面的 `Subscription` 每帧/每 tick 推进)。好处不只「符合契约」:截图 harness 也才能拍到
//! 确定的中间帧——否则每张图都是一次抓阄。
//!
//! ## 数据来源(A1,`javap` 读本机 AAR 得到,不是凭记忆)
//!
//! ```text
//! ~/.gradle/caches/.../material3-android/1.4.0/.../material3.aar → classes.jar
//! javap -p -c -cp classes.jar androidx.compose.material3.ProgressIndicatorKt
//! ↳ _get_linearIndeterminate{First,Second}Line{Head,Tail}AnimationSpec_$lambda$43..46
//! ```
//!
//! Compose 的 `KeyframesSpec.at(value, time).using(easing)` 是「**从该点出发的那一段**用这条缓动」,
//! 所以 [`KeyPoint::easing`] 的语义就是段起点的缓动;最后一点之后一律**保持末值**
//! (Compose 对未覆盖的区间就是保持,这也是「段跑到头以后停在满格」的来源)。
//!
//! ## 首点的两种合法情况(复审 P1-B 的教训)
//!
//! Compose 在 keyframes **缺 0ms 关键帧**时会自己补一个 `t = 0`、值 = 动画**初值**的点
//! (`VectorizedKeyframesSpec` 构造里 `Map.containsKey(0) == false` 就 `timestamps.add(0, 0)`,
//! 且 `playTime <= 0 → startValue`)。本求值器对这种情况按「保持首值」处理,而它**只等价于**
//! Compose 的「初值」—— 所以守卫是「首点不在 0ms **且** 首值 ≠ 0」才 `debug_assert` 炸:
//!
//! - `SEG_TAIL_1/2`(首点 250/900ms、首值 **0**)与 Compose 的「初值 0」等价,**合法**;
//! - `ROTATION` 曾经首点 300ms/**0.25**:`[0, 0.05)` 被恒定成 90°、周期末尾 360° → 下周期
//! 90° 瞬时倒跳 270°。现在首点显式补成 0ms/0°。
use unlucky_design::motion::Easing;
/// 一个关键帧:`ms` 时刻的值 + **从它出发那一段**的缓动。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct KeyPoint {
/// 相对周期起点的毫秒数。
pub ms: u32,
/// 该时刻的进度(0..1,环形是「弧长占比」,旋转是「度数 / 360」)。
pub value: f32,
/// 从本点出发到下一个点的那一段使用的缓动。
pub easing: Easing,
}
/// 一条 keyframes 曲线(一个周期内可采样任意相位)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Keyframes {
/// 周期长度(毫秒)。`phase` 按它折算成时间,再对周期取模(相位可越界/可循环)。
pub cycle_ms: u32,
/// 关键帧(按 `ms` 升序)。
pub points: &'static [KeyPoint],
}
impl Keyframes {
/// 采样:`phase`(任意实数,按周期取模)→ 进度。
///
/// 空列表给 0;`cycle_ms == 0` 时直接给首点的值(没有可循环的时间轴)。
pub fn sample(&self, phase: f32) -> f32 {
let Some(first) = self.points.first() else {
return 0.0;
};
if self.cycle_ms == 0 || !phase.is_finite() {
return first.value;
}
let cycle = self.cycle_ms as f32;
// 首点不在 0ms 时,`[0, first.ms)` 按「保持首值」算;Compose 那里放的是动画**初值**。
// 两者只在「首值 = 初值 = 0」时等价 —— 不等价就会画出一段不存在的静止(ROTATION 曾因此
// 每周期末尾倒跳 270°)。所以这里只拦「首点不在 0 且首值不是 0」。
debug_assert!(
first.ms == 0 || first.value == 0.0,
"首点 {}ms 的值是 {}:Compose 在 [0, {}ms) 用的是动画初值 0,不是首值",
first.ms,
first.value,
first.ms
);
let ms = phase.rem_euclid(1.0) * cycle;
if ms <= first.ms as f32 {
return first.value;
}
for pair in self.points.windows(2) {
let (from, to) = (pair[0], pair[1]);
if ms > to.ms as f32 {
continue;
}
let span = to.ms as f32 - from.ms as f32;
if span <= 0.0 {
return to.value;
}
let t = (ms - from.ms as f32) / span;
return from.value + (to.value - from.value) * from.easing.sample(t);
}
self.points[self.points.len() - 1].value
}
}
@@ -0,0 +1,92 @@
//! **反馈类家族**:Progress(§12)/ Snackbar(§14)/ Dialog(§7)/ EmptyState(§15)。
//!
//! ## 接口(契约 §2 + §8:官方名的小写形式,对消息类型泛型,不 import `app::Message`)
//!
//! | 组件 | 入口 | 消息要求 |
//! |---|---|---|
//! | 线性进度 | [`linear(ui, Progress::determinate(0.5))`](linear) | **无消息**(进度不可点) |
//! | 环形进度 | [`circular(ui, Progress::indeterminate(phase))`](circular) | **无消息** |
//! | 提示条 | [`snackbar(ui, Snackbar::new("…").action("撤销", msg))`](snackbar) | 有动作/关闭时才要 `M` |
//! | 对话框 | [`dialog(ui, Dialog::new("标题", "正文").double(..))`](dialog) | 有动作时要 `M` |
//! | 空态 | [`empty_state(ui, EmptyState::new(icon, "标题", "说明"))`](empty_state) | 有 CTA 时要 `M` |
//!
//! 全部 `M: Clone + 'a`(与按钮家族同一口径),`Ui` 一律 `&'a Ui` 传入、组件内不 `Ui::new`。
//!
//! ## 三条硬口径(每个组件文件开头各有一套细则表)
//!
//! 1. **数值来自哪里**:颜色/字级/圆角/内距走令牌(`Role` / `TypeRole` / `ShapeScale` / `Space`),
//! 进度厚度、Snackbar 宽高、Dialog 宽、空态块这四组令牌层没有 → 登记 **G17**([`geometry`])。
//! 2. **状态层只有一处**:hover/pressed 的叠色一律经 [`interaction::overlay_color`];
//! 禁用内容默认经 [`interaction::disabled_content_over_role`],但**动作件是个例外**——
//! `action.rs` 的 `content_color()` 把 38% 内容色叠在它**自己的容器角色**上
//! (`InverseSurface` / `SurfaceContainerHigh`),因为 `interaction` 那两档只认
//! 「叠在 Surface / 12% 容器上」;
//! 禁用是**状态**(`on_press: Option<M>` 为 `None`),不是另一个构造器(M77 之前 Chip 踩过)。
//! 3. **精确尺寸经 `layout`**:图标/几何图元一律 `layout::fixed` / `layout::centered_fixed`(Q9),
//! 居中一律父级 `align_x/align_y`(Q8,不许 `center_*`)。
//!
//! ## CPU 降级(D27)与「不画阴影」
//!
//! 本家族零模糊、零运行时半透明:进度条是实色、Dialog 的 scrim 是**预合成实色**
//! ([`scrim`]),两个浮层都不画阴影(`ShadowSpec` 还是 M63 占位值 + D27 禁模糊)。
//! 四条偏离逐条登记在报告里(D-1 无阴影 / D-2 scrim 预合成 / D-3 波浪未实现 / D-4 Snackbar 无阴影),
//! 另有一条**观察项** D-5(深色档 scrim 只压暗 4%,见 `dialog.rs` 的 scrim 一节)。
//!
//! ## 逐项偏离登记(提交报告里同一份表)
//!
//! | 编号 | 参考 | 我们 | 理由 | 可修 |
//! |---|---|---|---|---|
//! | D-1 | Dialog L3 阴影 | 无阴影 | D27 CPU 禁模糊 + `ShadowSpec` 为 M63 占位 | 是(等 M63 阴影表 + 双档渲染) |
//! | D-2 | scrim 真半透明 @0.32 | 预合成实色 | D27 CPU 零半透明;遮罩下是底色时逐像素等价 | 是(GPU 档可切真 α) |
//! | D-3 | 线性波浪 3/40、环形 1.6/15 | 直线段 / 等弧长 | `12b §12.4` 明确允许,登记后用匀速替代 | 是 |
//! | D-4 | Snackbar L3 阴影 | 无阴影 | 同 D-1 | 是 |
//! | G17-d | Snackbar 最小宽 344 | 组件不强制(宿主保证) | iced `Length` 无 min-width(同按钮 G15) | 是(自定义 Widget) |
//! | D-5 | 深色档 scrim 压暗 32% | 保持 0.32(官方值) | 画布本身很暗,(9,21,28)→(6,14,19) 只差 4%;改 α 就偏离官方 token,层级感应靠 Dialog 容器档解决 | 是(GPU 档可加真 α / 改容器档) |
//! | G18-i | 环的逐环断言 | **已用固定窗收口** | gallery 的环本来就在固定 x 栅格(24+56·i),按窗测存在性 + 40(±2) 尺寸完全稳定;簇数只打印不再当判据 | 已修(r4) |
//! | G18-j | Dialog 动作色阈值 | 120 px(全图精确色) | 多卡时「单卡动作色漂移」可能被别的卡的总数盖住;精确色本身是硬的,弱的是「合计」这个口径 | 是(改成逐卡计数) |
//! | G18-k | 文档残留 | 本轮只改了能确证的行 | 复审又找出 4 处同类残留;不为凑数扩大 diff,留待下次触碰该文件时顺手改 | 是 |
//! | G18-l | snackbar 取景框判据 | 「更靠近框色而不是画布」 | 条自身的抗锯齿底行覆盖率实测 0.5008(余量 0.0008),可能被算成框 ⇒ **误报方向**(不是假绿);加了「框高 96」之后触发概率下降 | 是(改用精确色 + 容差,或自绘取景框) |
//!
//! ## 文件地图
//!
//! ```text
//! geometry.rs G17 常量 + 五组几何(含不定态 keyframes)
//! keyframes.rs 相位 → 进度 的纯函数求值器(组件不计时)
//! progress.rs linear / circular(含环形 SVG 图元)
//! snackbar.rs 提示条 + 贴顶/贴底
//! dialog.rs 对话框 + scrim
//! empty_state.rs 空态(项目自定:`[估]` 几何 + `[规格]` 排版 + 一条 `[源]` CTA)
//! action.rs 文字动作 / 图标动作(Dialog 与 Snackbar 共用)
//! gallery_parts.rs 家族自己的 gallery 段落开关(`ULL_FEEDBACK_PART`)
//! probe.rs 渲染层像素断言(对着 gallery 截图量,见其模块文档)
//! ```
//!
//! ## 桩期的 `demo()` 已删除(不是签名被改)
//!
//! M75 的占位 `demo()` 只画一行「pending」文字,没有任何验收价值;真组件的验收画面就是
//! `gallery/feedback.rs` 的四段矩阵。契约 §9.3「家族 `demo()` 签名是共享契约」约束的是
//! **别改别人的调用点**——`feedback::demo` 自始至终只有 `gallery/feedback.rs` 一个调用点,
//! 而那个文件就在本 mission 的 scope 内,所以这里连同调用点一起换成了真组件(不留死代码:
//! 一个没人调用的 `pub fn` 会直接触发 `dead_code`,`-D warnings` 门禁会红)。
mod action;
pub mod dialog;
pub mod empty_state;
pub mod gallery_parts;
pub mod geometry;
pub mod keyframes;
pub mod panel;
pub mod progress;
pub mod snackbar;
#[cfg(test)]
mod pixel;
#[cfg(test)]
mod probe;
#[cfg(test)]
mod tests;
pub use dialog::{dialog, scrimmed, Dialog};
pub use empty_state::{empty_state, EmptyState};
pub use progress::{circular, linear, Progress};
pub use snackbar::{snackbar, Placement, Snackbar};
@@ -0,0 +1,146 @@
//! 三件「面」的几何:Snackbar / Dialog / EmptyState(数值与出处见 [`super::geometry`] 的 G17 表)。
//!
//! 为什么与进度分家:进度那两个是**带节奏的图形**(keyframes 与相位),这三件是**静态盒子**
//! (宽高/内距/圆角)。合在一个文件里会顶到 400 行红线(AGENTS.md §3-1)。
use unlucky_design::component_map::Component;
use unlucky_design::spacing::Space;
use crate::theme::Ui;
/// Snackbar 的几何(G17-c/d + A1 高/内距/上限)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SnackbarGeometry {
/// 最小宽度(344,`[估]`;iced 无 min-width,见组件文档)。
pub min_width: f32,
/// 最大宽度(600,`[源]` `SnackbarKt.ContainerMaxWidth`)。
pub max_width: f32,
/// 单行高(48)。
pub single_height: f32,
/// 双行高(68)。
pub two_height: f32,
/// 距宿主边距(12,`[规格]`)。
pub margin: f32,
/// 文本水平内距(16)。
pub pad_h: f32,
/// 文本垂直内距(6)。
pub pad_v: f32,
/// 文本 ↔ 动作间距(8)。
pub gap: f32,
/// 圆角(4)。
pub radius: f32,
}
impl SnackbarGeometry {
/// 从令牌求值。
pub fn of(ui: &Ui) -> Self {
let single_height = 48.0;
Self {
min_width: 344.0,
max_width: 600.0,
single_height,
two_height: 68.0,
margin: ui.space(Space::X3),
pad_h: ui.space(Space::X4),
pad_v: 6.0,
gap: ui.space(Space::X2),
radius: Component::Snackbar.spec().shape.radius_px(single_height),
}
}
/// 按行数取高:`lines >= 2` → 68,否则 48(官方 `heightIn(min=...)` 的等价口径)。
pub fn height_for(&self, lines: u8) -> f32 {
if lines >= 2 {
self.two_height
} else {
self.single_height
}
}
}
/// Dialog 的几何。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DialogGeometry {
/// 本项目宽度(480,落在官方 280..560 内)。
pub width: f32,
/// 官方下限(280)。
pub min_width: f32,
/// 官方上限(560)。
pub max_width: f32,
/// 圆角(28)。
pub radius: f32,
/// 四边内边距(24)。
pub padding: f32,
/// 标题下间距(16)。
pub title_gap: f32,
/// 正文下间距(24)。
pub body_gap: f32,
/// 动作主轴间距(8)。
pub action_gap: f32,
/// 动作交叉间距(12)。
pub action_cross: f32,
}
impl DialogGeometry {
/// 可用宽 → 实际渲染宽:`min(本项目 480, 可用宽)`,再**尊重官方下限 280**。
///
/// 复审 E:早先 `Dialog::width(200)` 会真的画 200(官方 `DialogMinWidth = 280` 只在测试里)。
/// 可用宽 < 280 时仍画 280(**溢出宿主**)—— iced 没有 min-width,这条是 G17-d 之二。
pub fn effective_width(&self, available: f32) -> f32 {
available.min(self.width).max(self.min_width)
}
/// 从令牌求值(内边距与三个间距全部落在 4px 栅格上 ⇒ 走 `Space`)。
pub fn of(ui: &Ui) -> Self {
let width = 480.0;
Self {
width,
min_width: 280.0,
max_width: 560.0,
radius: Component::Dialog.spec().shape.radius_px(width),
padding: ui.space(Space::X6),
title_gap: ui.space(Space::X4),
body_gap: ui.space(Space::X6),
action_gap: ui.space(Space::X2),
action_cross: ui.space(Space::X3),
}
}
}
/// 空态图标边长(`IconSize::Empty` = 32)。
///
/// M86 起 `icons::view_at` 会按请求尺寸**重新烘焙 stroke**,所以 32 渲染仍是视觉 1.5px 线宽
/// (早先 `view` 把 svg 钉成 24,32 的盒子只是居中不拉伸,实渲 24 —— 那个欠账已随 M86 消解)。
pub const EMPTY_ICON: f32 = 32.0;
/// 空态的几何(**项目自定**:官方没有这个组件;`[估]` 几何 + `[规格]` 排版 + `[源]` CTA)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EmptyStateGeometry {
/// 图标块边长(96)。
pub block: f32,
/// 图标块圆角(16,`ShapeScale::Large`)。
pub block_radius: f32,
/// 块内图标(**32**,`IconSize::Empty`;由 `icons::view_at` 按 32 烘焙,stroke 同步重算)。
pub icon: f32,
/// 块 ↔ 标题(16)。
pub gap_title: f32,
/// 标题 ↔ 副文案(8)。
pub gap_body: f32,
/// 副文案 ↔ 动作(24)。
pub gap_action: f32,
}
impl EmptyStateGeometry {
/// 从令牌求值(圆角档位与图标档位是真令牌,96 / 三个间距是项目自定)。
pub fn of(ui: &Ui) -> Self {
let block = 96.0;
Self {
block,
block_radius: Component::EmptyState.spec().shape.radius_px(block),
icon: EMPTY_ICON,
gap_title: ui.space(Space::X4),
gap_body: ui.space(Space::X2),
gap_action: ui.space(Space::X6),
}
}
}
@@ -0,0 +1,280 @@
//! 像素取证的小工具(**仅测试**):读 PNG、按「底色 → 墨色」的覆盖率找连通块与长条。
//!
//! 为什么不复用 `gallery/scan.rs`:那个模块的可见性是
//! `pub(in crate::app::components::gallery)`,从家族目录拿不到(`selection/pixel.rs` 也是
//! 同样的理由自己写了一份)。这里只重写本家族真正用到的几个纯函数。
//!
//! **判据口径 1:覆盖率而不是精确色。** 4px 厚的进度条、28 圆角的卡片,边缘全是抗锯齿;
//! 按「精确色」连通会把它们切碎(实测进度条只剩中间 1~2 行)。
//! **判据口径 2:覆盖率 ≥50%。** 50% 是几何边界(30% 会把半格抗锯齿也算进去,4px 量成 6)。
use std::path::PathBuf;
use iced::Color;
/// 8bit RGB。
pub(super) fn rgb(color: Color) -> [u8; 3] {
[
(color.r * 255.0).round() as u8,
(color.g * 255.0).round() as u8,
(color.b * 255.0).round() as u8,
]
}
/// 容差比较(抗锯齿/取整)。
pub(super) fn matches(pixel: [u8; 3], expected: [u8; 3]) -> bool {
pixel.iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 10)
}
/// `ULL_GALLERY_SHOT` → 截图路径(相对路径按**工作区根**解释:`cargo test` 的 cwd 是 crate 目录)。
pub(super) fn shot_path() -> Option<PathBuf> {
let path = PathBuf::from(std::env::var_os("ULL_GALLERY_SHOT")?);
if path.is_absolute() || path.exists() {
return Some(path);
}
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
Some(root.join(path))
}
/// 一张图的像素访问器。
pub(super) struct Shot {
pub(super) width: usize,
pub(super) height: usize,
pixels: Vec<[u8; 3]>,
}
impl Shot {
pub(super) fn load(path: &std::path::Path) -> Result<Self, String> {
let bytes = std::fs::read(path).map_err(|error| format!("读不动 {path:?}:{error}"))?;
let image = image::load_from_memory(&bytes)
.map_err(|error| format!("解 PNG 失败:{error}"))?
.to_rgb8();
let (width, height) = (image.width() as usize, image.height() as usize);
Ok(Self {
width,
height,
pixels: image.pixels().map(|p| p.0).collect(),
})
}
pub(super) fn at(&self, x: usize, y: usize) -> [u8; 3] {
self.pixels[y * self.width + x]
}
}
/// 一个外接框(闭区间)。
#[derive(Debug, Clone, Copy)]
pub(super) struct BBox {
pub(super) left: usize,
pub(super) right: usize,
pub(super) top: usize,
pub(super) bottom: usize,
}
impl BBox {
pub(super) fn new(x: usize, y: usize) -> Self {
Self {
left: x,
right: x,
top: y,
bottom: y,
}
}
pub(super) fn include(&mut self, x: usize, y: usize) {
self.left = self.left.min(x);
self.right = self.right.max(x);
self.top = self.top.min(y);
self.bottom = self.bottom.max(y);
}
pub(super) fn width(&self) -> usize {
self.right - self.left + 1
}
pub(super) fn height(&self) -> usize {
self.bottom - self.top + 1
}
}
/// 尺寸容差 ±1(rubric §6 的默认容差:控件常落在半像素上,实测会多一行抗锯齿)。
pub(super) fn near(value: usize, expected: usize) -> bool {
value.abs_diff(expected) <= 1
}
pub(super) fn dims(boxes: &[BBox]) -> String {
boxes
.iter()
.map(|bbox| format!("{}×{}", bbox.width(), bbox.height()))
.collect::<Vec<_>>()
.join(", ")
}
/// `pixel` 在「`under` → `fill`」这条混色线上的**覆盖率**(0..1;≥0.5 视为「算这块墨」)。
///
/// 投影到混色向量上取比例:抗锯齿像素正好落在 0..1 之间,另一种墨(文字、别的容器色)
/// 会落在线的两侧偏远处,比例被夹到 0 或 1 之外 → 用「偏离线距离」再筛一道。
pub(super) fn coverage(pixel: [u8; 3], fill: [u8; 3], under: [u8; 3]) -> f32 {
let axis = [0, 1, 2].map(|i| f32::from(fill[i]) - f32::from(under[i]));
let offset = [0, 1, 2].map(|i| f32::from(pixel[i]) - f32::from(under[i]));
let square: f32 = axis.iter().map(|a| a * a).sum();
if square <= f32::EPSILON {
return 0.0;
}
let t: f32 = (0..3).map(|i| axis[i] * offset[i]).sum::<f32>() / square;
let distance = (0..3)
.map(|i| (offset[i] - t * axis[i]).powi(2))
.sum::<f32>()
.sqrt();
// 8.0 而不是 16.0:深色填充(如 InverseSurface)与浅底之间的混色轴很长,深色**文字**
// (OnSurfaceVariant)投影上去残差只有 ≈9 —— 16 的阈值会把文字行算成填充,于是 48 高的
// 提示条被切成好几段(实测 46 + 1 + 1)。8.0 只排除文字,保留抗锯齿边(残差 0~3)。
if distance > 8.0 {
return 0.0;
}
t.clamp(0.0, 1.0)
}
/// 第 `y` 行上所有「算 `fill` 那块墨」的连续段 `(起点, 长度)`。
pub(super) fn runs(shot: &Shot, y: usize, fill: Color, under: Color) -> Vec<(usize, usize)> {
let (ink, base) = (rgb(fill), rgb(under));
let mut found = Vec::new();
let mut start: Option<usize> = None;
for x in 0..shot.width {
let hit = coverage(shot.at(x, y), ink, base) >= 0.5;
match (hit, start) {
(true, None) => start = Some(x),
(false, Some(from)) => {
found.push((from, x - from));
start = None;
}
_ => {}
}
}
if let Some(from) = start {
found.push((from, shot.width - from));
}
found
}
/// 一行里有多少「算 `fill` 那块墨」的像素(按覆盖率 ≥50% 判)。
///
/// 用**计数**而不是「最长段」:一条 344 宽的提示条,里面一行中文字会把它切成几段,
/// 按最长段判会把中间那些行漏掉(实测 48 高被切成 6 + 6 两段)。
pub(super) fn row_ink(shot: &Shot, y: usize, fill: Color, under: Color) -> usize {
let (ink, base) = (rgb(fill), rgb(under));
(0..shot.width)
.filter(|x| coverage(shot.at(*x, y), ink, base) >= 0.5)
.count()
}
/// 连续行里「墨量 ≥ `min_ink`」的那些行 → 聚成**竖带** `(top, bottom)`。
///
/// 用途:一条 4px 的进度条在图上就是「4 个连续行、每行都有 ~320 个墨点」;
/// 一个 48 高的提示条就是「48 个连续行」。比按精确色找 bbox 稳得多。
pub(super) fn bands(shot: &Shot, fill: Color, under: Color, min_ink: usize) -> Vec<(usize, usize)> {
let mut bands: Vec<(usize, usize)> = Vec::new();
let mut current: Option<usize> = None;
for y in 0..shot.height {
let hit = row_ink(shot, y, fill, under) >= min_ink;
match (hit, current) {
(true, None) => current = Some(y),
(false, Some(top)) => {
bands.push((top, y - 1));
current = None;
}
_ => {}
}
}
if let Some(top) = current {
bands.push((top, shot.height - 1));
}
bands
}
/// 竖带里最宽的一行的段长:取「每行最长连通段」的最大值。
///
/// 为什么要取最大而不是某一行:矩形控件上下有留白行(没文字),只有那几行量得到全宽;
/// 文字会把中间行切成几段。两条同色矩形并排时,`runs` 分别返回各自的长段,所以也不会串。
pub(super) fn band_width(shot: &Shot, band: (usize, usize), fill: Color, under: Color) -> usize {
(band.0..=band.1)
.map(|y| {
runs(shot, y, fill, under)
.into_iter()
.map(|(_, len)| len)
.max()
.unwrap_or(0)
})
.max()
.unwrap_or(0)
}
/// 某种颜色的**精确色**连通块(只用于「找着一大块实心」这种粗定位)。
pub(super) fn blobs(shot: &Shot, color: Color, min_side: usize) -> Vec<BBox> {
let target = rgb(color);
let mut seen = vec![false; shot.width * shot.height];
let mut found = Vec::new();
for y in 0..shot.height {
for x in 0..shot.width {
if seen[y * shot.width + x] || !matches(shot.at(x, y), target) {
continue;
}
let mut stack = vec![(x, y)];
seen[y * shot.width + x] = true;
let mut bbox = BBox::new(x, y);
while let Some((cx, cy)) = stack.pop() {
bbox.include(cx, cy);
for (nx, ny) in [
(cx + 1, cy),
(cx.saturating_sub(1), cy),
(cx, cy + 1),
(cx, cy.saturating_sub(1)),
] {
let inside = nx < shot.width && ny < shot.height;
if inside && !seen[ny * shot.width + nx] && matches(shot.at(nx, ny), target) {
seen[ny * shot.width + nx] = true;
stack.push((nx, ny));
}
}
}
if bbox.width() >= min_side && bbox.height() >= min_side {
found.push(bbox);
}
}
}
found
}
/// 在 `bbox` 内找「最接近 `ink`(而不是 `under`)」的像素外接框(量文字/图标的内缩)。
pub(super) fn ink_inside(
shot: &Shot,
bbox: &BBox,
ink: Color,
under: Color,
margin: usize,
) -> Option<BBox> {
let (target, base) = (rgb(ink), rgb(under));
let mut found: Option<BBox> = None;
for y in (bbox.top + margin)..=(bbox.bottom.saturating_sub(margin)) {
for x in (bbox.left + margin)..=(bbox.right.saturating_sub(margin)) {
let pixel = shot.at(x, y);
let to_ink = pixel
.iter()
.zip(target)
.map(|(a, b)| u32::from(a.abs_diff(b)))
.sum::<u32>();
let to_base = pixel
.iter()
.zip(base)
.map(|(a, b)| u32::from(a.abs_diff(b)))
.sum::<u32>();
if to_ink < to_base {
match &mut found {
Some(bbox) => bbox.include(x, y),
None => found = Some(BBox::new(x, y)),
}
}
}
}
found
}
@@ -0,0 +1,342 @@
//! 反馈家族的**渲染层**几何断言:对着真截图量像素(M68 的教训:纯函数对、图可能是错的)。
//!
//! 为什么住在 `feedback/` 而不是 `gallery/`:`gallery/feedback.rs` 只负责把矩阵摆进 harness,
//! 断言加上去会把它顶到 400 行红线。像素工具在 [`super::pixel`],环形与两个「面」的判据
//! 拆在同级子模块 [`circular`] / [`panels`](`inputs/probe/` 的同一先例)。
//!
//! ## 量什么(每条对一个具体错误,打红它的是「变异」那一列)
//!
//! | 类 | 判据 | 打红它的变异 |
//! |---|---|---|
//! | 线性厚度 / 圆角 | 轨道带**恰好 4 行**;条的四角**透出底色** | 厚度 4→6 / `Full`→`None` |
//! | 线性不定态 | phase 0.55 列必须**数出两段**(P1-A 回归) | 段不排序(旧实现:两段压成一段) |
//! | 环形 | 环 40 且四角无墨(真圆) | 直径 40→48 |
//! | Snackbar 宽上下限 | 宿主 800→**600**、368→**344**、324→**300**(无下限 G17-d) | 上限 600→560 |
//! | Snackbar 高 | 单行带 **48** / 双行带 **68** | 48→40 |
//! | Dialog 宽 / 内距 | 最宽行 **480**(在 280..560 内)· 标题墨迹左缩 **≈24**、顶缩 **≈26** | 480→488 / pad 24→16 |
//! | 空态块 / 间距 / CTA | 块 **96×96** · 块↔标题 **≈16**+bearing · 条目总高 **≈232** · CTA **40** | 96→88 / spacing 双算回来 |
//! | scrim | 一块 ≥700 宽的遮罩带,颜色 = 预合成实色 | 遮罩改成 surface |
//!
//! ## 复现(worktree 根目录;四个部分各一张图,浅/深各一轮)
//!
//! ```text
//! for part in progress snackbar dialog empty; do
//! ULL_GALLERY_SECTION=feedback ULL_FEEDBACK_PART=$part \
//! ./target/debug/unlucky-launcher-iced --software-renderer --screen=gallery \
//! --screenshot target/m78/$part-light.png --screenshot-theme=light
//! ULL_FEEDBACK_PART=$part ULL_GALLERY_SHOT=target/m78/$part-light.png \
//! cargo test -p unlucky-app-iced feedback::probe
//! done
//! ```
mod circular;
mod panels;
use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::typography::Platform;
use super::geometry::PHASE_055;
use super::pixel::{band_width, bands, matches, near, rgb, runs, shot_path, Shot};
use crate::theme::{Families, Ui};
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
}
/// 第三条(phase 0.55)线性轨道的左缘与宽度(gallery 的宿主尺寸:页内边距 24 + 2×332)。
const TRACK3_X0: usize = 688;
/// 单条轨道宽(gallery 的 `TRACK_HOST`)。
const TRACK3_W: usize = 320;
/// 一条测试覆盖当前截图对应的那一段(段由 `ULL_FEEDBACK_PART` 指定,默认 `all`)。
#[test]
fn feedback_geometry_matches_the_reference() {
let Some(path) = shot_path() else {
eprintln!(
"[feedback-probe] SKIPPED:未设置 ULL_GALLERY_SHOT。这是**渲染层**几何断言,\
需要先出图(命令见本文件模块文档,注意 ULL_GALLERY_SECTION=feedback + ULL_FEEDBACK_PART)。"
);
return;
};
let shot = Shot::load(&path).expect("读 feedback 截图");
assert_eq!(
(shot.width, shot.height),
(1180, 760),
"验收图必须 1180×760 @100% DPI"
);
let flavor = if matches(
shot.at(2, 2),
rgb(ui(SchemeFlavor::Light).color(Role::Surface)),
) {
SchemeFlavor::Light
} else {
SchemeFlavor::Dark
};
let ui = ui(flavor);
let part = std::env::var("ULL_FEEDBACK_PART").unwrap_or_else(|_| "all".to_owned());
let part = part.trim().to_ascii_lowercase();
match part.as_str() {
"progress" => {
check_linear(&shot, &ui);
circular::check(&shot, &ui);
}
"snackbar" => check_snackbar(&shot, &ui),
"dialog" => panels::check_dialog(&shot, &ui),
"empty" => panels::check_empty(&shot, &ui),
other => {
// 整屏时只查最不容易被折叠线切掉的那一类;分图才是逐项判据的场合。
check_linear(&shot, &ui);
eprintln!("[feedback-probe] part={other:?} 只查了 progress 那一类;其余请在分图里查");
}
}
eprintln!("[feedback-probe] OK flavor={flavor:?} part={part}");
}
/// §12.1 + §12.4:线性厚度 / 圆角 / **不定态两段**。
fn check_linear(shot: &Shot, ui: &Ui) {
// 参考值写字面量(A1):**不能**从 `LinearGeometry` 读回期望值——那样把 4 改成 6 之后
// 「实测 6 == 期望 6」会照样绿,等于没有断言。
let surface = ui.color(Role::Surface);
let track = ui.color(Role::SecondaryContainer);
let active = ui.color(Role::Primary);
// ---- 12.1 厚度:轨道带恰好 4 行(宿主 320 宽 ⇒ 每行 ~320 个轨道墨点)--------
let bar_bands = bands(shot, track, surface, 300);
assert!(
!bar_bands.is_empty(),
"没找到线性进度轨道(宿主宽 320,应有 ≥300 个墨点的行)"
);
for (top, bottom) in &bar_bands {
let height = bottom - top + 1;
assert!(
near(height, 4),
"12.1:线性进度厚必须 4(A1 Height;实测 {height} @ 行 {top})"
);
}
// ---- 12.1 圆角 full:条的四角必须透出底色(直角会把角填满)------------------
let (top, _) = bar_bands[0];
let (left, len) = runs(shot, top + 1, track, surface)
.into_iter()
.find(|(_, len)| *len >= 300)
.expect("轨道那一行必须有 ≥300 的长段");
assert!(
!matches(shot.at(left, top), rgb(track)),
"12.1:圆角 full(2) 的左上角必须透出底色(直角会填满)"
);
assert!(
matches(shot.at(left + len / 2, top + 1), rgb(track)),
"条的中间必须有墨(否则是没画出来,不是圆角)"
);
// ---- 12.4 不定态:phase 0.55 那一列必须**数出两段**(复审 P1-A 的回归断言)----
//
// 旧实现把两段按返回顺序铺 `FillPortion`(第一线在右、第二线在左),并且份额之和
// 在某 34/100 相位上 >1,被 iced 归一 ⇒ 图上只剩一段 100px 的台阶(x 749..848)。
let row = indeterminate_row(shot, active, surface);
let found: Vec<(usize, usize)> = runs(shot, row, active, surface)
.into_iter()
.filter(|(start, len)| {
*start >= TRACK3_X0 && start + len <= TRACK3_X0 + TRACK3_W + 2 && *len >= 8
})
.collect();
assert_eq!(
found.len(),
2,
"12.4:phase 0.55 必须是**两段**(P1-A 修完才成立),实测 {found:?} @ 行 {row}"
);
// 期望位置与单测**共用同一个常量**(`geometry::PHASE_055`):早先这里写 0.871、单测写
// 0.860,实测 963 距 want_end 966.7 只剩 0.28px,靠容差活着(复审 B)。
let expected = PHASE_055;
for (index, (start, len)) in found.iter().enumerate() {
let want_start = TRACK3_X0 as f32 + expected[index].0 * TRACK3_W as f32;
let want_end = TRACK3_X0 as f32 + expected[index].1 * TRACK3_W as f32;
assert!(
(*start as f32 - want_start).abs() <= 4.0,
"12.4 第 {} 段左端应在 {want_start:.1}(实测 {start})",
index + 1
);
assert!(
((start + len) as f32 - want_end).abs() <= 4.0,
"12.4 第 {} 段右端应在 {want_end:.1}(实测 {})",
index + 1,
start + len
);
}
eprintln!(
"[feedback-probe] progress:轨道带 {} 条(高 {:?})· phase 0.55 两段 {found:?}",
bar_bands.len(),
bar_bands
.iter()
.map(|(top, bottom)| bottom - top + 1)
.collect::<Vec<_>>(),
);
}
/// 线性不定态那一行的 y:主色墨迹在**第一条轨道**里出现、且不在确定态行的最上一组。
///
/// 不想硬编码坐标(gallery 一改间距就失准),所以按「主色横带 + 排除最高那条带」找。
fn indeterminate_row(shot: &Shot, active: iced::Color, surface: iced::Color) -> usize {
let found = bands(shot, active, surface, 20);
assert!(
found.len() >= 2,
"至少要找到确定态行与不定态行两组主色横带,实测 {found:?}"
);
// 第一组是确定态(0% 无墨、50%/100% 有墨),第二组是不定态
let (top, bottom) = found[1];
(top + bottom) / 2
}
/// §14:宽度上下限 + 单/双行高。
fn check_snackbar(shot: &Shot, ui: &Ui) {
// 参考值写字面量(A1):600 = SnackbarKt.ContainerMaxWidth,48/68 = SnackbarTokens。
let surface = ui.color(Role::Surface);
let bar = ui.color(Role::InverseSurface);
// 行墨量下限 200:最窄的那条只有 300 宽,里面还有 ~60px 的文字 ⇒ 每行 ~240 个实心点。
// 高度 ≥40 才算提示条(标题行偶有一两行落在这条混色线上,那类 1 行的带要滤掉)。
let found: Vec<(usize, usize)> = bands(shot, bar, surface, 200)
.into_iter()
.filter(|(top, bottom)| bottom - top + 1 >= 40)
.collect();
assert!(
found.len() >= 3,
"14.x:应有 ≥3 条提示条(上限宽 / 双行 / 两条窄的),实测 {}",
found.len()
);
// 宽度集 = 每条带里最宽的那一行(留白行没有文字,量得到全宽)。
let mut widths: Vec<usize> = found
.iter()
.map(|band| band_width(shot, *band, bar, surface))
.collect();
let mut heights: Vec<usize> = found.iter().map(|(top, bottom)| bottom - top + 1).collect();
widths.sort_unstable();
heights.sort_unstable();
assert!(
!widths.iter().any(|w| *w > 601),
"没有一条可以超过上限 600:实测 {widths:?}"
);
assert!(
widths.iter().any(|w| near(*w, 600)),
"上限宽必须是 600(SnackbarKt.ContainerMaxWidth,宿主 800 时夹取),实测宽度集 {widths:?}"
);
assert!(
widths.iter().any(|w| near(*w, 344)),
"宿区 368 那格必须量到 344(G17-d [估] 最小宽口径),实测 {widths:?}"
);
assert!(
widths.iter().any(|w| near(*w, 300)),
"宿区 324 那格必须量到 300(组件没有 min-width,G17-d),实测 {widths:?}"
);
assert!(
heights.iter().any(|h| near(*h, 48)),
"14.3:单行高 48,实测高度集 {heights:?}"
);
assert!(
heights.iter().any(|h| near(*h, 68)),
"14.3:双行高 68,实测高度集 {heights:?}"
);
// 14.6 动作色:`InversePrimary` 必须出现在提示条内(不是按钮家族的 OnSurfaceVariant)
let action = rgb(ui.color(Role::InversePrimary));
let action_pixels: usize = (0..shot.height)
.map(|y| {
(0..shot.width)
.filter(|x| matches(shot.at(*x, y), action))
.count()
})
.sum();
assert!(
action_pixels > 40,
"14.6:动作标签应是 InversePrimary(实测该色只有 {action_pixels} px)"
);
// ---- 位置语义(复审 D):两条 bottom 条的**条底距取景框底 = 12** ---------------
//
// 取景框是 gallery 给窄宿主画的 1px `OutlineVariant` 边(`framed_host`):透明宿主没有
// 可见底边时「贴底」在像素上不可证(旧宿主 72 = 48 + 2×12,上下留白相等)。
let frame = rgb(ui.color(Role::OutlineVariant));
let mut margins: Vec<usize> = Vec::new();
for (top, bottom) in &found {
let width = band_width(shot, (*top, *bottom), bar, surface);
if !(near(width, 300) || near(width, 344)) {
continue;
}
// 1px 描边常落在半像素上(两行各 ~50% 覆盖),精确色匹配抓不住 ⇒ 用「更靠近框色
// 而不是画布」判。但条自身(深色)比画布**更接近**框色,所以还要一条:这一行的
// 框色连续段必须**比条本身更宽**(框横贯整个宿主),否则会认成条自己(实测过:差值 1)。
let distance = |pixel: [u8; 3], target: [u8; 3]| -> u32 {
pixel
.iter()
.zip(target)
.map(|(a, b)| u32::from(a.abs_diff(b)))
.sum()
};
let surface_rgb = rgb(surface);
let frame_run = |y: usize| -> usize {
let mut longest = 0;
let mut current = 0;
for x in 0..shot.width {
let pixel = shot.at(x, y);
if distance(pixel, frame) < distance(pixel, surface_rgb) {
current += 1;
longest = longest.max(current);
} else {
current = 0;
}
}
longest
};
// 框的横贯长度 > 条宽 + 10 ⇒ 只有框那一两行满足
let is_frame_row = |y: usize| frame_run(y) >= width + 10;
let frame_row = ((bottom + 1)..shot.height)
.find(|y| is_frame_row(*y))
.unwrap_or_else(|| {
panic!("D:窄条下方没找到宿主取景框(条 y {top}..{bottom},宽 {width})")
});
let frame_top_row = (0..*top)
.rev()
.find(|y| is_frame_row(*y))
.unwrap_or_else(|| panic!("D:窄条上方没找到宿主取景框(条顶 y {top})"));
// 复审 P2-1:宿高回退到 72(上下留白相等)时这条会红。
assert!(
(*top as i64 - frame_top_row as i64 - 36).abs() <= 1,
"D:贴底 ⇒ 框顶到条顶应为 36(96 − 48 − 12),实测 {}",
*top as i64 - frame_top_row as i64
);
let frame_height = frame_row - frame_top_row + 1;
assert!(
frame_height.abs_diff(96) <= 1,
"D:取景框高应 96(宿主高),实测 {frame_height}"
);
margins.push(frame_row - bottom);
}
assert!(
!margins.is_empty(),
"D:应至少量到一条 bottom 条的宿主边距,实测 {margins:?}"
);
for margin in &margins {
assert!(
near(*margin, 12),
"D:贴底 ⇒ 条底距宿主底 12(官方外边距),实测 {margin}:{margins:?}"
);
}
eprintln!(
"[feedback-probe] snackbar:宽度集 {widths:?} · 高度集 {heights:?} · InversePrimary {action_pixels}px · 底边距 {margins:?}"
);
}
@@ -0,0 +1,158 @@
//! 环形进度的渲染层判据(`probe` 的子模块,`inputs/probe/` 的同一先例)。
//!
//! ## 判据:六个**固定窗**逐个测存在性与尺寸
//!
//! gallery 里这一排环在**固定 x 栅格**上:左缘 `24 + 56·i`(环宽 40、间距 16),共 6 个
//! (0% / 25% / 50% / 75% / 100% / ind 0.30)。判据是「每个窗里都必须有一圈
//! **40(±2)×40(±2)** 的墨迹(轨道 ∪ 激活色的并集)」:
//!
//! - 少一个环 → 该窗没有墨迹 → 红;
//! - 某个环被画小了(例如 20×20)→ 该窗墨迹的外接框变小 → 红;
//! - 0% 那格只有轨道、没有激活色 → 用「并集」判,不把「没有激活色」当失败。
//!
//! 早先用「主色连通簇数」判(只要求非空):把 5 个环涂掉仍然绿(复审 r3 P2-3 的反证),
//! 所以 G18-i 的处置从「不修」改成**已用固定窗收口**。簇数仍在日志里打印供复核。
//!
//! 不定态的**节奏**(4 圈/周期、跨周期不跳变)不在这里量:那是时间的函数,截图只有一帧,
//! 判据在 `tests/progress.rs::circular_indeterminate_rotation_is_four_turns_and_continuous`。
//!
//! 欠账 **G18-l**(登记不修):`snackbar` 的「取景框」判据会把条自身的抗锯齿底行算成框
//! (覆盖率 0.5008,余量 0.0008),方向是**误报**不是假绿;本轮给 snackbar 加了「框高 = 96」
//! 之后触发概率下降,但那 0.0008 的余量仍在。
use iced::Color;
use super::super::pixel::{blobs, coverage, dims, rgb, BBox, Shot};
use unlucky_design::color::Role;
use crate::theme::Ui;
/// 环直径(A1 `CircularProgressIndicatorTokens.Size = 40`)。
const DIAMETER: usize = 40;
/// 环排栅格的左缘(页内边距 24)。
const RING_X0: usize = 24;
/// 栅格间距(40 环 + 16 = `Space::X4`)。
const RING_PITCH: usize = 56;
/// 窗数(0 / 25 / 50 / 75 / 100 / ind)。
const RINGS: usize = 6;
/// 某个像素算不算「这块墨」(覆盖率 ≥50%,与 `pixel::runs` 同一口径)。
fn is_ink(pixel: [u8; 3], fill: Color, under: Color) -> bool {
coverage(pixel, rgb(fill), rgb(under)) >= 0.5
}
/// 环形那一排的判据:逐窗测「有没有一圈 40×40 的墨迹」。
pub(super) fn check(shot: &Shot, ui: &Ui) {
let active = ui.color(Role::Primary);
let track = ui.color(Role::SecondaryContainer);
let surface = ui.color(Role::Surface);
// 环带的 y:以第一个窗(0% 环,只有轨道色)的**圆环签名**定位 —— 中间那几十行在窗的
// 左右两侧都有墨迹;再向上/下各扩到「窗内还有墨」为止(把圆顶/圆底那两行收回来)。
//
// 为什么不按「每行墨点数 4..20」判:圆顶那一行笔画横跨 ~20~28 px,会落在区间外,
// 按连续段会把 40 高的带子切成 32 行;为什么不按「首行..末行」:深色档里正文的抗锯齿
// 像素也会落在轨道色的混色线上,跨度会扩到 274 行(两种都实测过)。
let ink_count = |y: usize, from: usize, to: usize| -> usize {
(from..to)
.filter(|x| is_ink(shot.at(*x, y), track, surface))
.count()
};
let left = RING_X0;
let right = RING_X0 + DIAMETER;
// 环带 = 第一个窗里「有墨」的**连续行组**中、高度 ≈40 的那一组。
//
// 走过的两条弯路都实测过:
// ①「每行墨点 4..20」→ 圆顶那一行笔画横跨 ~20~28 px,被排除,带子切成 32 行;
// ②「左右两侧都有墨」→ 进度条那一行也满足(通栏),会认成 4 行高的假带。
// 连续行组 + 高度窗口同时把「进度条」(4~5 行)与「正文行」排除在外。
let mut runs: Vec<(usize, usize)> = Vec::new();
let mut current: Option<(usize, usize)> = None;
for y in 0..shot.height {
match (ink_count(y, left, right) > 0, current) {
(true, None) => current = Some((y, y)),
(true, Some((top, _))) => current = Some((top, y)),
(false, Some(run)) => {
runs.push(run);
current = None;
}
(false, None) => {}
}
}
if let Some(run) = current {
runs.push(run);
}
let (top, bottom) = runs
.iter()
.copied()
.find(|(top, bottom)| (DIAMETER - 6..=DIAMETER + 6).contains(&(bottom - top + 1)))
.unwrap_or_else(|| panic!("没找到高约 {DIAMETER} 的环形带;窗口 0 的连续行组:{runs:?}"));
let mut report = Vec::new();
for index in 0..RINGS {
let left = RING_X0 + RING_PITCH * index;
let right = left + DIAMETER - 1;
let mut ink: Option<BBox> = None;
for y in top..=bottom {
for x in left..=right {
let pixel = shot.at(x, y);
if is_ink(pixel, active, surface) || is_ink(pixel, track, surface) {
match &mut ink {
Some(found) => found.include(x, y),
None => {
ink = Some(BBox {
left: x,
right: x,
top: y,
bottom: y,
})
}
}
}
}
}
let ink = ink.unwrap_or_else(|| {
panic!("第 {index} 个窗(x {left}..{right})里没有任何环墨迹:环丢了")
});
assert!(
(DIAMETER - 2..=DIAMETER + 2).contains(&ink.width())
&& (DIAMETER - 2..=DIAMETER + 2).contains(&ink.height()),
"第 {index} 个窗(x {left}..{right})里的环应 {DIAMETER}×{DIAMETER}(±2),实测 {}×{}",
ink.width(),
ink.height()
);
assert!(
ink.left >= left && ink.right <= right,
"第 {index} 个窗的墨迹越界:{}..{} 不在 {left}..{right}",
ink.left,
ink.right
);
report.push(format!("{index}:{}×{}", ink.width(), ink.height()));
}
// 激活色(弧)必须出现在 1.. 号窗里(0% 那格本来就只有轨道)。
for index in 1..RINGS {
let left = RING_X0 + RING_PITCH * index;
let count: usize = (top..=bottom)
.map(|y| {
(left..left + DIAMETER)
.filter(|x| is_ink(shot.at(*x, y), active, surface))
.count()
})
.sum();
assert!(
count > 40,
"第 {index} 个窗(x {left}..)的激活色弧只有 {count} px:环在但弧没画"
);
}
let clusters = blobs(shot, active, 10);
eprintln!(
"[feedback-probe] circular:固定窗 {RINGS} 个逐窗 [{ }](y {top}..{bottom})· \
主色连通簇 {} 个(仅打印,判据是窗)",
report.join(" / "),
clusters.len()
);
let _ = dims(&clusters);
}
@@ -0,0 +1,315 @@
//! 两个「面」的渲染层判据:Dialog(§7)与空态(§15)。`probe` 的子模块。
//!
//! ## Dialog 量什么
//!
//! - **逐卡宽度**:按列剖面把每张卡单独量出来(早先只量「最宽那张」,一宽一窄并排时会漏);
//! 本项目 480,窄宿主那格 360(`Dialog::width(360)`),且都必须落在官方 280..560 内;
//! - 内距:标题墨迹左缩 ≈24、顶缩 24+bearing(24..=30);
//! - **动作色**:`Role::Primary` 的**精确色**命中数(复审 A:早先用「离谁更近」把标题/正文字形
//! 也算成了 Primary —— 一张没有动作的卡也能"通过");并配两条反向证据:
//! ①无动作的那张卡里精确 Primary 必须是 **0**;②有动作的那张卡下半区必须有字形行。
//! - scrim:一块 ≥700 宽的遮罩带,色值 = 预合成实色。
//!
//! ## 空态量什么
//!
//! 图标块 96±2(1px 描边各吃 1 行)· 块↔标题 ≈16+bearing · 条目总高 ≈232 · CTA 40。
//!
//! 常量与 [`super::super::panel`] 里登记值对齐(480 / 280 / 96 / 232),动作色用**精确色**
//! 而不是斜坡判据。
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use super::super::dialog::scrim;
use super::super::empty_state::BLOCK_LAYER;
use super::super::pixel::{band_width, bands, ink_inside, matches, near, rgb, BBox, Shot};
use crate::theme::Ui;
/// Dialog 宽(本项目取值,官方区间 280..560 内)。
const DIALOG_W: usize = 480;
/// 窄宿主那格的宽(`Dialog::width(360)`)。
const DIALOG_NARROW: usize = 360;
/// 空态图标块边长。
const BLOCK: usize = 96;
/// 空态条目总高:96 块 + 16 + 32 标题 + 8 + 16 副文案 + 24 + 40 CTA。
const EMPTY_TOTAL: usize = 232;
/// 动作字形的精确色命中下限(实测 3 张有动作的卡合计 325 px)。
const ACTION_INK_MIN: usize = 120;
/// §7:逐卡宽度 / 内距 / 动作色 / scrim。
pub(super) fn check_dialog(shot: &Shot, ui: &Ui) {
let surface = ui.color(Role::Surface);
let card = ui.layer(MaterialLayer::Overlay);
let primary = rgb(ui.color(Role::Primary));
// ---- 7.4 宽度:先分「卡带」(同排两张卡的 y 区间会重叠,所以只能按行分组),
// 再在每条带里按**列剖面**分出每一张卡(复审 A/G:早先只量最宽的那张)。
// 卡带用**精确色**数行(不用斜坡):深色档里正文/描边的抗锯齿像素都落在
// 「surface → card」那条很短的斜坡上,用斜坡会把整屏都算成卡带(实测出现 1180 宽的假卡)。
let card_rgb = rgb(card);
let scrim_rgb = rgb(scrim(ui));
let card_rows: Vec<usize> = (0..shot.height)
.filter(|y| {
(0..shot.width)
.filter(|x| {
let pixel = shot.at(*x, *y);
matches(pixel, card_rgb) && !matches(pixel, scrim_rgb)
})
.count()
>= 200
})
.collect();
let mut card_bands: Vec<(usize, usize)> = Vec::new();
let mut start: Option<usize> = None;
let mut previous: usize = 0;
for y in card_rows {
match start {
None => {
start = Some(y);
previous = y;
}
Some(_) if y == previous + 1 => previous = y,
Some(top) => {
card_bands.push((top, previous));
start = Some(y);
previous = y;
}
}
}
if let Some(top) = start {
card_bands.push((top, previous));
}
assert!(
card_bands.len() >= 3,
"至少要找到 3 条卡带(第一排两张 + 双动作 + scrim 里那张),实测 {card_bands:?}"
);
// 每张卡记 (左缘, 宽):宽度给「逐卡」断言,左缘给内距断言用(不能按单行找左缘——
// 圆角 28 会让 band_top+4 那一行的卡片像素内缩 ~10px,实测内距被量成 12 而不是 24)。
//
// **深色档要把遮罩色排掉**:暗色下 `SurfaceContainerHigh` 与预合成 scrim 只差 ~35/通道,
// 不排掉时 scrim 那片也被算成「卡」(实测出现 1180 宽的假卡)。
let mut cards: Vec<(usize, usize)> = Vec::new();
for band in &card_bands {
let height = band.1 - band.0 + 1;
let profile: Vec<usize> = (0..shot.width)
.map(|x| {
(band.0..=band.1)
.filter(|y| {
let pixel = shot.at(x, *y);
matches(pixel, card_rgb) && !matches(pixel, scrim_rgb)
})
.count()
})
.collect();
let mut start: Option<usize> = None;
for (x, ink) in profile.iter().enumerate() {
let column = *ink >= height / 3;
match (column, start) {
(true, None) => start = Some(x),
(false, Some(from)) => {
if x - from >= 280 {
cards.push((from, x - from));
}
start = None;
}
_ => {}
}
}
if let Some(from) = start {
if shot.width - from >= 280 {
cards.push((from, shot.width - from));
}
}
}
let mut widths: Vec<usize> = cards.iter().map(|(_, width)| *width).collect();
widths.sort_unstable();
assert!(
widths.iter().any(|w| near(*w, DIALOG_W)),
"7.4:应有 {DIALOG_W} 宽的卡(G17-e 项目取值),实测逐卡宽度 {widths:?}"
);
assert!(
widths.iter().any(|w| near(*w, DIALOG_NARROW)),
"7.4:窄宿主那格应量到 {DIALOG_NARROW}(`Dialog::width(360)`,复审 E),实测 {widths:?}"
);
for width in &widths {
assert!(
(280..=560).contains(width),
"7.4:每张卡都必须落在官方 280..560 内,实测 {width}:{widths:?}"
);
}
// 内距与 scrim 只在一张卡上量(第一张,最靠左上)。
let first = card_bands[0];
let (card_left, width) = cards[0];
let top = first.0;
let bottom = first.1;
let bbox = BBox {
left: card_left,
right: card_left + width - 1,
top,
bottom,
};
let title =
ink_inside(shot, &bbox, ui.color(Role::OnSurface), card, 2).expect("卡片里必须有标题墨迹");
let inset = title.left.saturating_sub(card_left);
assert!(
(22..=28).contains(&inset),
"7.3:四边内距 24 ⇒ 标题墨迹左缩 ≈24(含 bearing 22..28),实测 {inset}"
);
let top_inset = title.top.saturating_sub(top);
assert!(
(24..=30).contains(&top_inset),
"7.3:上内距 24 ⇒ 标题墨迹顶缩 24+字形 bearing(24..30),实测 {top_inset}"
);
// ---- 7.6 动作色(复审 A:换掉「离谁更近」那条会被标题字形顶账的判据)--------
let exact_primary = |band: (usize, usize)| -> usize {
(band.0..=band.1)
.map(|y| {
(0..shot.width)
.filter(|x| matches(shot.at(*x, y), primary))
.count()
})
.sum()
};
let action_pixels: usize = (0..shot.height)
.map(|y| {
(0..shot.width)
.filter(|x| matches(shot.at(*x, y), primary))
.count()
})
.sum();
assert!(
action_pixels >= ACTION_INK_MIN,
"7.6:Dialog 动作标签应是 `Primary` 的**精确色**(实测只命中 {action_pixels} px;\
改成 OnSurfaceVariant 就会掉到 0)"
);
// 反向证据 ①:没有动作的那张卡(scrim 里那张)里精确 Primary 必须是 0 ——
// 这条挡住「把标题/正文的深色字形算成 Primary」这条假绿路径。
let quiet: Vec<(usize, usize)> = card_bands
.iter()
.copied()
.filter(|band| exact_primary(*band) == 0)
.collect();
assert!(
!quiet.is_empty(),
"7.6:本屏应有一张**无动作**的卡(scrim 里那张)其精确 Primary 为 0,实测每带都有 Primary 墨迹"
);
// 反向证据 ②:动作行确实存在 —— 命中最多的那条带下半区必须有字形行。
let loud = card_bands
.iter()
.copied()
.max_by_key(|band| exact_primary(*band))
.expect("卡带非空");
let half = loud.0 + (loud.1 - loud.0) / 2;
let action_rows = (half..=loud.1)
.filter(|y| {
(0..shot.width)
.filter(|x| matches(shot.at(*x, *y), primary))
.count()
>= 3
})
.count();
assert!(
action_rows >= 1,
"7.6:动作字形必须在卡片下半区(实测下半区精确 Primary 行数 {action_rows})"
);
// ---- 7.7 scrim -------------------------------------------------------------
let backdrop = scrim(ui);
let scrim_bands = bands(shot, backdrop, surface, 700);
assert!(
!scrim_bands.is_empty(),
"7.7:没找到 ≥700 宽的遮罩带(宿主 800 宽那格)"
);
eprintln!(
"[feedback-probe] dialog:逐卡宽度 {widths:?} · 标题左缩 {inset} · 顶缩 {top_inset} · \
精确 Primary {action_pixels}px(无动作卡 {} 张)· 动作行 {action_rows} 行 · scrim 带 {} 条",
quiet.len(),
scrim_bands.len()
);
}
/// §15:图标块 96(`[估]`)+ 间距 16 / 8 / 24 + CTA 40。
pub(super) fn check_empty(shot: &Shot, ui: &Ui) {
let surface = ui.color(Role::Surface);
let block = ui.layer(BLOCK_LAYER);
let found = bands(shot, block, surface, 40);
let (top, bottom) = found
.iter()
.copied()
.max_by_key(|(top, bottom)| bottom - top)
.filter(|(top, bottom)| (BLOCK - 2..=BLOCK + 2).contains(&(bottom - top + 1)))
.unwrap_or_else(|| {
panic!(
"15.1:没找到高 {BLOCK}(±2) 的图标块(`[估]` 项目自定值),实测量到的带:{found:?}"
)
});
let width = band_width(shot, (top, bottom), block, surface);
assert!(
(BLOCK - 2..=BLOCK + 2).contains(&width),
"15.1:图标块必须 {BLOCK}(±2,1px 描边)宽,实测 {width}"
);
// 图标本身:`view_at` 按 32 烘焙(stroke 同步重算),墨迹 ≈26~28。
let glyph = ink_inside(
shot,
&BBox {
left: 0,
right: shot.width - 1,
top,
bottom,
},
ui.color(Role::OnSurfaceVariant),
block,
2,
)
.expect("块里必须有图标墨迹");
assert!(
(22..=34).contains(&glyph.width()) && (22..=34).contains(&glyph.height()),
"15.1:图标墨迹应在 22..34(32 盒;`view` 时代只有 20×20),实测 {}×{}",
glyph.width(),
glyph.height()
);
// ---- P2-1 回归:块↔标题的实际间距(16 而不是 32)---------------------------
let bbox = BBox {
left: 0,
right: shot.width - 1,
top: bottom + 1,
bottom: shot.height - 1,
};
let title = ink_inside(shot, &bbox, ui.color(Role::OnSurface), surface, 0)
.expect("块下面必须有标题墨迹");
let gap = title.top.saturating_sub(bottom + 1);
assert!(
(16..=24).contains(&gap),
"15.3:块↔标题应为 16(+ 字形 bearing),实测 {gap}(32 = spacing 与 gap 双算回来了)"
);
let cta = bands(shot, ui.color(Role::Primary), surface, 20);
let (cta_top, cta_bottom) = cta
.iter()
.copied()
.find(|(top, bottom)| near(bottom - top + 1, 40))
.unwrap_or_else(|| panic!("15.6:CTA 应是标准按钮(可见高 40),实测带 {cta:?}"));
let total = cta_bottom + 1 - top;
assert!(
total.abs_diff(EMPTY_TOTAL) <= 6,
"15.x:条目总高应 ≈{EMPTY_TOTAL}(96+16+32+8+16+24+40),实测 {total}"
);
eprintln!(
"[feedback-probe] empty:块 y {top}..{bottom}(高 {})· 宽 {width} · 图标 {}×{} · \
块↔标题 {gap} · 总高 {total} · CTA {cta_top}..{cta_bottom}",
bottom - top + 1,
glyph.width(),
glyph.height()
);
}
@@ -0,0 +1,308 @@
//! 进度指示器(`research/12b` §12):`linear` 与 `circular`,各有确定态与不定态。
//!
//! ## 两种形态、四种态
//!
//! | 形态 | 确定态 | 不定态 |
//! |---|---|---|
//! | [`linear`] | 轨道 `SecondaryContainer` + 指示条 `Primary`,厚 **4**、圆角 full | 官方 1750ms **双段**追逐([`phase_segments`]) |
//! | [`circular`] | 40 环 + 4 线宽,弧长 = 数值 × 360° | 官方 6000ms(弧长 0.1↔0.87 + 旋转 **0→1440°**:全局 1080° 线性 + 关键帧 360°,[`CircularGeometry::indeterminate`]) |
//!
//! ## 为什么不定态要调用方给 `phase`
//!
//! 契约 §2:组件不持有时钟。`phase`(0..1 的一个周期)由页面按 `Subscription` 推进;
//! harness 因此能拍**确定**的中间帧(否则每张图都是抓阄)。周期时长在几何里
//! ([`LinearGeometry::cycle_ms`] / [`CircularGeometry::cycle_ms`]),调用方按它换算。
//!
//! ## CPU 降级路径(D27)
//!
//! 本组件**零半透明、零模糊**:轨道/指示条都是不透明实色,环形是 `stroke` 实色。
//! 波浪形态(线性振幅 3 / 波长 40,环形振幅 1.6 / 波长 15,`[源]`)**未实现**,
//! 用直线段/等弧长替代——这是 `12b §12.4` 明确允许的登记项(见报告「偏离登记」)。
//!
//! ## 环形为什么手搓 SVG
//!
//! iced 的 `canvas` feature 没开(`Cargo.toml` 不归本 mission),而圆弧用容器边框画不出来。
//! 令牌图标的做法(24 网格 SVG path + `currentColor` + 内容哈希缓存)同样适用于几何图元:
//! 一份 handle 渲染两层(轨道一层、弧一层),相位不变时 iced 侧命中缓存。
use iced::border::Radius;
use iced::widget::{container, row, svg, Space};
use iced::{Background, Border, Element, Length};
use unlucky_design::color::Role;
use crate::theme::Ui;
use super::super::layout;
use super::geometry::{phase_segments, CircularGeometry, LinearGeometry};
/// `FillPortion` 的比例分辨率:0..1 的占比 × 1000 取整(1/1000 像素级,够用且不溢出 u16)。
const PORTIONS: f32 = 1000.0;
/// 弧长小于这个角度就不画激活层(圆头在 0 弧长上会画出一个点,看着像「已完成」)。
const MIN_SWEEP_DEG: f32 = 1.0;
/// 100% 时弧首尾重合,`A` 命令画不出整圆 ⇒ 超过这个角度改画整环。
const FULL_SWEEP_DEG: f32 = 359.9;
/// 进度的取值:确定(0..1)或不定(相位 0..1)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Amount {
/// 确定态:0..1。
Determinate(f32),
/// 不定态:一个周期内的相位(0..1,可越界,采样时按周期取模)。
Indeterminate(f32),
}
/// 一个进度指示器的取值(**不持有任何状态**)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Progress {
amount: Amount,
}
impl Progress {
/// 确定态:`value` 越界会被夹到 0..1(0% 只画轨道)。
pub fn determinate(value: f32) -> Self {
Self {
amount: Amount::Determinate(value.clamp(0.0, 1.0)),
}
}
/// 不定态:`phase` 是周期内的相位(调用方按 `cycle_ms` 推进)。
pub fn indeterminate(phase: f32) -> Self {
Self {
amount: Amount::Indeterminate(phase),
}
}
/// 取值(断言用;生产路径不读它,内部直接 match 字段)。
#[cfg(test)]
pub fn amount(self) -> Amount {
self.amount
}
/// 本帧要画的占轨道比例区间(0..1)。
///
/// 确定态 1 段、不定态 2 段(官方双线);区间可能为空(相位在「两段都在屏幕外」的时刻),
/// 调用方按 `end > start` 过滤。
pub fn spans(self) -> Vec<(f32, f32)> {
match self.amount {
Amount::Determinate(value) => vec![(0.0, value.clamp(0.0, 1.0))],
Amount::Indeterminate(phase) => phase_segments(phase).to_vec(),
}
}
}
/// 把 0..1 的区间**排序 + 合并重叠**,摊成「间隙 / 指示条」交替的列表(总长恒为 1.0)。
///
/// ## 为什么必须排序(复审 P1-A,视觉 r1 的 p1)
///
/// [`phase_segments`] 返回的两段**不保证有序**:第一线的前缘抢跑 250ms,所以它的区间在右,
/// 第二线从 0 起跑的区间在左。照顺序铺 `FillPortion` 会把左段接到右段**之后**,并且
/// `FillPortion` 之和一旦 ≠ 1000,iced 就按总和归一 —— 位置和宽度一起被压扁。
/// 实测 phase 0.55:本应两段 `[0.003,0.079]` + `[0.355,0.860]`(x 689..713 / 802..963),
/// 实际被画成一段 100px 的台阶(x 749..848),且 **34/100 个相位**上总和 >1(最大 1.99)。
///
/// 返回的每项是 `(是不是指示条, 占比)`;间隙项也显式给出来,才能断言「总和 = 1.0」。
pub fn layout_spans(spans: &[(f32, f32)]) -> Vec<(bool, f32)> {
let mut clipped: Vec<(f32, f32)> = spans
.iter()
.map(|(start, end)| (start.clamp(0.0, 1.0), end.clamp(0.0, 1.0)))
.filter(|(start, end)| end > start)
.collect();
clipped.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut merged: Vec<(f32, f32)> = Vec::new();
for (start, end) in clipped {
match merged.last_mut() {
Some(last) if start <= last.1 => last.1 = last.1.max(end),
_ => merged.push((start, end)),
}
}
let mut items = Vec::new();
let mut cursor = 0.0_f32;
for (start, end) in merged {
if start > cursor {
items.push((false, start - cursor));
}
items.push((true, end - start));
cursor = end;
}
if cursor < 1.0 {
items.push((false, 1.0 - cursor));
}
items
}
/// 占比 → `FillPortion` 整数份额,**总和恒为 `PORTIONS`**(累计舍入,最后一项吃掉残差)。
///
/// 为什么不用「每项各自 round」:各项独立取整后总和可能是 999 或 1001,iced 按总和归一,
/// 就又回到 P1-A 那个「整条被压扁」的坑。这里让每个**累计边界**取整,最后一项补足,
/// 于是任意相位下份额之和都精确等于 1000。
pub fn portion_shares(items: &[(bool, f32)]) -> Vec<(bool, u16)> {
let mut shares = Vec::with_capacity(items.len());
let mut accumulated = 0.0_f32;
let mut previous = 0_u16;
for (index, (is_bar, fraction)) in items.iter().enumerate() {
accumulated += fraction;
let target = if index + 1 == items.len() {
PORTIONS as u16
} else {
(accumulated * PORTIONS).round() as u16
};
let share = target.saturating_sub(previous);
previous = target;
shares.push((*is_bar, share));
}
shares
}
/// 线性进度条(轨道 + 指示条,厚 4、圆角 full)。
///
/// 轨道宽 `Fill`(由宿主决定),指示条按占比分到 `FillPortion` 上——**不需要知道轨道像素宽**,
/// 所以 50% 在任何宿主宽度下都精确是半条。
pub fn linear<'a, M: 'a>(ui: &'a Ui, progress: Progress) -> Element<'a, M> {
let geometry = LinearGeometry::of(ui);
let track_color = ui.color(Role::SecondaryContainer);
let active_color = ui.color(Role::Primary);
let mut bar = row![]
.width(Length::Fill)
.height(Length::Fixed(geometry.thickness));
for (is_bar, share) in portion_shares(&layout_spans(&progress.spans())) {
if share == 0 {
continue;
}
bar = bar.push(if is_bar {
container(Space::new().width(Length::Fill).height(Length::Fill))
.width(Length::FillPortion(share))
.height(Length::Fill)
.style(move |_theme| container::Style {
background: Some(Background::Color(active_color)),
border: Border {
radius: Radius::new(geometry.radius),
..Border::default()
},
..container::Style::default()
})
.into()
} else {
spacer(share)
});
}
container(bar)
.width(Length::Fill)
.height(Length::Fixed(geometry.thickness))
.style(move |_theme| container::Style {
background: Some(Background::Color(track_color)),
border: Border {
radius: Radius::new(geometry.radius),
..Border::default()
},
..container::Style::default()
})
.into()
}
/// 环形进度(40 直径 / 4 线宽;确定态画弧,不定态按官方节奏扫)。
pub fn circular<'a, M: 'a>(ui: &'a Ui, progress: Progress) -> Element<'a, M> {
let geometry = CircularGeometry::of(ui);
let (sweep, rotation) = match progress.amount {
Amount::Determinate(value) => (value.clamp(0.0, 1.0) * 360.0, 0.0),
Amount::Indeterminate(phase) => {
// 圈数直接乘 360(不做 rem_euclid):每周期 4 圈是官方口径,截断就丢转速。
let (sweep_fraction, turns) = geometry.indeterminate(phase);
(sweep_fraction * 360.0, turns * 360.0)
}
};
let track = glyph(
&geometry,
ring_source(&geometry),
ui.color(Role::SecondaryContainer),
);
// 0% 只画轨道(不画弧,也不画圆头小点);100% 画整环(`A` 命令的首尾重合画不出整圆)
let active = if sweep < MIN_SWEEP_DEG {
None
} else if sweep >= FULL_SWEEP_DEG {
Some(glyph(
&geometry,
ring_source(&geometry),
ui.color(Role::Primary),
))
} else {
Some(glyph(
&geometry,
arc_source(&geometry, sweep, rotation),
ui.color(Role::Primary),
))
};
let mut layers = vec![track];
layers.extend(active);
// Q9 的同族:几何图元也必须落在**固定尺寸**的盒子里,否则 usvg 会按 Contain 撑满父级。
layout::fixed(
geometry.diameter,
geometry.diameter,
iced::widget::stack(layers),
)
}
/// 两段之间/两端的透明占位。
fn spacer<'a, M: 'a>(share: u16) -> Element<'a, M> {
Space::new()
.width(Length::FillPortion(share))
.height(Length::Fill)
.into()
}
/// 一层几何图元(固定尺寸 + `currentColor` 上色)。
fn glyph<'a, M: 'a>(
geometry: &CircularGeometry,
source: String,
color: iced::Color,
) -> Element<'a, M> {
svg(svg::Handle::from_memory(source.into_bytes()))
.width(Length::Fixed(geometry.diameter))
.height(Length::Fixed(geometry.diameter))
.style(move |_theme, _status| svg::Style { color: Some(color) })
.into()
}
/// 整环(轨道用,也用作 100% 的激活层)。
fn ring_source(geometry: &CircularGeometry) -> String {
let (size, thickness) = (geometry.diameter, geometry.thickness);
let center = size / 2.0;
let radius = geometry.arc_radius();
format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{size}\" height=\"{size}\" \
viewBox=\"0 0 {size} {size}\"><circle cx=\"{center}\" cy=\"{center}\" r=\"{radius}\" \
fill=\"none\" stroke=\"currentColor\" stroke-width=\"{thickness}\"/></svg>"
)
}
/// 一段圆弧(`rotation` 起、扫 `sweep_deg`;0° = 12 点、顺时针 = M3 口径)。
fn arc_source(geometry: &CircularGeometry, sweep_deg: f32, rotation_deg: f32) -> String {
let (size, thickness) = (geometry.diameter, geometry.thickness);
let center = size / 2.0;
let radius = geometry.arc_radius();
let (x0, y0) = point(center, radius, rotation_deg);
let (x1, y1) = point(center, radius, rotation_deg + sweep_deg);
let large = u8::from(sweep_deg > 180.0);
format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{size}\" height=\"{size}\" \
viewBox=\"0 0 {size} {size}\"><path d=\"M {x0:.3} {y0:.3} A {radius} {radius} 0 \
{large} 1 {x1:.3} {y1:.3}\" fill=\"none\" stroke=\"currentColor\" \
stroke-width=\"{thickness}\" stroke-linecap=\"round\"/></svg>"
)
}
/// 极角(0° = 12 点,顺时针)→ 画布坐标(y 向下,所以向上取负)。
fn point(center: f32, radius: f32, deg: f32) -> (f32, f32) {
let rad = deg.to_radians();
(center + radius * rad.sin(), center - radius * rad.cos())
}
@@ -0,0 +1,193 @@
//! Snackbar(`research/12b` §14):底/顶部提示条 + 动作 + 关闭。
//!
//! ## 官方值(`[源]` = A1,`SnackbarTokens` + `SnackbarKt`,`javap` 核对)
//!
//! | # | 项 | 值 | 落点 |
//! |---|---|---|---|
//! | 14.1 | 圆角 | **4**(`CornerExtraSmall`) | `Component::Snackbar.spec().shape` |
//! | 14.2 | 容器色 | **`InverseSurface`**(高程 L3 是设计口头径,见下「不画阴影」) | [`Role::InverseSurface`] |
//! | 14.3 | 高 | 单行 **48** / 双行 **68** | [`SnackbarGeometry::height`] |
//! | 14.4 | 内距 | 水平 **16** / 垂直 **6** / 文本↔动作 **8** | `Space::X4` / 6 / `Space::X2` |
//! | 14.6 | 颜色 | 文本 `InverseOnSurface`、动作 **`InversePrimary`**、图标 24 `InverseOnSurface` | 组件层取角色 |
//! | — | 宽度上限 | **600**(`SnackbarKt.ContainerMaxWidth`) | `max_width` |
//!
//! ## 宽度上下限(一条**真实**限制,登记 G17-d)
//!
//! 官方只有上限 600,没有下限常数;本项目取 **344 `[估]`**(M2 时代口径,观感连续)。
//! iced 的 `Length` **没有 min-width**(与按钮家族 G15「最小宽 58 不施加」同一堵墙),
//! 所以:**上限在组件里强制**(`container::max_width`),**下限由宿主保证**——页面给提示条
//! 的区域至少 344 宽。gallery 用三个宿主宽度把这条限制摊开(300 → 300、344 → 344、800 → 600),
//! 渲染层断言量的是这三种实测值,不假装下限被组件强制住了。
//!
//! ## 超时与 dismiss:状态归调用方
//!
//! 组件**不计时**:显示时长、自动消失、`on_timeout` 全在调用方(页面)手里。
//! 组件的语义只有三条:`placement` 决定贴顶还是贴底(并给出 12 的外边距)、
//! `action` 给一个文字动作、`dismiss` 给关闭键。消息要不要触发「撤销」由调用方决定。
//!
//! ## 不画阴影
//!
//! 官方是 L3,但 D27(CPU 档禁模糊)与令牌层 `ShadowSpec` 仍是 M63 占位值这两条都指向
//! 「先不画」——层级靠 `InverseSurface` 与页面的 scrim/留白表达。登记为偏离 **D-4**
//! (D-3 是进度条的波浪降级,别混号)。
use iced::widget::{container, row, text};
use iced::{Alignment, Background, Border, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::super::interaction::Interaction;
use super::action;
use super::panel::SnackbarGeometry;
/// 贴底还是贴顶(官方两种都有;本项目默认贴底)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Placement {
/// 贴底(桌面默认:不挡页头,也不挡主操作)。
Bottom,
/// 贴顶(页头有刷新按钮时用于「结果在页头附近」的场景)。
Top,
}
impl Placement {
/// 垂直对齐:贴底 = `End`,贴顶 = `Start`。
fn vertical(self) -> Alignment {
match self {
Placement::Bottom => Alignment::End,
Placement::Top => Alignment::Start,
}
}
}
/// 一条提示条的装配描述(**不持有任何状态所有权**)。
#[derive(Debug, Clone)]
pub struct Snackbar<'a, M> {
text: &'a str,
lines: u8,
placement: Placement,
action: Option<(&'a str, M)>,
dismiss: Option<M>,
state: Option<Interaction>,
}
impl<'a, M> Snackbar<'a, M> {
/// 单行提示条(高 48)。
pub fn new(text: &'a str) -> Self {
Self {
text,
lines: 1,
placement: Placement::Bottom,
action: None,
dismiss: None,
state: None,
}
}
/// 双行(高 68)。**行数由调用方声明**:iced 没有 `heightIn(min=…)`,官方那两条高度值
/// 只能显式给(文本自己换行与否不改变容器高,这是 M3 的口径)。
pub fn two_lines(mut self) -> Self {
self.lines = 2;
self
}
/// 贴底 / 贴顶。
pub fn placement(mut self, placement: Placement) -> Self {
self.placement = placement;
self
}
/// 右侧文字动作(`InversePrimary`)。
pub fn action(mut self, label: &'a str, on_press: M) -> Self {
self.action = Some((label, on_press));
self
}
/// 关闭键(`InverseOnSurface`)。
pub fn dismiss(mut self, on_press: M) -> Self {
self.dismiss = Some(on_press);
self
}
/// 强制动作件的视觉态(gallery 取证用;运行期不要调)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
}
/// 画一条提示条。
///
/// 返回的元素是 **`Fill` 高度的容器**:贴底/贴顶由它内部的对齐决定,调用方只要给它一块
/// 可用区(`stack` 在页面上、或 gallery 的宿主盒),它就是那个位置上的居中提示条。
pub fn snackbar<'a, M: Clone + 'a>(ui: &'a Ui, spec: Snackbar<'a, M>) -> Element<'a, M> {
let geometry = SnackbarGeometry::of(ui);
let style = ui.style(TypeRole::BodyMedium);
let height = geometry.height_for(spec.lines);
let surface = ui.color(Role::InverseSurface);
let radius = iced::border::Radius::new(geometry.radius);
let mut content = row![]
.align_y(Alignment::Center)
.spacing(geometry.gap)
.push(
text(spec.text)
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::InverseOnSurface))
.width(Length::Fill),
);
if let Some((label, message)) = spec.action {
content = content.push(action::text_action(
ui,
label,
Role::InversePrimary,
Role::InverseSurface,
Some(message),
spec.state,
));
}
if let Some(message) = spec.dismiss {
content = content.push(action::icon_action(
ui,
IconKind::Close,
Role::InverseOnSurface,
Role::InverseSurface,
Some(message),
spec.state,
));
}
let bar = container(content)
.width(Length::Fill)
.max_width(geometry.max_width)
.height(Length::Fixed(height))
.padding(Padding {
top: geometry.pad_v,
right: geometry.pad_h,
bottom: geometry.pad_v,
left: geometry.pad_h,
})
.style(move |_theme| container::Style {
background: Some(Background::Color(surface)),
border: Border {
radius,
..Border::default()
},
..container::Style::default()
});
// 位置层:`Fill` 高度 + 外边距 12 + 水平居中;贴底/贴顶只改 `align_y`(Q8:不用 center_*)。
container(bar)
.width(Length::Fill)
.height(Length::Fill)
.padding(Padding::from(geometry.margin))
.align_x(Alignment::Center)
.align_y(spec.placement.vertical())
.into()
}
@@ -0,0 +1,223 @@
//! 纯函数级断言:几何对令牌、节奏对 A1、角色对变体。
//!
//! 这里**只测数值与结构**(M68 的教训:函数对不代表图对)。对着真截图量像素的是
//! [`super::probe`],两者缺一不可——本文件保证「数字对」,probe 保证「画出来是那个数字」。
use iced::Element;
use unlucky_design::color::{Role, Scheme, SchemeFlavor, SCRIM_ALPHA};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::typography::Platform;
use crate::theme::{to_iced, Families, Ui};
use super::super::interaction::{self, Interaction};
use super::dialog::scrim;
use super::panel::{DialogGeometry, EmptyStateGeometry, SnackbarGeometry};
use super::progress::Progress;
use super::snackbar::{Placement, Snackbar};
use super::{circular, dialog, empty_state, linear, snackbar, Dialog, EmptyState};
mod progress;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
pub(super) fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
}
// ---- §14 Snackbar -----------------------------------------------------------
/// 14.3 / 14.1 / 宽度上限:48 / 68 / 4 / 600。
#[test]
fn snackbar_heights_widths_and_radius_are_the_tokens() {
let geometry = SnackbarGeometry::of(&ui(SchemeFlavor::Light));
assert_eq!(geometry.height_for(1), 48.0, "14.3 单行 48");
assert_eq!(geometry.height_for(2), 68.0, "14.3 双行 68");
assert_eq!(geometry.radius, 4.0, "14.1 圆角 4(CornerExtraSmall)");
assert_eq!(geometry.max_width, 600.0, "SnackbarKt.ContainerMaxWidth");
assert_eq!(geometry.min_width, 344.0, "G17-d [估] 最小宽");
assert!(geometry.min_width < geometry.max_width, "上下限必须有区间");
assert_eq!(geometry.margin, 12.0, "G17-c [规格] 外边距 12");
assert_eq!(geometry.pad_h, 16.0, "A1 HorizontalSpacing");
assert_eq!(geometry.pad_v, 6.0, "A1 SnackbarVerticalPadding");
}
// ---- §7 Dialog --------------------------------------------------------------
/// 7.4:官方区间 **280..560**,本项目取 **480**,且 480 落在区间内。
#[test]
fn dialog_geometry_sits_inside_the_official_bounds() {
let geometry = DialogGeometry::of(&ui(SchemeFlavor::Light));
assert_eq!(geometry.min_width, 280.0, "A1 DialogMinWidth");
assert_eq!(geometry.max_width, 560.0, "A1 DialogMaxWidth");
assert_eq!(geometry.width, 480.0, "G17-e 本项目取值");
assert!(
geometry.min_width <= geometry.width && geometry.width <= geometry.max_width,
"自选宽必须落在官方区间内"
);
}
/// 复审 P2-2:`max(280)` 那条支以前一次都没被执行过(只有定义 + 一个调用点)。
///
/// 抓得住:有人把下限去掉(200 → 200)、或把 `min/max` 顺序写反(200 → 480 之外的值)。
/// 抓不住:渲染时是否真的画了 280(那需要真窗口;gallery 的 360 那格由 probe 的逐卡宽度兜)。
#[test]
fn dialog_width_respects_the_official_minimum() {
let geometry = DialogGeometry::of(&ui(SchemeFlavor::Light));
assert_eq!(
geometry.effective_width(200.0),
280.0,
"可用宽 200 → 仍画官方下限 280"
);
assert_eq!(geometry.effective_width(360.0), 360.0, "窄宿主取可用宽");
assert_eq!(
geometry.effective_width(800.0),
480.0,
"宽宿主夹到项目值 480"
);
}
/// 7.1 / 7.3 / 7.6:圆角 28、内距 24 / 16 / 24、动作 8 / 12。
#[test]
fn dialog_padding_and_action_gaps_are_the_tokens() {
let geometry = DialogGeometry::of(&ui(SchemeFlavor::Light));
assert_eq!(geometry.radius, 28.0, "7.1 CornerExtraLarge");
assert_eq!(geometry.padding, 24.0, "7.3 DialogPadding");
assert_eq!(geometry.title_gap, 16.0, "7.3 TitlePadding bottom");
assert_eq!(geometry.body_gap, 24.0, "7.3 TextPadding bottom");
assert_eq!(geometry.action_gap, 8.0, "7.6 ButtonsMainAxisSpacing");
assert_eq!(geometry.action_cross, 12.0, "7.6 ButtonsCrossAxisSpacing");
}
/// 7.7 + D27:scrim = 黑@0.32 叠在 canvas 上之后的**不透明**实色。
#[test]
fn scrim_is_scrimalpha_precomposited_onto_the_canvas() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let ui = ui(flavor);
let scheme = Scheme::baseline(flavor);
let canvas = scheme.get(MaterialLayer::Canvas.surface_role());
let expected = to_iced(scheme.scrim().over(canvas));
assert_eq!(
scrim(&ui),
expected,
"{flavor:?}:scrim 必须逐位等于「黑@0.32 叠 canvas」"
);
assert_eq!(
scrim(&ui).a,
1.0,
"D27:CPU 档不出现半透明(预合成后必须不透明)"
);
assert_ne!(
scrim(&ui),
ui.layer(MaterialLayer::Canvas),
"遮罩必须与画布有可见差异"
);
assert!((SCRIM_ALPHA - 0.32).abs() < f32::EPSILON, "令牌口径 α=0.32");
}
}
// ---- §15 空态(项目自定) ----------------------------------------------------
/// 15.1:图标块 96 / 圆角 16 / 图标 32(**全部 `[估]`**,官方没有这个组件)。
#[test]
fn empty_state_geometry_is_the_project_defined_set() {
let geometry = EmptyStateGeometry::of(&ui(SchemeFlavor::Light));
assert_eq!(geometry.block, 96.0);
assert_eq!(geometry.block_radius, 16.0, "ShapeScale::Large");
// 复审 C:登记值 32(`IconSize::Empty`)。这条守「登记值 = 交给 `view_at` 的 px」——
// 它抓不住「盒子 32 而 svg 仍钉 24」那种情况(那是渲染层的事,probe 量墨迹 22..34 兜)。
assert_eq!(
geometry.icon, 32.0,
"盒内图标 32(`view_at` 按 32 烘焙 stroke)"
);
// **复审 P2-1 的回归断言**:三个间距必须精确等于令牌档位。
// 早先这里只写 `> 0.0`,所以「Column::spacing(8) 与显式 gap(16/24) 双算成 32/40」
// 完全藏得住(渲染实测 264 vs 登记 232)。
assert_eq!(geometry.gap_title, 16.0, "块↔标题 = Space::X4");
assert_eq!(geometry.gap_body, 8.0, "标题↔副文案 = Space::X2");
assert_eq!(geometry.gap_action, 24.0, "副文案↔CTA = Space::X6");
assert_eq!(geometry.block, 96.0);
assert_eq!(
super::empty_state::BLOCK_LAYER,
MaterialLayer::Overlay,
"块底色是 SurfaceContainerHigh(材质层 Overlay)"
);
}
// ---- 结构 / 角色 ------------------------------------------------------------
/// 五个入口都能在**任意**消息类型下构建(契约 §2:不 import `app::Message`)。
#[test]
fn every_component_builds_for_a_foreign_message_type() {
#[derive(Clone, Debug, PartialEq)]
enum Other {
Yes,
No,
}
let ui = ui(SchemeFlavor::Light);
let _: Element<'_, Other> = linear(&ui, Progress::determinate(0.5));
let _: Element<'_, Other> = circular(&ui, Progress::indeterminate(0.3));
let _: Element<'_, Other> = snackbar(
&ui,
Snackbar::new("已保存")
.placement(Placement::Top)
.two_lines()
.action("撤销", Other::No)
.dismiss(Other::No),
);
let _: Element<'_, Other> = dialog(
&ui,
Dialog::new("确认删除?", "此操作不可撤销。")
.double(("取消", Other::No), ("删除", Other::Yes)),
);
let _: Element<'_, Other> = empty_state(
&ui,
EmptyState::new(
unlucky_design::icons::IconKind::Instances,
"还没有实例",
"建一个开始游戏",
)
.action("新建", Other::Yes),
);
// 无动作的三种形态也要能构建(DialogActions::None / 无 action 的 Snackbar)
let _: Element<'_, Other> = dialog(&ui, Dialog::new("已是最新版本", "没有可用更新。"));
let _: Element<'_, Other> = snackbar(&ui, Snackbar::new("已复制"));
let _: Element<'_, Other> = empty_state(
&ui,
EmptyState::new(
unlucky_design::icons::IconKind::Logs,
"没有日志",
"启动一次游戏后这里会有内容",
),
);
}
/// 状态层必须来自 `interaction`(不是组件里自己乘 α):hover 层不透明且与容器色不同。
#[test]
fn snackbar_state_layers_come_from_the_interaction_module() {
let ui = ui(SchemeFlavor::Light);
let hover = interaction::overlay_color(
&ui,
Interaction::Hovered,
Role::InversePrimary,
Role::InverseSurface,
)
.expect("hover 有状态层");
assert_eq!(hover.a, 1.0, "状态层必须是实色(CPU/GPU 一致)");
assert_ne!(
hover,
ui.color(Role::InverseSurface),
"叠了 8% 必须看得出来"
);
// 禁用内容走 token 口径(38%),不是「变灰一点」
let disabled = interaction::disabled_content_over_role(&ui, Role::OnSurface, false);
assert_ne!(disabled, ui.color(Role::OnSurface));
assert_eq!(disabled.a, 1.0);
}
@@ -0,0 +1,281 @@
//! 进度两式的纯函数断言(`tests` 的子模块,从 `tests.rs` 拆出来控行数)。
//!
//! 三条回归都住在这里:
//! - [`raw_segments_are_unordered_and_layout_sorts_them`]:`phase_segments` 的原始输出**不有序**
//! (视觉 r1 P1-A 就是把两段按顺序铺 `FillPortion`,图上被压成一段);
//! - [`portion_shares_always_sum_to_the_full_track`]:份额之和恒为 1000(旧实现 34/100 相位 >1);
//! - [`circular_indeterminate_rotation_is_four_turns_and_continuous`]:每周期 4 圈、跨周期不跳变
//! (代码 r1 P1-B:旧实现只有 1 圈,且末尾倒跳 270°)。
use unlucky_design::color::SchemeFlavor;
use unlucky_design::shape::ShapeScale;
use super::super::geometry::{phase_segments, CircularGeometry, LinearGeometry, PHASE_055};
use super::super::keyframes::{KeyPoint, Keyframes};
use super::super::progress::{layout_spans, portion_shares, Amount, Progress};
use super::ui;
// ---- §12 进度 ---------------------------------------------------------------
/// 12.1:线性厚度 **4**、圆角 full ⇒ 2(`ShapeScale::Full.radius_px(4)`)。
#[test]
fn linear_thickness_and_radius_come_from_the_tokens() {
let geometry = LinearGeometry::of(&ui(SchemeFlavor::Light));
assert_eq!(geometry.thickness, 4.0, "12.1 线性厚度 = 4(A1 Height)");
assert_eq!(
geometry.radius,
ShapeScale::Full.radius_px(4.0),
"12.1 圆角 full = 厚的一半"
);
assert_eq!(geometry.radius, 2.0);
assert_eq!(
geometry.cycle_ms, 1750,
"不定态周期 = LinearAnimationDuration"
);
}
/// 12.3:环形 **40 / 4**,环中线半径 (40 − 4) / 2 = **18**。
#[test]
fn circular_geometry_matches_the_tokens() {
let geometry = CircularGeometry::of(&ui(SchemeFlavor::Dark));
assert_eq!(
geometry.diameter, 40.0,
"A1 CircularProgressIndicatorTokens.Size"
);
assert_eq!(geometry.thickness, 4.0, "A1 ActiveThickness");
assert_eq!(geometry.radius, 20.0, "圆角 full = 直径的一半");
assert_eq!(geometry.arc_radius(), 18.0);
assert_eq!(
geometry.cycle_ms, 6000,
"A1 CircularAnimationProgressDuration"
);
}
/// 12.4:不定态两段**逐段非负**——这条只证「单段自身合法」,**不证两段的先后**
/// (视觉 r1 P1-A 就是从这里漏过去的:函数名曾经叫 `..._stay_ordered`,其实没验排序)。
#[test]
fn indeterminate_segments_are_each_well_formed() {
let mut travelled = false;
let mut previous_end = 0.0;
for step in 0..=200 {
let phase = step as f32 / 200.0;
for (start, end) in phase_segments(phase) {
assert!(
start <= end + f32::EPSILON,
"相位 {phase:.3}:段起点 {start} 大于终点 {end}(前缘/后缘写反就会这样)"
);
assert!(
(0.0..=1.0).contains(&start) && (0.0..=1.0).contains(&end),
"相位 {phase:.3}:段越出轨道 {start}..{end}"
);
}
let first = phase_segments(phase)[0].1;
if phase > 0.0 && phase < 0.5 && first > previous_end {
travelled = true;
}
previous_end = first;
}
assert!(travelled, "段前沿必须随相位右移(否则就是一根不动的条)");
assert_eq!(phase_segments(0.0), [(0.0, 0.0), (0.0, 0.0)]);
let (_, head) = phase_segments(1000.0 / 1750.0)[0];
assert!((head - 1.0).abs() < 1.0e-5, "第一线前沿应在 1000ms 到 1.0");
}
/// **复审 P1-A 的回归测试**:`phase_segments` 的原始输出**不有序**(第一线的前缘在右、第二线在左),
/// 所以铺布局前必须排序 + 合并;排序后的两段位置要与 A1 keyframes 对得上。
#[test]
fn raw_segments_are_unordered_and_layout_sorts_them() {
// 原始顺序:第一线(右,0.355..0.87)在前,第二线(左,0.003..0.079)在后
let raw = phase_segments(0.55);
assert!(
raw[0].0 > raw[1].0,
"phase 0.55 的原始两段应当是「右段在前、左段在后」,实际 {raw:?}"
);
let laid = layout_spans(&raw);
// 期望:间隙 / 左段 / 间隙 / 右段 / 间隙(5 项,总长 1.0)
assert_eq!(laid.len(), 5, "两段应摊成 5 项:{laid:?}");
assert!(!laid[0].0 && laid[1].0 && !laid[2].0 && laid[3].0 && !laid[4].0);
let left = laid[1].1;
let right = laid[3].1;
let left_start: f32 = laid[0].1;
let right_start: f32 = laid[0].1 + left + laid[2].1;
// 期望值来自 `geometry::PHASE_055`——与渲染层探针**共用**(复审 B:早先两边差 0.011)
let [(a0, a1), (b0, b1)] = PHASE_055;
assert!(
(left_start - a0).abs() < 0.01,
"左段起点 {left_start} vs {a0}"
);
assert!(
(left - (a1 - a0)).abs() < 0.01,
"左段宽 {left} vs {}",
a1 - a0
);
assert!(
(right_start - b0).abs() < 0.01,
"右段起点 {right_start} vs {b0}"
);
assert!(
(right - (b1 - b0)).abs() < 0.01,
"右段宽 {right} vs {}",
b1 - b0
);
let total: f32 = laid.iter().map(|(_, fraction)| fraction).sum();
assert!(
(total - 1.0).abs() < 1.0e-5,
"铺开的占比之和必须 = 1.0,实测 {total}"
);
}
/// 任意相位下 `FillPortion` 份额之和**恒为 1000**(iced 按总和归一 ⇒ 不等于 1000 就会压扁)。
///
/// 复审 P1-A 的实测反例:旧实现 **34/100 个相位**总和 >1(最大 1.99)。
#[test]
fn portion_shares_always_sum_to_the_full_track() {
for step in 0..=200 {
let phase = step as f32 / 200.0;
let items = layout_spans(&phase_segments(phase));
let shares = portion_shares(&items);
let total: u32 = shares.iter().map(|(_, share)| u32::from(*share)).sum();
assert_eq!(total, 1000, "相位 {phase:.3} 的份额之和是 {total}");
assert!(
shares.iter().filter(|(is_bar, _)| *is_bar).count() <= 2,
"最多两段指示条,相位 {phase:.3}"
);
// 指示条之间必须留间隙(除非两段相接,那时会被合并成一段)
let mut previous_bar = false;
for (is_bar, share) in &shares {
if *is_bar && previous_bar && *share > 0 {
panic!("相位 {phase:.3}:两段指示条相邻但没合并");
}
previous_bar = *is_bar;
}
}
}
/// 环形不定态:弧长在 0.1..0.87 之间(A1 min/max),旋转走满一圈。
#[test]
fn circular_indeterminate_sweeps_the_official_range() {
let geometry = CircularGeometry::of(&ui(SchemeFlavor::Light));
let mut min = f32::MAX;
let mut max = f32::MIN;
let mut rotation = f32::MIN;
for step in 0..=600 {
let (sweep, spin) = geometry.indeterminate(step as f32 / 600.0);
min = min.min(sweep);
max = max.max(sweep);
rotation = rotation.max(spin);
}
assert!(
(min - 0.1).abs() < 1.0e-3,
"弧长下限 0.1(CircularIndeterminateMinProgress),实测 {min}"
);
assert!((max - 0.87).abs() < 1.0e-3, "弧长上限 0.87,实测 {max}");
// P1-B:每周期 4 圈(1080° 全局 + 360° 关键帧),不是 1 圈
assert!(
(rotation - 4.0).abs() < 0.01,
"旋转应走满 4 圈,实测 {rotation}"
);
}
/// **复审 P1-B 的回归测试**:环形不定态每周期 4 圈(官方 1440°),且跨周期**不跳变**。
///
/// 修之前:`ROTATION` 首点在 300ms/90°,且没有全局旋转 ⇒
/// `turns(0.9999) = 1.0`、`turns(0.0) = 0.25`,跨周期差 0.75 圈 = **270° 瞬时倒跳**。
#[test]
fn circular_indeterminate_rotation_is_four_turns_and_continuous() {
let geometry = CircularGeometry::of(&ui(SchemeFlavor::Light));
// 每周期 4 圈 = 1440°(1080° 全局 + 360° 关键帧)
let start = geometry.indeterminate(0.0).1;
let end = geometry.indeterminate(1.0 - 1.0e-6).1;
assert!(
((end - start) - 4.0).abs() < 1.0e-3,
"每周期应为 4 圈(1440°),实测 {} 圈",
end - start
);
// 跨周期连续性:按「圈」取最短距离,必须 ≤ 一帧的位移(60fps ⇒ 4/60 ≈ 0.067 圈)
let before = geometry.indeterminate(1.0 - 1.0e-4).1;
let after = geometry.indeterminate(0.0).1;
let mut jump = (before - after).rem_euclid(1.0);
if jump > 0.5 {
jump = 1.0 - jump;
}
assert!(
jump <= 4.0 / 60.0,
"跨周期旋转跳变 {jump} 圈(应 ≤ 4/60;修之前是 0.75 圈 = 270°)"
);
// 给报告用的一行实测数值(`--nocapture` 可见):跨周期跳变 + 每周期圈数
eprintln!(
"[rotation] turns(0.9999)={before:.6} turns(0.0)={after:.6} 跳变={jump:.6} 圈({:.2}°)· 每周期 {:.4} 圈 = {:.1}°",
jump * 360.0,
end - start,
(end - start) * 360.0
);
// 旋转必须单调(相位 0 → 1 逐帧不回退,按圈算)
// 只扫 [0, 1) 的相位:phase == 1.0 就是下一周期的 0,回绕是**正常的**(mod 1 等价)。
let mut previous = geometry.indeterminate(0.0).1;
for step in 1..600 {
let phase = step as f32 / 600.0;
let current = geometry.indeterminate(phase).1;
assert!(
current >= previous - 1.0e-6,
"相位 {phase:.3}:旋转从 {previous} 回退到 {current}"
);
previous = current;
}
}
/// 确定态:越界值夹到 0..1(0% 只画轨道,100% 画满)。
#[test]
fn determinate_values_are_clamped() {
assert_eq!(
Progress::determinate(1.5).amount(),
Amount::Determinate(1.0)
);
assert_eq!(
Progress::determinate(-2.0).amount(),
Amount::Determinate(0.0)
);
assert_eq!(Progress::determinate(0.5).spans(), vec![(0.0, 0.5)]);
assert_eq!(Progress::determinate(0.0).spans(), vec![(0.0, 0.0)]);
}
/// keyframes 求值器:端点保持、相位循环、越界相位按周期取模。
#[test]
fn keyframes_hold_ends_and_wrap() {
const LINE: Keyframes = Keyframes {
cycle_ms: 1000,
points: &[
KeyPoint {
ms: 250,
value: 0.0,
easing: unlucky_design::motion::Easing::Linear,
},
KeyPoint {
ms: 750,
value: 1.0,
easing: unlucky_design::motion::Easing::Linear,
},
],
};
assert_eq!(LINE.sample(0.0), 0.0, "第一点之前保持首值");
assert_eq!(LINE.sample(0.25), 0.0, "正好在第一点");
assert!((LINE.sample(0.5) - 0.5).abs() < 1.0e-5, "中点线性插值");
assert_eq!(LINE.sample(0.75), 1.0);
assert_eq!(LINE.sample(0.9), 1.0, "最后一点之后保持末值");
assert_eq!(LINE.sample(1.25), 0.0, "相位 1.25 = 0.25(循环)");
assert_eq!(
LINE.sample(-0.75),
0.0,
"负相位按周期折返:-0.75 → 0.25 相位 = 250ms = 首点"
);
assert!(
(LINE.sample(-0.6) - 0.3).abs() < 1.0e-5,
"-0.6 → 0.4 相位 = 400ms = 30%"
);
assert_eq!(LINE.sample(f32::NAN), 0.0, "NaN 不 panic,给首值");
}
@@ -0,0 +1,173 @@
//! 按钮家族的状态矩阵(D30:浅/深 × 五态)。
//!
//! ## 矩阵形状
//!
//! ```text
//! | enabled | hovered | pressed | focused | disabled |
//! filled | [btn] | [btn] | [btn] | [btn] | [btn] |
//! tonal | ... (下同)
//! elevated |
//! outlined |
//! text |
//! icon·standard | [ico] | ...
//! icon·filled |
//! icon·tonal |
//! ```
//!
//! 40 个单元格共 5 列 × 8 行,一屏(1180×760)装不下,靠 `gallery/mod.rs` 的 scrollable 滚动;
//! 截图取的是**整窗**,所以首屏能看到按钮家族的全部 5 行(图标行可能需要滚动,报告里会说明)。
//!
//! ## 为什么每格都强制状态
//!
//! iced 的 `button::Status` 只由**真实鼠标**驱动,截图时没法让 40 个按钮同时 hover。
//! 组件层的 `Button::state(...)` 就是为这件事开的取证开关(`--force-state` 只能给整屏一种态)。
//! 强制值**只影响画什么**,不动任何状态机——所以矩阵图里的五态与运行期五态逐像素同源。
use iced::widget::{column, row};
use iced::{Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::super::buttons::{self, Button, IconVariant, Variant};
use super::super::interaction::Interaction;
use super::{cell_width, heading, row_label, state_cells, DemoMessage};
/// 按钮家族整段(标题 + 列头 + 5 行文字按钮 + 3 行图标按钮 + 一条图例说明)。
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let legend = ui.style(TypeRole::BodySmall);
let mut rows = column![header_row(ui)].spacing(ui.space(Space::X2));
for variant in Variant::ALL {
let cells = Interaction::ALL
.iter()
.map(|state| cell(ui, text_button(ui, variant, *state)))
.collect::<Vec<_>>();
rows = rows.push(
row![
row_label(ui, variant.name()),
row(cells).spacing(ui.space(Space::X2))
]
.spacing(ui.space(Space::X2)),
);
}
for variant in IconVariant::ALL {
let cells = Interaction::ALL
.iter()
.map(|state| cell(ui, icon_button(ui, variant, *state)))
.collect::<Vec<_>>();
rows = rows.push(
row![
row_label(ui, variant.name()),
row(cells).spacing(ui.space(Space::X2))
]
.spacing(ui.space(Space::X2)),
);
}
column![
heading(
ui,
"buttons · filled / tonal / elevated / outlined / text + icon",
"12b §1/§2:高 40 · 圆角 full(20) · 内边距 24(带图标 16/24)· 图标 18 间距 8 · \
hover 8% / focus 10% / pressed 10% / disabled 容器 10% 内容 38%"
),
rows,
iced::widget::text(
"图例:列 = 五态(focus 与 pressed 同 α 10%,看起来一样是规范要求);\
行 = 变体。虚线不表示任何东西,只是矩阵栅格。"
)
.size(legend.size_px)
.font(ui.font_of(legend))
.color(ui.color(Role::OnSurfaceVariant)),
]
.spacing(ui.space(Space::X2))
.into()
}
/// 列头(五态名)。
fn header_row<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let style = ui.style(TypeRole::LabelSmall);
let mut head = row![row_label(ui, "")].spacing(ui.space(Space::X2));
for (name, width) in state_cells(ui) {
head = head.push(
iced::widget::container(
iced::widget::text(name)
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::OnSurfaceVariant)),
)
.width(Length::Fixed(width)),
);
}
head.into()
}
/// 一个矩阵单元格(固定宽,内容左对齐;高度只由按钮自己决定)。
fn cell<'a>(ui: &'a Ui, content: Element<'a, DemoMessage>) -> Element<'a, DemoMessage> {
iced::widget::container(content)
.width(Length::Fixed(cell_width(ui)))
.into()
}
/// 文字按钮的一格(强制成 `state`)。
fn text_button<'a>(ui: &'a Ui, variant: Variant, state: Interaction) -> Element<'a, DemoMessage> {
// 标签用变体名 + 首字母大写:图上要能一眼对上左栏,且不引入 i18n 依赖(gallery 不是产品页)
let label = match variant {
Variant::Filled => "Filled",
Variant::Tonal => "Tonal",
Variant::Elevated => "Elevated",
Variant::Outlined => "Outlined",
Variant::Text => "Text",
};
// 五态共用**同一套**装配,只有「有没有消息」随禁用变:
// 早先禁用格单独走 `Button::disabled(label)`,把前后图标一起丢了,于是 disabled 列看起来
// 像另一个组件(视觉 reviewer P2-3)。
let mut spec = if state.is_disabled() {
Button::disabled(label)
} else {
Button::new(label, DemoMessage::Pressed(label))
}
.state(state);
if variant != Variant::Text {
// 两个样例都必须是**描边**字形:`Play` 是填充字形,和 `ChevronRight` 混在一颗按钮里
// 会显得线宽不一致(视觉 reviewer P2-3)。主操作允许实心,但只能单独出现。
spec = spec
.leading(IconKind::Download)
.trailing(IconKind::ChevronRight);
}
match variant {
Variant::Filled => buttons::filled(ui, spec),
Variant::Tonal => buttons::tonal(ui, spec),
Variant::Elevated => buttons::elevated(ui, spec),
Variant::Outlined => buttons::outlined(ui, spec),
Variant::Text => buttons::text(ui, spec),
}
}
/// 图标按钮的一格(强制成 `state`)。
fn icon_button<'a>(
ui: &'a Ui,
variant: IconVariant,
state: Interaction,
) -> Element<'a, DemoMessage> {
let press = if state.is_disabled() {
None
} else {
Some(DemoMessage::Pressed(variant.name()))
};
buttons::icon(ui, variant, IconKind::Settings, press, Some(state))
}
@@ -0,0 +1,365 @@
//! gallery 里的反馈类小节:把状态矩阵摆进 harness(**断言不在这里**,在
//! [`crate::app::components::feedback::probe`])。
//!
//! ## 段落切分:`ULL_FEEDBACK_PART`(本文件自己的开关)
//!
//! `ULL_GALLERY_SECTION=feedback` 只能把**整个家族**拎出来,而四件的高度超过 1180×760 首屏,
//! 所以再加一层**家族内**切分(契约 §9.2 的口径:环境变量 + 默认向后兼容 + 未识别的值回落
//! 默认并打 stderr;区别只是它住在**本家族自己的文件**里,不动 `gallery/mod.rs`):
//!
//! ```text
//! ULL_FEEDBACK_PART=all # 默认:四件全渲染(progress → snackbar → empty → dialog)
//! ULL_FEEDBACK_PART=progress # Linear 0/50/100 + 三相位 · Circular 0/25/50/75/100 + 不定态
//! ULL_FEEDBACK_PART=snackbar # 四格:贴顶·上限宽 / 双行 / 宿主 300 / 宿主 344
//! ULL_FEEDBACK_PART=dialog # 两形态并排 + 双动作 + 一屏 scrim
//! ULL_FEEDBACK_PART=empty # 空态(带 CTA)
//! ```
//!
//! ## 宿主尺寸为什么写死
//!
//! 量宽度必须有确定的宿主宽:`Snackbar` 的 600 是「宿主给 800 时的夹取值」、344 与 300 是
//! 「宿主给多少就是多少」的反证(组件没有 min-width,G17-d)。`Linear` 轨道统一 320(一行 3 格 = 960 < 1128)。
//!
//! 复现(worktree 根目录):
//!
//! ```text
//! ULL_GALLERY_SECTION=feedback ULL_FEEDBACK_PART=progress ./target/debug/unlucky-launcher-iced \
//! --software-renderer --screen=gallery \
//! --screenshot target/m78/progress-light.png --screenshot-theme=light
//! ULL_GALLERY_SHOT=target/m78/progress-light.png cargo test -p app-iced feedback::probe
//! ```
use iced::widget::{column, container, row, text};
use iced::{Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::super::feedback::gallery_parts::{Part, DIALOG, EMPTY, PROGRESS, SNACKBAR};
use super::super::feedback::{self, Dialog, EmptyState, Placement, Progress, Snackbar};
use super::super::interaction::Interaction;
use super::{heading, DemoMessage};
/// 线性进度的宿主宽(格宽,不是组件尺寸)。
const TRACK_HOST: f32 = 320.0;
/// `Snackbar` 的宿主宽:**条宽 + 2×12 外边距**(12 是官方外边距,组件的定位层会扣掉)。
///
/// 800(条夹到上限 600)/ 368(条 344)/ 324(条 300,用来证明**没有**下限,G17-d)。
const HOST_WIDE: f32 = 800.0;
const HOST_MIN: f32 = 368.0;
const HOST_NARROW: f32 = 324.0;
/// `Snackbar` 宿主高(比 68 + 2×12 略高,留出贴顶/贴底的判定余量)。
const HOST_TALL: f32 = 96.0;
/// 窄宿主:给 `Dialog::width` 递 360,量「窄宿主不再被裁」的那一格(复审 E)。
const NARROW_HOST: f32 = 360.0;
/// 单行提示条的宿主高。
///
/// **96 而不是 72**(复审 D):72 = 48 + 2×12 时上下外边距相等,像素上分不出「贴底」还是
/// 「居中」;96 让贴底那格的上留白 = 36、下留白 = 12,探针能量到「条底距宿主底 = 12」。
const HOST_SHORT: f32 = 96.0;
/// 空态宿主高:内容本征高 ≈96+16+32+8+16+24+40 ≈ 232,留出上下各 40 的余量。
const HOST_EMPTY: f32 = 320.0;
/// 反馈类家族的小节。
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let part = Part::from_env();
part.log_once();
let mut body = column![heading(
ui,
"feedback · progress / snackbar / dialog / empty state",
"12b §7/§12/§14/§15:Linear 厚 4 圆角 full · Circular 40/4 · Snackbar 48/68 上限宽 600 · \
Dialog R28 pad24 宽 480 · EmptyState 项目自定[估](官方无此组件)"
)]
.spacing(ui.space(Space::X3));
if part.has(PROGRESS) {
body = body.push(progress_block(ui));
}
if part.has(SNACKBAR) {
body = body.push(snackbar_block(ui));
}
if part.has(EMPTY) {
body = body.push(empty_block(ui));
}
if part.has(DIALOG) {
// 只有这一屏不画进度/提示条时,才把三形态与 scrim 都摆开(高度够)。
let roomy = !part.has(PROGRESS) && !part.has(SNACKBAR);
body = body.push(dialog_block(ui, roomy));
}
body.into()
}
/// 一行判据说明(`LabelSmall`,与矩阵的正文档分开)。
fn caption<'a>(ui: &'a Ui, value: &'a str) -> Element<'a, DemoMessage> {
let style = ui.style(TypeRole::LabelSmall);
text(value)
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::OnSurfaceVariant))
.into()
}
/// 带 1px 取景框的宿主:`Snackbar` 的贴底/贴顶需要一条**可见的宿主边界**才量得出来
/// (描边画在盒内但**不参与布局**:内容区仍是整盒宽,实测条宽不受影响)。
/// (透明宿主的底边在图上不存在,`Placement::Bottom` 就不可证)。
fn framed_host<'a>(
ui: &'a Ui,
width: f32,
height: f32,
content: impl Into<Element<'a, DemoMessage>>,
) -> Element<'a, DemoMessage> {
let line = ui.color(Role::OutlineVariant);
container(content)
.width(Length::Fixed(width))
.height(Length::Fixed(height))
.style(move |_theme| container::Style {
border: iced::Border {
color: line,
width: crate::theme::STROKE_PX,
radius: iced::border::Radius::default(),
},
..container::Style::default()
})
.into()
}
/// 固定宽宿主(量宽度用:宿主宽写死,组件内部的占比/夹取才有确定值)。
fn host<'a>(
width: f32,
height: Option<f32>,
content: impl Into<Element<'a, DemoMessage>>,
) -> Element<'a, DemoMessage> {
let mut box_ = container(content).width(Length::Fixed(width));
if let Some(height) = height {
box_ = box_.height(Length::Fixed(height));
}
box_.into()
}
/// 一列:[进度条, 说明]。
fn track<'a>(ui: &'a Ui, label: &'a str, progress: Progress) -> Element<'a, DemoMessage> {
column![
host(TRACK_HOST, None, feedback::linear(ui, progress)),
caption(ui, label)
]
.spacing(ui.space(Space::X2))
.into()
}
/// 一列:[环, 说明]。
fn ring<'a>(ui: &'a Ui, label: &'a str, progress: Progress) -> Element<'a, DemoMessage> {
column![feedback::circular(ui, progress), caption(ui, label)]
.spacing(ui.space(Space::X2))
.into()
}
/// §12:Linear 0/50/100 + 四个相位;Circular 0/25/50/75/100 + 三个相位。
fn progress_block<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let determinate = row![
track(ui, "0%", Progress::determinate(0.0)),
track(ui, "50%", Progress::determinate(0.5)),
track(ui, "100%", Progress::determinate(1.0)),
]
.spacing(ui.space(Space::X3));
let indeterminate = row![
track(ui, "phase 0.15", Progress::indeterminate(0.15)),
track(ui, "phase 0.35", Progress::indeterminate(0.35)),
track(ui, "phase 0.55", Progress::indeterminate(0.55)),
]
.spacing(ui.space(Space::X3));
let rings = row![
ring(ui, "0%", Progress::determinate(0.0)),
ring(ui, "25%", Progress::determinate(0.25)),
ring(ui, "50%", Progress::determinate(0.5)),
ring(ui, "75%", Progress::determinate(0.75)),
ring(ui, "100%", Progress::determinate(1.0)),
ring(ui, "ind 0.30", Progress::indeterminate(0.30)),
]
.spacing(ui.space(Space::X4))
.align_y(iced::Alignment::Start);
column![
caption(
ui,
"Linear · determinate 0% / 50% / 100%(轨道 SecondaryContainer · 指示条 Primary)"
),
determinate,
caption(
ui,
"Linear · indeterminate(官方 1750ms 双段追逐;下面是同一周期的三个相位)"
),
indeterminate,
caption(
ui,
"Circular · 直径 40 · 线宽 4 · 弧长 = 数值 × 360°(第六格 = 不定态 phase 0.30)"
),
rings,
]
.spacing(ui.space(Space::X2))
.into()
}
/// 提示条的宿主:固定宽 + 提示条宿主高(比 68 + 2×12 略高,贴顶/贴底都量得到)。
fn bar_host<'a>(
width: f32,
content: impl Into<Element<'a, DemoMessage>>,
) -> Element<'a, DemoMessage> {
host(width, Some(HOST_TALL), content)
}
/// §14:四格 —— 贴顶·上限宽 / 双行 + action + 关闭 / 宿主 300 / 宿主 344。
fn snackbar_block<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let press = |id| DemoMessage::Pressed(id);
let wide = bar_host(
HOST_WIDE,
feedback::snackbar(
ui,
Snackbar::new("版本清单已刷新")
.placement(Placement::Top)
.dismiss(press("snackbar/wide")),
),
);
let two_lines = host(
HOST_WIDE,
Some(HOST_TALL * 2.0),
feedback::snackbar(
ui,
Snackbar::new(
"启动失败:找不到 Java 21。请在「设置 · Java」里指定运行时路径,或让启动器自动下载一个 21 的运行时。",
)
.two_lines()
.placement(Placement::Top)
.action("去设置", press("snackbar/settings"))
.dismiss(press("snackbar/close"))
.state(Interaction::Hovered),
),
);
let mid = framed_host(
ui,
HOST_MIN,
HOST_SHORT,
feedback::snackbar(
ui,
Snackbar::new("已删除实例")
.placement(Placement::Bottom)
.action("撤销", press("snackbar/undo")),
),
);
let narrow = framed_host(
ui,
HOST_NARROW,
HOST_SHORT,
feedback::snackbar(ui, Snackbar::new("宿主 324").placement(Placement::Bottom)),
);
column![
caption(
ui,
"Snackbar · 贴顶 + 上限宽(宿主 800 → 夹到 600)· 关闭键 InverseOnSurface"
),
wide,
caption(
ui,
"Snackbar · 双行 68 + action InversePrimary + 关闭键(动作强制 hover 态)"
),
two_lines,
caption(
ui,
"宿主 324(取景框)⇒ 条 300:组件没有 min-width,宿主给多少就是多少(G17-d);贴底 = 条底距框底 12"
),
narrow,
caption(ui, "宿主 368 ⇒ 条 344:最小宽的取值口径([估])"),
mid,
]
.spacing(ui.space(Space::X2))
.into()
}
/// §15:空态(项目自定;浅/深两种主题各出一张图,主题来自 `--screenshot-theme`)。
fn empty_block<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let press = |id| DemoMessage::Pressed(id);
host(
HOST_WIDE,
Some(HOST_EMPTY),
feedback::empty_state(
ui,
EmptyState::new(
IconKind::Instances,
"还没有任何实例",
"建一个实例,或者从整合包导入。[项目自定 · 估]",
)
.action("新建实例", press("empty/create")),
),
)
}
/// §7:三形态(无动作 / 单动作 / 双动作)+ 一屏 scrim。
///
/// `full = true` 表示**这一屏只渲染 dialog 段**(`ULL_FEEDBACK_PART=dialog` 或 `=empty` 之类
/// 单独出图):此时多摆一屏 scrim,并把三形态都摆开;全家族同屏时留高度给别的段。
fn dialog_block<'a>(ui: &'a Ui, full: bool) -> Element<'a, DemoMessage> {
let press = |id| DemoMessage::Pressed(id);
// 页面把自己的**可用宽**递给对话框(组件取 `min(可用宽, 480)`)——窄窗口时靠这一手,
// 组件自己无法从宿主推(iced 的 Length 没有 min-width,见 `Dialog::width` 的说明)。
let plain = feedback::dialog(
ui,
Dialog::new("已是最新版本", "当前没有可用更新。").width(HOST_WIDE),
);
let single = feedback::dialog(
ui,
Dialog::new("已导入整合包", "共 42 个模组,缺失 0 个。")
.confirm("知道了", press("dialog/ok")),
);
// 窄宿主那格:可用宽 360 ⇒ `min(480, 360) = 360`,且不得低于官方下限 280。
let narrow = feedback::dialog(
ui,
Dialog::new("窄宿主 360", "可用宽 360 时取 min(480, 360)。").width(NARROW_HOST),
);
// 强制 hover:动作件的状态层必须来自 `interaction`(不是组件里自己乘 8%)。
let double = feedback::dialog(
ui,
Dialog::new("删除实例「1.20.1 生存」?", "存档与模组会一起删除。")
.double(
("取消", press("dialog/cancel")),
("删除", press("dialog/delete")),
)
.state(Interaction::Hovered),
);
let mut body = column![caption(
ui,
"Dialog · R28 · SurfaceContainerHigh + L3(**本项目不画阴影,见 D-1**)· pad 24(标题下 16 / 正文下 24)· 动作 Primary 间距 8",
)]
.spacing(ui.space(Space::X2));
body = body.push(row![plain, single].spacing(ui.space(Space::X6)));
// 双动作 + 窄宿主并排:探针要同时量到 480 与 360 两种宽
body = body.push(row![double, narrow].spacing(ui.space(Space::X6)));
if full {
body = body.push(caption(
ui,
"Dialog · scrim = 黑@0.32 叠 canvas 的实色(D27 预合成;详见 dialog.rs 模块文档)",
));
body = body.push(host(
HOST_WIDE,
Some(HOST_EMPTY),
feedback::scrimmed(
ui,
feedback::dialog(ui, Dialog::new("已是最新版本", "当前没有可用更新。")),
),
));
}
body.into()
}
@@ -0,0 +1,341 @@
//! gallery 里的输入类小节(D30 的状态矩阵登记):TextField / Search / Slider / Menu。
//!
//! ## 为什么分成四个 `part`
//!
//! 一整段(四组件 × 五态 × 两式)比 760 高的窗口还高,`--screen=gallery` 的截图只能看到顶部。
//! 所以本文件把证据拆成四个自足的 `part`([`text_field_part`] / [`search_part`] /
//! [`slider_part`] / [`menu_part`]),[`section`] 按 D30 的顺序把它们竖排——
//! 出图时用副本工程把 `section()` 换成单个 `part`(只调段序、不改组件代码,报告里有 diff 与说明)。
//!
//! ## 矩阵形状
//!
//! ```text
//! TextField(两行 × 五态) 列 = enabled / hovered / pressed / focused / disabled
//! filled | [字段] | ...
//! outlined | [字段] | ...
//! 另加三格:空 / 有值 / 错误(9.7 的判据要单独一眼)
//!
//! Search (五行 + 一个展开面板)
//! Slider (五态一行 + 0% / 50% / 100% 三行 + 离散 4 段一行)
//! Menu (五态各一项的一只菜单 + 一只「展开态」菜单:选中 / 分隔线 / 禁用)
//! ```
//!
//! ## 每个格子都强制状态
//!
//! 与按钮家族同一个理由:截图时没法让 20 个控件同时 hover。`state(..)` 只是取证开关,
//! 不动任何状态机——图上的五态与运行期五态同源。
use iced::widget::{column, container, row, text};
use iced::{Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::super::inputs::{
self, Field, FieldVariant, Menu, MenuItem, SearchBar, SearchView, Slider,
};
use super::super::interaction::Interaction;
use super::{cell_width, heading, row_label, state_cells, DemoMessage};
/// 演示消息(组件只认泛型,gallery 的映射在这里落地)。
fn pressed(id: &'static str) -> DemoMessage {
DemoMessage::Pressed(id)
}
/// 输入类家族整段(四个 part 竖排;见模块文档的「为什么分成四个 part」)。
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
column![
heading(
ui,
"inputs · text field / search / slider / menu",
"12b §9/§11/§8 + 15 §20:TextField 高 56(无标签 40)· Search 条 56 药丸 + 图标可视边 16 \
(= pad 12 + offset 4)· Slider 轨 16 + 竖条 4×44 · Menu 项 48 + L2 阴影"
),
text_field_part(ui),
search_part(ui),
slider_part(ui),
menu_part(ui),
]
.spacing(ui.space(Space::X8))
.into()
}
/// 一行小字(图例/说明)。
fn note<'a>(ui: &'a Ui, value: &'a str) -> Element<'a, DemoMessage> {
let style = ui.style(TypeRole::BodySmall);
text(value)
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::OnSurfaceVariant))
.into()
}
/// 固定宽的矩阵格。
fn cell<'a>(ui: &'a Ui, content: impl Into<Element<'a, DemoMessage>>) -> Element<'a, DemoMessage> {
container(content)
.width(Length::Fixed(cell_width(ui)))
.into()
}
/// 五态列头。
fn state_header<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let style = ui.style(TypeRole::LabelSmall);
let mut head = row![row_label(ui, "")].spacing(ui.space(Space::X2));
for (name, width) in state_cells(ui) {
head = head.push(
container(
text(name)
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::OnSurfaceVariant)),
)
.width(Length::Fixed(width)),
);
}
head.into()
}
/// 一行矩阵(左侧标签 + 五格)。
fn state_row<'a>(
ui: &'a Ui,
label: &'a str,
cells: Vec<Element<'a, DemoMessage>>,
) -> Element<'a, DemoMessage> {
row![
row_label(ui, label),
row(cells).spacing(ui.space(Space::X2))
]
.spacing(ui.space(Space::X2))
.into()
}
/// TextField:两式 × 五态 + 空 / 有值 / 错误 三格。
fn text_field_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let mut matrix = column![state_header(ui)].spacing(ui.space(Space::X2));
for variant in FieldVariant::ALL {
let cells = Interaction::ALL
.iter()
.map(|state| {
cell(
ui,
inputs::text_field(
ui,
Field::new("实例一")
.variant(variant)
.label("名称")
.state(*state),
|_| pressed("field"),
),
)
})
.collect::<Vec<_>>();
matrix = matrix.push(state_row(ui, variant.name(), cells));
}
// 三格用 **320** 宽(= 2 个矩阵格):9.2 的最小宽 280 只在这里可验(矩阵格 160 更窄)
let wide = Length::Fixed(cell_width(ui) * 2.0);
let extras = row![
inputs::text_field(
ui,
Field::new("")
.label("名称")
.placeholder("请输入名称")
.width(wide),
|_| pressed("field"),
),
inputs::text_field(
ui,
Field::new("1.21.4")
.label("名称")
.leading(IconKind::Folder)
.width(wide),
|_| pressed("field"),
),
inputs::text_field(
ui,
Field::new("")
.variant(FieldVariant::Outlined)
.label("名称")
.placeholder("必填")
.trailing(IconKind::Error)
.supporting("该名称已被占用")
.error()
.width(wide),
|_| pressed("field"),
),
]
.spacing(ui.space(Space::X2));
column![
note(
ui,
"列 = 五态 · 行 = 两式。静息指示线/描边 1px、聚焦与错误 2px(A1 FocusOutlineWidth);\
错误态线/标签/辅助文本全 Error"
),
matrix,
note(
ui,
"另加:空(占位 OnSurfaceVariant)/ 有值(带前置图标 24)/ 错误(2px + 辅助文本)"
),
extras,
]
.spacing(ui.space(Space::X2))
.into()
}
/// Search:五态 bar 竖排 + 一个展开的 docked 面板。
fn search_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let bar_width = ui.space(Space::X8) * 12.0;
let mut bars = column![].spacing(ui.space(Space::X2));
for state in Interaction::ALL {
let bar = inputs::search_bar(
ui,
SearchBar::new("")
.placeholder("搜索实例 / 版本 / mod")
.state(state)
.width(Length::Fixed(bar_width)),
|_| pressed("search"),
);
bars = bars.push(
row![row_label(ui, state.name()), bar]
.spacing(ui.space(Space::X2))
.align_y(iced::Alignment::Center),
);
}
let results: &[&str] = &[
"实例:原版 1.21.4",
"版本:Fabric 0.16.9",
"mod:Sodium 0.6.5",
];
let view = inputs::search_view(
ui,
SearchView::new("1.21", results, "没有匹配的实例").placeholder("搜索实例 / 版本 / mod"),
|_| pressed("search"),
);
column![
note(
ui,
"bar:高 56 · 圆角 full(28) · SurfaceContainerHigh · 图标可视边 16 = pad 12 + offset 4(M69 不变量)· \
聚焦 1px Secondary;view:docked,头部 56 + 1px Outline 分隔 + 结果区最小高 240"
),
row![bars, view].spacing(ui.space(Space::X6)),
]
.spacing(ui.space(Space::X2))
.into()
}
/// Slider:五态一行 + 0%/50%/100% 三行 + 离散 4 段一行。
fn slider_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let cells = Interaction::ALL
.iter()
.map(|state| {
let enabled = !state.is_disabled();
let mut spec = Slider::new(ui, 0.5).state(*state);
if enabled {
spec = spec.on_change(|_| pressed("slider"));
}
cell(ui, inputs::slider(spec))
})
.collect::<Vec<_>>();
let track_width = ui.space(Space::X8) * 14.0;
let mut values = column![].spacing(ui.space(Space::X2));
for (label, fraction) in [("0%", 0.0), ("50%", 0.5), ("100%", 1.0)] {
let spec = Slider::new(ui, fraction)
.on_change(|_| pressed("slider"))
.state(Interaction::Enabled);
values = values.push(
row![
row_label(ui, label),
container(inputs::slider(spec)).width(Length::Fixed(track_width)),
]
.spacing(ui.space(Space::X2))
.align_y(iced::Alignment::Center),
);
}
// 离散:4 段 = 3 个内部停靠点(25 / 50 / 75%),当前 25% ⇒ 能看到 50% 与 75% 两个点
let discrete = row![
row_label(ui, "离散 4"),
container(inputs::slider(
Slider::new(ui, 0.25)
.steps(4)
.on_change(|_| pressed("slider"))
.state(Interaction::Enabled)
))
.width(Length::Fixed(track_width)),
]
.spacing(ui.space(Space::X2))
.align_y(iced::Alignment::Center);
column![
note(
ui,
"轨 16 full(Primary / SecondaryContainer)· 竖条 4×44 full(overflow 上下各 14)· \
focus/pressed 收窄到 2 · 命中 48 · 离散停靠点 4"
),
state_row(ui, "五态", cells),
note(ui, "0% / 50% / 100%:竖条在两端贴边但不越界(左缘 = 轨道左缘,右缘 = 轨道右缘)"),
values,
note(
ui,
"离散:steps(4) ⇒ 3 个内部停靠点(25/50/75%),未激活轨在停靠点两侧留 6 空隙,\n 激活段只画到竖条中心",
),
discrete,
]
.spacing(ui.space(Space::X2))
.into()
}
/// Menu:一只五态菜单 + 一只展开态菜单(选中 / 分组线 / 禁用)。
fn menu_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let width = ui.space(Space::X8) * 8.0;
let mut states = Menu::new().width(Length::Fixed(width));
for state in Interaction::ALL {
let mut item = MenuItem::new("重命名", pressed("menu")).state(state);
if state.is_disabled() {
item = MenuItem::disabled("重命名").state(state);
}
states = states.item(item.leading(IconKind::Folder));
}
let open = Menu::new()
.width(Length::Fixed(width))
.item(MenuItem::new("重命名", pressed("menu")).leading(IconKind::Folder))
.item(
MenuItem::new("复制实例", pressed("menu"))
.leading(IconKind::Download)
.trailing(IconKind::Check)
.selected(),
)
.divider()
.item(MenuItem::disabled("删除").leading(IconKind::Trash));
column![
note(
ui,
"容器 SurfaceContainer + L2 阴影(blur 6 / offset 2 / Shadow 角色)· 圆角 4(令牌表)· \
项高 48 · 水平 12 · 上下留白 8 · 选中项 SecondaryContainer 指示器内缩 4、圆角 8"
),
row![
column![note(ui, "五态(每项强制一态)"), inputs::menu(ui, states)]
.spacing(ui.space(Space::X2)),
column![
note(ui, "展开:含选中项 / 分组分隔线(复用 divider)/ 不可点项"),
inputs::menu(ui, open)
]
.spacing(ui.space(Space::X2)),
]
.spacing(ui.space(Space::X6)),
]
.spacing(ui.space(Space::X2))
.into()
}
@@ -0,0 +1,375 @@
//! `--screen=gallery` 的壳:**视觉验收 harness**(D30 / D39 §5),不是产品页面。
//!
//! ## 它是什么、不是什么
//!
//! - 是:**状态矩阵的取景框**。每个家族在自己的 `gallery/<family>.rs` 里摆出
//! 「五变体 × 五态」的矩阵,一张截图就能判完该家族(浅/深各一张 = 10 张证据)。
//! - 不是:页面。gallery 允许堆叠、不分页、不做空态,**难看不算问题**;
//! 但**每个组件本身**必须按官方口径做到位。
//!
//! ## 并行地基(后续四条线怎么加东西)
//!
//! 五个家族文件在下面 `mod` 里**全部预先声明**了,每个都有自己的 `section()`:
//!
//! ```text
//! gallery/buttons.rs ← COMPBASE(本 mission)
//! gallery/selection.rs ← 后续 mission(只改这个文件)
//! gallery/inputs.rs ← 同上
//! gallery/feedback.rs ← 同上
//! gallery/navigation.rs ← 同上
//! ```
//!
//! 加组件 = 只改自己那个文件里的 `section()`,**不用碰本文件**(这是「五条线并行不冲突」的
//! 具体保证,也是 tower 会亲手验的一条)。
//!
//! ## DemoMessage 为什么独立
//!
//! 契约 §2:组件不 import `crate::app::Message`。gallery 用自己定义的消息枚举,
//! 正好当「组件能脱离业务验收」的示范——`app.rs` 只做一次 `.map(Message::Demo)` 就接上了。
//!
//! ## 截图命令(D30)
//!
//! ```text
//! unlucky-launcher-iced --software-renderer --screen=gallery \
//! --screenshot out.png --screenshot-theme=light
//! ```
//!
//! `--screenshot` 不带主题时**默认 Light**(可复现;见 `cli::Options::capture_flavor`)。
//!
//! ## 段落过滤:环境变量 `ULL_GALLERY_SECTION`(M76 起)
//!
//! 五段拼成一列、外面套 `scrollable`,而截图只截**首屏** `1180×760`。段数一多,后面的家族
//! 就落到折叠线以下(实测:selection 段从 y≈735 起,只剩标题一行)——那条线的
//! 「渲染层像素断言」就没有一个像素可量。所以这里加一个**段落选择器**:
//!
//! ```text
//! ULL_GALLERY_SECTION=all # 默认:五段全渲染(行为与加开关之前逐像素一致)
//! ULL_GALLERY_SECTION=buttons # 只渲染 buttons 段
//! ULL_GALLERY_SECTION=selection # 只渲染 selection 段(本 mission 的取证与 probe 用它)
//! ```
//!
//! 取值:`all` / `buttons` / `divider` / `selection` / `inputs` / `feedback` / `navigation`。
//! 语义是「**不构建**没被选中的段」(不是构建后隐藏):`column` 里根本不 push 它,
//! 于是该家族的 `view` 一行都不跑。未识别的值**回落 `all`** 并在 stderr 打一行提示
//! (静默回落会让人拿错图还以为拍的是那一段)。
//!
//! 为什么用环境变量而不是 `--screen=gallery:<family>`:`cli.rs` 不归组件线(M82 在改它),
//! 而环境变量既能被测试(`cargo test` 里 `std::env::set_var`)又不用跟别人的 CLI 解析抢文件。
//! 各家族的 `demo()` 签名**不因此改变**——本文件只决定「push 哪几段」。
//!
//! 实际渲染了哪几段会打一行 `[gallery] section=... → 渲染 ...`(首帧一次),
//! 拿它当出图日志的证据。
use std::sync::atomic::{AtomicBool, Ordering};
use iced::widget::{column, container, scrollable, text};
use iced::{Alignment, Background, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::shot::flavor_slug;
use crate::theme::Ui;
use super::divider;
use super::interaction::Interaction;
use super::layout;
mod buttons;
mod feedback;
mod inputs;
mod navigation;
#[cfg(test)]
mod probe;
#[cfg(test)]
mod scan;
mod selection;
/// 段落过滤的环境变量名(语义见模块文档「段落过滤」)。
pub const SECTION_ENV: &str = "ULL_GALLERY_SECTION";
/// 段落开关:**构建**哪几段(`false` = 这一段连 `view` 都不跑)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Sections {
buttons: bool,
divider: bool,
selection: bool,
inputs: bool,
feedback: bool,
navigation: bool,
}
impl Sections {
/// 五段全渲染(默认)。
const ALL: Self = Self {
buttons: true,
divider: true,
selection: true,
inputs: true,
feedback: true,
navigation: true,
};
/// 只有一段。
fn only(name: &str) -> Option<Self> {
let mut sections = Self {
buttons: false,
divider: false,
selection: false,
inputs: false,
feedback: false,
navigation: false,
};
match name {
"buttons" => sections.buttons = true,
"divider" => sections.divider = true,
"selection" => sections.selection = true,
"inputs" => sections.inputs = true,
"feedback" => sections.feedback = true,
"navigation" => sections.navigation = true,
_ => return None,
}
Some(sections)
}
/// 读 `ULL_GALLERY_SECTION`。
///
/// 未设置 / 空 / `all`(大小写不敏感)→ 全渲染;**未识别的值 → 回落全渲染 + stderr 一行**。
fn from_env() -> Self {
let Ok(raw) = std::env::var(SECTION_ENV) else {
return Self::ALL;
};
let name = raw.trim().to_ascii_lowercase();
if name.is_empty() || name == "all" {
return Self::ALL;
}
if let Some(sections) = Self::only(&name) {
return sections;
}
eprintln!(
"[gallery] {SECTION_ENV}={raw:?} 不是已知段(all|buttons|divider|selection|inputs|feedback|navigation),已回落 all"
);
Self::ALL
}
/// 本次渲染的段名(stderr 日志 / 测试断言用)。
fn names(self) -> Vec<&'static str> {
let mut names = Vec::new();
for (on, name) in [
(self.buttons, "buttons"),
(self.divider, "divider"),
(self.selection, "selection"),
(self.inputs, "inputs"),
(self.feedback, "feedback"),
(self.navigation, "navigation"),
] {
if on {
names.push(name);
}
}
names
}
}
/// `[gallery] ... 渲染 ...` 只打一次(`view` 每帧都会跑)。
static SECTION_LOGGED: AtomicBool = AtomicBool::new(false);
fn log_sections(sections: Sections) {
if SECTION_LOGGED.swap(true, Ordering::Relaxed) {
return;
}
let raw = std::env::var(SECTION_ENV).unwrap_or_else(|_| "all".to_owned());
eprintln!(
"[gallery] section={raw} → 渲染 {} 段:{}",
sections.names().len(),
sections.names().join(", ")
);
}
/// gallery 自己的消息(与业务 `Message` 解耦,契约 §2)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DemoMessage {
/// 某个演示按钮被按下(`&'static str` = 演示 id,只用于日志)。
Pressed(&'static str),
}
impl DemoMessage {
/// 演示 id(日志用;将来加变体时这里跟着改,`app.rs` 不用动)。
pub fn id(&self) -> &'static str {
match self {
DemoMessage::Pressed(id) => id,
}
}
}
/// gallery 根视图(整窗:不带标题栏与导航轨,验收图上只有组件)。
pub fn view<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let page = ui.style(TypeRole::BodySmall);
let header = column![
text("Component gallery")
.size(ui.emphasized(TypeRole::TitleLarge).size_px)
.font(ui.emphasized_font(TypeRole::TitleLarge))
.color(ui.color(Role::OnSurface)),
text(format!(
"M3 Expressive component base · flavor={} · 1180×760 @100% DPI · \
focus 与 pressed 同为 10%(令牌口径,图上一样是对的)",
flavor_slug(ui)
))
.size(page.size_px)
.font(ui.font_of(page))
.color(ui.color(Role::OnSurfaceVariant)),
]
.spacing(ui.space(Space::X1));
// 小节之间**不画分隔线**:rubric(15 §22.4)要求同屏同类线 ≤3,
// 而验收图里要给分隔线自己留证据(见 `divider_proof`)。分组靠 32px 留白 + 标题。
//
// 段落过滤(`ULL_GALLERY_SECTION`):**没被选中的段根本不 push**,于是该家族的
// `view` 一行都不跑——不是「构建后隐藏」。默认 `all` 与加开关之前逐像素一致。
let picked = Sections::from_env();
log_sections(picked);
let mut sections = column![].spacing(ui.space(Space::X8)).width(Length::Fill);
if picked.buttons {
sections = sections.push(buttons::section(ui));
}
if picked.divider {
sections = sections.push(divider_proof(ui));
}
if picked.selection {
sections = sections.push(selection::section(ui));
}
if picked.inputs {
sections = sections.push(inputs::section(ui));
}
if picked.feedback {
sections = sections.push(feedback::section(ui));
}
if picked.navigation {
sections = sections.push(navigation::section(ui));
}
// 滚动条:默认是主题给的 10px 深灰 thumb,压过组件本体(视觉 reviewer P2-4)。
// 这里把它改成 **透明轨 + OutlineVariant thumb**;宽度 10px 是 `scrollable` 的内部常量
// (该控件不暴露 Scrollbar 配置),所以只做了「低对比」这一半,已登记为偏离 D7。
let body = scrollable(sections)
.height(Length::Fill)
.width(Length::Fill)
.style(move |theme, status| {
// `scrollable::Style` 没有 `Default`:底色/间隙从主题默认值出发,只覆盖竖轨
let base = scrollable::default(theme, status);
scrollable::Style {
vertical_rail: scrollable::Rail {
background: None,
border: iced::Border::default(),
scroller: scrollable::Scroller {
background: Background::Color(ui.color(Role::OutlineVariant)),
border: iced::Border {
radius: ui.radius(ShapeScale::Full, ui.space(Space::X2)),
..iced::Border::default()
},
},
},
..base
}
});
// 页面留白走 `layout::inset`(左右/上下各 24):这是 Q8/Q9 那两个 helper 的第三个兄弟,
// 存在意义是「只改内边距、不改尺寸」,也顺手避免把 Padding 的上右下左写反。
let page_padding = ui.space(Space::X6);
container(layout::inset(
page_padding,
page_padding,
column![header, body].spacing(ui.space(Space::X4)),
))
.width(Length::Fill)
.height(Length::Fill)
.style(|_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))),
..container::Style::default()
})
.into()
}
/// 分隔线的视觉证据(§22):一条通栏 + 一条列表内缩进,各自带说明。
///
/// 单独一节而不是散落各处:`15 §22.4` 说「同屏同类线 ≤3」,这两条是**唯一**计入的两条。
fn divider_proof<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let caption = ui.style(TypeRole::LabelSmall);
let label = |text_value: &'a str| {
text(text_value)
.size(caption.size_px)
.font(ui.font_of(caption))
.color(ui.color(Role::OnSurfaceVariant))
};
column![
heading(
ui,
"divider · full / list-inset",
"15 §22:厚度 1px(stroke::HAIRLINE)· 颜色 OutlineVariant · 列表内左缩进 16 · 同屏 ≤3 条"
),
label("full (通栏,区块分隔)"),
divider::full::<DemoMessage>(ui),
label("list (左缩进 16)"),
divider::list::<DemoMessage>(ui),
]
.spacing(ui.space(Space::X2))
.into()
}
/// 一个家族小节的标题(`index.title`)+ 一行判据说明。
///
/// 五个家族文件共用(`pub(super)`),保证五段的节奏一致——这也是 rubric D4
/// 「一屏字号档 ≤4」的落点:小节只有 `TitleMedium` + `BodySmall` 两档。
pub(crate) fn heading<'a>(ui: &'a Ui, title: &'a str, note: &'a str) -> Element<'a, DemoMessage> {
let note_style = ui.style(TypeRole::BodySmall);
column![
text(title)
.size(ui.emphasized(TypeRole::TitleMedium).size_px)
.font(ui.emphasized_font(TypeRole::TitleMedium))
.color(ui.color(Role::OnSurface)),
text(note)
.size(note_style.size_px)
.font(ui.font_of(note_style))
.color(ui.color(Role::OnSurfaceVariant)),
]
.spacing(ui.space(Space::X1))
.into()
}
/// 状态矩阵的列头:五态的固定次序(`Interaction::ALL` 的口径)。
///
/// 列宽 = [`cell_width`](**160** = `Space::X8 × 5`,是矩阵栅格,不是组件尺寸)。
pub(crate) fn state_cells(ui: &Ui) -> Vec<(&'static str, f32)> {
Interaction::ALL
.iter()
.map(|state| (state.name(), cell_width(ui)))
.collect()
}
/// 矩阵单元格宽度(只影响 harness 的取景,不是组件尺寸)。
///
/// 160 = `Space::X8 × 5`:5 列 + 1 个标签列 + 4 个列间距 + 页左右各 24
/// = 6×160 + 4×8 + 48 = **1040 < 1180**,所以第 5 列(disabled)不再被窗口右缘裁掉。
/// 早先是 192(×6),总宽 1232 撑破窗口,disabled 列被裁掉一半(视觉 reviewer P2-3)。
pub(crate) fn cell_width(ui: &Ui) -> f32 {
ui.space(Space::X8) * 5.0
}
/// 一个矩阵行的左侧标签(变体名)。
pub(crate) fn row_label<'a>(ui: &'a Ui, name: &'a str) -> Element<'a, DemoMessage> {
let style = ui.style(TypeRole::BodySmall);
container(
text(name)
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::OnSurfaceVariant)),
)
.width(Length::Fixed(cell_width(ui)))
.align_y(Alignment::Center)
.into()
}
@@ -0,0 +1,352 @@
//! gallery 里的导航类小节:四块状态矩阵 + 一块长文案块(**断言在 `navigation::probe`**)。
//!
//! ## 块选择器 `ULL_NAV_PART`
//!
//! `ULL_GALLERY_SECTION=navigation` 只隔离家族;本家族自己就有四块矩阵 + 长文案块,竖排比 760 高,
//! 一屏只拍得到第一块。所以按契约 §9.2 的同一套规则再切一层(环境变量 + 默认兼容 + 未知值回落并打
//! stderr):`ULL_NAV_PART=list|tabs|drawer|appbar|long|all`(默认 `all`)。
//!
//! ```text
//! ULL_GALLERY_SECTION=navigation ULL_NAV_PART=list ./target/debug/unlucky-launcher-iced.exe //! --software-renderer --screen=gallery --screenshot target/m79/list-light.png --screenshot-theme=light
//! ULL_NAV_DIR=target/m79 cargo test -p unlucky-app-iced navigation::probe
//! ```
//!
//! ## 矩阵形状
//!
//! ```text
//! ListItem 行 = 一 / 两 / 三行(44 / 60 / 76) 列 = 选中 / 未选中 / 禁用(三列槽位完全相同)
//! Tabs 三行 Primary(选中第 1/2/3 个)+ 一行 Secondary + 一行五态
//! Drawer rail 88 与 drawer 240 **同尺度并排**(D33),各 3 项、第 2 项 hover、第 3 项禁用
//! AppBar 静息 / 滚动两态(56 高;滚动只换容器色 + L2)
//! long 长文案:页头 + 三行 ListItem + drawer(单行约束 D-M79-5 的取证)
//! ```
//!
//! 取景板(`SurfaceContainerLow`)不是装饰:`Surface` 的页头/导航与页面底色同色,不垫一层板子,
//! 截图里量不到边界(probe 就是靠板量 88 / 240 的)。
use iced::widget::{column, container, row, text};
use iced::{Background, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::super::interaction::Interaction;
use super::super::navigation::{
self, app_bar, list_item, navigation, tabs, AppBar, AppBarAction, ListItem, NavGroup, NavItem,
NavLayout, Navigation, Tab, Tabs, TabsVariant,
};
use super::{heading, DemoMessage};
/// 块选择器的环境变量名(语义见模块文档)。
pub const PART_ENV: &str = "ULL_NAV_PART";
/// 本次渲染哪一块(`all` = 四块都渲染,默认)。
fn part() -> String {
let Ok(raw) = std::env::var(PART_ENV) else {
return "all".to_owned();
};
let name = raw.trim().to_ascii_lowercase();
match name.as_str() {
"" | "all" | "list" | "tabs" | "drawer" | "appbar" | "long" => name,
_ => {
eprintln!(
"[gallery] {PART_ENV}={raw:?} 不是已知块(all|list|tabs|drawer|appbar|long),已回落 all"
);
"all".to_owned()
}
}
}
/// 演示消息(组件只认泛型,映射在 gallery 侧落地)。
fn pressed() -> DemoMessage {
DemoMessage::Pressed("navigation")
}
fn icon(name: &str) -> IconKind {
match name {
"download" => IconKind::Download,
"logs" => IconKind::Logs,
"settings" => IconKind::Settings,
_ => IconKind::Instances,
}
}
/// 取景板:`SurfaceContainerLow` 底(`Surface` 的页头/导航要靠它才有边界)。
fn plate<'a>(
ui: &'a Ui,
pad: f32,
content: impl Into<Element<'a, DemoMessage>>,
) -> Element<'a, DemoMessage> {
container(content)
.padding(pad)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::SurfaceContainerLow))),
..container::Style::default()
})
.into()
}
/// 一行小字图例。
fn note<'a>(ui: &'a Ui, value: &'a str) -> Element<'a, DemoMessage> {
let style = ui.style(TypeRole::BodySmall);
text(value)
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::OnSurfaceVariant))
.into()
}
/// 导航类家族的小节。
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let picked = part();
let mut body = column![heading(
ui,
"navigation · list item / tabs / drawer / app bar",
"12b §4/§6/§16 + 15 §21:ListItem 44/60/76 · Tabs 48 + 指示条 3 · \
Drawer = rail 88 ⇄ drawer 240(同一容器)· AppBar 56 + 滚动换色"
)]
.spacing(ui.space(Space::X8));
if picked == "all" || picked == "list" {
body = body.push(list_part(ui));
}
if picked == "all" || picked == "tabs" {
body = body.push(tabs_part(ui));
}
if picked == "all" || picked == "drawer" {
body = body.push(drawer_part(ui));
}
if picked == "all" || picked == "appbar" {
body = body.push(app_bar_part(ui));
}
if picked == "long" {
body = body.push(navigation::matrix::long_part(ui, pressed));
}
body.into()
}
/// 一个列表格(宽 = `nav_drawer_width` 240:够放下 icon + 两行文本,且与 drawer 药丸 216 可区分)。
fn list_cell<'a>(ui: &'a Ui, item: ListItem<'a, DemoMessage>) -> Element<'a, DemoMessage> {
container(list_item(ui, item))
.width(Length::Fixed(ui.metrics().nav_drawer_width))
.into()
}
/// ListItem:三行高 × {选中 / 未选中 / 禁用}。
fn list_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let states = ["selected", "enabled", "disabled"];
let mut grid = column![].spacing(ui.space(Space::X4));
let header = states
.iter()
.map(|name| {
container(note(ui, name))
.width(Length::Fixed(ui.metrics().nav_drawer_width))
.into()
})
.collect::<Vec<Element<'a, DemoMessage>>>();
grid = grid.push(
row![
note(ui, "rows +16 each · 44 / 60 / 76"),
row(header).spacing(ui.space(Space::X2))
]
.spacing(ui.space(Space::X2)),
);
for (index, lines) in ["one line", "two lines", "three lines"].iter().enumerate() {
let mut cells = row![].spacing(ui.space(Space::X2));
for state in states {
// 三列的**内容完全相同**(同样的槽位),只有态不同——否则「选中 trailing = Primary」
// 这条结论没有可比对象(视觉 r1 P2-2 点名)
let mut item = ListItem::new(lines, pressed())
.leading(icon("instances"))
.trailing(icon("settings"));
if index >= 1 {
item = item.supporting("3 versions · 1.21.4");
}
if index >= 2 {
item = item.overline("RECENT");
}
item = match state {
"selected" => item.selected(),
// 禁用是修饰器:这里只清消息,槽位全部保留
"disabled" => item.disabled(),
_ => item.state(Interaction::Enabled),
};
cells = cells.push(list_cell(ui, item));
}
grid = grid.push(cells);
}
column![
note(
ui,
"ListItem · 选中 = SecondaryContainer(偏离 D-M79-1:A1 的 ListItem 不换底色)· \
禁用 = OnSurface@38%(A1 ListTokens)"
),
grid,
]
.spacing(ui.space(Space::X2))
.into()
}
/// Tabs:两行 Primary(短 / 长文案各选中一次 → 指示条宽度必须不同)+ Secondary + 五态。
fn tabs_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let primary_row = |selected: usize| {
let mut builder = Tabs::primary();
for (index, label) in ["Logs", "Instance details", "Settings"].iter().enumerate() {
let mut tab = Tab::new(label, pressed());
if index == 2 {
// 6.1:带图标的 Tabs 容器高 64(纯文字 48)
tab = tab.icon(icon("settings"));
}
if index == selected {
tab = tab.selected();
}
builder = builder.tab(tab);
}
tabs(ui, builder)
};
let state_row = {
let mut builder = Tabs::primary();
for (index, state) in [
Interaction::Enabled,
Interaction::Hovered,
Interaction::Focused,
Interaction::Pressed,
Interaction::Disabled,
]
.iter()
.enumerate()
{
let mut tab = Tab::new("Tab", pressed()).state(*state);
if index == 0 {
tab = tab.selected();
}
builder = builder.tab(tab);
}
tabs(ui, builder)
};
let secondary = tabs(
ui,
Tabs::secondary()
.tab(Tab::new("Release", pressed()).selected())
.tab(Tab::new("Snapshot", pressed()))
.tab(Tab::new("Legacy", pressed()).disabled()),
);
// 两式的名字进图例(同时把 `TabsVariant::ALL` 变成可点检的常量,而不是死代码)
let legend = TabsVariant::ALL
.iter()
.map(|variant| note(ui, variant.name()))
.collect::<Vec<_>>();
column![
note(
ui,
"Tabs · Primary 三行 = 选中第 1 / 2 / 3 个(指示条跟着走且宽度跟随该项);Secondary:1px SurfaceVariant"
),
row(legend).spacing(ui.space(Space::X4)),
primary_row(0),
primary_row(1),
primary_row(2),
secondary,
note(ui, "Primary 五态(第 1 项选中;未选项 hover/focus/pressed 提到 OnSurface)"),
state_row,
]
.spacing(ui.space(Space::X2))
.into()
}
/// 导航容器:rail 88 与 drawer 240 **同尺度并排**(D33 的可验收形式)。
fn drawer_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
// 取景板高 288(= Space::X8 × 9):装得下 drawer 的「两个分组 + 三项 + 两次标题」,否则末行被裁
let plate_height = ui.space(Space::X8) * 9.0;
let mut panes = row![].spacing(ui.space(Space::X6));
for layout in NavLayout::ALL {
let builder = match layout {
NavLayout::Rail => Navigation::rail(),
NavLayout::Drawer => Navigation::drawer(),
};
let pane = builder
.group(
NavGroup::titled("LIBRARY")
.item(NavItem::new("Instances", icon("instances"), pressed()).selected())
.item(
NavItem::new("Download", icon("download"), pressed())
.state(Interaction::Hovered),
),
)
.group(
NavGroup::titled("SYSTEM")
.item(NavItem::new("Settings", icon("settings"), pressed()).disabled()),
);
let width = match layout {
NavLayout::Rail => ui.metrics().nav_rail_width,
NavLayout::Drawer => ui.metrics().nav_drawer_width,
};
// 取景板比容器大一圈(pad 8):`Surface` 的导航与页面底色同色,板要露边才量得到
let pad = ui.space(Space::X2);
panes = panes.push(
container(container(navigation(ui, pane)).height(Length::Fixed(plate_height)))
.width(Length::Fixed(width + pad * 2.0))
.height(Length::Fixed(plate_height + pad * 2.0))
.padding(pad)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::SurfaceContainerLow))),
..container::Style::default()
}),
);
}
column![
note(
ui,
"D33:同一个容器两个宽度 —— rail(药丸 56×32)|drawer(药丸 216×56,左右各缩 12); 第 2 项 hover、第 3 项禁用"
),
panes,
row![
note(ui, "左→右:"),
note(ui, NavLayout::Rail.name()),
note(ui, NavLayout::Drawer.name()),
]
.spacing(ui.space(Space::X2)),
]
.spacing(ui.space(Space::X2))
.into()
}
/// AppBar small:静息 / 滚动两态(56 高;滚动换容器色 + L2)。
fn app_bar_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let bar = |scrolled: bool| {
app_bar(
ui,
AppBar::new("Instances")
.subtitle(if scrolled {
"scrolled · SurfaceContainer + L2"
} else {
"resting · Surface + L0"
})
.leading(AppBarAction::inert(icon("instances")))
.trailing(AppBarAction::new(icon("settings"), pressed()))
.scrolled(scrolled),
)
};
column![
note(
ui,
"AppBar small · 56 高(A1 64,偏离 D-M79-2)· 滚动只换容器色与高程,不改高度(A1 无 OnScroll…Height)"
),
plate(ui, ui.space(Space::X2), column![bar(false), bar(true)].spacing(ui.space(Space::X2))),
]
.spacing(ui.space(Space::X2))
.into()
}
@@ -0,0 +1,368 @@
//! 渲染层几何断言:**对着真截图量像素**(M68 的教训:纯函数返回对,图可能是错的)。
//!
//! `buttons::tests` 只能证明「几何结构体里的数字是 40/20/24」——M68 的药丸正是这样:单测全绿、
//! 图上是 88×32(容器被 `center_x(Length::Fill)` 覆盖)。唯一能抓住这类错误的是**真渲染的像素**。
//! CI 没有桌面,所以这里不自己开窗:先用 `--screen=gallery --screenshot` 出真窗截图,本测试读
//! 那张 PNG 量几何、与令牌逐项比对。**没给图就跳过并大声说出来**(不静默通过)。
//!
//! 复现命令(在 worktree 根目录):
//!
//! ```text
//! cargo build -p unlucky-app-iced --bin unlucky-launcher-iced
//! ./target/debug/unlucky-launcher-iced --software-renderer --screen=gallery \
//! --screenshot target/m75/gallery-light.png --screenshot-theme=light
//! ULL_GALLERY_SHOT=target/m75/gallery-light.png cargo test -p unlucky-app-iced gallery::probe
//! ```
//!
//! 量什么(每条对一个已发生过的错误):实心块高 **40**(12b 1.1)· 药丸顶行**左内缩 ≈14** ⇒ 圆角 full=20
//! (12b 1.2,8 圆角会接近通栏)· IconButton **40×40**(12b 2.1)· 字形墨迹 **16~26**
//! (Q9:24 盒被填满会到 40)· 分隔线 **1px**(22.1)· 禁用容器 **on-surface@10%**(按钮族)存在(12b 1.8)。
use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::typography::Platform;
use crate::theme::{Families, Ui};
use crate::app::components::interaction;
use super::scan::{matches, nearer, rgb, Shot};
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
}
/// 一条 1dp 细线在竖直方向的「墨量覆盖率」:各行相对底色的不透明度之和(1dp ⇒ ≈1.0)。
///
/// **只统计「长横线行」**(连续非底色像素 > 900):标题/正文那些行也有几百个零散墨迹像素,
/// 按「任意非底色像素数」筛会把文字算进墨量(首版实测 57.9,明显是文字被算进去了)。
fn line_coverage(shot: &Shot, surface: [u8; 3], line: [u8; 3]) -> f32 {
let mut total = 0.0;
for y in 0..shot.height {
let mut longest = 0;
let mut current = 0;
let mut sum = 0.0f32;
let mut run_sum = 0.0f32;
for x in 0..shot.width {
let pixel = shot.at(x, y);
if !matches(pixel, surface) {
current += 1;
run_sum += delta_ratio(pixel, surface, line);
if current > longest {
longest = current;
sum = run_sum;
}
} else {
current = 0;
run_sum = 0.0;
}
}
if longest > 900 {
total += sum / longest as f32;
}
}
total
}
/// `pixel` 相对 `surface` 的偏移占「满档 line 色偏移」的比例(0..=1)。
fn delta_ratio(pixel: [u8; 3], surface: [u8; 3], line: [u8; 3]) -> f32 {
let channel = |i: usize| {
let full = f32::from(line[i]) - f32::from(surface[i]);
if full.abs() < 1.0 {
return 0.0;
}
((f32::from(pixel[i]) - f32::from(surface[i])) / full).clamp(0.0, 1.0)
};
let (dr, dg, db) = (channel(0), channel(1), channel(2));
(dr + dg + db) / 3.0
}
/// `ULL_GALLERY_SHOT` → 截图路径(相对路径按**工作区根**解释:`cargo test` 的 cwd 是 crate 目录)。
fn shot_path() -> Option<std::path::PathBuf> {
let raw = std::env::var_os("ULL_GALLERY_SHOT")?;
let path = std::path::PathBuf::from(raw);
if path.is_absolute() || path.exists() {
return Some(path);
}
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
Some(root.join(path))
}
/// 一条测试覆盖全部几何断言(每张图各跑一次)。
#[test]
fn gallery_geometry_matches_the_tokens() {
let Some(path) = shot_path() else {
eprintln!(
"[probe] SKIPPED:未设置 ULL_GALLERY_SHOT。这是**渲染层**几何断言,\
需要先出一张真窗截图(命令见本文件模块文档)。"
);
return;
};
let shot = Shot::load(&path).expect("读 gallery 截图");
assert_eq!(
(shot.width, shot.height),
(1180, 760),
"验收图必须是 D30 的 1180×760 @100% DPI"
);
// 用左上角背景色判定深浅(页面 padding 区就是 `Surface`)
let corner = shot.at(2, 2);
let flavor = if matches(corner, rgb(ui(SchemeFlavor::Light).color(Role::Surface))) {
SchemeFlavor::Light
} else {
SchemeFlavor::Dark
};
let ui = ui(flavor);
// ---- 12b 1.1 / 1.2:实心按钮 40 高、药丸圆角 20 ------------------------------
let primary = shot.bbox_of(rgb(ui.color(Role::Primary)));
assert!(
primary.len() >= 2,
"至少该找到 Filled 按钮与 Filled IconButton 两块实心(实际 {})",
primary.len()
);
let mut label_button = None;
for bbox in &primary {
// 字形(OnPrimary)会把实心块割出小碎片,只判「成块的」(40×40 = 1600)
if bbox.area() < 800 {
continue;
}
assert!(
(39..=41).contains(&bbox.height()),
"实心块高 {}(参考 40,12b 1.1)@ {:?}",
bbox.height(),
(bbox.left, bbox.top)
);
if bbox.width() > 60 {
label_button = Some(*bbox);
}
// 12b 2.1:IconButton 容器精确 40×40(别再用区间——reviewer 已证 +2px 抓不住)
if bbox.width() < 60 {
// 宽 = 40(最宽那行是实色,量得准);高会把底部抗锯齿行排除掉,所以是 39 或 40。
// 区间收到 39..=40 仍能抓 ±2px 的回归(42 → 41、38 → 37 都会落区间外)。
assert_eq!(bbox.width(), 40, "IconButton 容器宽必须 40(12b 2.1)");
assert!(
(39..=40).contains(&bbox.height()),
"IconButton 容器高应 40(实色 39 + 抗锯齿行),实测 {}",
bbox.height()
);
}
}
let label_button = label_button.expect("没找到文字按钮的实心块");
assert!(
label_button.width() >= 58,
"12b 1.7:最小宽 58,实测 {}",
label_button.width()
);
// 含抗锯齿的真实外框:中心列上「更接近填充色而不是底色」的连续行
let fill = rgb(ui.color(Role::Primary));
let surface = rgb(ui.color(Role::Surface));
let center = (label_button.left + label_button.right) / 2;
let mut true_top = label_button.top;
while true_top > 0 && nearer(shot.at(center, true_top - 1), fill, surface) {
true_top -= 1;
}
let mut true_bottom = label_button.bottom;
while true_bottom + 1 < shot.height && nearer(shot.at(center, true_bottom + 1), fill, surface) {
true_bottom += 1;
}
let true_height = true_bottom - true_top + 1;
// **精确 40**:变异成 42 必须红。上一版写的是 `(39..=41).contains(..)`,
// reviewer 把高度改成 42 重新出图后断言照样绿 —— 那就是「渲层断言等于没有」。
assert_eq!(
true_height, 40,
"可见高必须精确 40(12b 1.1);变异成 42 这里就会红"
);
// 圆角档位:量**顶部那一行的左内缩**。按像素中心口径,第 k 行(k 从 0 起)的闭式是
// inset(k) = r − √(r² − (r − k − 0.5)²);r=20、k=1 给 **12.40**(实测 12~14),
// r=8 只有 ≈3.6,直角为 0。比「数圆角行数」抗锯齿得多。
let top_row_left = (label_button.left..=label_button.right)
.find(|x| matches(shot.at(*x, label_button.top), fill))
.expect("顶部行应至少有一个填充像素");
let top_inset = top_row_left - label_button.left;
assert!(
(11..=17).contains(&top_inset),
"药丸圆角应 = 20(12b 1.2):顶部行左内缩应 ≈14,实测 {top_inset}(8 圆角 ≈4,直角 0)"
);
// ---- 12b 1.4:带前置图标的左内边距 = 16(用 Filled 格量,避开圆角只扫中间带)----
// 判「墨迹」的方式:比容器色更接近 OnPrimary。不要用字面量白——浅色页面底色 (246,250,255)
// 与白在容差内,会把圆角外的底色当成墨迹(首次实现就踩了,量出 3)。
let on_primary = rgb(ui.color(Role::OnPrimary));
let mut left_ink: Option<usize> = None;
for y in (label_button.top + 12)..=(label_button.bottom - 12) {
for x in (label_button.left + 4)..=label_button.right {
if nearer(shot.at(x, y), on_primary, fill) {
left_ink = Some(left_ink.map_or(x, |best: usize| best.min(x)));
}
}
}
let icon_pad = left_ink.expect("Filled 格应带前置图标(量内边距用)") - label_button.left;
// 图标盒左缘 = 16;字形在 24 viewBox 里自带 ≈5.3 左边距 ⇒ 墨迹落在 21 附近
// (16 → 21;24 → 29;8 → 13,三档互不重叠)
assert!(
(18..=24).contains(&icon_pad),
"12b 1.4:带图标左内边距应 16(墨迹 ≈21),实测 {icon_pad}"
);
// ---- Q9:IconButton 的字形不许撑满 40 盒 -----------------------------------
let tonal = rgb(ui.color(Role::SecondaryContainer));
let tonal_boxes = shot.bbox_of(tonal);
let icon_states = tonal_boxes
.iter()
.filter(|bbox| (39..=41).contains(&bbox.width()) && (39..=41).contains(&bbox.height()))
.count();
// 只有 **enabled** 列是精确的 SecondaryContainer(hover/pressed/focus 列叠了状态层,颜色不同),
// 所以这里只要求 ≥1:量几何一只就够,五态的可见性由图本身给。
assert!(
icon_states >= 1,
"tonal IconButton 至少 1 个(实际 {icon_states})"
);
let gear = rgb(ui.color(Role::OnSecondaryContainer));
let mut measured_glyph = false;
let mut glyph_width = 0;
for bbox in tonal_boxes
.iter()
.filter(|bbox| (39..=41).contains(&bbox.width()))
{
if let Some(ink) = shot.ink_inside(bbox, gear) {
let width = ink.width();
if width > glyph_width {
glyph_width = width;
}
assert!(
(16..=26).contains(&width),
"图标盒 24 的字形墨迹应 16~26 宽,实测 {width}(Q9 回归时会接近 40)"
);
measured_glyph = true;
}
}
assert!(measured_glyph, "没量到 IconButton 的字形");
// ---- 22.1:分隔线 1px(按「非底色的长横线」判,不按精确色:线被抗锯齿摊到两行)----
let surface = rgb(ui.color(Role::Surface));
let mut runs = 0;
for y in 0..shot.height {
let mut longest = 0;
let mut current = 0;
for x in 0..shot.width {
if !matches(shot.at(x, y), surface) {
current += 1;
longest = longest.max(current);
} else {
current = 0;
}
}
if longest > 300 {
runs += 1;
}
}
// 两条线 × 各摊 1~2 行 ⇒ 2..=4 行
assert!(
(2..=4).contains(&runs),
"分隔线应 2 条(每条 1px,抗锯齿最多摊 2 行)⇒ 2..=4 行,实测 {runs} 行"
);
// **墨量覆盖率**:一条 1dp 的线,其各行相对底色的不透明覆盖率之和应 ≈1.0。
// 1.5dp 的线会到 ≈1.5,2dp 会到 ≈2.0 —— 这是「容忍 1.5 倍粗」的补丁。
let full = rgb(ui.color(Role::OutlineVariant));
// 屏上正好 2 条线(通栏 + 列表内缩),所以把总墨量除以 2 得到「单条线的覆盖率」
let coverage = line_coverage(&shot, surface, full) / 2.0;
assert!(
(0.8..=1.3).contains(&coverage),
"单条分隔线墨量覆盖率应 ≈1.0(= 1dp),实测 {coverage:.2}(1.5dp 会到 ~1.5)"
);
// ---- 12b 1.8 禁用态:容器 + **每一行**的内容色(A1 逐变体不同)----------------
//
// 上一版把两个 reviewer 都指出的重复块留在这里(`disabled_colors` 被调两次、
// `disabled_container` 被绑定两次),且只有一条「全图数墨迹」的护栏 —— 深色下阈值失效、
// 单行回退也抓不住。现在按矩阵的行逐条断言。
//
// **按钮族**:容器 10%(官方 `*ButtonTokens.DisabledContainerOpacity = 0.1f`)、内容 38%。
// M86 之前这里和 chip 共用同一个 12% 常量,所以断言与实现「一起错」、永远同源——拆族后才对得上。
let (disabled_container, _) = interaction::disabled_button_colors(&ui, Role::Surface);
assert!(
shot.bbox_of(rgb(disabled_container))
.iter()
.any(|b| b.width() > 60),
"没找到禁用态实心按钮(12b 1.8:容器 = on-surface@10%)"
);
// 矩阵行序(gallery/buttons.rs 的 Variant::ALL + IconVariant::ALL),行距 48 = 40 + 8
let row_step = 48usize;
let rows: [(usize, Role, bool, &str); 8] = [
(0, Role::OnSurfaceVariant, true, "filled"),
(1, Role::OnSurface, true, "tonal"), // A1:FilledTonal 是例外
(2, Role::OnSurfaceVariant, true, "elevated"),
(3, Role::OnSurfaceVariant, false, "outlined"),
(4, Role::OnSurfaceVariant, false, "text"),
(5, Role::OnSurface, false, "icon·standard"), // A1:IconButton 全是 OnSurface
(6, Role::OnSurface, false, "icon·filled"),
// 第 7 行(矩阵最后一行的 tonal IconButton)上一版漏了 —— reviewer 把它的禁用墨迹涂回
// 满档色时 probe 全绿。A1 同样是 OnSurface(`FilledTonalIconButtonTokens`)。
(7, Role::OnSurface, true, "icon·tonal"),
];
let column_from = shot.width * 2 / 3; // 矩阵最右列 = disabled 列
for (index, role, on_container, name) in rows {
let y0 = label_button.top + index * row_step;
let y1 = y0 + 44;
// **与实现同源**:按钮族的内容色一律走 `disabled_button_content`(38% 叠在 10% 底上)。
// 这里若用面状控件族的 `disabled_content_over_role`(12% 底),断言的底就比实现宽一档,
// 差的 1/255 会让「容器对了内容错了」这类错逃过门控——M86 明确要防这个。
let ink = interaction::disabled_button_content(&ui, role, on_container);
let count = shot.count_in(rgb(ink), column_from, shot.width, y0, y1);
assert!(
count > 40,
"{name} 行的禁用内容({role:?}@38%)在 disabled 列里只有 {count} px —— 说明这一行没走 A1 的禁用角色"
);
}
// filled 禁用容器里不许出现满档 OnPrimary(旧实现是白字压在容器上 = 1.23:1)
let full_content = rgb(ui.color(Role::OnPrimary));
for bbox in shot.bbox_of(rgb(disabled_container)) {
if bbox.width() < 60 {
continue;
}
// 只扫**内带**:圆角外的页面底色与 OnPrimary 在容差内,贴边扫描会误报。
for y in (bbox.top + 12)..=(bbox.bottom - 12) {
for x in (bbox.left + 12)..=(bbox.right - 12) {
assert!(
!matches(shot.at(x, y), full_content),
"禁用容器内出现满档 OnPrimary @({x},{y}):禁用标签没降到 38%"
);
}
}
}
eprintln!(
"[probe] OK flavor={:?}:
Filled 药丸 {}×{}(参考 高 40 / 宽 ≥58)
顶行左内缩 {}(r=20 应 ≈14;8 圆角 ≈4;直角 0)
实心块 {} 个(含 40×40 IconButton)
分隔线长行 {} 行(每条 1px)
禁用容器 (on-surface@10%,按钮族) 命中 · 逐行禁用内容断言 {} 行通过
分隔线墨量覆盖率 {:.2}(1dp 应 ≈1.0)
字形墨迹 {}px(24 盒应 16~26)",
flavor,
label_button.width(),
true_height,
top_inset,
primary.len(),
runs,
rows.len(),
coverage,
glyph_width
);
}
@@ -0,0 +1,183 @@
//! 截图像素扫描工具(`gallery::probe` 的底层,四个后续家族的 probe 也能复用)。
//!
//! 只做「按颜色找连通块 / 量外接框 / 量某行的连续长度」三件事,不含任何判据——
//! 判据在同级 `probe.rs` 里,这样每条家族线可以自带 probe 而不用重写扫描逻辑。
use std::path::Path;
use iced::Color;
/// 颜色比对的通道容差(抗锯齿/取整)。
const TOLERANCE: u8 = 10;
/// iced 颜色 → 8bit RGB(截图里的原值)。
pub(super) fn rgb(color: Color) -> [u8; 3] {
[
(color.r * 255.0).round() as u8,
(color.g * 255.0).round() as u8,
(color.b * 255.0).round() as u8,
]
}
/// 颜色是否落在容差内。
pub(super) fn matches(pixel: [u8; 3], expected: [u8; 3]) -> bool {
pixel
.iter()
.zip(expected)
.all(|(a, b)| a.abs_diff(b) <= TOLERANCE)
}
/// 一张图的像素访问器。
pub(super) struct Shot {
pub(super) width: usize,
pub(super) height: usize,
pixels: Vec<[u8; 3]>,
}
impl Shot {
pub(super) fn load(path: &Path) -> Result<Self, String> {
let bytes = std::fs::read(path).map_err(|error| format!("读不动 {path:?}:{error}"))?;
let image = image::load_from_memory(&bytes)
.map_err(|error| format!("解 PNG 失败:{error}"))?
.to_rgb8();
let (width, height) = (image.width() as usize, image.height() as usize);
Ok(Self {
width,
height,
pixels: image.pixels().map(|p| p.0).collect(),
})
}
pub(super) fn at(&self, x: usize, y: usize) -> [u8; 3] {
self.pixels[y * self.width + x]
}
/// 某种颜色的全部像素的外接框(连通块)。
pub(super) fn bbox_of(&self, color: [u8; 3]) -> Vec<BBox> {
let mut seen = vec![false; self.width * self.height];
let mut boxes = Vec::new();
for y in 0..self.height {
for x in 0..self.width {
if seen[y * self.width + x] || !matches(self.at(x, y), color) {
continue;
}
let mut stack = vec![(x, y)];
seen[y * self.width + x] = true;
let mut bbox = BBox::new(x, y);
while let Some((cx, cy)) = stack.pop() {
bbox.include(cx, cy);
for (nx, ny) in [
(cx + 1, cy),
(cx.saturating_sub(1), cy),
(cx, cy + 1),
(cx, cy.saturating_sub(1)),
] {
let inside = nx < self.width && ny < self.height;
if inside && !seen[ny * self.width + nx] && matches(self.at(nx, ny), color)
{
seen[ny * self.width + nx] = true;
stack.push((nx, ny));
}
}
}
if bbox.area() > 200 {
boxes.push(bbox);
}
}
}
boxes
}
/// 在矩形 `[x0,x1) × [y0,y1)` 内数某颜色的像素(按行/列取证用)。
pub(super) fn count_in(
&self,
color: [u8; 3],
x0: usize,
x1: usize,
y0: usize,
y1: usize,
) -> usize {
(y0..y1.min(self.height))
.map(|y| {
(x0..x1.min(self.width))
.filter(|x| matches(self.at(*x, y), color))
.count()
})
.sum()
}
/// `bbox` 内某颜色的墨迹外接框(量字形用)。
///
/// 内缩 3px:不内缩会把按钮自身的抗锯齿边算成墨迹(实测把 24 盒字形量成 40)。
pub(super) fn ink_inside(&self, bbox: &BBox, color: [u8; 3]) -> Option<BBox> {
const MARGIN: usize = 3;
let mut ink: Option<BBox> = None;
for y in (bbox.top + MARGIN)..=(bbox.bottom - MARGIN) {
for x in (bbox.left + MARGIN)..=(bbox.right - MARGIN) {
if matches(self.at(x, y), color) {
match &mut ink {
Some(found) => found.include(x, y),
None => ink = Some(BBox::new(x, y)),
}
}
}
}
ink
}
}
/// 一个外接框(闭区间)。
#[derive(Debug, Clone, Copy)]
#[allow(clippy::struct_field_names)]
pub(super) struct BBox {
pub(super) left: usize,
pub(super) right: usize,
pub(super) top: usize,
pub(super) bottom: usize,
}
impl BBox {
pub(super) fn new(x: usize, y: usize) -> Self {
Self {
left: x,
right: x,
top: y,
bottom: y,
}
}
pub(super) fn include(&mut self, x: usize, y: usize) {
self.left = self.left.min(x);
self.right = self.right.max(x);
self.top = self.top.min(y);
self.bottom = self.bottom.max(y);
}
pub(super) fn width(&self) -> usize {
self.right - self.left + 1
}
pub(super) fn height(&self) -> usize {
self.bottom - self.top + 1
}
pub(super) fn area(&self) -> usize {
self.width() * self.height()
}
}
/// `pixel` 更接近 `fill` 还是 `surface`。
pub(super) fn nearer(pixel: [u8; 3], fill: [u8; 3], surface: [u8; 3]) -> bool {
let dist = |a: [u8; 3], b: [u8; 3]| -> u32 {
a.iter()
.zip(b)
.map(|(x, y)| (i32::from(*x) - i32::from(y)).unsigned_abs())
.sum()
};
dist(pixel, fill) < dist(pixel, surface)
}
@@ -0,0 +1,37 @@
//! gallery 里的选择类小节:把状态矩阵摆进 harness(**断言不在这里**)。
//!
//! 矩阵本体的构造在 [`selection::matrix`](12 行 × 5 列:checkbox 四态 / radio / switch /
//! chip 三类),消息用 `()`(矩阵只供视觉验收),这里一次 `.map(..)` 接成 `DemoMessage`。
//!
//! ## 渲染层几何断言去哪了
//!
//! 在 [`crate::app::components::selection::probe`](`selection/probe.rs`)。挪出去的原因是
//! 400 行红线(复审 P2-5 同一类问题):断言 + 像素扫描会把这个文件顶到 458 行。
//!
//! 跑法(worktree 根目录):
//!
//! ```text
//! ULL_GALLERY_SECTION=selection ./target/debug/unlucky-launcher-iced --software-renderer \
//! --screen=gallery --screenshot target/m76/selection-light.png --screenshot-theme=light
//! ULL_GALLERY_SHOT=target/m76/selection-light.png cargo test -p unlucky-app-iced selection::probe
//! ```
use iced::widget::column;
use iced::Element;
use super::super::selection;
use crate::theme::Ui;
use super::DemoMessage;
/// 选择类家族的小节:标题 + 矩阵 + 图例。
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let pressed = |()| DemoMessage::Pressed("selection");
column![
selection::matrix::heading_and_note(ui).map(pressed),
selection::matrix::matrix(ui).map(pressed),
selection::matrix::legend(ui).map(pressed),
]
.spacing(ui.space(unlucky_design::spacing::Space::X2))
.into()
}
+310
View File
@@ -0,0 +1,310 @@
//! 组件目录的自检门禁(`src/components/**`)。
//!
//! 为什么要有这份:`tests/no_hardcoded_tokens.rs` 只扫**色值**和 `shell/` 的尺寸字面量,
//! 组件库目录是后来才开的——如果不管,五条并行线会各自写回 `.width(24.0)` 与
//! `center_x(Length::Fill)`,M68 那两个形态级错误就会被复制五份。
//!
//! 四条判据(每条对应一个已发生过的错误):
//!
//! | 判据 | 抓的是什么 | 历史 |
//! |---|---|---|
//! | Q8:禁 `center_x(` / `center_y(` / `.center(` | 容器尺寸被 `Fill` 静默覆盖 | 药丸 56×32 → 88×32 |
//! | Q9:图标必须经 `layout::fixed` / `centered_fixed` | 字形被 Button 的 Fill 撑满 | 24 的字形渲染成 46px |
//! | T2:禁尺寸字面量 | 组件里重复定义令牌值 | rubric T2 |
//! | 400 行 | 单文件上限(AGENTS.md §3-1) | `guards.yml` 的 file-size job |
//!
//! 扫描对象是**源码文本**而不是布局结果——这不是偷懒:布局结果要真窗口(CI 无桌面),
//! 而「把尺寸交给 `center_*`」这个错误 100% 出现在源码形态里(M68 就是这样被抓的)。
//! 像素级几何另有 `gallery::probe` 对着真截图量。
use std::path::{Path, PathBuf};
/// 单文件行数上限(AGENTS.md §3-1)。
const MAX_LINES: usize = 400;
fn src_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src")
}
fn components_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("src")
.join("components")
}
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);
}
}
}
/// 全部组件源文件(`mod.rs` 也要扫:它是共享地基,最容易混进字面量)。
fn sources() -> Vec<(PathBuf, String)> {
let mut files = Vec::new();
rust_files(&components_dir(), &mut files);
files.sort();
assert!(
files.len() >= 15,
"只扫到 {} 个组件源文件:扫描逻辑坏了,门禁等于没跑",
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()
}
/// 去掉 `#[cfg(test)]` 之后的全部内容:测试里写参考值是**应该的**(12b 的 40/24/20
/// 必须在测试里以字面量出现,否则测不出偏离)。
fn product_part(text: &str) -> &str {
match text.find("#[cfg(test)]") {
Some(at) => &text[..at],
None => text,
}
}
/// 代码行(跳过注释行:文档里写 `.center_x(` 是在解释坑,不该判违规)。
fn code_lines(text: &str) -> impl Iterator<Item = (usize, &str)> {
text.lines().enumerate().filter_map(|(index, line)| {
let trimmed = line.trim_start();
if trimmed.starts_with("//") {
None
} else {
Some((index + 1, line))
}
})
}
#[test]
fn q8_no_center_shortcuts_that_swallow_fixed_sizes() {
let mut offenders = Vec::new();
for (path, text) in sources() {
for (line_no, line) in code_lines(product_part(&text)) {
for pattern in ["center_x(", "center_y(", ".center("] {
if line.contains(pattern) {
offenders.push(format!(
"{}:{line_no} {line}\n → {pattern} 会把容器尺寸改成 Fill(Q8),\
居中请用父级 align_x 或 layout::centered_fixed",
path.display()
));
}
}
}
}
assert!(offenders.is_empty(), "Q8 违规:\n{}", offenders.join("\n"));
}
#[test]
fn q9_icons_never_reach_a_button_unwrapped() {
let mut offenders = Vec::new();
for (path, text) in sources() {
let product = product_part(&text);
// 允许两种写法:同一行里已经包在 layout:: 里;或它是 `layout::fixed(` 多行调用的实参
// (图标常作为第 3 个参数出现在后两行,所以往回看 4 行)。
let mut window: Vec<&str> = Vec::new();
for (line_no, line) in code_lines(product) {
if line.contains("icons::view") {
let wrapped =
line.contains("layout::fixed") || line.contains("layout::centered_fixed");
let continued = window.iter().rev().any(|prev| {
prev.contains("layout::fixed") || prev.contains("layout::centered_fixed")
});
if !wrapped && !continued {
offenders.push(format!(
"{}:{line_no} {line}\n → 图标必须经 layout::fixed / centered_fixed 包一层(Q9)",
path.display()
));
}
}
if !line.trim().is_empty() {
window.push(line);
if window.len() > 4 {
window.remove(0);
}
}
}
}
assert!(offenders.is_empty(), "Q9 违规:\n{}", offenders.join("\n"));
}
/// 布局调用后面直接跟数字 → 判违规(`0` 与 `0.0` 放行: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 t2_no_size_literals_in_components() {
let mut offenders = Vec::new();
for (path, text) in sources() {
for (line_no, line) in code_lines(product_part(&text)) {
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_no} {line} -> {pattern} 后面写了数字 {number}",
path.display()
));
}
from = after;
}
}
}
}
assert!(
offenders.is_empty(),
"组件里不许有尺寸字面量(走 LayoutMetrics / Space / ComponentMetrics):\n{}",
offenders.join("\n")
);
}
/// 单文件 ≤400 行:**扫整个 `src/`**,不只 components。
///
/// M90 r2(P1-2)的教训:这条守门原来只遍历 `components/`,于是 `pages/harness.rs` 悄悄长到
/// 402 行没人拦,直到 `guards.yml` 的 file-size job 在 CI 上判红才发现。`guards.yml` 的口径是
/// **全仓**(`git ls-files '*.rs' '*.md' …`),这里至少把本 crate 的 `src/**` 对齐:
/// 谁在 `app/` / `pages/` / `shell/` 把文件推过线,`cargo test` 就先红,不用等 CI。
#[test]
fn every_source_file_stays_under_the_line_limit() {
let mut files = Vec::new();
rust_files(&src_dir(), &mut files);
assert!(
files.len() >= 40,
"src/ 至少 40 个 .rs,实际 {}:扫描逻辑坏了,这条门禁等于没跑",
files.len()
);
let mut offenders = Vec::new();
for path in files {
let text = std::fs::read_to_string(&path).expect("读源文件");
let lines = text.lines().count();
if lines > MAX_LINES {
offenders.push(format!(
"{}: {lines} 行(上限 {MAX_LINES})",
path.display()
));
}
}
assert!(
offenders.is_empty(),
"单文件 ≤400 行(AGENTS.md §3-1,guards.yml 按全仓判红):\n{}",
offenders.join("\n")
);
}
/// 四个家族目录必须存在且结构完整。
///
/// **这里刻意不再限制「pub fn 有几个」**:M76 往 `selection/` 里加 Checkbox/Radio 是它的本职工作,
/// 而家族线被明确要求不许改 `gate.rs`——把「桩还在不在」写成 pub fn 计数,等于给 M76 埋一个
/// 它无权拆的地雷(reviewer P1-C)。越界与否由 merge gate 的 scope 判定(各家族目录只归对应
/// mission),门禁只管「地基还在」:
///
/// - 四个家族目录各有 `mod.rs`;
/// - `components/mod.rs` 仍声明这五个家族 + gallery(谁把声明删了,这里先红)。
#[test]
fn the_five_families_are_declared_and_present() {
let declared =
std::fs::read_to_string(components_dir().join("mod.rs")).expect("读 components/mod.rs");
for family in ["selection", "inputs", "feedback", "navigation"] {
let dir = components_dir().join(family);
let file = dir.join("mod.rs");
assert!(file.is_file(), "家族目录 {} 缺 mod.rs", dir.display());
assert!(
declared.contains(&format!("pub mod {family};")),
"components/mod.rs 少了 `pub mod {family};` 声明"
);
}
assert!(
declared.contains("pub mod gallery;") && declared.contains("pub mod buttons;"),
"components/mod.rs 的声明被动了"
);
}
/// gallery 的五个家族文件必须都在(`mod.rs` 声明了它们,缺一个就编译不过——
/// 这条在并行线删文件/改名时给出人话,而不是一堆 `unresolved module`)。
#[test]
fn gallery_keeps_one_file_per_family() {
for family in ["buttons", "selection", "inputs", "feedback", "navigation"] {
let path = components_dir()
.join("gallery")
.join(format!("{family}.rs"));
assert!(
path.is_file(),
"gallery 缺 {}(本文件结构是并行地基)",
path.display()
);
let text = std::fs::read_to_string(&path).expect("读 gallery 家族文件");
assert!(
text.contains("fn section"),
"{} 必须提供 section()(gallery/mod.rs 调它)",
path.display()
);
}
}
/// 门禁自身不能空转:造几个违规样本喂给判据。
#[test]
fn the_gate_catches_planted_violations() {
assert_eq!(
size_literal("Length::Fixed(40.0))").as_deref(),
Some("40.0")
);
assert_eq!(size_literal("0)").as_deref(), None);
assert_eq!(
size_literal("ui.metrics().control_height)").as_deref(),
None
);
assert!(product_part("a\n#[cfg(test)]\n.width(40.0)\n")
.find(".width(40.0)")
.is_none());
assert!(product_part("a\n.width(40.0)\n")
.find(".width(40.0)")
.is_some());
// Q8 判据本身
for code in ["x.center_x(Length::Fill)", "x.center(Length::Fill)"] {
assert!(
code.contains("center_x(") || code.contains(".center("),
"判据写坏了"
);
}
}
@@ -0,0 +1,393 @@
//! 文本框(`research/12b` §9):Filled / Outlined 两式,**受控组件**。
//!
//! ## 参考值(`[源]` = A1,本机 `material3-android:1.4.0` 的 `*TextFieldTokens` + `TextFieldDefaults`)
//!
//! | # | 检查项 | 参考值 | 落点 |
//! |---|---|---|---|
//! | 9.1 | 高度 | min **56**(带标签);无标签单行本项目 **40** | `FIELD_HEIGHT`(G16)/ `LM.control_height` |
//! | 9.2 | 最小宽 | **280** → **G21 未施加**(iced 的 `Length` 无 min-width,同按钮 G15) | 文档 |
//! | 9.3 | 形状 | Outlined **4**;Filled 顶圆角 4、底直角 | `Component::TextField.spec().shape` |
//! | 9.4 | 描边 / 指示线 | Outlined 静息 1dp `Outline` → hover 1dp `OnSurface` → 聚焦 **2dp `Primary`**;Filled 底指示线静息 `OnSurfaceVariant` | [`Filled`] / [`Palette`] |
//! | 9.5 | 字级 | 输入/标签 `BodyLarge 16/24`;辅助/错误 `BodySmall 12/16` | `TypeRole` |
//! | 9.6 | 图标 | 前后 **24**、`OnSurfaceVariant`;图标可视边 **12**、文本从 **48** 起 | `FieldGeometry` |
//! | 9.7 | 错误态 | 线/标签/辅助文本全 **`Error`**、线宽 **2dp** | [`Field::error`] |
//! | 9.8 | 禁用态 | Filled 容器 `OnSurface`@**4%**(→ 令牌只有 12%,**登记偏离 D10**);Outlined 描边 `OnSurface`@**12%**;内容 @**38%** | `interaction::*` |
//!
//! ## 三处口径说明
//!
//! 1. **状态差异落在指示线/描边/标签上,容器底色不叠状态层**——这是 A1 文本字段的口径
//! (`HoverActiveIndicatorColor` / `FocusActiveIndicatorColor` 等 token),与按钮家族
//! 「叠一个 8%/10% 的状态层」不同。press 在 A1 里**没有**独立 token,本实现按 hover 处理(登记 D11)。
//! 2. 标签是「**常浮起**」的两段式:有标签就固定占上面那 16px(`BodySmall`),不做上浮动画
//! (M3 的动画由 motion mission 统一接,登记 D12)。
//! 3. 缺省宽度 `Length::Fill`:文本框在页面里按列宽伸展,最小宽 280 无法在 iced 表达(G21)。
use iced::widget::text::LineHeight;
use iced::widget::{column, container, row, text, text_input};
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::{self, Interaction};
use super::super::layout;
use super::geometry::FieldGeometry;
/// 两式容器(12b 9.3 / 9.4 的差别:Filled 有容器色 + 底部指示线;Outlined 只有描边)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FieldVariant {
/// 实心:`SurfaceContainerHighest` + 底部指示线,顶圆角 4 底直角。
Filled,
/// 描边:透明容器 + 1dp `Outline`,四角 4。
Outlined,
}
impl FieldVariant {
/// 两式全序(gallery 的行序)。
pub const ALL: [FieldVariant; 2] = [FieldVariant::Filled, FieldVariant::Outlined];
/// 稳定名字(图例 / 测试信息)。
pub const fn name(self) -> &'static str {
match self {
FieldVariant::Filled => "filled",
FieldVariant::Outlined => "outlined",
}
}
}
/// 一个文本框的装配描述(不持有 `String`:受控组件只借值,契约 §2)。
#[derive(Debug, Clone)]
pub struct Field<'a> {
variant: FieldVariant,
value: &'a str,
label: Option<&'a str>,
placeholder: &'a str,
supporting: Option<&'a str>,
leading: Option<IconKind>,
trailing: Option<IconKind>,
error: bool,
state: Option<Interaction>,
width: Length,
}
impl<'a> Field<'a> {
/// 一个 Filled 文本框。
pub fn new(value: &'a str) -> Self {
Self {
variant: FieldVariant::Filled,
value,
label: None,
placeholder: "",
supporting: None,
leading: None,
trailing: None,
error: false,
state: None,
width: Length::Fill,
}
}
/// 选式(Filled / Outlined)。
pub fn variant(mut self, variant: FieldVariant) -> Self {
self.variant = variant;
self
}
/// 浮动标签(`BodySmall`,常浮起,见模块文档第 2 条)。
pub fn label(mut self, label: &'a str) -> Self {
self.label = Some(label);
self
}
/// 占位文案(只在值为空时可见,`OnSurfaceVariant`)。
pub fn placeholder(mut self, placeholder: &'a str) -> Self {
self.placeholder = placeholder;
self
}
/// 辅助/错误说明(字段下方 `BodySmall`)。
pub fn supporting(mut self, supporting: &'a str) -> Self {
self.supporting = Some(supporting);
self
}
/// 前置图标(24,图标可视边 12)。
pub fn leading(mut self, kind: IconKind) -> Self {
self.leading = Some(kind);
self
}
/// 后置图标(24,右侧内边距 16)。
pub fn trailing(mut self, kind: IconKind) -> Self {
self.trailing = Some(kind);
self
}
/// 错误态:线 / 标签 / 辅助文本转 `Error`,线宽 2dp(9.7)。
pub fn error(mut self) -> Self {
self.error = true;
self
}
/// 强制视觉态(gallery 状态矩阵取证;运行期不要调)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
/// 定宽(默认 `Fill`;gallery 的矩阵格用它钉宽度)。
pub fn width(mut self, width: Length) -> Self {
self.width = width;
self
}
}
/// 一个字段在某一态下的全套颜色(9.4 / 9.7 / 9.8 的落点)。
#[derive(Debug, Clone, Copy, PartialEq)]
struct Palette {
/// 容器底色;`None` = 透明(Outlined)。
fill: Option<Color>,
/// 描边/指示线色。
line: Color,
/// 描边/指示线宽(1 或 2)。
stroke: f32,
/// 标签(`BodySmall`)。
label: Color,
/// 输入文本。
input: Color,
/// 前后图标。
icon: Color,
/// 辅助文本。
supporting: Color,
/// 占位文本。
placeholder: Color,
}
/// 解析某一态的颜色(**唯一**做角色映射的地方)。
fn palette(ui: &Ui, variant: FieldVariant, state: Interaction, error: bool) -> Palette {
let geometry = FieldGeometry::of(ui, true, None);
if state.is_disabled() {
// 9.8:内容一律 OnSurface@38%;Filled 容器叠 12%(官方 4%,令牌无 4% 档 → D10);
// Outlined 描边正好是官方 12%。
let container = interaction::disabled_colors(ui, Role::Surface).0;
let ink = interaction::disabled_content_over_role(
ui,
Role::OnSurface,
matches!(variant, FieldVariant::Filled),
);
return Palette {
fill: match variant {
FieldVariant::Filled => Some(container),
FieldVariant::Outlined => None,
},
line: match variant {
FieldVariant::Filled => ink,
FieldVariant::Outlined => container,
},
stroke: geometry.stroke,
label: ink,
input: ink,
icon: ink,
supporting: ink,
placeholder: ink,
};
}
let focused = matches!(state, Interaction::Focused);
let hovered = matches!(state, Interaction::Hovered | Interaction::Pressed);
let (line, stroke) = if error {
(ui.color(Role::Error), geometry.focus_stroke)
} else if focused {
(ui.color(Role::Primary), geometry.focus_stroke)
} else if hovered {
(ui.color(Role::OnSurface), geometry.stroke)
} else {
(
match variant {
FieldVariant::Filled => ui.color(Role::OnSurfaceVariant),
FieldVariant::Outlined => ui.color(Role::Outline),
},
geometry.stroke,
)
};
Palette {
fill: match variant {
FieldVariant::Filled => Some(ui.color(Role::SurfaceContainerHighest)),
FieldVariant::Outlined => None,
},
line,
stroke,
label: if error {
ui.color(Role::Error)
} else if focused {
ui.color(Role::Primary)
} else {
ui.color(Role::OnSurfaceVariant)
},
input: ui.color(Role::OnSurface),
icon: if error {
ui.color(Role::Error)
} else {
ui.color(Role::OnSurfaceVariant)
},
supporting: if error {
ui.color(Role::Error)
} else {
ui.color(Role::OnSurfaceVariant)
},
placeholder: ui.color(Role::OnSurfaceVariant),
}
}
/// 一个文本框(Filled / Outlined 由 [`Field::variant`] 选)。
///
/// 受控:值由外部持有,输入经 `on_input` 回传;`state` 强制成 `Disabled` 时输入被摘掉
/// (`on_input_maybe(None)`),所以禁用字段真的点不动,不只是变灰。
pub fn text_field<'a, M: Clone + 'a>(
ui: &'a Ui,
field: Field<'a>,
on_input: impl Fn(String) -> M + 'a,
) -> Element<'a, M> {
let geometry = FieldGeometry::of(ui, field.label.is_some(), field.leading);
let state = field.state.unwrap_or(Interaction::Enabled);
let colors = palette(ui, field.variant, state, field.error);
let input_style = ui.style(TypeRole::BodyLarge);
let label_style = ui.style(TypeRole::BodySmall);
let text_input = text_input(field.placeholder, field.value)
.on_input_maybe(if state.is_disabled() {
None
} else {
Some(on_input)
})
.size(input_style.size_px)
.line_height(LineHeight::Absolute(Pixels(input_style.line_height_px)))
.font(ui.font_of(input_style))
.padding(Padding::default())
.width(Length::Fill)
.style(move |_theme, _status| text_input::Style {
background: Background::Color(Color::TRANSPARENT),
border: Border::default(),
icon: colors.icon,
placeholder: colors.placeholder,
value: colors.input,
selection: colors.line,
});
let mut content = row![].align_y(Alignment::Center).spacing(geometry.icon_gap);
if let Some(kind) = field.leading {
// Q9:图标必须经固定尺寸容器,否则会被 Fill 约束撑满(M68 的 46px 字形)
content = content.push(layout::fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(kind, colors.icon),
));
}
content = content.push(text_input);
if let Some(kind) = field.trailing {
content = content.push(layout::fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(kind, colors.icon),
));
}
let label = field.label.map(|label| {
text(label)
.size(label_style.size_px)
.line_height(LineHeight::Absolute(Pixels(label_style.line_height_px)))
.font(ui.font_of(label_style))
.color(colors.label)
});
// 内容块:带标签 = [标签 16 + 输入 24] 共 40,居中在内容区里;无标签 = 单行 24 居中。
// **垂直居中交给容器**(Q8 的教训:不要指望子元素自己贴边)。
let inner: Element<'a, M> = match label {
Some(label) => column![label, content].width(Length::Fill).into(),
None => content.into(),
};
let content_area = container(inner)
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::Center)
.padding(Padding {
top: 0.0,
right: geometry.padding.right,
bottom: 0.0,
left: geometry.padding.left,
});
// Filled:容器色 + **底部指示线**(静息 1dp / 聚焦与错误 2dp);Outlined:透明 + 描边。
// 指示线占容器底边那 1~2px(内容区因此少 1~2px,官方也是画在 padding-box 底边上的)。
let body: Element<'a, M> = match field.variant {
FieldVariant::Filled => {
let indicator = container(iced::widget::space::horizontal())
.width(Length::Fill)
.height(Length::Fixed(colors.stroke))
.style(move |_theme| container::Style {
background: Some(Background::Color(colors.line)),
..container::Style::default()
});
column![content_area, indicator].into()
}
FieldVariant::Outlined => content_area.into(),
};
let border = Border {
color: if matches!(field.variant, FieldVariant::Outlined) {
colors.line
} else {
Color::TRANSPARENT
},
width: if matches!(field.variant, FieldVariant::Outlined) {
colors.stroke
} else {
0.0
},
// Filled 是「顶圆角 4、底直角」= CornerExtraSmallTop;ShapeScale 没有「部分角」,
// 这里按官方语义手工指定上下角(登记 D13)。
radius: match field.variant {
FieldVariant::Filled => iced::border::Radius {
top_left: ui.radius_dp(geometry.shape).top_left,
top_right: ui.radius_dp(geometry.shape).top_right,
bottom_left: 0.0,
bottom_right: 0.0,
},
FieldVariant::Outlined => ui.radius_dp(geometry.shape),
},
};
let field_el: Element<'a, M> = container(body)
.width(field.width)
.height(Length::Fixed(geometry.height))
.style(move |_theme| container::Style {
background: colors.fill.map(Background::Color),
border,
..container::Style::default()
})
.into();
match field.supporting {
Some(supporting) => {
let note = text(supporting)
.size(label_style.size_px)
.line_height(LineHeight::Absolute(Pixels(label_style.line_height_px)))
.font(ui.font_of(label_style))
.color(colors.supporting);
column![field_el, layout::inset(0.0, ui.space(Space::X1), note)].into()
}
None => field_el,
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,115 @@
//! field 的单测(从 `field.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
}
fn build(field: Field<'_>, flavor: SchemeFlavor) -> Element<'_, u8> {
let ui = Box::leak(Box::new(ui(flavor)));
text_field(ui, field, |_| 0_u8)
}
#[test]
fn every_variant_and_state_builds() {
for variant in FieldVariant::ALL {
for state in Interaction::ALL {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let field = Field::new("实例一")
.variant(variant)
.label("名称")
.placeholder("请输入")
.supporting("辅助说明")
.leading(IconKind::Folder)
.trailing(IconKind::Close)
.state(state);
let _: Element<'_, u8> = build(field, flavor);
}
}
}
}
/// 9.4:聚焦 2dp `Primary`、hover 1dp `OnSurface`、静息 Outlined 1dp `Outline`(Filled 用 OnSurfaceVariant)。
#[test]
fn outline_colors_and_widths_follow_the_state() {
let ui = ui(SchemeFlavor::Light);
let rest = palette(&ui, FieldVariant::Outlined, Interaction::Enabled, false);
assert_eq!(rest.line, ui.color(Role::Outline));
assert_eq!(rest.stroke, 1.0);
assert_eq!(rest.fill, None, "Outlined 无容器色");
let hover = palette(&ui, FieldVariant::Outlined, Interaction::Hovered, false);
assert_eq!(hover.line, ui.color(Role::OnSurface));
assert_eq!(hover.stroke, 1.0);
let focus = palette(&ui, FieldVariant::Outlined, Interaction::Focused, false);
assert_eq!(focus.line, ui.color(Role::Primary));
assert_eq!(focus.stroke, 2.0, "FocusOutlineWidth = 2dp");
assert_eq!(focus.label, ui.color(Role::Primary), "FocusLabelColor");
// A1 没有 Pressed token:按 hover 处理(D11),不是漏做
assert_eq!(
palette(&ui, FieldVariant::Outlined, Interaction::Pressed, false),
hover
);
}
/// 9.7:错误态线/标签/辅助全 `Error`,线宽 2dp;错误优先于聚焦。
#[test]
fn error_beats_focus_and_paints_everything_error() {
let ui = ui(SchemeFlavor::Light);
let error = palette(&ui, FieldVariant::Outlined, Interaction::Focused, true);
assert_eq!(error.line, ui.color(Role::Error));
assert_eq!(error.label, ui.color(Role::Error));
assert_eq!(error.supporting, ui.color(Role::Error));
assert_eq!(error.stroke, 2.0, "错误线宽 2dp");
assert_eq!(error.input, ui.color(Role::OnSurface), "ErrorInputColor");
}
/// 9.8:禁用内容 38%、Outlined 描边 12%、Filled 容器 12%(官方 4%,令牌缺口 → D10)。
#[test]
fn disabled_uses_the_token_alphas() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let ui = ui(flavor);
let container = interaction::disabled_colors(&ui, Role::Surface).0;
let filled = palette(&ui, FieldVariant::Filled, Interaction::Disabled, false);
assert_eq!(filled.fill, Some(container));
assert_eq!(
filled.input,
interaction::disabled_content_over_role(&ui, Role::OnSurface, true)
);
let outlined = palette(&ui, FieldVariant::Outlined, Interaction::Disabled, false);
assert_eq!(outlined.fill, None);
assert_eq!(outlined.line, container, "DisabledOutlineOpacity = 0.12");
assert_eq!(
outlined.input,
interaction::disabled_content_over_role(&ui, Role::OnSurface, false)
);
assert_ne!(outlined.input, ui.color(Role::OnSurface), "禁用不许满档");
}
}
/// Filled 有容器色、Outlined 没有;两者圆角都取 `Component::TextField`(4)。
#[test]
fn filled_carries_the_container_role() {
let ui = ui(SchemeFlavor::Light);
assert_eq!(
palette(&ui, FieldVariant::Filled, Interaction::Enabled, false).fill,
Some(ui.color(Role::SurfaceContainerHighest)),
"FilledTextFieldTokens.ContainerColor"
);
assert_eq!(FieldGeometry::of(&ui, true, None).shape.dp(), 4.0);
}
@@ -0,0 +1,309 @@
//! 输入类家族的**内部几何**:能取令牌的一律取令牌,取不到的登记 G 编号后集中在这里。
//!
//! ## 为什么这个文件存在
//!
//! `components::gate` 的 `t2_no_size_literals_in_components` 扫 `.width(40.0)` 这类**调用点字面量**;
//! 但「TextField 高 56」这种 A 级参考值在 `LayoutMetrics` / `ComponentMetrics` 里确实没有落点
//! (令牌层是另一条 mission 的 scope,不许我改)。所以按契约 §6.2:**登记 G 编号 + 只在这个文件里
//! 定义一次**,组件实现一个数字都不写。
//!
//! ## 参考值表(`[源]` = A1,本机 `material3-android:1.4.0` 的 token 源码;`[估]` = 团队口径)
//!
//! | G | 名称 | 参考值 | 出处 | 本文件落点 |
//! |---|---|---|---|---|
//! | G16 | TextField 可见高(带标签) | **56** | `TextFieldDefaults.MinHeight` / `OutlinedTextFieldTokens.ContainerHeight` | [`FIELD_HEIGHT`] |
//! | G17 | 聚焦/错误描边 | **2** | `OutlinedTextFieldTokens.FocusOutlineWidth`、`FilledTextFieldTokens.FocusActiveIndicatorHeight` | [`FOCUS_STROKE`] |
//! | G18 | Slider 轨道 / 竖条 / 停靠点 | **16 / 4×44 / 4** | `SliderTokens.ActiveTrackHeight` / `HandleWidth`+`HandleHeight` / `StopIndicatorSize` | [`TRACK_HEIGHT`] 等 |
//! | G19 | 菜单项高 | **48** | `Menu.kt:524 MenuListItemContainerHeight` | [`MENU_ITEM_HEIGHT`] |
//! | G20 | Search 宽高 | **min 360 / max 720 / 面板最小 240** | `SearchBar.kt:2712-2713`、`DockedExpandedTableMinHeight` | [`SEARCH_MIN_WIDTH`] 等 |
//!
//! **已经能取令牌、不许在这里重复定义的**(同一数值只许一份,见 `ComponentMetrics` 模块文档):
//! 搜索条高 56 = `LayoutMetrics::search_bar_height`;无标签单行 40 = `LayoutMetrics::control_height`;
//! 命中区 48 = `LayoutMetrics::touch_target`;水平/上下内边距与图标间距 = `Space::{X2,X3,X4}`;
//! 图标 24 = `IconSize::Toolbar`;圆角 = `ComponentSpec::shape`(TextField/Menu = 4,Search = Full,
//! SearchView = ExtraLarge 28);一像素线 = `stroke::HAIRLINE`。
//!
//! ## 与文档的两处口径冲突(按「代码优先」处理,报告里有偏离登记)
//!
//! 1. `12b §8.1` 说菜单圆角「本项目 12(偏离官方 4)」;令牌表 `Component::Menu.spec().shape`
//! 是 `ExtraSmall`(**4**)。令牌层是 M69 定案的真源,本实现取 **4**,不取 12。
//! 2. `12b §9.1` 说「无标签单行 40」是登记偏离——40 正好是 `LM.control_height`,
//! 所以那条偏离在实现上是「用骨架令牌」而不是「用字面量」。
use iced::Padding;
use unlucky_design::component_map::Component;
use unlucky_design::icons::{IconKind, IconSize};
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use crate::theme::{Ui, STROKE_PX};
/// G16:文本字段可见高(带浮动标签)。
pub const FIELD_HEIGHT: f32 = 56.0;
/// G17:聚焦/错误态的描边与指示线宽(官方 2dp;令牌层的 `stroke::HAIRLINE` 只有 1dp)。
pub const FOCUS_STROKE: f32 = 2.0;
/// G18:Slider 轨道高(激活/未激活同值)。
pub const TRACK_HEIGHT: f32 = 16.0;
/// G18:Slider 竖条 handle 宽(静息 / hover)。
pub const HANDLE_WIDTH: f32 = 4.0;
/// G18:Slider 竖条 handle 宽(focus / pressed)——A1 在这两态收窄到 2。
pub const HANDLE_WIDTH_ACTIVE: f32 = 2.0;
/// G18:Slider 竖条 handle 高(纵向溢出轨道,上下各 14)。
pub const HANDLE_HEIGHT: f32 = 44.0;
/// G18:离散停靠点直径。
pub const STOP_SIZE: f32 = 4.0;
/// G18:停靠点与轨道段之间的空隙(官方 `StopIndicatorTrailingSpace = 6`;6 不在 4px 栅格上)。
pub const STOP_GAP: f32 = 6.0;
/// G19:菜单项高。
pub const MENU_ITEM_HEIGHT: f32 = 48.0;
/// G20:搜索条最小宽(列宽不足时**不许压窄**,降级为 40 高 TextField 或 icon 入口)。
pub const SEARCH_MIN_WIDTH: f32 = 360.0;
/// G20:搜索条最大宽。
pub const SEARCH_MAX_WIDTH: f32 = 720.0;
/// G20:展开面板最小高(空结果也不许矮于它)。
pub const SEARCH_VIEW_MIN_HEIGHT: f32 = 240.0;
/// 文本字段的几何(Filled / Outlined 共用;差别只在颜色与形状)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FieldGeometry {
/// 可见高:带标签 [`FIELD_HEIGHT`],无标签 `LM.control_height`。
pub height: f32,
/// 内容内边距(无图标 16/16;有前置图标 12/16,图标与文本间距另算)。
pub padding: Padding,
/// 前置图标可视左边距(无偏移,就是内边距 12)。
pub icon_inset: f32,
/// 图标 ↔ 文本间距(12:12 + 24 + 12 = 48 起文本,与官方 48 的图标槽一致)。
pub icon_gap: f32,
/// 图标边长(24)。
pub icon: f32,
/// 静息描边(一像素线)。
pub stroke: f32,
/// 聚焦/错误描边(2)。
pub focus_stroke: f32,
/// 圆角档位(`Component::TextField` = ExtraSmall)。
pub shape: ShapeScale,
}
impl FieldGeometry {
/// 求值;`labeled` = 带浮动标签(56),否则单行 40;`leading` = 有没有前置图标。
pub fn of(ui: &Ui, labeled: bool, leading: Option<IconKind>) -> Self {
let icon_inset = ui.space(Space::X3);
let pad_end = ui.space(Space::X4);
let padding = if leading.is_some() {
Padding {
top: 0.0,
right: pad_end,
bottom: 0.0,
left: icon_inset,
}
} else {
Padding {
top: 0.0,
right: pad_end,
bottom: 0.0,
left: pad_end,
}
};
Self {
height: if labeled {
FIELD_HEIGHT
} else {
ui.metrics().control_height
},
padding,
icon_inset,
icon_gap: ui.space(Space::X3),
icon: IconSize::Toolbar.px(),
stroke: STROKE_PX,
focus_stroke: FOCUS_STROKE,
shape: Component::TextField.spec().shape,
}
}
}
/// Slider 的几何(轨道 / 竖条 / 停靠点 / 命中区)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SliderGeometry {
/// 轨道高(16)。
pub track: f32,
/// 竖条宽(静息 4 / focus·pressed 2)。
pub handle: f32,
/// 竖条高(44)。
pub handle_tall: f32,
/// 停靠点直径(4)。
pub stop: f32,
/// 控件高 = 命中区(`LM.touch_target` 48:竖条 44 + 上下各 2)。
pub height: f32,
}
impl SliderGeometry {
/// 从令牌求值。
pub fn of(ui: &Ui) -> Self {
Self {
track: TRACK_HEIGHT,
handle: HANDLE_WIDTH,
handle_tall: HANDLE_HEIGHT,
stop: STOP_SIZE,
height: ui.metrics().touch_target,
}
}
}
/// 菜单(Menu / DropdownMenuItem)的几何。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MenuGeometry {
/// 项高(48)。
pub item_height: f32,
/// 水平内边距(12,来自 `Space::X3`)。
pub pad_h: f32,
/// 上下留白(8,来自 `Space::X2`)。
pub pad_v: f32,
/// 槽位图标边长(24)。
pub icon: f32,
/// 图标 ↔ 文本间距(12)。
pub gap: f32,
/// 容器圆角档位(`Component::Menu` = ExtraSmall 4)。
pub shape: ShapeScale,
}
impl MenuGeometry {
/// 从令牌求值。
pub fn of(ui: &Ui) -> Self {
Self {
item_height: MENU_ITEM_HEIGHT,
pad_h: ui.space(Space::X3),
pad_v: ui.space(Space::X2),
icon: IconSize::Toolbar.px(),
gap: ui.space(Space::X3),
shape: Component::Menu.spec().shape,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui() -> Ui {
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
}
/// G16 / G17:两个 2dp 与 56dp 的参考值钉在这里(改参考表会让这条红)。
#[test]
fn field_geometry_matches_the_reference_table() {
let ui = ui();
let labeled = FieldGeometry::of(&ui, true, None);
assert_eq!(labeled.height, 56.0, "G16 TextFieldDefaults.MinHeight");
assert_eq!(labeled.stroke, 1.0, "静息一像素线");
assert_eq!(labeled.focus_stroke, 2.0, "G17 FocusOutlineWidth");
assert_eq!(labeled.shape.dp(), 4.0, "CornerExtraSmall");
// 无标签单行 40 = 骨架令牌,不是字面量
let compact = FieldGeometry::of(&ui, false, None);
assert_eq!(compact.height, ui.metrics().control_height);
assert_eq!(compact.height, 40.0);
// 内边距:无图标 16/16
assert_eq!(compact.padding.left, 16.0);
assert_eq!(compact.padding.right, 16.0);
assert_eq!(compact.padding.left, compact.padding.right);
}
/// 前置图标:图标可视边 12、文本从 48 起(= 12 + 24 + 12,官方 48 的图标槽)。
#[test]
fn field_icon_slot_matches_the_official_48() {
let ui = ui();
let leading = FieldGeometry::of(&ui, true, Some(IconKind::Search));
assert_eq!(leading.icon, 24.0, "LeadingIconSize");
assert_eq!(leading.icon_inset, 12.0, "textFieldHorizontalIconPadding");
// 文本起点 = 图标可视边 12 + 图标 24 + 间距 12 = 48(官方图标槽)
assert_eq!(leading.icon_inset + leading.icon + leading.icon_gap, 48.0);
// 右侧仍是 16(官方内边距不因图标变)
assert_eq!(leading.padding.right, 16.0);
}
/// G18:轨道 16 / 竖条 4×44 / 停靠点 4 / 命中 48;竖条比轨道高 28 ⇒ 上下各溢出 14。
#[test]
fn slider_geometry_matches_the_reference_table() {
let ui = ui();
let slider = SliderGeometry::of(&ui);
assert_eq!(slider.track, 16.0, "ActiveTrackHeight");
assert_eq!(slider.handle, 4.0, "HandleWidth");
assert_eq!(slider.handle_tall, 44.0, "HandleHeight");
assert_eq!(slider.stop, 4.0, "StopIndicatorSize");
assert_eq!(
(slider.handle_tall - slider.track) / 2.0,
14.0,
"上下各溢出"
);
assert_eq!(slider.height, 48.0, "命中下限 LM.touch_target");
assert!(slider.height >= slider.handle_tall, "命中区必须容得下竖条");
// focus / pressed 收窄到 2(A1 FocusHandleWidth / PressedHandleWidth)
assert_eq!(HANDLE_WIDTH_ACTIVE, 2.0);
assert!(HANDLE_WIDTH_ACTIVE < slider.handle);
}
/// G19:项高 48、水平 12、上下 8、图标 24、圆角按令牌表(4,**不是** 12b 说的 12)。
#[test]
fn menu_geometry_matches_the_reference_table() {
let ui = ui();
let menu = MenuGeometry::of(&ui);
assert_eq!(menu.item_height, 48.0, "MenuListItemContainerHeight");
assert_eq!(menu.pad_h, 12.0, "DropdownMenuItemHorizontalPadding");
assert_eq!(menu.pad_v, 8.0, "DropdownMenuVerticalPadding");
assert_eq!(menu.icon, 24.0);
assert_eq!(
menu.shape.dp(),
4.0,
"MenuTokens.ContainerShape = CornerExtraSmall"
);
assert_eq!(
menu.shape,
Component::Menu.spec().shape,
"圆角只能来自令牌表"
);
assert_eq!(menu.gap, ui.space(Space::X3), "图标 ↔ 文本 12 走栅格令牌");
}
/// G20:搜索条 min 360 / max 720;面板最小高 240;条高取骨架令牌 56(**不在这里重复定义**)。
#[test]
fn search_constants_do_not_duplicate_layout_metrics() {
let ui = ui();
assert_eq!(SEARCH_MIN_WIDTH, 360.0);
assert_eq!(SEARCH_MAX_WIDTH, 720.0);
assert_eq!(SEARCH_VIEW_MIN_HEIGHT, 240.0);
assert_eq!(ui.metrics().search_bar_height, 56.0, "条高来自 LM");
assert_eq!(
ShapeScale::Full.radius_px(ui.metrics().search_bar_height),
28.0
);
assert_eq!(Component::Search.spec().shape, ShapeScale::Full);
// 图标可视边 = pad + offset(M69 的 SEARCH_BAR 不变量,本文件不重复定义 12/4)
let bar = Component::Search.metrics().expect("Search 有组件级度量");
assert_eq!(bar.pad_start + bar.icon_offset, 16.0);
assert_eq!(bar.icon, 24.0);
}
}
@@ -0,0 +1,354 @@
//! 菜单(`research/12b` §8):`Menu` 弹出层 + `DropdownMenuItem`。
//!
//! ## 参考值(`[源]` = A1,`MenuTokens` / `Menu.kt` 1.4.0)
//!
//! | # | 检查项 | 参考值 | 落点 |
//! |---|---|---|---|
//! | 8.1 | 容器圆角 | 官方 **4**(`ContainerShape = CornerExtraSmall`);`12b` 说「本项目 12」——**取令牌表的 4**(报告偏离 D18) | `Component::Menu.spec().shape` |
//! | 8.2 | 容器色 / 高程 | `SurfaceContainer` + **L2** | `Role::SurfaceContainer` + `Elevation::Level2` |
//! | 8.3 | 项高 | **48** | `MENU_ITEM_HEIGHT`(G19) |
//! | 8.4 | 项内边距 | 水平 **12**、菜单上下留白 **8** | `Space::X3` / `Space::X2` |
//! | 8.5 | 字级 / 图标 | 文本 `BodyMedium 14/20`;图标 **24** | `TypeRole` + `IconSize::Toolbar` |
//! | 8.6 | 选中项 | 容器 `SecondaryContainer` + 文本 `OnSecondaryContainer`;指示器水平内缩 **4**、圆角 **8** | [`MenuItem::selected`] |
//! | 8.7 | 宽度 | 不窄于触发元素、长文本不换行(`ellipsis` 本轮不做,登记 D19) | [`Menu::width`] |
//!
//! ## 弹出层阴影
//!
//! 高程取 `Component::Menu.spec().elevation`(= L2),阴影参数取 `Elevation::shadow()`
//! (`ShadowSpec`:blur 6 / offset 2 / alpha 0.14,令牌层给的值),阴影色取 `Role::Shadow`。
//! **容器色不取 `MaterialLayer::Overlay`**:那是 `SurfaceContainerHigh`,比 A1 的 `SurfaceContainer`
//! 高一档;`MaterialLayer` 只用来确认「弹出层属于 Overlay 语义」,颜色以 A1 为准。
//!
//! ## 分组分隔线
//!
//! 复用 [`crate::app::components::divider::full`](一像素线 + `OutlineVariant`),**不自己画线**。
//! 菜单水平内边距 12 由容器给,所以分隔线自然落在 12 内缩后的宽度上。
use iced::widget::text::LineHeight;
use iced::widget::{button, column, container, text};
use iced::{
Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow, Vector,
};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::elevation::Elevation;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::Ui;
use super::super::divider;
use super::super::interaction::{self, Interaction};
use super::super::layout;
use super::geometry::MenuGeometry;
/// 菜单里的一项。
#[derive(Debug, Clone)]
pub struct MenuItem<'a, M> {
label: &'a str,
leading: Option<IconKind>,
trailing: Option<IconKind>,
on_press: Option<M>,
selected: bool,
state: Option<Interaction>,
}
impl<'a, M> MenuItem<'a, M> {
/// 一个可点的项。
pub fn new(label: &'a str, on_press: M) -> Self {
Self {
label,
leading: None,
trailing: None,
on_press: Some(on_press),
selected: false,
state: None,
}
}
/// 一个禁用的项(无消息 = 点不动,与按钮家族同一口径)。
pub fn disabled(label: &'a str) -> Self {
Self {
label,
leading: None,
trailing: None,
on_press: None,
selected: false,
state: None,
}
}
/// 前置槽位(24)。
pub fn leading(mut self, kind: IconKind) -> Self {
self.leading = Some(kind);
self
}
/// 后置槽位(24;勾选/快捷键提示用)。
pub fn trailing(mut self, kind: IconKind) -> Self {
self.trailing = Some(kind);
self
}
/// 选中项(`SecondaryContainer` + `OnSecondaryContainer`)。
pub fn selected(mut self) -> Self {
self.selected = true;
self
}
/// 强制视觉态(gallery 状态矩阵取证)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
}
/// 菜单里的一行:普通项或分组分隔线。
#[derive(Debug, Clone)]
pub enum Entry<'a, M> {
/// 一个可点/禁用的项。
Item(MenuItem<'a, M>),
/// 分组分隔线(复用 `components::divider`)。
Divider,
}
/// 菜单弹出层。
#[derive(Debug, Clone)]
pub struct Menu<'a, M> {
entries: Vec<Entry<'a, M>>,
width: Length,
}
impl<'a, M> Default for Menu<'a, M> {
fn default() -> Self {
Self::new()
}
}
impl<'a, M> Menu<'a, M> {
/// 空菜单(默认按容器宽度伸展,宽度由调用方定)。
pub fn new() -> Self {
Self {
entries: Vec::new(),
width: Length::Fill,
}
}
/// 追加一项。
pub fn item(mut self, item: MenuItem<'a, M>) -> Self {
self.entries.push(Entry::Item(item));
self
}
/// 追加一条分组分隔线。
pub fn divider(mut self) -> Self {
self.entries.push(Entry::Divider);
self
}
/// 菜单宽度(8.7:不窄于触发元素由调用方保证)。
pub fn width(mut self, width: Length) -> Self {
self.width = width;
self
}
}
/// 一项的颜色(8.5 / 8.6 / 禁用)。
#[derive(Debug, Clone, Copy, PartialEq)]
struct ItemPalette {
/// 项底色;`None` = 透明(不画状态层)。
fill: Option<Color>,
/// 文本色。
ink: Color,
/// 图标色(前置槽位;A1 是 `OnSecondaryContainer`)。
icon: Color,
/// 圆角档位(选中指示器 8)。
shape: ShapeScale,
}
/// 解析一项的颜色:选中优先,其余按五态叠状态层(`interaction::overlay_color`)。
fn item_palette(ui: &Ui, state: Interaction, selected: bool) -> ItemPalette {
if state.is_disabled() {
// 禁用:内容 38%(无容器时叠在表面色上;菜单项本身透明)
let ink = interaction::disabled_content_over_role(ui, Role::OnSurface, false);
return ItemPalette {
fill: None,
ink,
icon: ink,
shape: ShapeScale::Small,
};
}
if selected {
// 选中项也要跟态走:状态层叠在 SecondaryContainer 上(不透明结果,D27)
let fill = interaction::overlay_color(
ui,
state,
Role::OnSecondaryContainer,
Role::SecondaryContainer,
)
.unwrap_or_else(|| ui.color(Role::SecondaryContainer));
return ItemPalette {
fill: Some(fill),
ink: ui.color(Role::OnSecondaryContainer),
icon: ui.color(Role::OnSecondaryContainer),
shape: ShapeScale::Small,
};
}
ItemPalette {
// 状态层叠在**菜单容器色**上(SurfaceContainer),得到不透明结果(D27)
fill: interaction::overlay_color(ui, state, Role::OnSurface, Role::SurfaceContainer),
ink: ui.color(Role::OnSurface),
icon: ui.color(Role::OnSecondaryContainer),
shape: ShapeScale::Small,
}
}
/// 菜单弹出层(`SurfaceContainer` + L2 阴影)。
pub fn menu<'a, M: Clone + 'a>(ui: &'a Ui, menu: Menu<'a, M>) -> Element<'a, M> {
let geometry = MenuGeometry::of(ui);
let spec = Component::Menu.spec();
let elevation = spec.elevation;
debug_assert_eq!(
elevation,
Elevation::Level2,
"MenuTokens.ContainerElevation"
);
let shadow_spec = elevation.shadow();
let panel = ui.color(Role::SurfaceContainer);
let shape = geometry.shape;
let mut rows = column![].width(Length::Fill);
for entry in menu.entries {
rows = rows.push(match entry {
Entry::Item(item) => item_row(ui, item, geometry),
// 分组分隔线:复用 `divider::full`(一像素线 + OutlineVariant),不自己画
Entry::Divider => divider::full::<M>(ui),
});
}
container(layout::inset(0.0, geometry.pad_v, rows))
.width(menu.width)
.style(move |_theme| container::Style {
background: Some(Background::Color(panel)),
border: Border {
radius: ui.radius_dp(shape),
..Border::default()
},
shadow: Shadow {
color: Color {
a: shadow_spec.alpha,
..ui.color(Role::Shadow)
},
offset: Vector::new(0.0, shadow_spec.offset_y_px),
blur_radius: shadow_spec.blur_px,
},
..container::Style::default()
})
.into()
}
/// 一行菜单项(用 `button` 承载命中与四态,几何与颜色自绘)。
fn item_row<'a, M: Clone + 'a>(
ui: &'a Ui,
item: MenuItem<'a, M>,
geometry: MenuGeometry,
) -> Element<'a, M> {
let forced = item.state.or(if item.on_press.is_none() {
Some(Interaction::Disabled)
} else {
None
});
// 选中项的背景也要跟态走:禁用时不许留满档 SecondaryContainer
let colors = item_palette(ui, forced.unwrap_or(Interaction::Enabled), item.selected);
let body = ui.style(TypeRole::BodyMedium);
let label = text(item.label)
.size(body.size_px)
.line_height(LineHeight::Absolute(Pixels(body.line_height_px)))
.font(ui.font_of(body))
.color(colors.ink);
let mut row = iced::widget::Row::new()
.align_y(Alignment::Center)
.spacing(geometry.gap);
if let Some(kind) = item.leading {
// Q9:图标必须经固定尺寸容器
row = row.push(layout::fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(kind, colors.icon),
));
}
row = row.push(label);
if let Some(kind) = item.trailing {
// **撑开**:label 与 trailing 之间插一个水平 Fill,trailing 才会被推到行右内边距上
// (M3 里 trailing 是「快捷键 / 子菜单箭头 / 勾」,必须在一列菜单里成一条竖线)。
// 没有 trailing 时**不插**:label 仍在左边,不会被居中或右对齐。
row = row.push(iced::widget::space::horizontal());
row = row.push(layout::fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(kind, colors.icon),
));
}
// 行必须撑满 item 宽度:上面的 Fill 空间要有宽度可撑(Q8 的教训:撑开靠容器,不靠子元素)。
// 顺带把内容垂直居中——`button` 只把内容放在 padding-box 的左上角。
let content = iced::widget::container(row)
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::Center);
let selected = item.selected;
let style = move |_theme: &iced::Theme, status: button::Status| {
let state = forced.unwrap_or_else(|| Interaction::from_status(status));
let colors = item_palette(ui, state, selected);
button::Style {
background: colors.fill.map(Background::Color),
text_color: colors.ink,
border: Border {
radius: ui.radius_dp(colors.shape),
..Border::default()
},
shadow: Shadow::default(),
snap: true,
}
};
let inset = ui.space(Space::X1);
let button = button(content)
.width(Length::Fill)
.height(Length::Fixed(geometry.item_height))
.padding(Padding {
top: 0.0,
// 选中指示器水平内缩 4(8.6 / 参考图纸),内容仍落在 12 上:12 = 4 + 8
right: geometry.pad_h - inset,
bottom: 0.0,
left: geometry.pad_h - inset,
})
.style(style);
let element: Element<'a, M> = match item.on_press {
Some(message) => button.on_press(message).into(),
None => button.into(),
};
container(element)
.width(Length::Fill)
.padding(Padding {
top: 0.0,
right: inset,
bottom: 0.0,
left: inset,
})
.into()
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,123 @@
//! menu 的单测(从 `menu.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
}
fn sample(flavor: SchemeFlavor) -> Element<'static, u8> {
let ui = Box::leak(Box::new(ui(flavor)));
let spec = Menu::new()
.item(MenuItem::new("重命名", 1_u8).leading(IconKind::Folder))
.item(
MenuItem::new("复制实例", 2_u8)
.selected()
.trailing(IconKind::Check),
)
.divider()
.item(MenuItem::disabled("删除").leading(IconKind::Trash));
menu(ui, spec)
}
/// 8.2:容器 `SurfaceContainer` + L2;阴影参数来自高程令牌。
#[test]
fn container_uses_surface_container_and_level2_shadow() {
let ui = ui(SchemeFlavor::Light);
assert_eq!(Component::Menu.spec().elevation, Elevation::Level2);
assert_eq!(
Component::Menu.spec().elevation.surface_role(),
Role::SurfaceContainer,
"L2 的容器角色与 MenuTokens.ContainerColor 一致"
);
let shadow = Elevation::Level2.shadow();
assert!(shadow.is_visible(), "菜单是浮层,必须有可见阴影");
assert_eq!(shadow.blur_px, 6.0);
assert_eq!(shadow.offset_y_px, 2.0);
// 容器色**不是** MaterialLayer::Overlay(那会高一档)
assert_ne!(
ui.layer(unlucky_design::elevation::MaterialLayer::Overlay),
ui.color(Role::SurfaceContainer)
);
}
/// 8.6:选中 = SecondaryContainer + OnSecondaryContainer;禁用内容 38%。
#[test]
fn selected_and_disabled_items_use_the_right_roles() {
let ui = ui(SchemeFlavor::Light);
let selected = item_palette(&ui, Interaction::Enabled, true);
assert_eq!(selected.fill, Some(ui.color(Role::SecondaryContainer)));
assert_eq!(selected.ink, ui.color(Role::OnSecondaryContainer));
assert_eq!(selected.icon, ui.color(Role::OnSecondaryContainer));
// 选中 + hover:状态层叠在 SecondaryContainer 上(不是满档底色)
let hovered = item_palette(&ui, Interaction::Hovered, true);
assert_ne!(hovered.fill, selected.fill);
assert_eq!(
hovered.fill,
interaction::overlay_color(
&ui,
Interaction::Hovered,
Role::OnSecondaryContainer,
Role::SecondaryContainer
)
);
let disabled = item_palette(&ui, Interaction::Disabled, true);
assert_eq!(disabled.fill, None, "禁用项不留满档选中底色");
assert_eq!(
disabled.ink,
interaction::disabled_content_over_role(&ui, Role::OnSurface, false)
);
assert_ne!(disabled.ink, ui.color(Role::OnSurface));
}
/// 五态:hover 8% / pressed 10% / focus 10% 都叠在 SurfaceContainer 上(不透明)。
#[test]
fn item_states_blend_on_the_menu_container() {
let ui = ui(SchemeFlavor::Light);
let rest = item_palette(&ui, Interaction::Enabled, false);
assert_eq!(rest.fill, None, "静息不叠色(菜单底色透出来)");
for state in [
Interaction::Hovered,
Interaction::Pressed,
Interaction::Focused,
] {
let fill = item_palette(&ui, state, false)
.fill
.expect("可交互态必须给底色");
assert_eq!(fill.a, 1.0, "结果必须不透明(D27)");
assert_ne!(Some(fill), rest.fill);
}
}
/// 8.3 / 8.4:项高 48、水平 12(内容)、选中指示器内缩 4 ⇒ 内容仍在 12。
#[test]
fn item_geometry_matches_the_reference_table() {
let ui = ui(SchemeFlavor::Light);
let geometry = MenuGeometry::of(&ui);
assert_eq!(geometry.item_height, 48.0);
assert_eq!(geometry.pad_h, 12.0);
assert_eq!(geometry.pad_v, 8.0);
let inset = ui.space(Space::X1);
assert_eq!(inset, 4.0);
assert_eq!((geometry.pad_h - inset) + inset, 12.0, "内容落在 12 上");
}
/// 三种形态(带槽位 / 选中 / 禁用 / 分组线)都能构建。
#[test]
fn menu_shapes_build() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let _: Element<'static, u8> = sample(flavor);
}
}
@@ -0,0 +1,42 @@
//! **输入类家族**(D39 §1 的 `inputs/`):§9 TextField / §20 Search / §11 Slider / §8 Menu。
//!
//! 四个组件的共同纪律(与本目录外的契约一一对应):
//!
//! - **对消息类型泛型**(`M: Clone`,不 import `crate::app::Message`)——组件库要能脱离业务验收;
//! - **`&'a Ui` 传入**,组件内部不 `Ui::new`(深浅热切靠外部重建整树);
//! - **不持有 `String`**:文本框与搜索条是受控组件(借值 + `on_input`/`on_change` 回传),
//! 滑块也只借一个比例;
//! - **hover/pressed/focus/disabled 一律走 [`super::interaction`]**(α 只有一处定义);
//! - **精确尺寸走 [`super::layout`]**(Q8/Q9 的两个固定解法),图标一律 `layout::fixed`;
//! - **零尺寸字面量**:能取令牌的取令牌,取不到的集中登记在 [`geometry`] 的 G16~G20。
//!
//! ## 文件分工
//!
//! | 文件 | 内容 |
//! |---|---|
//! | [`geometry`] | 内部几何 + G 编号常量(`FieldGeometry` / `SliderGeometry` / `MenuGeometry`) |
//! | [`field`] | [`text_field`](Filled / Outlined) |
//! | [`search`] | [`search_bar`](收起)+ [`search_view`](展开 docked) |
//! | [`slider`] | [`slider`](自绘 `Widget`:轨道 16 + 竖条 4×44) |
//! | [`menu`] | [`menu`](`Menu` + `MenuItem` + 分组分隔线) |
//!
//! ## 与契约 §2 示例签名的关系
//!
//! 契约示例是 `filled(ui, label, on_press)`;本家族的四个组件都比三个参数多(变体 × 五态 ×
//! 前后槽位 × 错误/辅助文本),所以照按钮家族的先例把「部件参数」收进构造器结构体:
//! `text_field(ui, Field::new(value).label("名称"), on_input)`。**泛型纪律一字不改**,
//! 差别只是位置参数变命名参数(按钮家族已登记同一决定,见 `buttons` 模块文档)。
pub mod geometry;
mod field;
mod menu;
#[cfg(test)]
mod probe;
mod search;
mod slider;
pub use field::{text_field, Field, FieldVariant};
pub use menu::{menu, Menu, MenuItem};
pub use search::{search_bar, search_view, SearchBar, SearchView};
pub use slider::{slider, Slider};
@@ -0,0 +1,347 @@
//! 渲染层几何断言(D30 / M68 的教训:**纯函数对 ≠ 图画对**)。
//!
//! ## 为什么这个文件在 `inputs/` 而不是 `gallery/probe.rs`
//!
//! 像素工具 `gallery::scan` 是 `mod scan;`(**私有**于 `gallery`),我的 probe 拿不到它,
//! 而 `gallery/mod.rs` 不在本 mission 的 scope 里。所以这里自带一份最小的读图器
//! (约 40 行:读 PNG / 取点 / 连通块 / 行覆盖),判据全在本文件——已作为 finding 报给 tower
//! (建议把 `gallery::scan` 提升为 `pub(crate)`,四个家族就能共用一份)。
//!
//! ## 怎么跑(worktree 根目录)
//!
//! ```text
//! # ① 出图:副本工程只把 inputs 段提到首位(组件代码逐字节相同),见 target/m77/sync.py
//! python target/m77/sync.py && (cd /d/tmp/m77-shot && cargo build --no-default-features ...)
//! M77_PART=text ./target/debug/unlucky-launcher-iced.exe --software-renderer --screen=gallery \
//! --screenshot target/m77/text-light.png --screenshot-theme=light
//! # ② 量图
//! ULL_INPUTS_DIR=target/m77 cargo test -p unlucky-app-iced inputs::probe
//! ```
//!
//! 没给 `ULL_INPUTS_DIR` 就**大声跳过**(不静默通过),与 `gallery::probe` 的口径一致。
//!
//! ## 量什么(每条都对着一个「已经可能画错」的形态)
//!
//! | 断言 | 抓的错误 |
//! |---|---|
//! | 文本框高 **56**、宽 **320** | 高度被内容/内边距吃掉(不是 56 的 A 级值) |
//! | Filled 底部指示线 静息 **1.0** / 聚焦 **2.0** 墨量 | 把 2dp 画成 1dp(或反过来) |
//! | Outlined 描边 静息 **1.0** / 聚焦 **2.0** 墨量 | 同上 |
//! | 错误态:标签 / 线 / 辅助文本全 `Error` | 只把线染红、忘了标签与辅助文本 |
//! | 搜索条高 **56**、图标墨迹偏移 **20**(= 16 + viewBox 4) | 手抄 16/20 当图标边、或 pad 与 offset 少加一段 |
//! | docked 面板 宽 **360** / 头 **56** / 分隔线 **1.0 `Outline`** / 体 **240** | 用 `OutlineVariant`、或头部高度与 bar 不一致 |
//! | Slider 0%(竖条贴左)、50%(分界在中央)、100%(无未激活段) | 位移区间取错(0..W 而不是 handle/2..W−handle/2) |
//! | Slider 竖条 **4×44** | 画成圆点或撑满命中区 |
//! | 菜单项 pitch **48**、选中指示器内缩 **4**、容器高 **161** | 项高/内边距抄错 |
//! | 分组分隔线 **1.0** 墨量 | 分隔线变成 2px |
use std::path::{Path, PathBuf};
use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::typography::Platform;
use crate::theme::{Families, Ui};
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 颜色比对容差(抗锯齿/取整)。
const TOLERANCE: u8 = 10;
type Rgb = [u8; 3];
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
}
fn rgb(color: iced::Color) -> Rgb {
[
(color.r * 255.0).round() as u8,
(color.g * 255.0).round() as u8,
(color.b * 255.0).round() as u8,
]
}
fn matches(pixel: Rgb, expected: Rgb) -> bool {
matches_within(pixel, expected, TOLERANCE)
}
/// 指定容差的同色判断(量**阴影边**时要用更严的容差:阴影叠上去会落在 10 的容差内)。
fn matches_within(pixel: Rgb, expected: Rgb, tolerance: u8) -> bool {
pixel
.iter()
.zip(expected)
.all(|(a, b)| a.abs_diff(b) <= tolerance)
}
/// 一张图的最小访问器(读 PNG / 取点 / 连通块)。
struct Shot {
width: usize,
height: usize,
pixels: Vec<Rgb>,
}
/// 一张图里某颜色的连通块外接框(闭区间)。
#[derive(Debug, Clone, Copy)]
struct BBox {
left: usize,
right: usize,
top: usize,
bottom: usize,
}
impl BBox {
fn width(&self) -> usize {
self.right - self.left + 1
}
fn height(&self) -> usize {
self.bottom - self.top + 1
}
}
impl Shot {
fn load(path: &Path) -> Result<Self, String> {
let bytes = std::fs::read(path).map_err(|error| format!("读不动 {path:?}:{error}"))?;
let image = image::load_from_memory(&bytes)
.map_err(|error| format!("解 PNG 失败:{error}"))?
.to_rgb8();
let (width, height) = (image.width() as usize, image.height() as usize);
Ok(Self {
width,
height,
pixels: image.pixels().map(|p| p.0).collect(),
})
}
fn at(&self, x: usize, y: usize) -> Rgb {
self.pixels[y * self.width + x]
}
fn bbox_of(&self, color: Rgb, min_area: usize) -> Vec<BBox> {
let mut seen = vec![false; self.width * self.height];
let mut boxes = Vec::new();
for y in 0..self.height {
for x in 0..self.width {
if seen[y * self.width + x] || !matches(self.at(x, y), color) {
continue;
}
let mut stack = vec![(x, y)];
seen[y * self.width + x] = true;
let mut bbox = BBox {
left: x,
right: x,
top: y,
bottom: y,
};
while let Some((cx, cy)) = stack.pop() {
bbox.left = bbox.left.min(cx);
bbox.right = bbox.right.max(cx);
bbox.top = bbox.top.min(cy);
bbox.bottom = bbox.bottom.max(cy);
for (nx, ny) in [
(cx + 1, cy),
(cx.saturating_sub(1), cy),
(cx, cy + 1),
(cx, cy.saturating_sub(1)),
] {
if nx < self.width
&& ny < self.height
&& !seen[ny * self.width + nx]
&& matches(self.at(nx, ny), color)
{
seen[ny * self.width + nx] = true;
stack.push((nx, ny));
}
}
}
if bbox.width() * bbox.height() >= min_area {
boxes.push(bbox);
}
}
}
boxes
}
fn count_in(&self, color: Rgb, x0: usize, x1: usize, y0: usize, y1: usize) -> usize {
let mut total = 0;
for y in y0..y1.min(self.height) {
for x in x0..x1.min(self.width) {
if matches(self.at(x, y), color) {
total += 1;
}
}
}
total
}
}
/// 一行像素相对底色向 `line` 靠拢的比例(0..=1)——1dp 的线在各行上摊开时总和 ≈ 1.0。
fn coverage(pixel: Rgb, surface: Rgb, line: Rgb) -> f32 {
let channel = |i: usize| {
let full = f32::from(line[i]) - f32::from(surface[i]);
if full.abs() < 1.0 {
return 0.0;
}
((f32::from(pixel[i]) - f32::from(surface[i])) / full).clamp(0.0, 1.0)
};
(channel(0) + channel(1) + channel(2)) / 3.0
}
/// 竖直方向上的**线墨量**:`y0..y1` 每行在 x 区间上的平均覆盖之和(1dp ⇒ ≈1.0)。
fn line_ink(
shot: &Shot,
x0: usize,
x1: usize,
y0: usize,
y1: usize,
surface: Rgb,
line: Rgb,
) -> f32 {
let mut total = 0.0;
for y in y0..y1.min(shot.height) {
let mut sum = 0.0;
for x in x0..x1.min(shot.width) {
sum += coverage(shot.at(x, y), surface, line);
}
total += sum / (x1 - x0) as f32;
}
total
}
/// 某一行里「两色之一」的最长连续段 `(start, end)`,允许 ≤`max_gap` 的空隙。
///
/// 用它而不是「本行最左/最右」:深色档的说明文字里有零星像素落在令牌色容差内,
/// 取 min/max 会把轨道量成从 x=24 起(实测)。
fn longest_run(shot: &Shot, y: usize, a: Rgb, b: Rgb, max_gap: usize) -> Option<(usize, usize)> {
let mut best: Option<(usize, usize)> = None;
let mut start: Option<usize> = None;
let mut last: Option<usize> = None;
for x in 0..shot.width {
if matches(shot.at(x, y), a) || matches(shot.at(x, y), b) {
if start.is_none() {
start = Some(x);
}
last = Some(x);
} else if let (Some(from), Some(to)) = (start, last) {
if x - to > max_gap {
if best.is_none_or(|(p, q)| to - from > q - p) {
best = Some((from, to));
}
start = None;
last = None;
}
}
}
if let (Some(from), Some(to)) = (start, last) {
if best.is_none_or(|(p, q)| to - from > q - p) {
best = Some((from, to));
}
}
best
}
/// `bbox` 范围内「整行至少 `min_pixels` 个同色像素」的行区间(闭区间)。
///
/// 菜单的阴影会把 bbox 外扩 ~5px,而阴影行绝不会有 100 个容器色像素——用它把阴影滤掉。
fn row_extent(shot: &Shot, bbox: &BBox, color: Rgb, min_pixels: usize) -> (usize, usize) {
let mut first = None;
let mut last = None;
for y in bbox.top..=bbox.bottom {
let mut count = 0;
for x in bbox.left..=bbox.right {
// 容差收到 2:**阴影边**会落在默认容差 10 内,会把容器量高 ~5px
if matches_within(shot.at(x, y), color, 2) {
count += 1;
}
}
if count >= min_pixels {
first.get_or_insert(y);
last = Some(y);
}
}
(
first.expect("行内没有足够的同色像素"),
last.expect("行内没有足够的同色像素"),
)
}
/// `pixel` 更接近 `fill` 还是 `surface`(含平局都算 `fill`)。
fn nearer(pixel: Rgb, fill: Rgb, surface: Rgb) -> bool {
let dist = |a: Rgb, b: Rgb| -> u32 {
a.iter()
.zip(b)
.map(|(x, y)| (i32::from(*x) - i32::from(y)).unsigned_abs())
.sum()
};
dist(pixel, fill) <= dist(pixel, surface)
}
/// 截图目录(`ULL_INPUTS_DIR`);没给就返回 `None`(调用方大声跳过)。
fn shot_dir() -> Option<PathBuf> {
let raw = std::env::var_os("ULL_INPUTS_DIR")?;
let path = PathBuf::from(raw);
if path.is_absolute() || path.is_dir() {
return Some(path);
}
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
Some(root.join(path))
}
/// 取一张图;缺图直接 panic(给了目录就说明这轮要认真量)。
fn shot(dir: &Path, part: &str, flavor: SchemeFlavor) -> Shot {
let slug = match flavor {
SchemeFlavor::Light => "light",
SchemeFlavor::Dark => "dark",
};
let path = dir.join(format!("{part}-{slug}.png"));
let shot = Shot::load(&path).unwrap_or_else(|error| panic!("{error}"));
assert_eq!(
(shot.width, shot.height),
(1180, 760),
"验收图必须是 D30 的 1180×760 @100% DPI"
);
shot
}
/// 一个矩阵格在某列的竖直「实色高度」:从 `bbox.top-4` 往上/下走到不再接近 `fill` 为止。
fn vertical_extent(
shot: &Shot,
bbox: &BBox,
fill: Rgb,
surface: Rgb,
column: usize,
) -> (usize, usize) {
let mut top = bbox.top;
while top > 0 && nearer(shot.at(column, top - 1), fill, surface) {
top -= 1;
}
let mut bottom = bbox.bottom;
while bottom + 1 < shot.height && nearer(shot.at(column, bottom + 1), fill, surface) {
bottom += 1;
}
(top, bottom)
}
macro_rules! require_dir {
() => {
match shot_dir() {
Some(dir) => dir,
None => {
eprintln!(
"[inputs::probe] SKIPPED:未设置 ULL_INPUTS_DIR。这是**渲染层**断言,\
需要先按本文件模块文档出 8 张图(target/m77/*.png)。"
);
return;
}
}
};
}
mod menu;
mod search;
mod slider;
mod text;
@@ -0,0 +1,102 @@
//! M77 probe · menu(从 `probe.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。
use super::*;
/// 8.3 / 8.4 / 8.6:菜单容器高 161、项 pitch 48、选中指示器内缩 4、分组分隔线 1dp。
#[test]
fn menu_container_items_and_divider() {
let dir = require_dir!();
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let shot = shot(&dir, "menu", flavor);
let ui = ui(flavor);
let container = rgb(ui.color(Role::SurfaceContainer));
let selected = rgb(ui.color(Role::SecondaryContainer));
let divider = rgb(ui.color(Role::OutlineVariant));
// 右侧那只 = 「展开态」菜单。容器色块可能被**分组分隔线 / 选中项**切成多块,
// 所以先把 x>290 的块并成一个外接框,再逐行量容器色(容差 2,滤掉阴影外扩的 ~5px)。
let menus = shot.bbox_of(container, 2000);
let right: Vec<BBox> = menus.iter().filter(|b| b.left > 290).copied().collect();
assert!(!right.is_empty(), "找不到右侧的「展开态」菜单");
let mut open = right[0];
for b in right.iter().skip(1) {
open.left = open.left.min(b.left);
open.top = open.top.min(b.top);
open.right = open.right.max(b.right);
open.bottom = open.bottom.max(b.bottom);
}
let (top, bottom) = row_extent(&shot, &open, container, 1);
open.top = top;
open.bottom = bottom;
// 布局高 **161** = 8 + 48×3 + 1 + 8;抗锯齿会吃掉上下各 1 行 ⇒ 实测 160
assert!(
(159..=162).contains(&open.height()),
"8.3/8.4 菜单高应 161(= 8 + 48×3 + 1 + 8):实测 {}",
open.height()
);
// 选中项:SecondaryContainer,高 48、左右各内缩 4
let selected_box = shot
.bbox_of(selected, 2000)
.into_iter()
.filter(|b| b.height() >= 44 && b.height() <= 50)
.max_by_key(|b| b.width())
.expect("找不到选中项的 SecondaryContainer");
assert!(
(46..=48).contains(&selected_box.height()),
"8.3 项高应 48:实测 {}",
selected_box.height()
);
// 容器左右缘:在第一项那一行按严格同色扫(bbox 会被阴影外扩 ~2px,不能用)
let scan_y = open.top + 10;
let row: Vec<usize> = (open.left..=open.right)
.filter(|x| matches_within(shot.at(*x, scan_y), container, 2))
.collect();
let (menu_left, menu_right) = (*row.first().unwrap(), *row.last().unwrap());
assert_eq!(selected_box.left - menu_left, 4, "8.6 选中指示器左内缩应 4");
assert_eq!(
menu_right - selected_box.right,
4,
"8.6 选中指示器右内缩应 4"
);
// 分组分隔线:1dp OutlineVariant 墨量 ≈1.0(位置在选中项与最后一项之间)
let ink = line_ink(
&shot,
open.left + 20,
open.right - 20,
selected_box.bottom + 1,
selected_box.bottom + 6,
container,
divider,
);
assert!(
(0.6..=1.4).contains(&ink),
"8.x 分组分隔线应 1dp `OutlineVariant`:实测墨量 {ink:.2}"
);
// trailing 槽位必须靠到**行右内边距**上(视觉 r5 P1:早先它紧贴 label 右边)。
//
// 位置公式:item 右缘 = menu_right − 4(选中指示器内缩);button 内容右缘 = item 右缘 −
// (pad_h − inset) = menu_right − 12;trailing 图标盒 24 的**墨迹**再内缩 k(`Check` 字形
// 在 24 viewBox 里右边界 ≈ 5,与 leading 图标「可视边 12、墨迹 16」同一口径)。
// 所以墨迹右缘 ≈ menu_right − 17。变异(去掉撑开的 Fill)会让它贴着 label ⇒ 差 ~120。
let trailing_ink_color = rgb(ui.color(Role::OnSecondaryContainer));
let mut trailing_right = 0usize;
for y in selected_box.top..=selected_box.bottom {
for x in selected_box.left..=selected_box.right {
if matches(shot.at(x, y), trailing_ink_color) {
trailing_right = trailing_right.max(x);
}
}
}
let trailing_offset = menu_right - trailing_right;
assert!(
(16..=18).contains(&trailing_offset),
"8.6/trailing:带 trailing 的项,其 trailing 墨迹右缘应在 item 右内边距上\
(menu_right − 17,允许 ±1):实测 menu_right={menu_right}、墨迹右缘={trailing_right}\
⇒ 偏 {trailing_offset}(贴 label 时会是 ~120)"
);
}
}
@@ -0,0 +1,80 @@
//! M77 probe · search(从 `probe.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。
use super::*;
/// 20.1 / 20.4 / 20.6 / 20.8 / 20.9:条 56、图标墨迹偏移 20(= 16 + viewBox 4)、
/// docked 面板 360 / 头 56 / 分隔线 1.0 `Outline` / 体 240。
#[test]
fn search_bar_and_docked_panel_geometry() {
let dir = require_dir!();
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let shot = shot(&dir, "search", flavor);
let ui = ui(flavor);
let bar_color = rgb(ui.color(Role::SurfaceContainerHigh));
let ink = rgb(ui.color(Role::OnSurface));
let bars = shot.bbox_of(bar_color, 2000);
let bar = bars
.iter()
.filter(|b| b.height() <= 60 && b.width() >= 360)
.min_by_key(|b| b.top)
.copied()
.expect("找不到搜索条(高 ≤60、宽 ≥360)");
assert_eq!(bar.height(), 56, "20.1 搜索条高必须 56");
assert!(bar.width() >= 360, "20.2 搜索条宽 ≥ 360");
// 20.4:图标墨迹左缘 = pad 12 + offset 4 + viewBox 内 ≈4 = 20(±1)
let inner = (bar.top + 12)..(bar.bottom - 12);
let mut left_ink = usize::MAX;
for y in inner.clone() {
for x in bar.left..bar.right {
if matches(shot.at(x, y), ink) {
left_ink = left_ink.min(x);
}
}
}
let offset = left_ink - bar.left;
assert!(
(19..=21).contains(&offset),
"20.4 前置图标墨迹偏移应 20(= 12 + 4 + viewBox 4):实测 {offset}"
);
// 20.6 / 20.9:docked 面板 360 宽,头 56 + 1px 分隔线 + 体 240
// (头与体被那条线切成**两个同色块**,所以按 top 排序取前两块)
let mut parts = bars
.iter()
.filter(|b| b.width() == 360)
.copied()
.collect::<Vec<_>>();
parts.sort_by_key(|b| b.top);
assert_eq!(parts.len(), 2, "docked 面板 = 头部 + 结果区两块");
let (header, body) = (parts[0], parts[1]);
assert!(
(55..=56).contains(&header.height()),
"20.6 面板头部高 56(与 bar 同高;顶部圆角会吃掉 1 行抗锯齿,实测 {})",
header.height()
);
assert_eq!(body.height(), 240, "20.9 结果区最小高 240");
assert!(
(2..=3).contains(&(body.top - header.bottom)),
"20.8 头与体之间正好 1dp(CPU 档摊成 2 行 ⇒ 隔 2~3 行):实测 {}",
body.top - header.bottom
);
let panel = header;
// 20.8:分隔线是 `Outline`(不是 OutlineVariant),1dp 墨量 ≈1.0
let divider_ink = line_ink(
&shot,
panel.left + 40,
panel.right - 40,
panel.top + 50,
panel.top + 62,
bar_color,
rgb(ui.color(Role::Outline)),
);
assert!(
(0.7..=1.3).contains(&divider_ink),
"20.8 头部分隔线应 1dp `Outline`:实测墨量 {divider_ink:.2}"
);
}
}
@@ -0,0 +1,96 @@
//! M77 probe · slider(从 `probe.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。
use super::*;
/// 11.1~11.6:竖条 4×44、0% 贴左、50% 分界在中央、100% 无未激活段(thumb 永不越界)。
#[test]
fn slider_thumb_positions_at_zero_half_and_full() {
let dir = require_dir!();
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let shot = shot(&dir, "slider", flavor);
let ui = ui(flavor);
let active = rgb(ui.color(Role::Primary));
let inactive = rgb(ui.color(Role::SecondaryContainer));
// 三条「值行」= 宽 ≥400 的轨道(0% / 50% / 100%),按 y 排序
let mut rows: Vec<(usize, usize, usize)> = Vec::new();
for y in 0..shot.height {
if let Some((left, right)) = longest_run(&shot, y, active, inactive, 20) {
// 先按「跨度 ≥300」收行(把抗锯齿行也纳进来),band 建好后再按宽度筛。
if right - left + 1 >= 300 {
rows.push((y, left, right));
}
}
}
// 合并相邻的 y(同一条轨道占 16 行),并把每行的端点累积成 min/max——
// 首行/末行都是抗锯齿行,只取它们会把轨道量窄 2~3px。
let mut bands: Vec<(usize, usize, usize, usize)> = Vec::new();
for (y, left, right) in rows {
match bands.last_mut() {
Some(last) if y - last.1 <= 3 => {
last.1 = y;
last.2 = last.2.min(left);
last.3 = last.3.max(right);
}
_ => bands.push((y, y, left, right)),
}
}
// 值行是 448 宽(`Space::X8 × 14`);五态行 5 条轨道会被并成 ~663 的带,按宽度筛掉
let bands: Vec<(usize, usize, usize, usize)> = bands
.into_iter()
.filter(|(_, _, left, right)| (440..=470).contains(&(right - left + 1)))
.collect();
assert_eq!(
bands.len(),
4,
"值行应有 4 条(0% / 50% / 100% / 离散):实测 {}",
bands.len()
);
for (label, band) in ["0%", "50%", "100%"].iter().zip(bands.iter()) {
let (top, bottom, band_left, band_right) = *band;
let mid = (top + bottom) / 2; // 轨道中心行
let (left, right) = (band_left, band_right);
let width = right - left + 1;
let handle_centre = left + 2 + (width - 4) / 2; // 位移区间 = handle/2 .. W-handle/2
let boundary = (left..=right)
.find(|x| matches(shot.at(*x, mid), inactive))
.unwrap_or(right + 1);
match *label {
"0%" => assert!(
boundary <= left + 4,
"11.x 0% 时未激活轨必须从竖条(4 宽)之后立刻开始:实测分界 {}",
boundary - left
),
"50%" => assert!(
boundary.abs_diff(handle_centre) <= 2,
"11.x 50% 的轨道分界应在正中({handle_centre}):实测 {boundary}"
),
_ => {
let tail = shot.count_in(inactive, left, right + 1, mid, mid + 1);
assert_eq!(tail, 0, "11.x 100% 时不该有未激活段:实测 {tail}px");
}
}
// 竖条必须落在轨道内(0% / 100% 的最狠情形)
let handles: Vec<BBox> = shot
.bbox_of(active, 100)
.into_iter()
.filter(|b| b.width() == 4 && b.height() >= 40)
.collect();
assert!(!handles.is_empty(), "11.2 找不到 4×44 的竖条");
for handle in handles {
assert!(
handle.left >= left && handle.right <= right,
"11.4 竖条越界:{handle:?} 不在轨道 {left}..{right} 内"
);
assert!(
(43..=44).contains(&handle.height()),
"11.2 竖条高必须 44(抗锯齿会吃掉 1 行,实测 {})",
handle.height()
);
assert_eq!(handle.width(), 4, "11.2 竖条宽必须 4");
}
}
}
}
@@ -0,0 +1,205 @@
//! M77 probe · text(从 `probe.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。
use super::*;
/// 9.1 / 9.2:Filled 高 56、宽 320;Filled 指示线 静息 1.0 / 聚焦 2.0 墨量。
#[test]
fn text_field_height_width_and_indicator_thickness() {
let dir = require_dir!();
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let shot = shot(&dir, "text", flavor);
let ui = ui(flavor);
let surface = rgb(ui.color(Role::Surface));
let fill = rgb(ui.color(Role::SurfaceContainerHighest));
let line = rgb(ui.color(Role::OnSurfaceVariant));
let primary = rgb(ui.color(Role::Primary));
let mut boxes = shot.bbox_of(fill, 400);
boxes.sort_by_key(|b| (b.top, b.left));
let wide: Vec<&BBox> = boxes.iter().filter(|b| b.width() >= 280).collect();
// 320 宽的有两个 Filled(空 / 有值);第三格是 Outlined 错误态——它没有容器色,
// 所以这里只要求 ≥2,错误态由 `outlined_stroke_and_error_paint` 单独验。
assert!(
wide.len() >= 2,
"{flavor:?}: 至少要有 2 个 320 宽的 Filled 文本字段(实测 {})",
wide.len()
);
// 320 宽那三个 = 「空 / 有值 / 错误」三格;高度必须精确 56
for bbox in wide.iter().take(2) {
assert!(
(280..=320).contains(&bbox.width()),
"9.2 最小宽 280:实测 {}(320 宽格)",
bbox.width()
);
let column = bbox.right - 20;
let (top, bottom) = vertical_extent(&shot, bbox, fill, surface, column);
assert_eq!(
bottom - top + 1,
56,
"9.1 高度必须精确 56(带浮动标签):实测 {flavor:?} @x={column}"
);
}
// 矩阵第一行(Filled 五态)=最靠上的 5 个 160 宽格:列序 = Interaction::ALL
// 五态里 disabled 的容器是 `OnSurface@12%`(另一种颜色),所以这里只有 4 块:
// enabled / hovered / pressed / focused —— 列序与 `Interaction::ALL` 一致。
let row: Vec<&BBox> = boxes
.iter()
.filter(|b| (140..=170).contains(&b.width()))
.take(4)
.collect();
assert_eq!(row.len(), 4, "{flavor:?}: Filled 矩阵应有 4 个同色格");
let enabled = row[0];
let focused = row[3];
// 静息指示线 1dp:墨量 ≈ 1.0
let enabled_ink = line_ink(
&shot,
enabled.left + 8,
enabled.right - 8,
enabled.bottom,
enabled.bottom + 4,
fill,
line,
);
assert!(
(0.6..=1.4).contains(&enabled_ink),
"9.4 静息指示线应 1dp:实测墨量 {enabled_ink:.2}"
);
// 聚焦指示线 2dp:墨量 ≈ 2.0(画成 1dp 会掉到 ~1.0)
let focused_ink = line_ink(
&shot,
focused.left + 8,
focused.right - 8,
focused.bottom,
focused.bottom + 5,
fill,
primary,
);
assert!(
(1.5..=2.5).contains(&focused_ink),
"9.4 聚焦指示线应 2dp:实测墨量 {focused_ink:.2}(1dp 会掉到 ≈1.0)"
);
assert!(
focused_ink > enabled_ink + 0.5,
"聚焦必须比静息粗一档({focused_ink:.2} vs {enabled_ink:.2})"
);
}
}
/// 9.4 / 9.7:Outlined 描边 静息 1dp / 聚焦 2dp;错误态线/标签/辅助文本全 `Error`。
///
/// 量法:**在某一列的竖线上扫**(不是找同色块)——深色档里 hover 描边的抗锯齿像素也会落进
/// `Outline` 的容差里,按「最上面的同色块」取会取错行(实测 dark 取到了 hover 那一行)。
#[test]
fn outlined_stroke_and_error_paint() {
let dir = require_dir!();
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let shot = shot(&dir, "text", flavor);
let ui = ui(flavor);
let surface = rgb(ui.color(Role::Surface));
let fill = rgb(ui.color(Role::SurfaceContainerHighest));
let outline = rgb(ui.color(Role::Outline));
let primary = rgb(ui.color(Role::Primary));
let error = rgb(ui.color(Role::Error));
// 矩阵第一列(enabled)的左缘与行距:列宽 160 + 间距 8 = 168
let left = shot
.bbox_of(fill, 400)
.into_iter()
.filter(|b| (140..=170).contains(&b.width()))
.min_by_key(|b| (b.top, b.left))
.expect("找不到 Filled 矩阵格")
.left;
let filled_bottom = shot
.bbox_of(fill, 400)
.into_iter()
.filter(|b| (140..=170).contains(&b.width()))
.min_by_key(|b| (b.top, b.left))
.expect("找不到 Filled 矩阵格")
.bottom;
let column = |index: usize| left + index * 168 + 120;
// 静息(第 1 列)描边 1dp:在竖线上找第一处比底色更接近 `Outline` 的像素
let rest_y = ((filled_bottom + 8)..shot.height)
.find(|y| nearer(shot.at(column(0), *y), outline, surface))
.expect("竖线上找不到 Outlined 静息描边");
let rest_ink = line_ink(
&shot,
column(0) - 40,
column(0) + 40,
rest_y.saturating_sub(2),
rest_y + 3,
surface,
outline,
);
// 描边的墨量随**亚像素相位**在 1.0~1.67 之间浮动(同一 1dp:整行对齐 = 1.0,
// 落在半像素上 = 1 行满 + 1 行 0.67)。所以静息只做宽松下限,**宽度差异由比值判**。
assert!(
(0.8..=1.9).contains(&rest_ink),
"9.4 Outlined 静息描边应 1dp(实测墨量 {rest_ink:.2};2dp 会到 2.0)"
);
// 聚焦(第 4 列)描边 2dp
let focus_y = ((filled_bottom + 8)..shot.height)
.find(|y| nearer(shot.at(column(3), *y), primary, surface))
.expect("竖线上找不到 Outlined 聚焦描边");
let focus_ink = line_ink(
&shot,
column(3) - 40,
column(3) + 40,
focus_y.saturating_sub(2),
focus_y + 4,
surface,
primary,
);
assert!(
(1.8..=2.3).contains(&focus_ink),
"9.4 Outlined 聚焦描边应 2dp(实测墨量 {focus_ink:.2};1dp 会掉到 1.67)"
);
let ratio = focus_ink / rest_ink;
assert!(
(1.6..=2.5).contains(&ratio),
"聚焦必须正好粗一倍(2dp/1dp):实测比值 {ratio:.2}({focus_ink:.2} vs {rest_ink:.2})"
);
// 9.7:错误格——描边 2dp + 标签(框内上带)+ 辅助文本(框下)全 `Error`
let error_box = shot
.bbox_of(error, 2000)
.into_iter()
.filter(|b| b.width() >= 280)
.max_by_key(|b| b.width())
.expect("找不到错误态字段");
assert!(
error_box.height() >= 50,
"错误格应是一整个 320 宽的字段(实测 {}×{})",
error_box.width(),
error_box.height()
);
let label_ink = shot.count_in(
error,
error_box.left,
error_box.right + 1,
error_box.top + 2,
error_box.top + 22,
);
let supporting_ink = shot.count_in(
error,
error_box.left,
error_box.right + 1,
error_box.bottom + 1,
error_box.bottom + 30,
);
assert!(
label_ink > 20,
"9.7 错误标签没变成 Error(框内上带墨迹 {label_ink}px)"
);
assert!(
supporting_ink > 20,
"9.7 错误辅助文本没变成 Error(框下墨迹 {supporting_ink}px)"
);
}
}
@@ -0,0 +1,353 @@
//! 搜索(`research/15` §20):官方是**两个组件**——[`search_bar`](收起)+ [`search_view`](展开)。
//!
//! ## 参考值(`[源]` = A1;本机 `material3-android:1.4.0` 的 `SearchBarTokens` / `SearchBar.kt`)
//!
//! | # | 检查项 | 参考值 | 落点 |
//! |---|---|---|---|
//! | 20.1 | 高 + 圆角 | 高 **56**、圆角 **full = 28**(药丸,不是圆角矩形) | `LM.search_bar_height` + `Component::Search.spec().shape` |
//! | 20.2 | 宽 | min **360** / max **720**(G20:iced 无 min-width,只施加 max) | [`SEARCH_MAX_WIDTH`](super::geometry::SEARCH_MAX_WIDTH) |
//! | 20.3 | 容器色 / 高程 | `SurfaceContainerHigh` + **L0**(token 写 L3,默认值是 L0,按观感取 L0) | `Role::SurfaceContainerHigh` |
//! | 20.4 | 图标位 | leading 24/`OnSurface`、trailing 24/`OnSurfaceVariant`;**可视边 16 = pad 12 + offset 4** | `ComponentMetrics::SEARCH_BAR` |
//! | 20.5 | 文本 / 占位 / 聚焦 | 输入 `BodyLarge`+`OnSurface`;占位 `OnSurfaceVariant`;聚焦 `Secondary` 1px 指示 | `TypeRole` / `Role::Secondary` |
//! | 20.6 | docked 面板 | 头部 **56**、形状 `CornerExtraLarge` = **28** | `ShapeScale::ExtraLarge` |
//! | 20.8 | 头部 / 结果区分隔 | **1px `Outline`**(**不是** `OutlineVariant`!) | 见下 |
//! | 20.9 | 面板尺寸 | 顶距 8、最小高 **240**、docked 宽 **360** | §20B |
//!
//! ## 三处实现口径
//!
//! 1. **图标可视边一律 `pad + icon_offset` 求和**(`SEARCH_BAR.pad_start = 12` + `icon_offset = 4` = 16)。
//! M69 把这两个值钉成不变量,本文件**不写 16、也不写 12/4**——只做加法。
//! 2. `20.8` 的分隔线用 `Role::Outline`,而 `components::divider` 给的是 `OutlineVariant`
//! (那是正文分隔线的角色)。两个角色在 A1 里明确不同,所以这里**不复用** `divider`,
//! 只按一像素线(`stroke::HAIRLINE`)画一条 —— 复用会把 20.8 判成「报 OutlineVariant 即错」。
//! 3. bar 的 hover / pressed / disabled 在 `SearchBarTokens` 里**没有** token。
//! hover/pressed 按通用状态层(`interaction::overlay_color`,8%/10%),disabled 按令牌的
//! 12%/38%,都登记为 [估](D14 / D15);焦点指示用 A1 的 `FocusIndicatorColor = Secondary`。
use iced::widget::text::LineHeight;
use iced::widget::{column, container, row, text, text_input};
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels};
use unlucky_design::color::Role;
use unlucky_design::component_map::{Component, ComponentMetrics};
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::{Ui, STROKE_PX};
use super::super::interaction::{self, Interaction};
use super::super::layout;
use super::geometry::{SEARCH_MAX_WIDTH, SEARCH_MIN_WIDTH, SEARCH_VIEW_MIN_HEIGHT};
/// 搜索条(收起形态)。
///
/// 状态矩阵用 [`SearchBar::state`] 强制;运行期五态由 `Tracker`/`text_input` 的真实事件驱动。
#[derive(Debug, Clone)]
pub struct SearchBar<'a> {
value: &'a str,
placeholder: &'a str,
trailing: Option<IconKind>,
state: Option<Interaction>,
width: Length,
}
impl<'a> SearchBar<'a> {
/// 一条收起态搜索条(默认占位文案由页面给,组件内不拼中文)。
pub fn new(value: &'a str) -> Self {
Self {
value,
placeholder: "",
trailing: Some(IconKind::Filter),
state: None,
width: Length::Fill,
}
}
/// 占位文案。
pub fn placeholder(mut self, placeholder: &'a str) -> Self {
self.placeholder = placeholder;
self
}
/// 强制视觉态(gallery 取证)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
/// 定宽(默认 `Fill`)。
pub fn width(mut self, width: Length) -> Self {
self.width = width;
self
}
}
/// 搜索条内的一行内容(bar 与 view 头部共用,保证「展开时头部不跳」——20.6)。
#[allow(clippy::too_many_arguments)]
fn bar_content<'a, M: Clone + 'a>(
ui: &'a Ui,
value: &'a str,
placeholder: &'a str,
trailing: Option<IconKind>,
state: Interaction,
on_input: impl Fn(String) -> M + 'a,
) -> Element<'a, M> {
// 20.4:可视边 = pad + offset(**求和**,不抄 16)
let bar: ComponentMetrics = Component::Search.metrics().expect("Search 有组件级度量");
// 20.4:图标**可视边** = pad + offset(M69 不变量)。这里反解出偏移,两侧共用同一个量。
let icon_edge = bar.pad_start + bar.icon_offset;
let icon_offset = icon_edge - bar.pad_start;
let ink = if state.is_disabled() {
interaction::disabled_content_over_role(ui, Role::OnSurface, true)
} else {
ui.color(Role::OnSurface)
};
let subtle = if state.is_disabled() {
ink
} else {
ui.color(Role::OnSurfaceVariant)
};
let body_style = ui.style(TypeRole::BodyLarge);
let input = text_input(placeholder, value)
.on_input_maybe(if state.is_disabled() {
None
} else {
Some(on_input)
})
.size(body_style.size_px)
.line_height(LineHeight::Absolute(Pixels(body_style.line_height_px)))
.font(ui.font_of(body_style))
.padding(Padding::default())
.width(Length::Fill)
.style(move |_theme, _status| text_input::Style {
background: Background::Color(Color::TRANSPARENT),
border: Border::default(),
icon: subtle,
placeholder: subtle,
value: ink,
selection: ink,
});
// 前置图标:左边 = pad(容器内边距 12)+ offset(4)= 16。两层分别来自令牌的两个字段。
let leading = container(layout::fixed(
bar.icon,
bar.icon,
icons::view::<M>(IconKind::Search, ink),
))
.padding(Padding {
top: 0.0,
right: 0.0,
bottom: 0.0,
left: icon_offset,
});
let mut contents = row![leading, input]
.align_y(Alignment::Center)
.spacing(bar.gap);
if let Some(kind) = trailing {
// 尾随图标:右边 = pad_end + offset(镜像同式),所以右缘也在 16 上
let trailing = container(layout::fixed(
bar.icon,
bar.icon,
icons::view::<M>(kind, subtle),
))
.padding(Padding {
top: 0.0,
right: icon_offset,
bottom: 0.0,
left: 0.0,
});
contents = contents.push(trailing);
}
container(contents)
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::Center)
.padding(Padding {
top: 0.0,
right: bar.pad_end,
bottom: 0.0,
left: bar.pad_start,
})
.into()
}
/// 收起态搜索条(56 高、药丸、`SurfaceContainerHigh`、无可见阴影)。
pub fn search_bar<'a, M: Clone + 'a>(
ui: &'a Ui,
bar: SearchBar<'a>,
on_input: impl Fn(String) -> M + 'a,
) -> Element<'a, M> {
let state = bar.state.unwrap_or(Interaction::Enabled);
let component = Component::Search.spec();
let height = ui.metrics().search_bar_height;
let background = if state.is_disabled() {
interaction::disabled_colors(ui, Role::Surface).0
} else {
let role = Role::SurfaceContainerHigh;
interaction::overlay_color(ui, state, Role::OnSurface, role).unwrap_or(ui.color(role))
};
// 20.5:聚焦指示 = 1px `Secondary`(A1 的 token 存在但 Compose 未消费 → 标 [规格])
let focus_ring = matches!(state, Interaction::Focused);
let border = Border {
color: if focus_ring {
ui.color(Role::Secondary)
} else {
Color::TRANSPARENT
},
width: if focus_ring { STROKE_PX } else { 0.0 },
radius: ui.radius(component.shape, height),
};
container(bar_content(
ui,
bar.value,
bar.placeholder,
bar.trailing,
state,
on_input,
))
.width(bar.width)
// 20.2:max **720**(`SearchBarMaxWidth`)。min 360 无法在 iced 表达(G20),由页面保证列宽。
.max_width(SEARCH_MAX_WIDTH)
.height(Length::Fixed(height))
.style(move |_theme| container::Style {
background: Some(Background::Color(background)),
border,
..container::Style::default()
})
.into()
}
/// 展开态搜索面板(docked 形态:桌面 1180 窗口永远用它,`20C` 的结论)。
///
/// 形状:头部 56(半径 28)+ 1px `Outline` 分隔 + 结果区(最小 240)。
#[derive(Debug, Clone)]
pub struct SearchView<'a> {
value: &'a str,
placeholder: &'a str,
empty: &'a str,
results: &'a [&'a str],
width: f32,
}
impl<'a> SearchView<'a> {
/// 一个展开面板(`empty` = 空结果文案,由页面给 i18n 文本)。
pub fn new(value: &'a str, results: &'a [&'a str], empty: &'a str) -> Self {
Self {
value,
placeholder: "",
empty,
results,
width: SEARCH_MIN_WIDTH,
}
}
/// 占位文案。
pub fn placeholder(mut self, placeholder: &'a str) -> Self {
self.placeholder = placeholder;
self
}
}
/// 展开态搜索面板。
pub fn search_view<'a, M: Clone + 'a>(
ui: &'a Ui,
view: SearchView<'a>,
on_input: impl Fn(String) -> M + 'a,
) -> Element<'a, M> {
let panel = ui.color(Role::SurfaceContainerHigh);
let header_height = ui.metrics().search_bar_height;
let body_style = ui.style(TypeRole::BodyMedium);
// 20.8:头部/结果区分隔是 **`Outline`**(正文分隔线才是 OutlineVariant)
let divider = container(iced::widget::space::horizontal())
.width(Length::Fill)
.height(Length::Fixed(STROKE_PX))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::Outline))),
..container::Style::default()
});
let mut rows = column![].width(Length::Fill).spacing(0);
for result in view.results {
rows = rows.push(
container(
text(*result)
.size(body_style.size_px)
.font(ui.font_of(body_style))
.color(ui.color(Role::OnSurface)),
)
.width(Length::Fill)
.padding(Padding {
top: ui.space(Space::X2),
right: ui.space(Space::X4),
bottom: ui.space(Space::X2),
left: ui.space(Space::X4),
}),
);
}
if view.results.is_empty() {
let small = ui.style(TypeRole::BodySmall);
rows = rows.push(
container(
text(view.empty)
.size(small.size_px)
.font(ui.font_of(small))
.color(ui.color(Role::OnSurfaceVariant)),
)
.width(Length::Fill)
.padding(Padding {
top: ui.space(Space::X4),
right: ui.space(Space::X4),
bottom: ui.space(Space::X4),
left: ui.space(Space::X4),
}),
);
}
let body = container(rows)
.width(Length::Fill)
.height(Length::Fixed(SEARCH_VIEW_MIN_HEIGHT))
.align_y(Alignment::Start);
let panel_content = column![
container(bar_content(
ui,
view.value,
view.placeholder,
Some(IconKind::Close),
Interaction::Enabled,
on_input,
))
.width(Length::Fill)
.height(Length::Fixed(header_height)),
divider,
body,
];
container(panel_content)
.width(Length::Fixed(view.width))
.style(move |_theme| container::Style {
background: Some(Background::Color(panel)),
// 20.6:docked 面板 = CornerExtraLarge(28);bar ⇄ view 半径不变(§20C)
border: Border {
radius: ui.radius_dp(ShapeScale::ExtraLarge),
..Border::default()
},
..container::Style::default()
})
.into()
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,93 @@
//! search 的单测(从 `search.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
}
fn leaked(flavor: SchemeFlavor) -> &'static Ui {
Box::leak(Box::new(ui(flavor)))
}
/// **M69 不变量**:图标可视边 = pad + offset(12 + 4 = 16),且本实现只做加法。
#[test]
fn icon_edge_is_pad_plus_offset_not_a_literal() {
let bar = Component::Search.metrics().expect("Search 有组件级度量");
assert_eq!(bar.pad_start, 12.0, "M69 钉住的 pad_start");
assert_eq!(bar.icon_offset, 4.0, "M69 钉住的 icon_offset");
assert_eq!(bar.pad_start + bar.icon_offset, 16.0);
assert_eq!(bar.icon, 24.0, "SearchBarTokens 图标 24");
// 文本从 pad(12)起:图标(16..40)+ gap 8 → 48 是文字的实际起点
assert_eq!(
bar.pad_start + bar.icon_offset + bar.icon + bar.gap,
48.0,
"图标槽 + 间距后文字起点 48"
);
}
/// 20.1:高 56、圆角 full = 28(药丸);条高来自 LM,不重复定义。
#[test]
fn bar_height_and_radius_come_from_tokens() {
let ui = ui(SchemeFlavor::Light);
assert_eq!(ui.metrics().search_bar_height, 56.0);
assert_eq!(
ShapeScale::Full.radius_px(ui.metrics().search_bar_height),
28.0,
"SearchBarCornerRadius = InputFieldHeight / 2"
);
assert_eq!(Component::Search.spec().shape, ShapeScale::Full);
// 高程按观感取 L0(无可见阴影)
assert_eq!(
Component::Search.spec().elevation,
unlucky_design::elevation::Elevation::Level0
);
}
/// 20.6 / 20.9:面板头部 56、形状 ExtraLarge(28)、最小高 240、docked 宽 360。
#[test]
fn docked_panel_geometry() {
let ui = ui(SchemeFlavor::Light);
assert_eq!(ShapeScale::ExtraLarge.dp(), 28.0, "DockedContainerShape");
assert_eq!(
SEARCH_VIEW_MIN_HEIGHT, 240.0,
"DockedExpandedTableMinHeight"
);
assert_eq!(SEARCH_MIN_WIDTH, 360.0, "SearchBarMinWidth = docked 面板宽");
assert_eq!(ui.metrics().search_bar_height, 56.0, "头部与 bar 同高");
}
/// bar 与 view 都能构建(含禁用态:输入被摘掉)。
#[test]
fn both_forms_build() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
for state in Interaction::ALL {
let ui = leaked(flavor);
let bar = SearchBar::new("").placeholder("搜索").state(state);
let _: Element<'_, u8> = search_bar(ui, bar, |_| 0_u8);
let results: &[&str] = &["实例一", "1.21.4"];
let view = SearchView::new("实例", results, "没有结果").placeholder("搜索");
let _: Element<'_, u8> = search_view(ui, view, |_| 0_u8);
}
}
}
/// 空结果也必须给面板内容(20C:不许收回到 bar)。
#[test]
fn empty_results_still_render_a_panel() {
let ui = leaked(SchemeFlavor::Light);
let empty: &[&str] = &[];
let view = SearchView::new("xyz", empty, "没有结果");
let _: Element<'_, u8> = search_view(ui, view, |_| 0_u8);
}
@@ -0,0 +1,396 @@
//! 滑块(`research/12b` §11,A1):**自绘 `Widget`**——M3 Expressive 的滑块是「竖条」,不是圆点。
//!
//! ## 参考值(`[源]` = A1,`SliderTokens`)
//!
//! | # | 检查项 | 参考值 | 落点 |
//! |---|---|---|---|
//! | 11.1 | 轨道高 | **16**,圆角 full(= 8) | `TRACK_HEIGHT`(G18) |
//! | 11.2 | handle 形态 | **4 宽 × 44 高**,圆角 full;focus / pressed 收窄到 **2** | `HANDLE_WIDTH` / `HANDLE_HEIGHT` |
//! | 11.3 | 颜色 | 激活轨 `Primary`、未激活轨 `SecondaryContainer`、handle `Primary` | `Role::*` |
//! | 11.4 | handle 与轨道 | 纵向溢出:上下各 **14**((44−16)/2) | 推导 |
//! | 11.5 | 命中高度 | **48**(`LM.touch_target`;竖条 44 + 上下各 2) | `SliderGeometry::height` |
//! | 11.6 | 禁用 | 激活轨 `OnSurface`@**38%**、未激活 @**12%**、handle @**38%** | `interaction::*` |
//! | — | 离散停靠点 | **4** 圆点 `SecondaryContainer`(`StopIndicatorSize`);尾随间距 6 | [`Slider::steps`] |
//!
//! ## 为什么必须自绘
//!
//! iced 内置 `slider` 只能画「轨道 + 圆形/矩形 handle(高度 = 控件高)」,
//! **没有停靠点、没有禁用态、无法强制取证态**;而 §11.2 的 4×44 竖条要求 handle 高(44)
//! 与轨道高(16)分离。所以这里用 `advanced::Widget` 自绘:布局只有「宽 × 48」一个原子,
//! 轨道/激活段/停靠点/竖条全部用 `fill_quad` 画进同一个 bounds——几何因此在像素级可断言。
//!
//! ## 状态层([估],登记 D16)
//!
//! `SliderTokens` **没有**状态层直径(M3 Expressive 去掉了旧版的水滴 halo),hover/pressed 只体现为
//! handle 宽度与颜色。为了让五态在验收图上可辨且 α 只认令牌,这里把**通用状态层**
//! (`interaction::overlay_color`:hover 8% / focus 10% / pressed 10%)叠在 handle 上,
//! 宽度仍按 A1 收窄——两件事都来自令牌,没有新数值。
//!
//! ## 拖动
//!
//! `on_change` 有值就启用拖动与点击跳转;`None` = 禁用(也不吃事件)。
//! 值由外部持有(受控组件),拖动只 `publish` 新比例:组件不持有业务状态(契约 §2)。
use iced::advanced::layout::{self, Layout};
use iced::advanced::renderer::Quad;
use iced::advanced::widget::{tree, Tree};
use iced::advanced::{mouse, Clipboard, Shell, Widget};
use iced::{Background, Border, Color, Element, Event, Length, Rectangle, Size};
use unlucky_design::color::Role;
use crate::theme::Ui;
use super::super::interaction::{self, Interaction, Tracker};
use super::geometry::{SliderGeometry, HANDLE_HEIGHT, HANDLE_WIDTH_ACTIVE, STOP_GAP, STOP_SIZE};
mod track;
pub use track::TrackGeometry;
use track::{fraction_at, quantize, EPSILON};
/// 一个滑块(受控:`fraction` 由外部给,变化经 `on_change` 回传)。
pub struct Slider<'a, M> {
ui: &'a Ui,
fraction: f32,
steps: Option<u32>,
on_change: Option<Box<dyn Fn(f32) -> M + 'a>>,
forced: Option<Interaction>,
}
impl<'a, M> Slider<'a, M> {
/// 连续值滑块(0.0 ~= 1.0)。
pub fn new(ui: &'a Ui, fraction: f32) -> Self {
Self {
ui,
fraction: fraction.clamp(0.0, 1.0),
steps: None,
on_change: None,
forced: None,
}
}
/// 离散值:`steps` = 分段数(4 → 5 个停靠点 0 / 25 / 50 / 75 / 100%)。
pub fn steps(mut self, steps: u32) -> Self {
self.steps = Some(steps.max(1));
self.fraction = quantize(self.fraction, self.steps);
self
}
/// 值变化回调;不给 = 禁用。
pub fn on_change(mut self, on_change: impl Fn(f32) -> M + 'a) -> Self {
self.on_change = Some(Box::new(on_change));
self
}
/// 强制视觉态(gallery 状态矩阵取证)。
pub fn state(mut self, state: Interaction) -> Self {
self.forced = Some(state);
self
}
/// 当前比例(量化后)。
fn value(&self) -> f32 {
quantize(self.fraction, self.steps)
}
/// 禁用与否:没有回调就是禁用(与按钮 `on_press: Option<M>` 同一口径)。
fn enabled(&self) -> bool {
self.on_change.is_some() && !matches!(self.forced, Some(Interaction::Disabled))
}
}
/// `Widget` 的状态:五态状态机([`Tracker`])+ 是否正在拖动。
#[derive(Debug, Default)]
struct State {
tracker: Tracker,
dragging: bool,
}
/// 一个圆角实心块(轨道的所有部件都是它)。
fn quad(bounds: Rectangle, radius: f32, color: Color) -> (Quad, Background) {
(
Quad {
bounds,
border: Border {
radius: radius.into(),
..Border::default()
},
..Quad::default()
},
Background::Color(color),
)
}
impl<'a, M, Theme, Renderer> Widget<M, Theme, Renderer> for Slider<'a, M>
where
M: Clone,
Renderer: iced::advanced::Renderer,
{
fn size(&self) -> Size<Length> {
Size {
width: Length::Fill,
height: Length::Fixed(SliderGeometry::of(self.ui).height),
}
}
fn tag(&self) -> tree::Tag {
tree::Tag::of::<State>()
}
fn state(&self) -> tree::State {
tree::State::new(State {
tracker: Tracker::new(self.enabled()),
dragging: false,
})
}
fn layout(
&mut self,
_tree: &mut Tree,
_renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
layout::atomic(
limits,
Length::Fill,
Length::Fixed(SliderGeometry::of(self.ui).height),
)
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
_renderer: &Renderer,
_clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, M>,
_viewport: &Rectangle,
) {
if self.forced.is_some() {
return;
}
let enabled = self.enabled();
let geometry = SliderGeometry::of(self.ui);
let bounds = layout.bounds();
let state = tree.state.downcast_mut::<State>();
if state.tracker.set_enabled(enabled) {
shell.request_redraw();
}
let mut target: Option<f32> = None;
match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
if enabled && cursor.is_over(bounds) =>
{
if let Some(position) = cursor.position() {
state.dragging = true;
target = Some(fraction_at(bounds, position.x, geometry.handle));
shell.capture_event();
}
}
Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => {
if let Some(position) = cursor.position() {
target = Some(fraction_at(bounds, position.x, geometry.handle));
shell.capture_event();
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
state.dragging = false;
}
_ => {}
}
if let Some(fraction) = target {
let next = quantize(fraction, self.steps);
if (next - self.value()).abs() >= EPSILON {
if let Some(on_change) = &self.on_change {
self.fraction = next;
shell.publish(on_change(next));
}
}
}
if state.tracker.event(event, cursor.is_over(bounds)) {
shell.request_redraw();
}
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
_theme: &Theme,
_style: &iced::advanced::renderer::Style,
layout: Layout<'_>,
_cursor: mouse::Cursor,
_viewport: &Rectangle,
) {
let ui = self.ui;
let geometry = SliderGeometry::of(ui);
let bounds = layout.bounds();
let state = self
.forced
.unwrap_or_else(|| tree.state.downcast_ref::<State>().tracker.state());
let disabled = state.is_disabled();
let (active_color, inactive_color, handle_color, handle_width) = if disabled {
(
interaction::disabled_content_over_role(ui, Role::OnSurface, false),
interaction::disabled_colors(ui, Role::Surface).0,
interaction::disabled_content_over_role(ui, Role::OnSurface, false),
geometry.handle,
)
} else {
// 状态层只叠在 handle 上(D16):α 全部来自 interaction(hover 8 / focus 10 / pressed 10)
let handle_color =
interaction::overlay_color(ui, state, Role::OnSurface, Role::Primary)
.unwrap_or_else(|| ui.color(Role::Primary));
let handle_width = match state {
Interaction::Focused | Interaction::Pressed => HANDLE_WIDTH_ACTIVE,
_ => geometry.handle,
};
(
ui.color(Role::Primary),
ui.color(Role::SecondaryContainer),
handle_color,
handle_width,
)
};
let centre_y = bounds.y + bounds.height / 2.0;
let track_x = bounds.x;
let track_w = bounds.width;
let track = TrackGeometry::of(bounds, self.value());
let centre_x = track.handle_centre();
let track_rect = |x: f32, width: f32| Rectangle {
x,
y: centre_y - geometry.track / 2.0,
width: width.max(0.0),
height: geometry.track,
};
// 离散停靠点:A1 的 `StopIndicatorColor = SecondaryContainer` 与未激活轨**同色**,
// 所以停靠点只有在「未激活轨在它两侧留出空隙」时才读得出来(官方也是这么画的:
// `StopIndicatorSize = 4` + `StopIndicatorTrailingSpace = 6`)。空隙两侧各留半个点 + 6。
// **不含两端**:0% 与 100% 上不画停靠点(那是轨道自己的圆角端,官方也不画)
let stops: Vec<f32> = match self.steps {
Some(steps) if steps >= 2 => (1..steps)
.map(|index| TrackGeometry::of(bounds, index as f32 / steps as f32).handle_centre())
.collect(),
_ => Vec::new(),
};
let gap = STOP_SIZE / 2.0 + STOP_GAP;
// 未激活轨:从**竖条中心**到右端,按停靠点切成段(每段自己圆角,端点读数更清楚)
let mut cursor = centre_x;
let mut segments: Vec<(f32, f32)> = Vec::new();
for anchor in stops.iter().copied() {
if anchor <= cursor {
continue;
}
segments.push((cursor, anchor - gap - cursor));
cursor = anchor + gap;
}
if track_x + track_w > cursor {
segments.push((cursor, track_x + track_w - cursor));
}
for (x, width) in segments {
if width <= 0.0 {
continue;
}
let (inactive, inactive_bg) =
quad(track_rect(x, width), geometry.track / 2.0, inactive_color);
renderer.fill_quad(inactive, inactive_bg);
}
// 激活轨:从容器左缘到**竖条中心**(与官方一致:激活段托在竖条下面)
let active_w = track.active_width;
if active_w > 0.0 {
let (active, active_bg) = quad(
track_rect(track_x, active_w),
geometry.track / 2.0,
active_color,
);
renderer.fill_quad(active, active_bg);
}
// 停靠点本体:4dp 圆点,只画在**未激活侧**(激活段已经走过的不重复画)
if !disabled {
for anchor in stops
.iter()
.copied()
.filter(|anchor| *anchor > centre_x + EPSILON)
{
let (stop, stop_bg) = quad(
Rectangle {
x: anchor - STOP_SIZE / 2.0,
y: centre_y - STOP_SIZE / 2.0,
width: STOP_SIZE,
height: STOP_SIZE,
},
STOP_SIZE / 2.0,
inactive_color,
);
renderer.fill_quad(stop, stop_bg);
}
}
// 竖条:4×44(focus / pressed 收窄到 2),圆角 full
let (bar, bar_bg) = quad(
Rectangle {
x: track.handle_centre() - handle_width / 2.0,
y: centre_y - HANDLE_HEIGHT / 2.0,
width: handle_width,
height: HANDLE_HEIGHT,
},
handle_width / 2.0,
handle_color,
);
renderer.fill_quad(bar, bar_bg);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
_viewport: &Rectangle,
_renderer: &Renderer,
) -> mouse::Interaction {
if !self.enabled() {
return mouse::Interaction::None;
}
if tree.state.downcast_ref::<State>().dragging {
return mouse::Interaction::Grabbing;
}
if cursor.is_over(layout.bounds()) {
mouse::Interaction::Grab
} else {
mouse::Interaction::None
}
}
}
impl<'a, M, Theme, Renderer> From<Slider<'a, M>> for Element<'a, M, Theme, Renderer>
where
M: Clone + 'a,
Theme: 'a,
Renderer: iced::advanced::Renderer + 'a,
{
fn from(slider: Slider<'a, M>) -> Self {
Element::new(slider)
}
}
/// 便捷入口:把 [`Slider`] 直接变成 `Element`(与其它组件的函数式签名一致)。
pub fn slider<'a, M: Clone + 'a>(spec: Slider<'a, M>) -> Element<'a, M> {
Element::new(spec)
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,122 @@
//! slider 的单测(从 `slider.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui() -> Ui {
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
}
fn track() -> Rectangle {
Rectangle {
x: 100.0,
y: 200.0,
width: 300.0,
height: 48.0,
}
}
/// 11.1 / 11.2 / 11.4:轨道 16、竖条 4×44、上下各溢出 14。
#[test]
fn track_and_handle_are_the_m3_expressive_shapes() {
let g = SliderGeometry::of(&ui());
assert_eq!(g.track, 16.0);
assert_eq!((g.handle, g.handle_tall), (4.0, 44.0));
assert_eq!((g.handle_tall - g.track) / 2.0, 14.0);
assert_eq!(g.stop, 4.0);
assert_eq!(g.height, 48.0, "命中区 = LM.touch_target");
}
/// 0% / 50% / 100% 三档的位置:竖条**永不越界**,激活段与轨道分界一致。
#[test]
fn thumb_stays_inside_the_track_at_both_ends() {
let bounds = track();
let zero = TrackGeometry::of(bounds, 0.0);
assert_eq!(zero.handle_left, bounds.x, "0% 时竖条左缘 = 轨道左缘");
assert!(zero.handle_left >= bounds.x);
assert_eq!(zero.active_width, 2.0, "0% 时激活段只剩竖条半宽");
let half = TrackGeometry::of(bounds, 0.5);
assert!((half.handle_left - (bounds.x + bounds.width / 2.0 - 2.0)).abs() < 0.01);
let full = TrackGeometry::of(bounds, 1.0);
assert_eq!(
full.handle_right,
bounds.x + bounds.width,
"100% 时右缘贴轨道右缘"
);
assert!(full.handle_right <= bounds.x + bounds.width);
// 激活段画到**竖条中心**为止(= 右缘 − 2),不是画到右缘:100% 时也被竖条自己盖住
assert_eq!(full.active_width, bounds.width - 2.0);
}
/// 越界的比例(<0 或 >1)被 clamp,不会把竖条推到轨道外。
#[test]
fn out_of_range_fractions_are_clamped() {
let bounds = track();
for fraction in [-3.0, -0.01, 0.0, 1.0, 1.5, 9.0] {
let geometry = TrackGeometry::of(bounds, fraction);
assert!(
geometry.handle_left >= bounds.x,
"{fraction} 的竖条左缘越界:{}",
geometry.handle_left
);
assert!(
geometry.handle_right <= bounds.x + bounds.width,
"{fraction} 的竖条右缘越界:{}",
geometry.handle_right
);
}
}
/// 离散值:4 段 → 1/4 步进;连续值原样。
#[test]
fn discrete_sliders_quantize_to_the_stops() {
assert_eq!(quantize(0.3, Some(4)), 0.25);
assert_eq!(quantize(0.4, Some(4)), 0.5);
assert_eq!(quantize(1.4, Some(4)), 1.0);
assert_eq!(quantize(0.3, None), 0.3);
// 停靠点位置 = 五等分,且都落在轨道内
let bounds = track();
for index in 0..=4 {
let geometry = TrackGeometry::of(bounds, index as f32 / 4.0);
assert!(geometry.handle_left >= bounds.x);
assert!(geometry.handle_right <= bounds.x + bounds.width);
}
}
/// 拖动换算:指针在轨道左缘 → 0、右缘 → 1,中间线性。
#[test]
fn fraction_at_is_the_inverse_of_handle_center() {
let bounds = track();
for fraction in [0.0, 0.25, 0.5, 0.75, 1.0] {
let centre = TrackGeometry::of(bounds, fraction).handle_centre();
let back = fraction_at(bounds, centre, 4.0);
assert!((back - fraction).abs() < 1e-4, "{fraction} → {back}");
}
assert_eq!(fraction_at(bounds, bounds.x, 4.0), 0.0);
assert_eq!(fraction_at(bounds, bounds.x + bounds.width, 4.0), 1.0);
}
/// 没有回调 = 禁用:不吃拖动,也不响应状态机。
#[test]
fn slider_without_a_callback_is_disabled() {
let ui = ui();
let slider: Slider<'_, u8> = Slider::new(&ui, 0.5);
assert!(!slider.enabled());
let enabled: Slider<'_, u8> = Slider::new(&ui, 0.5).on_change(|_| 0_u8);
assert!(enabled.enabled());
let forced: Slider<'_, u8> = Slider::new(&ui, 0.5)
.on_change(|_| 0_u8)
.state(Interaction::Disabled);
assert!(!forced.enabled(), "强制禁用时不吃事件");
}
@@ -0,0 +1,66 @@
//! Slider 的**纯几何**(无 iced 画布依赖):比例 ⇄ 像素,`draw` 与单测共用同一条公式。
//!
//! 单独成文件的原因:`slider.rs` 已到 400 行红线,而这几条公式是「0% / 100% 竖条不越界」的
//! 唯一实现点——独立出来才好被人一眼审完。`draw` 与 `#[cfg(test)] mod tests` 都调这里。
use iced::Rectangle;
use super::super::geometry::HANDLE_WIDTH;
/// 比例比较的容差(浮点拖动会产生 1e-7 级抖动,别让它每帧发一条消息)。
pub(super) const EPSILON: f32 = 1e-4;
/// 量化到停靠点(连续值时原样返回)。
pub(super) fn quantize(fraction: f32, steps: Option<u32>) -> f32 {
match steps {
Some(steps) => {
let steps = steps as f32;
(fraction.clamp(0.0, 1.0) * steps).round() / steps
}
None => fraction.clamp(0.0, 1.0),
}
}
/// 一条竖条的横坐标(**位移区间是 `handle/2 .. width - handle/2`**,所以 0%/100% 都不越界)。
fn handle_center(bounds: Rectangle, fraction: f32, handle: f32) -> f32 {
let travel = (bounds.width - handle).max(0.0);
bounds.x + handle / 2.0 + travel * fraction.clamp(0.0, 1.0)
}
/// 指针横坐标 → 比例(`handle_center` 的逆函数,同样不越界)。
pub(super) fn fraction_at(bounds: Rectangle, x: f32, handle: f32) -> f32 {
let travel = (bounds.width - handle).max(1.0);
((x - bounds.x - handle / 2.0) / travel).clamp(0.0, 1.0)
}
/// 轨道 / 停靠点 / 竖条的纯几何(像素级断言用;`Widget::draw` 与测试共用同一条公式)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TrackGeometry {
/// 激活段的左缘。
pub active_left: f32,
/// 激活段的宽。
pub active_width: f32,
/// 竖条左缘(`f = 0` 时应 = 轨道左缘)。
pub handle_left: f32,
/// 竖条右缘(`f = 1` 时应 = 轨道右缘)。
pub handle_right: f32,
}
impl TrackGeometry {
/// 按轨道矩形与比例求值(`f` 会被 clamp 到 0..=1)。
pub fn of(track: Rectangle, fraction: f32) -> Self {
let centre = handle_center(track, fraction, HANDLE_WIDTH);
let handle_left = centre - HANDLE_WIDTH / 2.0;
Self {
active_left: track.x,
active_width: centre - track.x,
handle_left,
handle_right: handle_left + HANDLE_WIDTH,
}
}
/// 竖条中心(= 激活段与未激活段的分界)。
pub fn handle_centre(&self) -> f32 {
self.handle_left + HANDLE_WIDTH / 2.0
}
}
@@ -0,0 +1,305 @@
//! 禁用态的颜色口径:**两个族、两套 α**(M86 收口)。
//!
//! ## 为什么不是「一个 12%」
//!
//! 官方禁用 α 分两类(A 级:`material3-android-1.4.0.aar` 的字节码 `<clinit>`):
//!
//! | 族 | α | 官方 token | 消费者 |
//! |---|---|---|---|
//! | **按钮容器** | **0.10** | `*ButtonTokens.DisabledContainerOpacity`(`Filled` / `Outlined` / `Text` / `Elevated` / `Tonal` / `Baseline`) | `buttons::assemble` |
//! | **面状控件**容器与描边 | **0.12** | `AssistChipTokens.FlatDisabledOutlineOpacity`、`FilterChipTokens.FlatDisabled(SelectedContainer/UnselectedOutline)Opacity`、`SliderTokens.DisabledInactiveTrackOpacity`、`SwitchTokens.DisabledTrackOpacity` | chip / switch / slider / TextField 描边 |
//! | 内容(两族共用) | 0.38 | `Disabled{Icon,LabelText,Input}Opacity` | 全部 |
//!
//! 历史上这里只有一个 12% 常量供两族共用:**按钮那半边一直错、chip/slider/switch 那半边一直对**。
//! 现在按族分开两个入口——取错入口的后果是「差 1/255」,肉眼看不见,只有像素 probe 会红,
//! 所以入口**必须按名字就能选对**。
//!
//! ⚠️ TextField 的 **Filled 容器**不在这两族里:官方
//! `FilledTextFieldTokens.FieldDisabledContainerOpacity = 0.04f`,我们仍在用面状族的值
//! (既有 D10 偏离,未在本轮收口)。`inputs::field` 走 [`disabled_colors`] 的口径,行为未变。
//!
//! ⚠️ **filled-tonal 是另一条已知偏离**:官方
//! `FilledTonalButtonTokens.DisabledContainerOpacity = 0.12f`(`javap -p -constants` 可见它是个
//! **编译期常量**,被内联进 `getDefaultFilledTonalButtonColors` 的 `ldc_w 0.12f`,所以调用点是常量、
//! `getDisabledContainerOpacity()` 不会被调用);而本模块的 `disabled_button_*` **不区分变体**,
//! tonal 与 filled 共用 0.10 ⇒ tonal 实际画 10%。这不是对齐、是登记过的偏离;要修得让入口知道变体,
//! 本轮(M86)不做。
use iced::Color;
use unlucky_design::color::state::{
DISABLED_CONTAINER_ALPHA, DISABLED_CONTENT_ALPHA, DISABLED_SURFACE_ALPHA,
};
use unlucky_design::color::{Role, Scheme};
use crate::theme::{to_iced, Ui};
/// 禁用态内容色(**按角色、按有没有容器**求值)——A1 口径的入口(**面状控件族**的底)。
///
/// - `role`:由变体决定(Filled/Outlined/Text/Elevated = `OnSurfaceVariant`;
/// FilledTonal 与三式 IconButton = `OnSurface`,见 `Variant::disabled_content`);
/// - `on_container = true`:叠在**面状控件族的 12% 容器**([`DISABLED_SURFACE_ALPHA`])上
/// (Filled TextField / 有容器控件在 12% 底上求内容时);
/// `false`:直接叠在 **surface** 上(Text / Outlined / standard IconButton / 描边类控件的内容)。
///
/// **按钮家族不要用这个函数**(按钮容器是 10%):走 [`disabled_button_content`]。
///
/// 之前这里写死 `OnSurfaceVariant` 且只有一个「底」,导致 FilledTonal 与 IconButton 两族偏浅
/// 15/255(视觉 r3 用像素量的)。
pub fn disabled_content_over_role(ui: &Ui, role: Role, on_container: bool) -> Color {
let scheme = Scheme::baseline(ui.flavor());
disabled_content_with_alpha(&scheme, role, on_container, DISABLED_SURFACE_ALPHA)
}
/// 禁用态内容色——**按钮家族**(`on_container`:是否叠在本族 10% 容器上)。
///
/// 与 [`disabled_content_over_role`] 的差别只有「底」的 α:按钮 10%、面状控件 12%。
/// 两个入口分开是 M86 的结论:合成一个数会让一族永远差 1/255,肉眼看不见、只有 probe 会红。
pub fn disabled_button_content(ui: &Ui, role: Role, on_container: bool) -> Color {
let scheme = Scheme::baseline(ui.flavor());
disabled_content_with_alpha(&scheme, role, on_container, DISABLED_CONTAINER_ALPHA)
}
/// 两个族共用的内容色求值(「底」的 α 由调用方给)。
fn disabled_content_with_alpha(
scheme: &Scheme,
role: Role,
on_container: bool,
container_alpha: f32,
) -> Color {
let base = if on_container {
scheme
.get(Role::OnSurface)
.with_alpha(container_alpha)
.over(scheme.get(Role::Surface))
} else {
scheme.get(Role::Surface)
};
to_iced(
scheme
.get(role)
.with_alpha(DISABLED_CONTENT_ALPHA)
.over(base),
)
}
/// 禁用态的一对颜色 `(容器, 内容)`——**面状控件族**(12% 容器/描边)。
///
/// ⚠️ 这个名字是历史遗留(当年只有它一个),**语义是「面状控件族」**:
/// - chip 容器与描边(`AssistChipTokens.FlatDisabledOutlineOpacity = 0.12f` 等)
/// - switch 轨道(`SwitchTokens.DisabledTrackOpacity = 0.12f`)
/// - slider 未激活轨(`SliderTokens.DisabledInactiveTrackOpacity = 0.12f`)
/// - TextField 禁用描边(Filled 容器的 4% 是另一条 D10 偏离,不在这里收口)
///
/// **按钮家族用 [`disabled_button_colors`]**(0.10,`*ButtonTokens.DisabledContainerOpacity = 0.1f`)。
///
/// `ui` 不暴露 [`Scheme`],这里按同一个 flavor 求一次基线方案——调的是
/// `unlucky_design` 的**同一个**函数与同一套角色,不是第二份令牌。
///
/// 顺序必须「先容器后内容」:Compose 也是这么画的,先叠容器再叠 label,
/// 所以内容叠的角色是容器实色而不是底下的 surface。
pub fn disabled_colors(ui: &Ui, surface: Role) -> (Color, Color) {
disabled_colors_with_alpha(ui, surface, DISABLED_SURFACE_ALPHA)
}
/// 禁用态的一对颜色 `(容器, 内容)`——**按钮家族**(10% 容器)。
///
/// - 容器 = `OnSurface` @ 10%([`DISABLED_CONTAINER_ALPHA`])叠在 `surface` 上;
/// - 内容 = **默认** `OnSurfaceVariant` @ 38%;按变体的取值走 [`disabled_button_content`]。
///
/// 除了按钮家族,**没有别的消费者**:面状控件(chip / slider 轨道 / switch 轨道 / 描边)走
/// [`disabled_colors`] 的 12%。
pub fn disabled_button_colors(ui: &Ui, surface: Role) -> (Color, Color) {
disabled_colors_with_alpha(ui, surface, DISABLED_CONTAINER_ALPHA)
}
/// 两个族共用的求值:容器 = `OnSurface` @ `container_alpha` 叠在 `surface` 上,内容 = 38% 叠在容器上。
fn disabled_colors_with_alpha(ui: &Ui, surface: Role, container_alpha: f32) -> (Color, Color) {
let scheme = Scheme::baseline(ui.flavor());
let base = scheme.get(surface);
let container = scheme
.get(Role::OnSurface)
.with_alpha(container_alpha)
.over(base);
// 这里是**默认口径**(OnSurfaceVariant);按变体取值请走 [`disabled_content_over_role`]
// ——FilledTonal 与三式 IconButton 的禁用内容是 `OnSurface`(A1)。
let content = scheme
.get(Role::OnSurfaceVariant)
.with_alpha(DISABLED_CONTENT_ALPHA)
.over(container);
(to_iced(container), to_iced(content))
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
}
/// 两族的 α 必须都钉住(只钉一条,另一族被合并时会静默错 1/255)。
#[test]
fn both_disabled_alpha_classes_are_pinned_to_the_tokens() {
// 绑定成局部变量再过断言:直接比两个常量会被 clippy 判成「常量断言」。
let button_alpha = DISABLED_CONTAINER_ALPHA;
let surface_alpha = DISABLED_SURFACE_ALPHA;
let content_alpha = DISABLED_CONTENT_ALPHA;
// 按钮容器:官方 *ButtonTokens.DisabledContainerOpacity = 0.1f
assert!(
(button_alpha - 0.10).abs() < f32::EPSILON,
"按钮禁用容器 α 必须是 0.10(官方 DisabledContainerOpacity);\
0.12 是面状控件那一类,别混用"
);
// 面状控件:chip 描边 / slider 未激活轨 / switch 轨道 = 0.12f
assert!(
(surface_alpha - 0.12).abs() < f32::EPSILON,
"面状控件禁用 α 必须是 0.12(官方 FlatDisabledOutlineOpacity / \
DisabledInactiveTrackOpacity / DisabledTrackOpacity)"
);
assert!((content_alpha - 0.38).abs() < f32::EPSILON);
assert!(
surface_alpha > button_alpha,
"两族必须可区分:面状控件比按钮容器更实({surface_alpha} > {button_alpha})"
);
}
/// **面状控件族**的那一对颜色 = 0.12 预混合(chip / switch / slider / TextField 描边)。
///
/// ⚠️ 期望值**写死 0.12 / 0.38**,不从 `DISABLED_SURFACE_ALPHA` 取:
/// 从被测常量求期望是**自指断言**——常量一改,expected 跟着改,永远相等(复审 r1 P2-1 实测:
/// 把 `DISABLED_SURFACE_ALPHA` 改成 0.10,旧版这条照样绿)。现在常量改坏必红。
#[test]
fn surface_family_disabled_colors_are_the_12_percent_class() {
const EXPECTED_SURFACE_ALPHA: f32 = 0.12;
const EXPECTED_CONTENT_ALPHA: f32 = 0.38;
let actual_alpha = DISABLED_SURFACE_ALPHA;
assert_eq!(
actual_alpha, EXPECTED_SURFACE_ALPHA,
"面状控件族 α 必须是官方 0.12f;改常量会让下面每一条合成色断言同时红"
);
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let ui = ui(flavor);
let scheme = Scheme::baseline(flavor);
let base = scheme.get(Role::Surface);
let expected_container = scheme
.get(Role::OnSurface)
.with_alpha(EXPECTED_SURFACE_ALPHA)
.over(base);
let (surface, surface_content) = disabled_colors(&ui, Role::Surface);
assert_eq!(
surface,
to_iced(expected_container),
"{flavor:?}:面状控件容器 = on-surface@12% 叠表面(期望值硬编码,不取自常量)"
);
assert_eq!(
surface_content,
to_iced(
scheme
.get(Role::OnSurfaceVariant)
.with_alpha(EXPECTED_CONTENT_ALPHA)
.over(expected_container)
),
"{flavor:?}:面状控件内容仍是 38%,只是叠的底不同"
);
assert_ne!(surface, ui.color(Role::Surface), "12% 叠上去必须能看出来");
assert_eq!(surface.a, 1.0, "禁用容器也是实色(CPU/GPU 一致)");
assert_eq!(surface_content.a, 1.0, "禁用内容也是实色");
// 内容叠在容器上 vs 叠在表面上,是两回事(probe 量的是前者)
assert_ne!(
surface_content,
to_iced(
scheme
.get(Role::OnSurfaceVariant)
.with_alpha(EXPECTED_CONTENT_ALPHA)
.over(base)
),
"{flavor:?}:内容必须叠在容器上"
);
// 跨族对照:面状族的合成色必须与按钮族不同(两族一旦合流,这条红)
let (button, _) = disabled_button_colors(&ui, Role::Surface);
assert_ne!(
surface, button,
"{flavor:?}:面状族 12% 与按钮族 10% 必须落在不同像素值上"
);
}
}
/// **按钮家族**的那一对颜色 = 0.10 预混合,且与面状控件族**必须落在不同像素值上**。
///
/// 期望值同样写死(0.10 / 0.38),理由见上一条:自指断言抓不住常量被改坏。
#[test]
fn button_family_disabled_colors_are_the_10_percent_class() {
const EXPECTED_BUTTON_ALPHA: f32 = 0.10;
const EXPECTED_CONTENT_ALPHA: f32 = 0.38;
let actual_alpha = DISABLED_CONTAINER_ALPHA;
assert_eq!(
actual_alpha, EXPECTED_BUTTON_ALPHA,
"按钮族 α 必须是官方 0.1f(*ButtonTokens.DisabledContainerOpacity)"
);
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let ui = ui(flavor);
let scheme = Scheme::baseline(flavor);
let base = scheme.get(Role::Surface);
let expected_container = scheme
.get(Role::OnSurface)
.with_alpha(EXPECTED_BUTTON_ALPHA)
.over(base);
let (button, button_content) = disabled_button_colors(&ui, Role::Surface);
assert_eq!(
button,
to_iced(expected_container),
"{flavor:?}:按钮容器 = on-surface@10% 叠表面(官方 DisabledContainerOpacity = 0.1f)"
);
assert_eq!(
button_content,
to_iced(
scheme
.get(Role::OnSurfaceVariant)
.with_alpha(EXPECTED_CONTENT_ALPHA)
.over(expected_container)
),
);
let (surface, _) = disabled_colors(&ui, Role::Surface);
assert_ne!(
button, surface,
"{flavor:?}:10% 与 12% 必须落在不同像素值上(合成一个数就是这次要防的回归)"
);
// 内容色同理:按钮 38% 叠 10% 底 ≠ 面状 38% 叠 12% 底
assert_ne!(
button_content,
disabled_content_over_role(&ui, Role::OnSurfaceVariant, true),
"{flavor:?}:两族的「有容器」内容色不能同源"
);
assert_eq!(
disabled_button_content(&ui, Role::OnSurfaceVariant, true),
button_content,
"{flavor:?}:按钮内容入口必须与按钮颜色入口同源"
);
// 无容器时两族完全一致(都叠 surface)——差异只在「有容器」那一支
assert_eq!(
disabled_button_content(&ui, Role::OnSurfaceVariant, false),
disabled_content_over_role(&ui, Role::OnSurfaceVariant, false),
"{flavor:?}:没有容器时不该有族差异"
);
}
}
}
@@ -0,0 +1,239 @@
//! 交互状态层:hover / focus / pressed / disabled 的**唯一实现点**(D39 §3)。
//!
//! ## 为什么必须只有一处
//!
//! M3 的状态层不是「另一种灰」,而是**内容色自身 + 固定 alpha** 叠在容器色上
//! (`unlucky_design::color::state` 的文档)。五个家族各写一遍
//! `if hovered { 0.08 }`,第一周就会出现 0.08 / 0.1 / 0.12 三种 hover——
//! 这一层存在的意义就是让「组件里出现 α 字面量」变成不可能:
//!
//! | 态 | α | 令牌来源 |
//! |---|---|---|
//! | hover | 0.08 | [`StateLayer::Hover`] |
//! | focus | 0.10 | [`StateLayer::Focus`] |
//! | pressed | 0.10 | [`StateLayer::Pressed`] |
//! | dragged | 0.16 | [`StateLayer::Dragged`](按钮家族不用,列表拖拽用) |
//! | disabled 容器(**按钮家族**) | 0.10 | [`DISABLED_CONTAINER_ALPHA`](按钮 `*ButtonTokens.DisabledContainerOpacity`) |
//! | disabled 容器/描边(**面状控件族**) | 0.12 | [`DISABLED_SURFACE_ALPHA`](chip / slider 轨道 / switch 轨道 / TextField 禁用描边) |
//! | disabled 内容 | 0.38 | [`DISABLED_CONTENT_ALPHA`] |
//!
//! ## ⚠️ 禁用 α 有**两类**(M86 收口,别再合成一个数)
//!
//! 官方禁用 α 从来不是「一个 12%」:
//!
//! - **按钮容器 0.10**:`BaselineButtonTokens` / `FilledButtonTokens` / `OutlinedButtonTokens` /
//! `TextButtonTokens` / `ElevatedButtonTokens` / `TonalButtonTokens` 的 `DisabledContainerOpacity`
//! = `0.1f`(AAR 字节码 `<clinit>` 里的 `ldc 0.1f`);
//! - **面状控件 0.12**:`AssistChipTokens.FlatDisabledOutlineOpacity` / `FilterChipTokens.
//! FlatDisabledSelectedContainerOpacity` / `SliderTokens.DisabledInactiveTrackOpacity` /
//! `SwitchTokens.DisabledTrackOpacity` 都是 `0.12f`。
//!
//! 本模块过去只有一个 12% 常量供两条线共用:**按钮那半边一直错、chip/slider/switch 那半边一直对**。
//! 现在按族分两个入口,谁也别再拿一个数服务两族:
//!
//! | 入口 | α | 谁用 |
//! |---|---|---|
//! | [`disabled_button_colors`] / [`disabled_button_content`] | 0.10 | **只有按钮家族**(`buttons::assemble`) |
//! | [`disabled_colors`] / [`disabled_content_over_role`] | 0.12 | **面状控件族**:chip 容器与描边、switch 轨道、slider 未激活轨、TextField 禁用描边(`disabled_colors` 这个名字是历史遗留,**语义是面状控件族**) |
//!
//! 上表右列是**唯一**允许的取值口;左列只是给人看的,代码里一个数字都不许出现
//! (`components::gate` 会扫)。
//!
//! ## 两个消费形态
//!
//! 1. **iced `button` 系**(本 mission 的按钮家族):拿 [`Interaction`] 直接配 [`overlay_color`];
//! 2. **自定义 `Widget`**(后续若需要自绘轨道/滑块):用 [`Tracker`] 把事件折成五态,
//! `update()` 的返回值告诉你「要不要重绘」,`state()` 给出当前态。
//!
//! ## 与 `Ui::state_over` 的关系
//!
//! 颜色一律经 [`crate::theme::Ui::state_over`] 求值(它返回**不透明**结果,所以 GPU/CPU 两档
//! 逐像素一致,D27)。本模块只做「哪一态叠哪一层」的映射,不做任何颜色算术。
use iced::widget::button;
use iced::Color;
use unlucky_design::color::{Role, StateLayer};
use crate::theme::Ui;
mod tracker;
// 本 mission 没有自绘控件,所以这条再导出在 crate 内暂时无人使用;
// 它是给四条家族线(Slider / Switch 轨道要自绘)的公共入口,路径与拆分前一致。
#[allow(unused_imports)]
pub use tracker::Tracker;
// 本模块会被四条家族线各扩一次:加东西前先看行数余额(单文件 ≤400,AGENTS.md §3-1)。
// `mod.rs` 只装「五态 + 颜色映射」这类纯函数;事件 → 态的**状态机在 [`tracker`]**,
// 需要新的状态机就加到那边,别把本文件再撑到红线上。
/// 一个控件的视觉态(五态,M3 的完整集合)。
///
/// `Enabled` 是**静息态**(不叠状态层);`Focused` / `Pressed` 在 M3 里 α 相同(都 10%),
/// 所以两者在图上会一模一样——这是规范,不是漏画(12b §1.8 判据只要求 α 对得上)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Interaction {
/// 静息:可交互,不叠状态层。
Enabled,
/// 悬停:叠 8%。
Hovered,
/// 键盘焦点:叠 10%。
Focused,
/// 按下:叠 10%。
Pressed,
/// 禁用:按钮容器 10% / 面状控件 12% / 内容 38%,**不叠状态层**。
Disabled,
}
impl Interaction {
/// 五态全序(gallery 的状态矩阵按这个次序出图)。
pub const ALL: [Interaction; 5] = [
Interaction::Enabled,
Interaction::Hovered,
Interaction::Pressed,
Interaction::Focused,
Interaction::Disabled,
];
/// 稳定名字(截图文件名 / 图例 / 测试失败信息用)。
pub const fn name(self) -> &'static str {
match self {
Interaction::Enabled => "enabled",
Interaction::Hovered => "hovered",
Interaction::Focused => "focused",
Interaction::Pressed => "pressed",
Interaction::Disabled => "disabled",
}
}
/// 是否禁用(禁用不是「第六态」,它要不要画容器由组件决定)。
pub const fn is_disabled(self) -> bool {
matches!(self, Interaction::Disabled)
}
/// 该态要叠的状态层;`None` = 不叠(静息与禁用)。
pub const fn layer(self) -> Option<StateLayer> {
match self {
Interaction::Enabled | Interaction::Disabled => None,
Interaction::Hovered => Some(StateLayer::Hover),
Interaction::Focused => Some(StateLayer::Focus),
Interaction::Pressed => Some(StateLayer::Pressed),
}
}
/// 从 iced 的 [`button::Status`] 推导(`button` 只有四态,没有 focus)。
///
/// 用 `button` 做组件的家族一律经这里,**不许自己 match status 再各写一遍颜色**。
pub const fn from_status(status: button::Status) -> Self {
match status {
button::Status::Active => Interaction::Enabled,
button::Status::Hovered => Interaction::Hovered,
button::Status::Pressed => Interaction::Pressed,
button::Status::Disabled => Interaction::Disabled,
}
}
}
/// 当前态叠在 `container` 上的**不透明**颜色;`None` = 该态不叠。
///
/// `content` 是容器上的前景角色(Filled 按钮用 `OnPrimary`;中性表面上的控件用 `OnSurfaceVariant`)。
pub fn overlay_color(ui: &Ui, state: Interaction, content: Role, container: Role) -> Option<Color> {
state
.layer()
.map(|layer| ui.state_over(layer, content, container))
}
mod disabled;
pub use disabled::{
disabled_button_colors, disabled_button_content, disabled_colors, disabled_content_over_role,
};
#[cfg(test)]
mod tests {
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
}
/// 契约 §3 的状态层 α 全表:**钉住令牌值**(改令牌会让这条红,改组件抓不到)。
///
/// 禁用态的两族 α(按钮 0.10 / 面状控件 0.12)在 [`super::disabled`] 的测试里,
/// 那边还负责「两族不能同源」的判据。
#[test]
fn state_layer_alphas_are_the_contract_values() {
assert!((StateLayer::Hover.alpha() - 0.08).abs() < f32::EPSILON);
assert!((StateLayer::Focus.alpha() - 0.10).abs() < f32::EPSILON);
assert!((StateLayer::Pressed.alpha() - 0.10).abs() < f32::EPSILON);
assert!((StateLayer::Dragged.alpha() - 0.16).abs() < f32::EPSILON);
}
#[test]
fn five_states_map_onto_four_layers() {
assert_eq!(Interaction::ALL.len(), 5);
assert_eq!(Interaction::Enabled.layer(), None);
assert_eq!(Interaction::Disabled.layer(), None);
assert_eq!(Interaction::Hovered.layer(), Some(StateLayer::Hover));
assert_eq!(Interaction::Focused.layer(), Some(StateLayer::Focus));
assert_eq!(Interaction::Pressed.layer(), Some(StateLayer::Pressed));
// focus 与 pressed 同 α(M3 口径)——图上一样是**对的**
assert_eq!(
Interaction::Focused.layer().map(StateLayer::alpha),
Interaction::Pressed.layer().map(StateLayer::alpha)
);
}
#[test]
fn overlay_color_is_the_token_blend_not_local_math() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let ui = ui(flavor);
for (state, layer) in [
(Interaction::Hovered, StateLayer::Hover),
(Interaction::Focused, StateLayer::Focus),
(Interaction::Pressed, StateLayer::Pressed),
] {
assert_eq!(
overlay_color(&ui, state, Role::OnPrimary, Role::Primary),
Some(ui.state_over(layer, Role::OnPrimary, Role::Primary)),
"{flavor:?}/{state:?} 必须逐位等于令牌求值结果"
);
assert!(
overlay_color(&ui, state, Role::OnSurface, Role::Surface).is_some(),
"可交互态必须给颜色"
);
}
assert_eq!(
overlay_color(&ui, Interaction::Enabled, Role::OnSurface, Role::Surface),
None
);
assert_eq!(
overlay_color(&ui, Interaction::Disabled, Role::OnSurface, Role::Surface),
None,
"禁用态不叠状态层(它换的是容器/内容色)"
);
}
}
#[test]
fn hover_is_weaker_than_pressed_on_the_same_container() {
let ui = ui(SchemeFlavor::Light);
let hover = overlay_color(&ui, Interaction::Hovered, Role::OnSurface, Role::Surface)
.expect("hover 有颜色");
let pressed = overlay_color(&ui, Interaction::Pressed, Role::OnSurface, Role::Surface)
.expect("pressed 有颜色");
assert_ne!(hover, pressed, "8% 与 10% 必须能区分");
assert_eq!(hover.a, 1.0, "结果必须不透明(GPU/CPU 逐像素一致)");
}
}
@@ -0,0 +1,155 @@
//! 自定义 `Widget` 的状态机([`Tracker`]):把 iced 事件折成 [`Interaction`] 五态。
//!
//! 与 [`super`] 的分工:`super` 只有「态 → 颜色」的纯映射;**事件 → 态**在这里。
//! 本 mission 的按钮家族全走 iced 的 `button`(自带四态),所以 `Tracker` 目前只有单测在用——
//! 它给四个后续家族里**要自绘**的控件(Slider 轨道 / Switch 轨道 / 进度条)准备。
//!
//! 加东西前先看行数余额(单文件 ≤400,AGENTS.md §3-1)。
use iced::Event;
use super::Interaction;
/// 自定义 `Widget` 的状态机:把 iced 事件折成 [`Interaction`]。
///
/// 这是 D39 §3 说的「状态机的公共部分」。用法(自定义 Widget 的 `update` 里):
///
/// ```ignore
/// if self.tracker.event(event, cursor.is_over(layout.bounds())) {
/// shell.request_redraw(); // 只有真变了才重绘
/// }
/// let style = style_of(self.tracker.state());
/// ```
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Tracker {
enabled: bool,
hovered: bool,
focused: bool,
pressed: bool,
}
#[allow(dead_code)]
impl Tracker {
/// 新建;`enabled` 由组件自己决定(例如 `on_press.is_some()`)。
pub fn new(enabled: bool) -> Self {
Self {
enabled,
..Self::default()
}
}
/// 启用/禁用热切换(禁用时清掉悬停与按下:禁用元素不该留着 hover 高亮)。
pub fn set_enabled(&mut self, enabled: bool) -> bool {
let changed = self.enabled != enabled;
self.enabled = enabled;
if !enabled {
self.hovered = false;
self.pressed = false;
self.focused = false;
}
changed
}
/// 键盘焦点(由 `operation`/`shell` 侧的聚焦消息驱动)。
pub fn set_focused(&mut self, focused: bool) -> bool {
let next = focused && self.enabled;
let changed = self.focused != next;
self.focused = next;
changed
}
/// 折进一个事件;返回「视觉态变了,要重绘吗」。
///
/// `over` = 指针是否在控件 bounds 内(`Cursor::is_over(bounds)` 的结果,由调用方算——
/// 这样本函数不依赖 `Layout`,可以在单测里直接喂)。
pub fn event(&mut self, event: &Event, over: bool) -> bool {
if !self.enabled {
return false;
}
let before = self.state();
match event {
Event::Mouse(iced::mouse::Event::CursorMoved { .. }) => self.hovered = over,
Event::Mouse(iced::mouse::Event::CursorLeft) => {
self.hovered = false;
self.pressed = false;
}
Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left)) => {
self.pressed = over;
}
Event::Mouse(iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left)) => {
self.pressed = false;
}
_ => {}
}
self.state() != before
}
/// 当前态(优先级:按下 > 焦点 > 悬停 > 静息;禁用最高)。
pub const fn state(&self) -> Interaction {
if !self.enabled {
Interaction::Disabled
} else if self.pressed {
Interaction::Pressed
} else if self.focused {
Interaction::Focused
} else if self.hovered {
Interaction::Hovered
} else {
Interaction::Enabled
}
}
}
/// `Tracker` 的行为用例(自绘控件用,与颜色无关)。
#[cfg(test)]
mod tests {
use super::*;
use iced::Event;
fn moved() -> Event {
Event::Mouse(iced::mouse::Event::CursorMoved {
position: iced::Point::ORIGIN,
})
}
#[test]
fn tracker_folds_events_into_the_five_states() {
let mut tracker = Tracker::new(true);
assert_eq!(tracker.state(), Interaction::Enabled);
assert!(tracker.event(&moved(), true), "首次悬停要报变化");
assert_eq!(tracker.state(), Interaction::Hovered);
assert!(!tracker.event(&moved(), true), "重复悬停不该再报变化");
let press = Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left));
assert!(tracker.event(&press, true));
assert_eq!(tracker.state(), Interaction::Pressed);
let release = Event::Mouse(iced::mouse::Event::ButtonReleased(
iced::mouse::Button::Left,
));
assert!(tracker.event(&release, true));
assert_eq!(tracker.state(), Interaction::Hovered);
assert!(tracker.set_focused(true));
assert_eq!(tracker.state(), Interaction::Focused, "焦点态优先于悬停");
let left = Event::Mouse(iced::mouse::Event::CursorLeft);
tracker.event(&left, false);
assert_eq!(tracker.state(), Interaction::Focused, "鼠标离开不影响焦点");
}
#[test]
fn disabled_tracker_swallows_everything() {
let mut tracker = Tracker::new(true);
assert!(tracker.set_enabled(false));
assert_eq!(tracker.state(), Interaction::Disabled);
let press = Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left));
assert!(!tracker.event(&press, true), "禁用态不响应事件");
assert_eq!(tracker.state(), Interaction::Disabled);
assert!(!tracker.set_focused(true), "禁用态拿不到焦点");
}
}
+129
View File
@@ -0,0 +1,129 @@
//! 布局陷阱的两个固定解法(D39 §4):**要精确尺寸就调这里,别手写**。
//!
//! ## 陷阱 Q8:`center_x(Length::Fill)` 会把已设的 `.width()` 覆盖成 Fill
//!
//! iced 的 `container::center_x/center_y/center` 不只是「对齐」——它们**同时**把容器该轴的
//! `Length` 改成 `Fill`(源码里 `self.width = Length::Fill`)。于是
//! `.width(56).center_x(Length::Fill)` 最终是 `Fill`,56 被父级宽度吃掉:
//! M68 的导航药丸因此从 **56×32 变成 88×32**(父级是 88 宽的轨),图上看着「横贯全轨」,
//! 而单元测试测的 `indicator_size()` 返回值一直是 56×32 —— **函数对、图错**。
//!
//! 正确写法是二选一:
//!
//! - 尺寸留在**自己**身上,居中交给**父级**(`row/column` 的 `align_x`)——`shell/nav.rs` 的做法;
//! - 或者用 [`centered_fixed`]:容器写死宽高,**用 `align_x/align_y` 居中**(它们只改对齐,
//! 不改 `Length`)。
//!
//! ## 陷阱 Q9:`Button` 给内容 Fill 约束,`svg().width(24)` 会被忽略
//!
//! `button` 在 layout 里把「内容可用区」作为约束交给子元素,`svg` 拿到的是 Fill,
//! usvg 于是按 `Contain` 撑满内区:**字形渲染成 46px、线宽从 1.5 变 2.9**(M68 视觉复审 P2-2),
//! 比导航图标重一倍。反过来「把 `.width(24)` 设在 button 上」也不行——那会缩掉点击区。
//!
//! 正确写法:**在中间插一层固定尺寸容器**——[`fixed`]:
//!
//! ```text
//! let icon = layout::fixed(24.0, 24.0, icons::view(kind, color)); // 字形 24×24
//! button(icon).width(46).height(48) // 命中区仍是 46×48
//! ```
//!
//! ## 为什么还要 [`inset`]
//!
//! 「左右缩进 16 的一条线」「内容四周留 24」这类**只改内边距、不改尺寸**的语义,
//! 写 `.padding(Padding::from([...]))` 时数组顺序(上右下左)极易写反;封装一次,
//! 全组件线只写一次顺序。
//!
//! ## 自检
//!
//! `components::gate` 会扫 `src/components/**`:出现 `center_x(` / `center_y(` / `.center(`
//! 即判红(这条门禁就是 Q8 的回归测试);图标一律经 [`fixed`] / [`centered_fixed`] 包一层。
use iced::widget::container;
use iced::{Alignment, Element, Length, Padding};
/// 固定尺寸的盒子:宽高**只由参数决定**,子元素贴左上(对齐交给子元素自己或父级)。
///
/// Q9 的解法:`svg` 放进 24×24 的盒子,它拿到的约束就是 24,`Contain` 无从撑满。
pub fn fixed<'a, M: 'a>(
width: f32,
height: f32,
content: impl Into<Element<'a, M>>,
) -> Element<'a, M> {
container(content)
.width(Length::Fixed(width))
.height(Length::Fixed(height))
.into()
}
/// 固定尺寸 + 内容居中(Q8 的解法)。
///
/// 用 `align_x/align_y` 而不是 `center_x/center_y`:前者只改对齐,后者会把本容器的
/// `Length` 改成 `Fill`,把参数里的宽高**静默丢掉**。
///
/// 典型用法:导航轨的选中指示器 [`centered_fixed(56.0, 32.0, icon)`](centered_fixed)。
pub fn centered_fixed<'a, M: 'a>(
width: f32,
height: f32,
content: impl Into<Element<'a, M>>,
) -> Element<'a, M> {
container(content)
.width(Length::Fixed(width))
.height(Length::Fixed(height))
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.into()
}
/// 只加内边距、不碰尺寸(`horizontal` = 左右,`vertical` = 上下)。
///
/// 语义等价于 `.padding(Padding { top: vertical, right: horizontal, bottom: vertical,
/// left: horizontal })`,但只暴露两个参数——避免把「上右下左」写反。
pub fn inset<'a, M: 'a>(
horizontal: f32,
vertical: f32,
content: impl Into<Element<'a, M>>,
) -> Element<'a, M> {
container(content)
.padding(Padding {
top: vertical,
right: horizontal,
bottom: vertical,
left: horizontal,
})
.into()
}
/// 单侧缩进(只缩左侧;分隔线的「列表内左缩进 16」用这个)。
pub fn inset_start<'a, M: 'a>(start: f32, content: impl Into<Element<'a, M>>) -> Element<'a, M> {
container(content)
.width(Length::Fill)
.padding(Padding {
left: start,
..Padding::default()
})
.into()
}
#[cfg(test)]
mod tests {
use super::*;
/// Q8 的**形态级**钉法:这三个 API 一旦内部改用 `center_*`,这条测试之外的门禁
/// (`components::gate`)也会红;这里再钉一次「参数就是参数」,防止签名被悄悄改成
/// `impl Into<Length>` 之类。
#[test]
fn helpers_take_pixel_sizes_not_lengths() {
let _: fn(f32, f32, Element<'static, ()>) -> Element<'static, ()> = fixed;
let _: fn(f32, f32, Element<'static, ()>) -> Element<'static, ()> = centered_fixed;
let _: fn(f32, f32, Element<'static, ()>) -> Element<'static, ()> = inset;
let _: fn(f32, Element<'static, ()>) -> Element<'static, ()> = inset_start;
}
#[test]
fn helpers_build_a_tree_without_panicking() {
let _: Element<'_, ()> = fixed(24.0, 24.0, iced::widget::text("x"));
let _: Element<'_, ()> = centered_fixed(56.0, 32.0, iced::widget::text("x"));
let _: Element<'_, ()> = inset(16.0, 8.0, iced::widget::text("x"));
let _: Element<'_, ()> = inset_start(16.0, iced::widget::text("x"));
}
}
+58
View File
@@ -0,0 +1,58 @@
//! 组件库(D39 定案):五家族并行,**目录所有权切分**,接口见
//! `DevDocs/SubAgentsTalk/app-iced-组件库契约.md`。
//!
//! ## 模块地图与归属(谁能写哪里)
//!
//! ```text
//! components/
//! mod.rs ← 本文件(COMPBASE 独占):只声明子模块,不含实现
//! interaction.rs ← COMPBASE 独占:hover/focus/pressed/disabled 的唯一实现
//! layout.rs ← COMPBASE 独占:Q8/Q9 两个布局陷阱的固定解法
//! buttons/ ← COMPBASE(12b §1 按钮家族 + §2 IconButton)
//! divider.rs ← COMPBASE(15 §22)
//! selection/ ← 本文件只放桩;后续 mission 在自己的目录里填(§18/§19/§10/§13)
//! inputs/ ← 同上(§9/§20/§11/§8)
//! feedback/ ← 同上(§12/§14/§7/§15)
//! navigation/ ← 同上(§4/§6/§21/§16)
//! gallery/ ← 视觉验收 harness(`--screen=gallery`)
//! ```
//!
//! **后续 mission 只写自己目录内的文件,不改本文件**:四个家族桩已经在这里声明好,
//! 每个家族文件自带一个 `demo()`(占位 → 真组件),gallery 侧对应文件也在
//! `gallery/` 里预置了同构的桩。加组件 = 只改自家目录里的文件。
//!
//! ## 组件签名纪律(契约 §2)
//!
//! 组件**对消息类型泛型**,不 import `crate::app::Message`:
//!
//! ```text
//! pub fn filled<'a, M: Clone + 'a>(ui: &'a Ui, ...) -> Element<'a, M>
//! ```
//!
//! `Ui` 一律 `&'a Ui` 传入,组件内部**不许** `Ui::new`;深浅热切靠外部重建整树。
//!
//! ## 令牌纪律
//!
//! 组件里**零**色值 / 字号 / 半径 / 间距 / α 字面量:颜色走 [`crate::theme::Ui`],
//! 状态层走 [`interaction`],尺寸走 `unlucky_design` 的 `LayoutMetrics` / `Space` /
//! `IconSize` / `ShapeScale`。缺令牌 → 登记 G 编号(续 15 §25 的 G13),**不发明**。
//! [`gate`] 里的测试会扫本目录源码执行这条纪律。
//!
//! ## 为什么 `components` 由 `app.rs` 声明
//!
//! `src/lib.rs` 是五条组件线共用的文件(并行线改它必然冲突)。本模块挂在
//! `app.rs` 的 `#[path]` 声明下,路径仍是 `src/components/`,这样 COMPBASE 的
//! 全部改动都落在自己 scope 内。
pub mod buttons;
pub mod divider;
pub mod feedback;
pub mod gallery;
pub mod inputs;
pub mod interaction;
pub mod layout;
pub mod navigation;
pub mod selection;
#[cfg(test)]
mod gate;
@@ -0,0 +1,353 @@
//! AppBar(small)(`research/12b` §16):页头 56、标题 + 副标题、leading / trailing 槽位、滚动换色。
//!
//! ## 参考值(`[源]` = A1,本轮 `javap` 实读本机 AAR)
//!
//! | # | 检查项 | 参考值 | 本项目 |
//! |---|---|---|---|
//! | 16.1 | 页头高 | `AppBarSmallTokens.ContainerHeight` = **64** | **56** = `LM.page_header_height`(登记偏离 −8) |
//! | 16.3 | 容器 | `AppBarTokens.ContainerColor = Surface` + L0;**滚动后** `OnScrollContainerColor = SurfaceContainer` + `OnScrollContainerElevation`(L2) | 同 |
//! | 16.4 | 字级 | `TitleFont = TitleLarge`(emphasized w500)· `SubtitleFont = LabelMedium` + `SubtitleColor = OnSurfaceVariant` | 同 |
//! | 16.5 | 图标 | `IconSize = 24`;leading `OnSurface`、trailing `OnSurfaceVariant` | 同 |
//! | 16.6 | 左右内边距 | `LeadingSpace = 4` / `TrailingSpace = 4`(+ IconButton 自带 40 宽 → 视觉起手 20) | 同 |
//!
//! ## 「滚动时的高度变化」——**规范上不存在**(不是我们没做)
//!
//! A1 的 `AppBarTokens` 全字段实读(`javap -p -c androidx.compose.material3.tokens.AppBarTokens`):
//!
//! ```text
//! AvatarSize 32 · ContainerColor Surface · ContainerElevation · ContainerShape CornerNone
//! IconButtonSpace · IconSize 24 · LeadingIconColor · LeadingSpace 4
//! OnScrollContainerColor SurfaceContainer · OnScrollContainerElevation ← 滚动只改这两项
//! SubtitleColor/SubtitleFont · TitleColor/TitleFont · TrailingIconColor · TrailingSpace 4
//! ```
//!
//! **整个 tokens 包里 `OnScroll*` 只有两个字段**(颜色与高程,见 `grep` 全量核对的报告),
//! 没有任何 `OnScroll…Height`;高度只在**尺寸档**(`AppBarSmallTokens.ContainerHeight = 64`)里,
//! 而那是 `small` / `medium` / `large` 三个**不同组件**的分档,不是滚动驱动的形变。
//! 所以滚动时**高度不变**、只换容器色 + 升到 L2 阴影——这是规范,不是偏离。
//!
//! 我们与规范的两处差异各登记一条:
//!
//! - **D-M79-2** 高度 56 而不是 64(项目骨架口径 `LM.page_header_height`,已有页面按 56 排版);
//! - 不做 `Medium` / `Large` 的**折叠(collapsing)**形态——那是另外两个组件(`AppBarMediumTokens`
//! / `AppBarLargeTokens`),本 mission 的 §16 只要求 small。
use iced::widget::text::{LineHeight, Wrapping};
use iced::widget::{button, container, text};
use iced::{
Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow, Vector,
};
use unlucky_design::color::Role;
use unlucky_design::elevation::Elevation;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::{self, Interaction};
use super::super::layout;
use super::geometry::AppBarGeometry;
/// 页头里的一个图标动作。
#[derive(Debug, Clone)]
pub struct AppBarAction<M> {
icon: IconKind,
on_press: Option<M>,
}
impl<M> AppBarAction<M> {
/// 一个可点的图标动作。
pub fn new(icon: IconKind, on_press: M) -> Self {
Self {
icon,
on_press: Some(on_press),
}
}
/// 一个装饰性图标(无消息)。
pub fn inert(icon: IconKind) -> Self {
Self {
icon,
on_press: None,
}
}
}
/// 一条 AppBar small。
#[derive(Debug, Clone)]
pub struct AppBar<'a, M> {
title: &'a str,
subtitle: Option<&'a str>,
leading: Option<AppBarAction<M>>,
trailing: Vec<AppBarAction<M>>,
scrolled: bool,
}
impl<'a, M> AppBar<'a, M> {
/// 标题(`TitleLarge`,emphasized)。
pub fn new(title: &'a str) -> Self {
Self {
title,
subtitle: None,
leading: None,
trailing: Vec::new(),
scrolled: false,
}
}
/// 副标题(`LabelMedium` + `OnSurfaceVariant`)。
pub fn subtitle(mut self, subtitle: &'a str) -> Self {
self.subtitle = Some(subtitle);
self
}
/// 前置动作(导航返回 / 汉堡;`OnSurface`)。
pub fn leading(mut self, action: AppBarAction<M>) -> Self {
self.leading = Some(action);
self
}
/// 追加一个后置动作(`OnSurfaceVariant`)。
pub fn trailing(mut self, action: AppBarAction<M>) -> Self {
self.trailing.push(action);
self
}
/// 滚动状态:`true` = 容器色 `SurfaceContainer` + L2 阴影(A1 `OnScroll*`)。
pub fn scrolled(mut self, scrolled: bool) -> Self {
self.scrolled = scrolled;
self
}
}
/// 一个图标动作按钮(40×40 命中区、图标 24 经 `layout::fixed`——Q9)。
fn action<'a, M: Clone + 'a>(
ui: &'a Ui,
action: AppBarAction<M>,
geometry: AppBarGeometry,
color: Color,
) -> Element<'a, M> {
let hovered =
interaction::overlay_color(ui, Interaction::Hovered, Role::OnSurface, Role::Surface);
let icon = layout::fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(action.icon, color),
);
let hit = ui.metrics().control_height;
// 图标要落在 40×40 命中区的**正中**:`Container` 默认 `horizontal_alignment: Left`
// (`iced_widget-0.14.2/src/container.rs`),只给 `align_y` 会让 24px 图标贴左缘——
// 视觉 r1 实测前导墨迹 `38..58`(应 `44..68`)。所以**两轴都显式给**(规则 Q21)。
let button = button(
container(icon)
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center),
)
.width(Length::Fixed(hit))
.height(Length::Fixed(hit))
.padding(0)
.style(move |_theme, status| button::Style {
background: some_if_hovered(status, hovered),
text_color: color,
border: Border {
// IconButton 的圆角是 full:按命中区边长折算(`radius_dp` 不接受 Full)
radius: ui.radius(ShapeScale::Full, hit),
..Border::default()
},
shadow: Shadow::default(),
snap: true,
});
match action.on_press {
Some(message) => button.on_press(message).into(),
None => button.into(),
}
}
/// hover 时给背景(`None` 的其他态不叠状态层)。
fn some_if_hovered(status: button::Status, hovered: Option<Color>) -> Option<Background> {
match status {
button::Status::Hovered => hovered.map(Background::Color),
_ => None,
}
}
/// AppBar small(页头 56 / Surface / 滚动换色)。
pub fn app_bar<'a, M: Clone + 'a>(ui: &'a Ui, bar: AppBar<'a, M>) -> Element<'a, M> {
let geometry = AppBarGeometry::of(ui);
let title_style = ui.emphasized(TypeRole::TitleLarge);
let mut texts = iced::widget::Column::new().spacing(0);
texts = texts.push(
text(bar.title)
.size(title_style.size_px)
.line_height(LineHeight::Absolute(Pixels(title_style.line_height_px)))
// 单行:页头高固定 56,长标题折行会顶穿页头并与 trailing 动作重叠(偏离 D-M79-5)
.wrapping(Wrapping::None)
.font(ui.font_of(title_style))
.color(ui.color(Role::OnSurface)),
);
if let Some(subtitle) = bar.subtitle {
let style = ui.style(TypeRole::LabelMedium);
texts = texts.push(
text(subtitle)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.wrapping(Wrapping::None)
.font(ui.font_of(style))
.color(ui.color(Role::OnSurfaceVariant)),
);
}
let mut row = iced::widget::Row::new()
.align_y(Alignment::Center)
.spacing(ui.space(unlucky_design::spacing::Space::X2));
if let Some(leading) = bar.leading {
// 16.5:leading 图标 `OnSurface`
row = row.push(action(ui, leading, geometry, ui.color(Role::OnSurface)));
}
// 文本块吃掉剩余宽度并**裁切**:这一条同时解决两件事——
// ① 标题与 trailing 之间必须有 Fill(M77 Q8 同款),② 单行超长标题切在动作按钮之前,
// 不会重叠(`clip(true)` 是 iced 里唯一能裁子元素越界的方式;没有 ellipsis,见 D-M79-5)。
row = row.push(container(texts).width(Length::Fill).clip(true));
for action_item in bar.trailing {
// 16.5:trailing 图标 `OnSurfaceVariant`
row = row.push(action(
ui,
action_item,
geometry,
ui.color(Role::OnSurfaceVariant),
));
}
let background = if bar.scrolled {
ui.color(Role::SurfaceContainer)
} else {
ui.color(Role::Surface)
};
let elevation = scroll_elevation(bar.scrolled);
let shadow = if matches!(elevation, Elevation::Level0) {
Shadow::default()
} else {
let spec = elevation.shadow();
Shadow {
color: Color {
a: spec.alpha,
..ui.color(Role::Shadow)
},
offset: Vector::new(0.0, spec.offset_y_px),
blur_radius: spec.blur_px,
}
};
container(row)
.width(Length::Fill)
.height(Length::Fixed(geometry.height))
// 内容行(40 高的动作按钮 + 文本块)必须落在 56 页头的**垂直中线**上:
// `Container` 的 vertical_alignment 默认 Top,只给 `width` 会让整行贴顶
// (视觉 r1 实测前导图标墨迹 y=204..223,居中应是 209..227)。
.align_y(Alignment::Center)
.padding(Padding {
top: 0.0,
right: geometry.edge,
bottom: 0.0,
left: geometry.edge,
})
.style(move |_theme| container::Style {
background: Some(Background::Color(background)),
border: Border {
// 16.3:ContainerShape = CornerNone
radius: ui.radius_dp(ShapeScale::None),
..Border::default()
},
shadow,
..container::Style::default()
})
.into()
}
/// 滚动态的高程档(A1 `AppBarTokens.OnScrollContainerElevation`;静息 L0 = `ContainerElevation`)。
fn scroll_elevation(scrolled: bool) -> Elevation {
if scrolled {
Elevation::Level2
} else {
Elevation::Level0
}
}
#[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",
},
)
}
/// 16.1:页头高 = `LM.page_header_height`(56),并**钉死**它与 A1 的 64 不同。
#[test]
fn height_is_pinned_to_the_page_header_token() {
let ui = ui();
let geometry = AppBarGeometry::of(&ui);
assert_eq!(geometry.height, ui.metrics().page_header_height);
assert_eq!(geometry.height, 56.0);
assert_ne!(
geometry.height, 64.0,
"64 是 A1 AppBarSmallTokens.ContainerHeight;本项目按骨架令牌取 56(D-M79-2)"
);
}
/// 16.5 / 16.6:图标 24、左右内边距 4、头像 32。
#[test]
fn icon_and_edges_come_from_tokens() {
let geometry = AppBarGeometry::of(&ui());
assert_eq!(geometry.icon, 24.0);
assert_eq!(geometry.edge, 4.0);
assert_eq!(geometry.avatar, 32.0);
}
/// 「滚动只换色 + 升 L2」:这条把 `OnScroll*` 的两个字段钉住(高度**不在**这里面)。
#[test]
fn scroll_switches_color_and_elevation_only() {
let ui = ui();
assert_eq!(scroll_elevation(false), Elevation::Level0);
assert_eq!(scroll_elevation(true), Elevation::Level2);
// 高度与滚动无关:同一个几何(这条是「滚动不改高度」的可执行声明)
assert_eq!(
AppBarGeometry::of(&ui).height,
ui.metrics().page_header_height,
"高度只由骨架令牌决定,没有 OnScroll…Height"
);
}
/// 树能建出来(leading + 副标题 + 两个 trailing + 滚动态)。
#[test]
fn view_builds_a_tree() {
let ui = ui();
let _: Element<'_, ()> = app_bar(
&ui,
AppBar::new("Instances")
.subtitle("4 instances")
.leading(AppBarAction::new(IconKind::Instances, ()))
.trailing(AppBarAction::new(IconKind::Settings, ()))
.trailing(AppBarAction::inert(IconKind::Logs))
.scrolled(true),
);
}
}
@@ -0,0 +1,140 @@
//! NavigationDrawer / NavigationRail(`research/15` §21 + 12b §5):**同一个容器的两个宽度**(D33)。
//!
//! 本文件只装「容器」:宽度(88 ⇄ 240)、`CornerLargeEnd` 圆角、分组标题、组间分隔线。
//! 一项长什么样(`NavItem` / `NavGroup` / 颜色求值 / 药丸排布)在 `drawer/item.rs`。
//!
//! ## 参考值与 D33(详见 `geometry.rs` 的表)
//!
//! ```text
//! 折叠 = Rail 88 = LayoutMetrics::nav_rail_width 项高 52 = 药丸 32 + 4 + 标签 16
//! 展开 = Drawer 240 = LayoutMetrics::nav_drawer_width 项高 56 = A1 ActiveIndicatorHeight(G24)
//! 药丸:rail 88 − 2×16 = 56(M68 的 56×32);drawer 240 − 2×12 = 216(= A1 的 360 − 2×12)
//! ```
//!
//! 两者共用 [`navigation`] 一个入口、一套颜色角色、一套 full 圆角药丸;切换时圆角语言不变,
//! 一屏导航容器数恒为 1(§21.9)。**不是两套组件**——D33 的可验收形式就是「同一函数两个宽度」。
//!
//! ## Q8 的现场
//!
//! M68 的药丸写 56 宽后用 `center_x(Length::Fill)` 居中,容器尺寸被 Fill 覆盖成 88 宽(图上横贯全轨)。
//! 这里药丸宽由 [`NavPaneGeometry::pill_width`] 给出,居中一律交给父级 `align_x`。
//!
//! ## 分组标题(只 drawer 侧,§21.8)
//!
//! `TitleSmall` + `OnSurfaceVariant`,**不可点**;组与组之间 1px `OutlineVariant`
//! (复用 [`crate::app::components::divider`]),不用背景色块分区。
use iced::widget::container;
use iced::{Background, Border, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::super::divider;
use super::geometry::{NavLayout, NavPaneGeometry};
mod item;
use item::{line, nav_row};
pub use item::{NavGroup, NavItem};
/// 导航容器(D33:`layout` 决定 88 还是 240,其余全同)。
#[derive(Debug, Clone)]
pub struct Navigation<'a, M> {
layout: NavLayout,
groups: Vec<NavGroup<'a, M>>,
}
impl<'a, M> Navigation<'a, M> {
/// 折叠态:rail(88)。
pub fn rail() -> Self {
Self {
layout: NavLayout::Rail,
groups: Vec::new(),
}
}
/// 展开态:标准常驻 drawer(240)。
pub fn drawer() -> Self {
Self {
layout: NavLayout::Drawer,
groups: Vec::new(),
}
}
/// 追加分组(组间自动画 1px `OutlineVariant`)。
pub fn group(mut self, group: NavGroup<'a, M>) -> Self {
self.groups.push(group);
self
}
}
/// 导航容器(rail ⇄ drawer 的唯一入口)。
pub fn navigation<'a, M: Clone + 'a>(ui: &'a Ui, nav: Navigation<'a, M>) -> Element<'a, M> {
let geometry = NavPaneGeometry::of(ui, nav.layout);
let gap = match nav.layout {
NavLayout::Rail => ui.metrics().nav_item_gap,
// drawer 的项是紧贴的(56 已经含了内部留白,§21.4)
NavLayout::Drawer => 0.0,
};
let mut body = iced::widget::Column::new().spacing(gap).width(Length::Fill);
for (index, group) in nav.groups.into_iter().enumerate() {
// 组间分隔线只属于 drawer:rail 88 宽里一条通栏线只会切碎药丸的节奏(§21.8 的分区是 drawer 语义)
if index > 0 && matches!(nav.layout, NavLayout::Drawer) {
body = body.push(container(divider::full::<M>(ui)).padding(Padding {
top: ui.space(unlucky_design::spacing::Space::X2),
bottom: ui.space(unlucky_design::spacing::Space::X2),
left: 0.0,
right: 0.0,
}));
}
// 标题只在 drawer 侧出现(rail 88 宽放不下文字,§21.8 的分区是 drawer 语义)
if let (NavLayout::Drawer, Some(title)) = (nav.layout, group.title) {
body = body.push(
container(line::<M>(
ui,
title,
TypeRole::TitleSmall,
ui.color(Role::OnSurfaceVariant),
))
.width(Length::Fill)
.padding(Padding {
top: ui.space(unlucky_design::spacing::Space::X2),
right: geometry.pad_start,
bottom: ui.space(unlucky_design::spacing::Space::X1),
left: geometry.pill_inset + geometry.pad_start,
}),
);
}
for item in group.items {
body = body.push(nav_row(ui, item, geometry));
}
}
container(body)
.width(Length::Fixed(geometry.width))
.height(Length::Fill)
// 轨顶留白走 padding(不是 spacer 元素:会被 column 的 spacing 再加一档,M68 P3-5)
.padding(Padding {
top: ui.space(unlucky_design::spacing::Space::X6),
right: 0.0,
bottom: 0.0,
left: 0.0,
})
.style(move |_theme| container::Style {
// §21.3 标准(常驻)= Surface + L0;形状 CornerLargeEnd(内侧两角 16)
background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))),
border: Border {
radius: geometry.radius,
..Border::default()
},
..container::Style::default()
})
.into()
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,296 @@
//! 导航项的公共类型与一行渲染(`drawer.rs` 的私有子模块:单文件 ≤400 行,AGENTS.md §3-1)。
//!
//! 拆出去的是「一项长什么样」(`NavItem` / `NavGroup` + 颜色求值 + 一行布局),
//! `drawer.rs` 只留「容器长什么样」(宽度、圆角、分组标题、分隔线)。
use iced::widget::text::{LineHeight, Wrapping};
use iced::widget::{button, container, text};
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::Ui;
use super::super::super::interaction::{self, Interaction};
use super::super::super::layout;
use super::super::geometry::{NavLayout, NavPaneGeometry};
/// 一条导航项。
#[derive(Debug, Clone)]
pub struct NavItem<'a, M> {
pub(super) label: &'a str,
pub(super) icon: IconKind,
pub(super) selected: bool,
pub(super) on_press: Option<M>,
pub(super) state: Option<Interaction>,
}
impl<'a, M> NavItem<'a, M> {
/// 一个可点的导航项(图标 + 文案)。
pub fn new(label: &'a str, icon: IconKind, on_press: M) -> Self {
Self {
label,
icon,
selected: false,
on_press: Some(on_press),
state: None,
}
}
/// 当前项(药丸 + `OnSecondaryContainer`)。
pub fn selected(mut self) -> Self {
self.selected = true;
self
}
/// 强制视觉态(gallery 取证;不动状态机)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
/// 转禁用:**只清消息**(修饰器;图标 / 选中态 / 槽位全部保留)。
pub fn disabled(mut self) -> Self {
self.on_press = None;
self
}
}
/// 一个分组(drawer 侧可带标题;rail 侧标题会被忽略——rail 只有图标)。
#[derive(Debug, Clone)]
pub struct NavGroup<'a, M> {
pub(super) title: Option<&'a str>,
pub(super) items: Vec<NavItem<'a, M>>,
}
impl<'a, M> NavGroup<'a, M> {
/// 无标题分组。
pub fn new() -> Self {
Self {
title: None,
items: Vec::new(),
}
}
/// 带标题分组(§21.8:`TitleSmall` + `OnSurfaceVariant`,不可点)。
pub fn titled(title: &'a str) -> Self {
Self {
title: Some(title),
items: Vec::new(),
}
}
/// 追加一项。
pub fn item(mut self, item: NavItem<'a, M>) -> Self {
self.items.push(item);
self
}
}
impl<'a, M> Default for NavGroup<'a, M> {
fn default() -> Self {
Self::new()
}
}
/// 一项的颜色(按态 + 选中求值)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) struct ItemPalette {
/// 药丸底色(`None` = 不画药丸 / 不叠状态层)。
pub(super) pill: Option<Color>,
/// 图标色。
pub(super) icon: Color,
/// 标签色。
pub(super) label: Color,
}
pub(super) fn palette(
ui: &Ui,
layout: NavLayout,
state: Interaction,
selected: bool,
) -> ItemPalette {
if state.is_disabled() {
let ink = interaction::disabled_content_over_role(ui, Role::OnSurface, false);
return ItemPalette {
pill: None,
icon: ink,
label: ink,
};
}
if selected {
let pill = interaction::overlay_color(
ui,
state,
Role::OnSecondaryContainer,
Role::SecondaryContainer,
)
.unwrap_or_else(|| ui.color(Role::SecondaryContainer));
return ItemPalette {
pill: Some(pill),
icon: ui.color(Role::OnSecondaryContainer),
// rail 的选中标签是 `Secondary`(12b 5.6);drawer 是 `OnSecondaryContainer`(§21.7)
label: match layout {
NavLayout::Rail => ui.color(Role::Secondary),
NavLayout::Drawer => ui.color(Role::OnSecondaryContainer),
},
};
}
// 未选中:静息 `OnSurfaceVariant`,hover/focus/pressed 提到 `OnSurface`
let ink = match state {
Interaction::Enabled => ui.color(Role::OnSurfaceVariant),
_ => ui.color(Role::OnSurface),
};
ItemPalette {
pill: interaction::overlay_color(ui, state, Role::OnSurface, Role::Surface),
icon: ink,
label: ink,
}
}
/// 一块文字(显式行高,让项高与行高对得上)。
pub(super) fn line<'a, M: 'a>(
ui: &'a Ui,
value: &'a str,
role: TypeRole,
color: Color,
) -> Element<'a, M> {
let style = ui.style(role);
text(value)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
// 单行(项高固定 52/56):长分组名/实例名不许折行(偏离 D-M79-5)
.wrapping(Wrapping::None)
.font(ui.font_of(style))
.color(color)
.into()
}
/// 一项(rail:图标在上、标签在下;drawer:图标在左、标签在右)。
pub(super) fn nav_row<'a, M: Clone + 'a>(
ui: &'a Ui,
item: NavItem<'a, M>,
geometry: NavPaneGeometry,
) -> Element<'a, M> {
let forced = item.state.or(if item.on_press.is_none() {
Some(Interaction::Disabled)
} else {
None
});
let state = forced.unwrap_or(Interaction::Enabled);
let colors = palette(ui, geometry.layout, state, item.selected);
let radius = iced::border::Radius::new(geometry.pill_height / 2.0);
let icon = layout::fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(item.icon, colors.icon),
);
// 药丸本体:宽高都来自几何(rail 56×32 / drawer 216×56),居中交给父级 align_x
let pill = move |content: Element<'a, M>| -> Element<'a, M> {
let fill = colors.pill;
container(content)
.width(Length::Fixed(geometry.pill_width))
.height(Length::Fixed(geometry.pill_height))
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: fill.map(Background::Color),
border: Border {
radius,
..Border::default()
},
..container::Style::default()
})
.into()
};
let body: Element<'a, M> = match geometry.layout {
NavLayout::Rail => {
// rail:药丸(56×32,图标居中)+ 4 + 标签(LabelMedium)
let mut stack = iced::widget::Column::new()
.spacing(geometry.gap)
.width(Length::Fixed(geometry.width))
.align_x(Alignment::Center);
stack = stack.push(pill(icon));
// 标签要裁到轨宽:单行超长时切在 88 内,不会溢出到内容区(D-M79-5)
stack = stack.push(
container(line::<M>(
ui,
item.label,
TypeRole::LabelMedium,
colors.label,
))
.width(Length::Fixed(geometry.width))
.align_x(Alignment::Center)
.clip(true),
);
stack.into()
}
NavLayout::Drawer => {
// drawer:药丸就是一整行(216×56),内容从 pad_start 16 起、图标↔label 12
let row = iced::widget::Row::new()
.align_y(Alignment::Center)
.spacing(geometry.gap)
.padding(Padding {
top: 0.0,
right: geometry.pad_end,
bottom: 0.0,
left: geometry.pad_start,
});
// label 吃掉剩余宽度并裁切(单行 + 不压到药丸右内边距,D-M79-5)
pill(Element::from(
row.push(icon)
.push(
container(line::<M>(
ui,
item.label,
TypeRole::LabelLarge,
colors.label,
))
.width(Length::Fill)
.clip(true),
)
.width(Length::Fill),
))
}
};
let height = geometry.item_height;
let clickable = button(body)
.width(Length::Fill)
.height(Length::Fixed(height))
.padding(0)
.style(move |_theme, _status| button::Style {
// 底色由药丸给(rail 的 hover 层也画在药丸上,与 drawer 同口径)
background: None,
text_color: colors.label,
border: Border::default(),
shadow: Shadow::default(),
snap: true,
});
let element: Element<'a, M> = match item.on_press {
Some(message) => clickable.on_press(message).into(),
None => clickable.into(),
};
// drawer 的项外距 12(`ItemPadding`):药丸两侧由内缩给出,所以整行再包一层水平内边距
match geometry.layout {
NavLayout::Drawer => container(element)
.width(Length::Fill)
.padding(Padding {
top: 0.0,
right: geometry.pill_inset,
bottom: 0.0,
left: geometry.pill_inset,
})
.into(),
NavLayout::Rail => element,
}
}
@@ -0,0 +1,115 @@
//! `drawer` 的单元测试(单文件 ≤400 行,AGENTS.md §3-1 拆出来)。
use super::super::super::interaction::{self, Interaction};
use super::item::palette;
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::icons::IconKind;
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",
},
)
}
/// §21.7:选中项图标/标签都是 `OnSecondaryContainer`,药丸 `SecondaryContainer`。
#[test]
fn drawer_selected_uses_secondary_container() {
let ui = ui();
let palette = palette(&ui, NavLayout::Drawer, Interaction::Enabled, true);
assert_eq!(palette.pill, Some(ui.color(Role::SecondaryContainer)));
assert_eq!(palette.icon, ui.color(Role::OnSecondaryContainer));
assert_eq!(palette.label, ui.color(Role::OnSecondaryContainer));
}
/// 12b §5.6:**rail** 的选中标签是 `Secondary`(与 drawer 不同,别串线)。
#[test]
fn rail_selected_label_is_secondary() {
let ui = ui();
let rail = palette(&ui, NavLayout::Rail, Interaction::Enabled, true);
let drawer = palette(&ui, NavLayout::Drawer, Interaction::Enabled, true);
assert_eq!(rail.label, ui.color(Role::Secondary));
assert_ne!(rail.label, drawer.label, "两条线的选中标签角色不同");
// 但图标与药丸相同(同一套语言)
assert_eq!(rail.icon, drawer.icon);
assert_eq!(rail.pill, drawer.pill);
}
/// 未选中:静息 `OnSurfaceVariant`;hover/focus/pressed 提到 `OnSurface` 且叠状态层。
#[test]
fn inactive_lifts_to_on_surface() {
let ui = ui();
let resting = palette(&ui, NavLayout::Drawer, Interaction::Enabled, false);
assert_eq!(resting.label, ui.color(Role::OnSurfaceVariant));
assert_eq!(resting.pill, None);
for state in [
Interaction::Hovered,
Interaction::Focused,
Interaction::Pressed,
] {
let active = palette(&ui, NavLayout::Drawer, state, false);
assert_eq!(active.label, ui.color(Role::OnSurface), "{state:?}");
assert_eq!(active.icon, ui.color(Role::OnSurface), "{state:?}");
assert!(active.pill.is_some(), "{state:?} 必须叠状态层");
}
}
/// 禁用:`OnSurface`@38%,不画药丸,不叠状态层。
#[test]
fn disabled_draws_no_pill() {
let ui = ui();
let palette = palette(&ui, NavLayout::Drawer, Interaction::Disabled, true);
assert_eq!(
palette.icon,
interaction::disabled_content_over_role(&ui, Role::OnSurface, false)
);
assert_eq!(palette.label, palette.icon);
assert_eq!(palette.pill, None);
}
/// 禁用是**修饰器**:只清消息,图标 / 选中态全部保留。
#[test]
fn disabled_only_clears_the_message() {
let item = NavItem::new("Settings", IconKind::Settings, ())
.selected()
.disabled();
assert!(item.on_press.is_none());
assert!(item.selected);
assert_eq!(item.icon, IconKind::Settings);
assert_eq!(item.label, "Settings");
}
/// D33:两种宽度都能建出树(同一个 `navigation` 入口)。
#[test]
fn both_layouts_build_a_tree() {
let ui = ui();
for layout in NavLayout::ALL {
let base = match layout {
NavLayout::Rail => Navigation::rail(),
NavLayout::Drawer => Navigation::drawer(),
};
let _: Element<'_, ()> = navigation(
&ui,
base.group(
NavGroup::titled("LIBRARY")
.item(NavItem::new("Instances", IconKind::Instances, ()).selected())
.item(NavItem::new("Download", IconKind::Download, ())),
)
.group(NavGroup::titled("SYSTEM").item(NavItem::new(
"Settings",
IconKind::Settings,
(),
))),
);
}
}
@@ -0,0 +1,333 @@
//! 导航类家族的**几何**:能取令牌的取令牌,取不到的在此登记 G 编号(不散落在组件里)。
//!
//! ## 参考值 → 落点(`[源]` = A1,`material3-android:1.4.0`;本轮逐条用 `javap` 读本机 AAR 复核)
//!
//! ```text
//! javap -p -c androidx.compose.material3.tokens.ListTokens → 56/72/88、16/16、图标 24
//! javap -p -c androidx.compose.material3.tokens.NavigationDrawerTokens
//! → ActiveIndicatorHeight 56、ActiveIndicatorWidth 336、ContainerWidth 360、CornerLargeEnd
//! javap -p -c androidx.compose.material3.NavigationDrawerItemDefaults
//! → ItemPadding = 12(bipush 12 → PaddingValues(12f, 0f))
//! javap -p -c androidx.compose.material3.tokens.PrimaryNavigationTabTokens
//! → ContainerHeight 48、IconAndLabelTextContainerHeight 64、ActiveIndicatorHeight 3、图标 24
//! javap -p -c androidx.compose.material3.tokens.SecondaryNavigationTabTokens → ContainerHeight 48
//! javap -p -c androidx.compose.material3.tokens.AppBarSmallTokens → ContainerHeight 64
//! ```
//!
//! | # | 量 | 参考值(A1) | 本项目 | 来源 | 落点 |
//! |---|---|---|---|---|---|
//! | 4.1 | ListItem 一行高 | **56** | **44** = `LM.row_height`(登记偏离 −12) | `[源]` `ListItemOneLineContainerHeight` | [`ListItemGeometry::heights`] |
//! | 4.2 | ListItem 两/三行高 | **72 / 88** | **60 / 76**(每多一行 +16,与官方同 delta) | `[源]` | 同上 |
//! | 4.3 | ListItem 左右内边距 | **16 / 16** | 同 | `[源]` `ListItemLeading/TrailingSpace` | `pad_start` / `pad_end` |
//! | 4.4 | ListItem leading 图标 | **24** | 同 | `[源]` `ListItemLeadingIconSize` | `icon`(`IconSize::Toolbar`) |
//! | 4.4b | 图标 ↔ 文本间距 | 无 token | **16** | `[估]`(Compose 由 ListItem 内部排版决定) | `gap`(`Space::X4`) |
//! | 6.1 | Tabs 容器高 | **48**(带图标 **64**) | 同 | `[源]` | [`TABS_HEIGHT`](**G22**:`LM` 无此档) |
//! | 6.2 | 指示条 | 高 **3**、圆角 **3**、`Primary` | 同 | `[源]` `ActiveIndicatorHeight` + `RoundedCornerShape(3dp)` | [`TABS_INDICATOR`](**G23**) |
//! | 6.6 | Secondary 分隔线 | **1dp `SurfaceVariant`** | 同 | `[源]` `DividerHeight` / `DividerColor` | [`TabsGeometry::secondary_bar`] |
//! | 21.4 | drawer 项高 | **56**(`heightIn(min = ActiveIndicatorHeight)`) | **56**(`LM.nav_item_height` 是 44,登记偏离 +12) | `[源]` | [`DRAWER_ITEM_HEIGHT`](**G24**) |
//! | 21.5 | drawer 指示器 | 宽 = 容器宽 − **24**(360 → 336);高 = 项高;圆角 full | 240 − 24 = **216** | `[源]` `ItemPadding = 12` | [`NavPaneGeometry::pill_width`] |
//! | 21.6 | drawer 项内 | 项外 **12**;内 start **16** / end **24**;图标 24;图标↔label **12**;label `LabelLarge` | 同 | `[源]` `ItemPadding` + `navigation/15 §21.6` | [`ComponentMetrics::NAVIGATION_DRAWER`] |
//! | 21.2 | drawer 形状 | **`CornerLargeEnd`**(只有靠内容那侧两角 16,贴边侧直角) | 同(右两角 16) | `[源]` `ContainerShape` | [`NavPaneGeometry::radius`] |
//! | 16.1 | AppBar small 高 | **64** | **56** = `LM.page_header_height`(登记偏离 −8) | `[源]` `AppBarSmallTokens.ContainerHeight` | [`AppBarGeometry::height`] |
//! | 16.5/16.6 | AppBar 图标 / 左右内边距 | **24** / **4** | 同 | `[源]` `AppBarTokens.IconSize/LeadingSpace/TrailingSpace` | [`AppBarGeometry`] |
//!
//! **G 编号**(续 `research/15` §25 的 G13;G16~G20 已被 selection/inputs 用掉):
//!
//! - **G22** Tabs 容器高 48 / 带图标 64:`LayoutMetrics` 无此档,收在本文件的 [`TABS_HEIGHT`];
//! - **G23** Tabs 指示条高 3 / 最小项宽 90:同上,收在本文件 [`TABS_INDICATOR`],
//! 而**最小项宽 90(`TabMinWidth`)本轮不施加**——iced 无 min-width,tab 按内容 hug,
//! 指示条因此严格等于「该 tab 的内容宽 + 2×16」,见 [`TabsGeometry::pad`] 的说明;
//! - **G24** drawer 项高 56:令牌 `LM.nav_item_height = 44` 是**列表行**的密集档,drawer 项按 A1 取 56。
//!
//! ## 两个「同一个容器两个宽度」(D33)
//!
//! [`NavPaneGeometry::of`] 是**唯一**一处同时知道 88 与 240 的地方:宽 → 药丸宽、项高、内边距
//! 全部由 `layout` 派生。Q8 的历史事故(药丸 56×32 被 `center_x(Fill)` 撑成 88×32)就出在
//! 「药丸宽度不是从容器宽派生」——这里把派生写成函数,组件层拿不到手写数字。
use iced::border::Radius;
use unlucky_design::component_map::{Component, ComponentMetrics};
use unlucky_design::icons::IconSize;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use crate::theme::Ui;
/// Tabs 容器高(A1 `PrimaryNavigationTabTokens.ContainerHeight`,**G22**)。
pub const TABS_HEIGHT: f32 = 48.0;
/// 带图标的 Tabs 容器高(A1 `IconAndLabelTextContainerHeight`,**G22**;本轮只做文字档)。
pub const TABS_ICON_HEIGHT: f32 = 64.0;
/// Tabs 指示条高(A1 `PrimaryNavigationTabTokens.ActiveIndicatorHeight`,**G23**)。
pub const TABS_INDICATOR: f32 = 3.0;
/// drawer 项高(A1 `NavigationDrawerItemDefaults` 的 `heightIn(min = ActiveIndicatorHeight)`,**G24**)。
pub const DRAWER_ITEM_HEIGHT: f32 = 56.0;
/// 导航容器的两种宽度(D33:**同一个容器**的折叠 / 展开,不是两套组件)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum NavLayout {
/// 折叠:宽 **88**(`LayoutMetrics::nav_rail_width`),项内「药丸在上、标签在下」。
Rail,
/// 展开:宽 **240**(`LayoutMetrics::nav_drawer_width`),项内「图标在左、标签在右」。
Drawer,
}
impl NavLayout {
/// 两种宽度(gallery)与测试用。
pub const ALL: [NavLayout; 2] = [NavLayout::Rail, NavLayout::Drawer];
/// 稳定名字(截图文件名 / 图例用)。
pub const fn name(self) -> &'static str {
match self {
NavLayout::Rail => "rail",
NavLayout::Drawer => "drawer",
}
}
}
/// ListItem 的几何(`research/12b` §4)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ListItemGeometry {
/// 一 / 两 / 三行行高(44 / 60 / 76;A1 是 56 / 72 / 88,本项目登记偏离 −12)。
pub heights: [f32; 3],
/// 内容起始侧内边距(16,A1 `ListItemLeadingSpace`)。
pub pad_start: f32,
/// 内容结束侧内边距(16,A1 `ListItemTrailingSpace`)。
pub pad_end: f32,
/// leading / trailing 图标边长(24,A1 `ListItemLeading/TrailingIconSize`)。
pub icon: f32,
/// 图标 ↔ 文本间距(16,`[估]`)。
pub gap: f32,
}
impl ListItemGeometry {
/// 从令牌求值。
pub fn of(ui: &Ui) -> Self {
let one = ui.metrics().row_height;
let step = ui.space(Space::X4);
Self {
// 每多一行 +16:与官方 56→72→88 的 delta 一致,差的只是基准值(−12)。
heights: [one, one + step, one + step * 2.0],
pad_start: ui.space(Space::X4),
pad_end: ui.space(Space::X4),
icon: IconSize::Toolbar.px(),
gap: ui.space(Space::X4),
}
}
/// `lines` 行的行高(1/2/3 之外按就近夹取,不 panic——调用方传入的是枚举值)。
pub fn height(&self, lines: usize) -> f32 {
self.heights[lines.clamp(1, 3) - 1]
}
/// `lines` 行的文本块高(行高 − 文本块 = 上下各一半的内边距)。
pub fn text_block(&self, ui: &Ui, lines: usize) -> f32 {
let headline = ui.style(typography_headline()).line_height_px;
match lines.clamp(1, 3) {
// 一行:主文本(BodyLarge 24)
1 => headline,
// 两行:主 + 辅助(BodyLarge 24 + BodyMedium 20)
2 => headline + ui.style(typography_supporting()).line_height_px,
// 三行:overline(LabelSmall 16)+ 主 + 辅助
_ => {
ui.style(typography_overline()).line_height_px
+ headline
+ ui.style(typography_supporting()).line_height_px
}
}
}
/// 垂直内边距 = (行高 − 文本块) / 2(一行 44 → 10,两行 60 → 8,三行 76 → 8)。
pub fn pad_y(&self, ui: &Ui, lines: usize) -> f32 {
(self.height(lines) - self.text_block(ui, lines)) / 2.0
}
}
/// ListItem 主文本字级(12b 4.5:`BodyLarge 16/24` `[源] ListTokens.ListItemLabelTextFont`)。
pub const fn typography_headline() -> unlucky_design::typography::TypeRole {
unlucky_design::typography::TypeRole::BodyLarge
}
/// ListItem 辅助文本字级(12b 4.5:`BodyMedium 14/20`)。
pub const fn typography_supporting() -> unlucky_design::typography::TypeRole {
unlucky_design::typography::TypeRole::BodyMedium
}
/// ListItem overline 字级(12b 4.5:`LabelSmall 11/16`)。
pub const fn typography_overline() -> unlucky_design::typography::TypeRole {
unlucky_design::typography::TypeRole::LabelSmall
}
/// Tabs 的几何(`research/12b` §6)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TabsGeometry {
/// 容器高(48,**G22**)。
pub height: f32,
/// 图标边长(24,A1 `Primary/SecondaryNavigationTabTokens.IconSize`)。
pub icon: f32,
/// 标签左右内边距(16,`[估]`:Compose `TabRow` 的 `HorizontalTextPadding`)。
///
/// 这条同时决定「指示条跟随选中项宽度」的**可测形态**:指示条宽 = 标签墨迹宽 + 2×16,
/// 所以两段文案长度不同的 tab,其指示条宽度必然不同(渲染层断言用它)。
pub pad: f32,
/// Primary 指示条高(3,**G23**)。
pub primary_bar: f32,
/// Secondary 分隔线厚度(1 = `LM.divider_thickness`)。
pub secondary_bar: f32,
}
impl TabsGeometry {
/// 从令牌求值。
pub fn of(ui: &Ui) -> Self {
Self {
height: TABS_HEIGHT,
icon: IconSize::Toolbar.px(),
pad: ui.space(Space::X4),
primary_bar: TABS_INDICATOR,
secondary_bar: ui.metrics().divider_thickness,
}
}
/// 指示条 / 分隔线高度:Primary 取 3px,Secondary 取 1px。
pub fn bar(&self, primary: bool) -> f32 {
if primary {
self.primary_bar
} else {
self.secondary_bar
}
}
/// Primary 指示条圆角:`RoundedCornerShape(3dp)` 落在 3px 高的条上,与 `Full`(=1.5 半径)渲染结果相同。
///
/// Secondary 的 1px 分隔线**没有圆角**:A1 是一条直线;早先给 0.5 圆角,相邻 tab 的分段之间
/// 因此留下 1px 亮缝(视觉 r1 P2-2 实测 `25..101 | 103..191 | 193..265`)。
pub fn indicator_radius(&self) -> Radius {
Radius::new(self.primary_bar / 2.0)
}
}
/// 导航容器(rail ⇄ drawer)的几何(`research/15` §21 + D33)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct NavPaneGeometry {
/// 哪种宽度(88 / 240)。
pub layout: NavLayout,
/// 容器宽(88 / 240)。
pub width: f32,
/// 项高(rail 52 = 药丸 32 + 4 + 标签 16;drawer 56,**G24**)。
pub item_height: f32,
/// 选中药丸宽(rail 56 = 88 − 2×16;drawer 216 = 240 − 2×12)。
pub pill_width: f32,
/// 选中药丸高(rail 32;drawer 56 = 项高)。
pub pill_height: f32,
/// 药丸离容器左右各缩多少(rail 16;drawer 12 = `ItemPadding`)。
pub pill_inset: f32,
/// 内容起始侧内边距(drawer 16;rail 由药丸居中决定)。
pub pad_start: f32,
/// 内容结束侧内边距(drawer 24)。
pub pad_end: f32,
/// 图标边长(24)。
pub icon: f32,
/// 图标 ↔ 标签间距(rail 是纵向 4;drawer 是横向 12)。
pub gap: f32,
/// 容器圆角(`CornerLargeEnd`:贴边侧直角、靠内容侧 16)。
pub radius: Radius,
}
impl NavPaneGeometry {
/// 从令牌求值:**88 与 240 只在这里分叉**,其余全部由宽度派生。
pub fn of(ui: &Ui, layout: NavLayout) -> Self {
let metrics = ui.metrics();
let item = drawer_metrics();
// CornerLargeEnd:只有靠内容那一侧(右侧)两角 16,贴边侧直角。
let corner = shape_radius();
let radius = Radius {
top_left: 0.0,
top_right: corner,
bottom_right: corner,
bottom_left: 0.0,
};
match layout {
NavLayout::Rail => {
let width = metrics.nav_rail_width;
let pill_inset = ui.space(Space::X4);
let pill_height = metrics.control_height - ui.space(Space::X2);
Self {
layout,
width,
// 52 = 药丸 32 + 4 + 标签行高 16(与 `shell/nav.rs` 的项高同源)
item_height: pill_height
+ ui.space(Space::X1)
+ ui.style(unlucky_design::typography::TypeRole::LabelMedium)
.line_height_px,
pill_width: width - pill_inset * 2.0,
pill_height,
pill_inset,
pad_start: pill_inset,
pad_end: pill_inset,
icon: IconSize::Toolbar.px(),
gap: ui.space(Space::X1),
radius,
}
}
NavLayout::Drawer => {
let width = metrics.nav_drawer_width;
Self {
layout,
width,
item_height: DRAWER_ITEM_HEIGHT,
pill_width: item.indicator_width(width),
pill_height: DRAWER_ITEM_HEIGHT,
pill_inset: item.inset,
pad_start: item.pad_start,
pad_end: item.pad_end,
icon: item.icon,
gap: item.gap,
radius,
}
}
}
}
}
/// 导航容器的组件级微度量(`ComponentMetrics::NAVIGATION_DRAWER`;缺令牌会直接炸,不静默回退)。
fn drawer_metrics() -> ComponentMetrics {
Component::NavigationDrawer
.metrics()
.expect("NavigationDrawer 的 ComponentMetrics 是 G10 的落点")
}
/// 容器圆角(`Component::NavigationDrawer.spec().shape = ShapeScale::Large` → 16)。
fn shape_radius() -> f32 {
let shape = Component::NavigationDrawer.spec().shape;
debug_assert_eq!(shape, ShapeScale::Large, "ContainerShape=CornerLargeEnd");
shape.dp()
}
/// AppBar(small)的几何(`research/12b` §16)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AppBarGeometry {
/// 页头高(56 = `LM.page_header_height`;A1 是 64,登记偏离 −8)。
pub height: f32,
/// 图标边长(24,A1 `AppBarTokens.IconSize`)。
pub icon: f32,
/// 左右内边距(4,A1 `AppBarTokens.Leading/TrailingSpace`)。
pub edge: f32,
/// 头像边长(32,A1 `AppBarTokens.AvatarSize`)。
pub avatar: f32,
}
impl AppBarGeometry {
/// 从令牌求值。
pub fn of(ui: &Ui) -> Self {
Self {
height: ui.metrics().page_header_height,
icon: IconSize::Toolbar.px(),
edge: ui.space(Space::X1),
avatar: IconSize::Empty.px(),
}
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,131 @@
//! `geometry` 的单元测试(split 拆出来的:单文件 ≤400 行,AGENTS.md §3-1)。
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",
},
)
}
/// 4.1 / 4.2:三种行高 44 / 60 / 76(A1 56/72/88 的登记偏离 −12)。
#[test]
fn list_item_heights_are_44_60_76() {
let geometry = ListItemGeometry::of(&ui());
assert_eq!(geometry.heights, [44.0, 60.0, 76.0]);
for lines in 1..=3 {
assert_eq!(geometry.height(lines), geometry.heights[lines - 1]);
}
// 官方 delta 也是每行 +16
assert_eq!(geometry.heights[1] - geometry.heights[0], 16.0);
assert_eq!(geometry.heights[2] - geometry.heights[1], 16.0);
}
/// 行高必须**装得下**文本块(否则文字会被裁):三行文本块 16+24+20 = 60 ≤ 76。
#[test]
fn list_item_rows_fit_their_text_block() {
let ui = ui();
let geometry = ListItemGeometry::of(&ui);
for lines in 1..=3 {
let block = geometry.text_block(&ui, lines);
let pad = geometry.pad_y(&ui, lines);
assert!(pad >= 0.0, "{lines} 行的垂直内边距是负数(行高装不下文本)");
assert!(
(block + pad * 2.0 - geometry.height(lines)).abs() < f32::EPSILON,
"{lines} 行:文本块 {block} + 2×{pad} ≠ 行高 {}",
geometry.height(lines)
);
}
assert_eq!(geometry.text_block(&ui, 1), 24.0, "BodyLarge 24/24");
assert_eq!(geometry.text_block(&ui, 2), 44.0, "24 + BodyMedium 20");
assert_eq!(geometry.text_block(&ui, 3), 60.0, "LabelSmall 16 + 24 + 20");
assert_eq!(geometry.pad_y(&ui, 1), 10.0, "(44 − 24) / 2");
assert_eq!(geometry.pad_y(&ui, 3), 8.0, "(76 − 60) / 2");
}
/// 21.1 / 21.5 / 21.6:drawer 240、药丸 216(左右各缩 12)、项高 56、内边距 16/24。
#[test]
fn drawer_pane_is_240_wide_with_a_216_pill() {
let geometry = NavPaneGeometry::of(&ui(), NavLayout::Drawer);
assert_eq!(geometry.width, 240.0, "D33 的展开宽度");
assert_eq!(geometry.pill_inset, 12.0, "ItemPadding");
assert_eq!(
geometry.pill_width, 216.0,
"240 − 2×12(A1: 360 − 2×12 = 336)"
);
assert_eq!(geometry.item_height, 56.0, "G24:A1 ActiveIndicatorHeight");
assert_eq!(geometry.pill_height, 56.0, "指示器高 = 项高");
assert_eq!((geometry.pad_start, geometry.pad_end), (16.0, 24.0));
assert_eq!(geometry.gap, 12.0, "图标 ↔ label");
assert_eq!(geometry.icon, 24.0);
// 药丸 + 两侧内缩 = 容器宽(这条是「药丸从容器宽派生」的等式,Q8 的回归点)
assert!((geometry.pill_width + geometry.pill_inset * 2.0 - geometry.width).abs() < 0.01);
}
/// D33:rail 与 drawer 是**同一个容器**的两个宽度(88 ⇄ 240),不是两套组件。
#[test]
fn rail_and_drawer_share_one_container() {
let ui = ui();
let rail = NavPaneGeometry::of(&ui, NavLayout::Rail);
let drawer = NavPaneGeometry::of(&ui, NavLayout::Drawer);
assert_eq!(rail.width, 88.0);
assert_eq!(drawer.width, 240.0);
assert!(rail.width < drawer.width);
// 同一个「药丸 = 容器宽 − 2×内缩」公式
for pane in [rail, drawer] {
assert!(
(pane.pill_width + pane.pill_inset * 2.0 - pane.width).abs() < 0.01,
"{:?} 的药丸宽不是从容器宽派生的",
pane.layout
);
// 圆角语言不变:都是 CornerLargeEnd(外侧直角、内侧 16)
assert_eq!((pane.radius.top_left, pane.radius.bottom_left), (0.0, 0.0));
assert_eq!(
(pane.radius.top_right, pane.radius.bottom_right),
(16.0, 16.0)
);
assert_eq!(pane.icon, 24.0);
}
// rail 的药丸是 56×32(M68 已修正的结论),不是 drawer 的 216×56
assert_eq!((rail.pill_width, rail.pill_height), (56.0, 32.0));
assert_eq!((drawer.pill_width, drawer.pill_height), (216.0, 56.0));
}
/// 6.1 / 6.2 / 6.6:Tabs 48 高、指示条 3、Secondary 线 1。
#[test]
fn tabs_geometry_matches_the_reference() {
let ui = ui();
let geometry = TabsGeometry::of(&ui);
assert_eq!(geometry.height, 48.0, "G22");
assert_eq!(geometry.icon, 24.0);
assert_eq!(geometry.pad, 16.0);
assert_eq!(geometry.bar(true), 3.0, "ActiveIndicatorHeight");
assert_eq!(geometry.bar(false), 1.0, "DividerHeight");
// RoundedCornerShape(3dp) 落在 3px 高的条上 = 半径 1.5(与 Full 同渲染)
assert_eq!(geometry.indicator_radius().top_left, 1.5);
}
/// 16.1 / 16.5:AppBar 56(LM)、图标 24、左右 4、头像 32。
#[test]
fn app_bar_geometry_matches_the_skeleton() {
let geometry = AppBarGeometry::of(&ui());
assert_eq!(
geometry.height, 56.0,
"LM.page_header_height(A1 64,登记偏离)"
);
assert_eq!((geometry.icon, geometry.edge), (24.0, 4.0));
assert_eq!(geometry.avatar, 32.0);
}
@@ -0,0 +1,306 @@
//! ListItem(`research/12b` §4):一行 / 两行 / 三行,leading / trailing 槽位,选中态,五态状态层。
//!
//! ## 参考值(`[源]` = A1,本轮 `javap` 实读本机 AAR 的 `ListTokens` / `ListItemColors`)
//!
//! | # | 检查项 | 参考值 | 落点 |
//! |---|---|---|---|
//! | 4.1 | 一行高 | A1 **56** → 本项目 **44**(`LM.row_height`,登记偏离 −12) | [`ListItemGeometry::heights`] |
//! | 4.2 | 两 / 三行高 | A1 **72 / 88** → 本项目 **60 / 76**(每多一行 +16,与官方同 delta) | 同上 |
//! | 4.3 | 左右内边距 | **16 / 16** | `pad_start` / `pad_end` |
//! | 4.4 | leading | 图标 **24** `OnSurfaceVariant` | `IconSize::Toolbar` |
//! | 4.5 | 字级 | 主 `BodyLarge 16/24`、辅助 `BodyMedium 14/20`、overline `LabelSmall 11/16` | `TypeRole` |
//! | 4.7 | 状态 | hover/focus/pressed **文本与图标色不变**,只叠状态层(8/10/10%) | [`interaction`] |
//! | 4.8 | 选中 trailing | 图标 → **`Primary`** | `Role::Primary` |
//!
//! ## 禁用是**修饰器**,不是构造函数(照 `selection/chip.rs` 复审 P2-1 的结论)
//!
//! [`ListItem::disabled`] **只清消息**:图标、选中态、状态层全部保留。早期 chip 的
//! `Chip::disabled(label)` 构造函数把图标和选中态一起丢了,图上是「另一行」,不是「这一行禁用」。
//!
//! ## 禁用内容角色是 **`OnSurface`**,不是默认的 `OnSurfaceVariant`
//!
//! A1 实读(`javap -p -c androidx.compose.material3.tokens.ListTokens`):
//!
//! ```text
//! ListItemDisabledLabelTextColor = OnSurface (+ ListItemDisabledLabelTextOpacity = 0.38f)
//! ListItemDisabledLeadingIconColor = OnSurface (+ 0.38f)
//! ListItemDisabledTrailingIconColor = OnSurface (+ 0.38f)
//! ```
//!
//! 所以这里必须走 `interaction::disabled_content_over_role(ui, OnSurface, false)`;
//! 用默认的 `disabled_colors()`(写死 `OnSurfaceVariant`)会偏浅一档。
//!
//! ## 选中态:本项目口径(**偏离登记 D-M79-1**)
//!
//! A1 的 `ListItemColors` **没有** selected 容器色(实读字段:containerColor / headline / leadingIcon /
//! overline / supporting / trailingIcon + 三个 disabled),选中只把 trailing 图标换成 `Primary`。
//! mission 要求「选中态(SecondaryContainer)」,且本项目的菜单项(M77)与 drawer 项都用
//! `SecondaryContainer` 表示「当前项」——三处保持同一语言,故 ListItem 也填 `SecondaryContainer`
//! + `OnSecondaryContainer`。**这是实质偏离**:A1 的 ListItem 选中行不换底色。
use iced::widget::text::{LineHeight, Wrapping};
use iced::widget::{button, container, text};
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::{self, Interaction};
use super::super::layout;
use super::geometry::{
typography_headline, typography_overline, typography_supporting, ListItemGeometry,
};
/// 一个列表项(一行 / 两行 / 三行由槽位决定)。
#[derive(Debug, Clone)]
pub struct ListItem<'a, M> {
overline: Option<&'a str>,
headline: &'a str,
supporting: Option<&'a str>,
leading: Option<IconKind>,
trailing: Option<IconKind>,
on_press: Option<M>,
selected: bool,
state: Option<Interaction>,
}
impl<'a, M> ListItem<'a, M> {
/// 一行项(主文本 + 可点消息)。
pub fn new(headline: &'a str, on_press: M) -> Self {
Self {
overline: None,
headline,
supporting: None,
leading: None,
trailing: None,
on_press: Some(on_press),
selected: false,
state: None,
}
}
/// 加一行辅助文本 → **两行**项(4.5:辅助 `BodyMedium`)。
pub fn supporting(mut self, supporting: &'a str) -> Self {
self.supporting = Some(supporting);
self
}
/// 加上方 overline → **三行**项(4.5:overline `LabelSmall`,`OnSurfaceVariant`)。
pub fn overline(mut self, overline: &'a str) -> Self {
self.overline = Some(overline);
self
}
/// 前置槽位(图标 24,`OnSurfaceVariant`)。
pub fn leading(mut self, kind: IconKind) -> Self {
self.leading = Some(kind);
self
}
/// 后置槽位(未选中 `OnSurface`,选中 `Primary`,见 4.8)。
pub fn trailing(mut self, kind: IconKind) -> Self {
self.trailing = Some(kind);
self
}
/// 选中态(`SecondaryContainer` + `OnSecondaryContainer`;偏离 D-M79-1 见模块文档)。
pub fn selected(mut self) -> Self {
self.selected = true;
self
}
/// 强制视觉态(gallery 状态矩阵取证;不动状态机)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
/// 转禁用:**只清消息**,槽位与选中态一律保留。
pub fn disabled(mut self) -> Self {
self.on_press = None;
self
}
/// 行数(1 + 有 overline + 有 supporting)。
pub fn lines(&self) -> usize {
usize::from(self.overline.is_some()) + 1 + usize::from(self.supporting.is_some())
}
}
/// 一项的颜色(按态 + 选中求值;禁用按 A1 的 `OnSurface`@38%)。
#[derive(Debug, Clone, Copy, PartialEq)]
struct ItemPalette {
fill: Option<Color>,
headline: Color,
supporting: Color,
leading: Color,
trailing: Color,
}
fn palette(ui: &Ui, state: Interaction, selected: bool) -> ItemPalette {
if state.is_disabled() {
// A1:ListItemDisabled*Color = OnSurface × 0.38(无容器时叠在 surface 上)
let ink = interaction::disabled_content_over_role(ui, Role::OnSurface, false);
return ItemPalette {
fill: None,
headline: ink,
supporting: ink,
leading: ink,
trailing: ink,
};
}
if selected {
let fill = interaction::overlay_color(
ui,
state,
Role::OnSecondaryContainer,
Role::SecondaryContainer,
)
.unwrap_or_else(|| ui.color(Role::SecondaryContainer));
return ItemPalette {
fill: Some(fill),
headline: ui.color(Role::OnSecondaryContainer),
supporting: ui.color(Role::OnSecondaryContainer),
leading: ui.color(Role::OnSecondaryContainer),
// 4.8:选中时 trailing 图标是 `Primary`(A1 ListItemSelectedTrailingIconColor)
trailing: ui.color(Role::Primary),
};
}
ItemPalette {
// 状态层叠在**行自己的底**(Surface)上,得到不透明结果(D27)
fill: interaction::overlay_color(ui, state, Role::OnSurface, Role::Surface),
headline: ui.color(Role::OnSurface),
supporting: ui.color(Role::OnSurfaceVariant),
leading: ui.color(Role::OnSurfaceVariant),
trailing: ui.color(Role::OnSurface),
}
}
/// 一行文本(显式行高:三行块的 16+24+20 必须能精确等于 60,靠的就是它)。
fn line<'a, M: 'a>(
ui: &'a Ui,
value: &'a str,
role: unlucky_design::typography::TypeRole,
color: Color,
) -> Element<'a, M> {
let style = ui.style(role);
text(value)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
// **单行**:行高是固定值(44/60/76),`Text` 默认 `Wrapping::Word` 会把长实例名
// 折成两行、把三行块顶穿。官方要的是单行 ellipsis,iced 没有 ellipsis(D19 先例)
// → 单行 + 超长由外层 `clip(true)` 直接切(偏离 D-M79-5)。
.wrapping(Wrapping::None)
.font(ui.font_of(style))
.color(color)
.into()
}
/// 列表项(`research/12b` §4)。
pub fn list_item<'a, M: Clone + 'a>(ui: &'a Ui, item: ListItem<'a, M>) -> Element<'a, M> {
let geometry = ListItemGeometry::of(ui);
let lines = item.lines();
let height = geometry.height(lines);
// 禁用 = 「没有消息」;`state` 是取证强制值,优先级更高(矩阵里画 hover 的禁用项也成立)
let forced = item.state.or(if item.on_press.is_none() {
Some(Interaction::Disabled)
} else {
None
});
let colors = palette(ui, forced.unwrap_or(Interaction::Enabled), item.selected);
let mut body = iced::widget::Column::new().spacing(0);
if let Some(overline) = item.overline {
body = body.push(line::<M>(
ui,
overline,
typography_overline(),
colors.supporting,
));
}
body = body.push(line::<M>(
ui,
item.headline,
typography_headline(),
colors.headline,
));
if let Some(supporting) = item.supporting {
body = body.push(line::<M>(
ui,
supporting,
typography_supporting(),
colors.supporting,
));
}
let mut content = iced::widget::Row::new()
.align_y(Alignment::Center)
.spacing(geometry.gap);
if let Some(kind) = item.leading {
// Q9:图标必须经固定尺寸容器(否则会被按钮的 Fill 约束撑满)
content = content.push(layout::fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(kind, colors.leading),
));
}
// 文本块吃掉剩余宽度并**裁切**:单行超长时切在行内边距上,不会压到 trailing
content = content.push(container(body).width(Length::Fill).clip(true));
if let Some(kind) = item.trailing {
content = content.push(layout::fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(kind, colors.trailing),
));
}
let pad_y = geometry.pad_y(ui, lines);
// 行必须撑满 item 宽度(Fill 空间要有宽度可撑),垂直靠 align_y 居中(`button` 默认贴左上)
let body = container(content)
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::Center);
let selected = item.selected;
let style = move |_theme: &iced::Theme, status: button::Status| {
let state = forced.unwrap_or_else(|| Interaction::from_status(status));
let colors = palette(ui, state, selected);
button::Style {
background: colors.fill.map(Background::Color),
text_color: colors.headline,
// 4.x:ListItem 的形状是 `CornerNone`(直角),没有圆角档
border: Border {
radius: ui.radius_dp(ShapeScale::None),
..Border::default()
},
shadow: Shadow::default(),
snap: true,
}
};
let button = button(body)
.width(Length::Fill)
.height(Length::Fixed(height))
.padding(Padding {
top: pad_y,
right: geometry.pad_end,
bottom: pad_y,
left: geometry.pad_start,
})
.style(style);
let element: Element<'a, M> = match item.on_press {
Some(message) => button.on_press(message).into(),
None => button.into(),
};
element
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,127 @@
//! `list_item` 的单元测试(split 拆出来的:单文件 ≤400 行,AGENTS.md §3-1)。
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",
},
)
}
/// 槽位决定行数:1 / 2 / 3(三行是 overline + headline + supporting)。
#[test]
fn line_count_follows_the_slots() {
let one = ListItem::<()>::new("Instances", ());
assert_eq!(one.lines(), 1);
let two = ListItem::<()>::new("Instances", ()).supporting("3 versions");
assert_eq!(two.lines(), 2);
let three = ListItem::<()>::new("Instances", ())
.overline("RECENT")
.supporting("3 versions");
assert_eq!(three.lines(), 3);
// overline 单独存在也是两行(overline + headline)
let overline_only = ListItem::<()>::new("Instances", ()).overline("RECENT");
assert_eq!(overline_only.lines(), 2);
}
/// **禁用是修饰器**:只清消息,槽位与选中态全部保留(复审 P2-1 那条)。
#[test]
fn disabled_only_clears_the_message() {
let item = ListItem::new("Instances", ())
.leading(unlucky_design::icons::IconKind::Instances)
.trailing(unlucky_design::icons::IconKind::Settings)
.supporting("3 versions")
.selected()
.disabled();
assert!(item.on_press.is_none(), "禁用必须清消息");
assert!(item.leading.is_some(), "禁用不许删前置图标");
assert!(item.trailing.is_some(), "禁用不许删后置图标");
assert!(item.selected, "禁用不许删选中态");
assert!(item.supporting.is_some(), "禁用不许删槽位");
assert_eq!(item.lines(), 2);
}
/// 禁用内容角色是 **`OnSurface`**@38%(A1 ListTokens),不是默认的 `OnSurfaceVariant`。
#[test]
fn disabled_content_uses_on_surface() {
let ui = ui();
let palette = palette(&ui, Interaction::Disabled, false);
let expected = interaction::disabled_content_over_role(&ui, Role::OnSurface, false);
assert_eq!(palette.headline, expected);
assert_eq!(palette.leading, expected);
assert_eq!(palette.trailing, expected);
assert_ne!(
palette.headline,
interaction::disabled_content_over_role(&ui, Role::OnSurfaceVariant, false),
"走默认的 OnSurfaceVariant 就会偏浅一档(这条钉住 A1 的角色)"
);
assert!(palette.fill.is_none(), "禁用不叠状态层容器");
}
/// 选中态:`SecondaryContainer` + `OnSecondaryContainer`,trailing 是 `Primary`(4.8)。
#[test]
fn selected_row_is_secondary_container() {
let ui = ui();
let palette = palette(&ui, Interaction::Enabled, true);
assert_eq!(palette.fill, Some(ui.color(Role::SecondaryContainer)));
assert_eq!(palette.headline, ui.color(Role::OnSecondaryContainer));
assert_eq!(palette.trailing, ui.color(Role::Primary), "4.8");
}
/// 4.7:hover / pressed 只叠状态层,**内容色不变**。
#[test]
fn interaction_states_keep_the_content_colors() {
let ui = ui();
let resting = palette(&ui, Interaction::Enabled, false);
for state in [
Interaction::Hovered,
Interaction::Focused,
Interaction::Pressed,
] {
let active = palette(&ui, state, false);
assert_eq!(active.headline, resting.headline, "{state:?} 不许改文本色");
assert_eq!(active.leading, resting.leading, "{state:?} 不许改图标色");
assert!(active.fill.is_some(), "{state:?} 必须叠状态层");
}
assert_eq!(resting.fill, None, "静息态不叠状态层");
}
/// 三档行高与 `LM.row_height` 的关系**钉死**(有人按 A1 改回 56 时这里会红)。
#[test]
fn heights_are_pinned_to_the_row_height_token() {
let ui = ui();
let geometry = ListItemGeometry::of(&ui);
assert_eq!(geometry.heights[0], ui.metrics().row_height);
assert_eq!(geometry.heights, [44.0, 60.0, 76.0]);
assert_ne!(
geometry.heights[0], 56.0,
"56 是 A1 的值;本项目按 12b §4.1 取 44(改回去会破坏页面排版)"
);
}
/// 元素构造不 panic(`Element` 树的形状也一并过一遍)。
#[test]
fn view_builds_a_tree() {
let ui = ui();
let _: Element<'_, ()> = list_item(
&ui,
ListItem::new("Instances", ())
.overline("RECENT")
.supporting("3 versions")
.leading(unlucky_design::icons::IconKind::Instances)
.trailing(unlucky_design::icons::IconKind::Settings)
.selected(),
);
let _: Element<'_, ()> = list_item(&ui, ListItem::new("Logs", ()).disabled());
}
@@ -0,0 +1,110 @@
//! 导航家族的长文案取证块(D-M79-5):**单行 + 裁切**,页面真接长实例名时不许折行。
//!
//! 为什么不放在 `gallery/navigation.rs`:那个文件已经贴到 400 行红线(AGENTS.md §3-1),
//! 而它属于 harness 单文件、scope 只授权一个路径。按 `selection::matrix` 的先例,把**取证块**
//! 收进组件目录、对消息类型泛型(`pressed: impl Fn() -> M`),gallery 只做一次 `.map`。
use iced::widget::{column, container, text};
use iced::{Background, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::{
app_bar, list_item, navigation, tabs, AppBar, AppBarAction, ListItem, NavGroup, NavItem,
Navigation, Tab, Tabs,
};
/// 一行小字图例。
fn note<'a, M: 'a>(ui: &'a Ui, value: &'a str) -> Element<'a, M> {
let style = ui.style(TypeRole::BodySmall);
text(value)
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::OnSurfaceVariant))
.into()
}
/// 取景板(`SurfaceContainerLow`):`Surface` 的页头与页面底色同色,要垫一层才量得到边界。
fn plate<'a, M: 'a>(ui: &'a Ui, pad: f32, content: impl Into<Element<'a, M>>) -> Element<'a, M> {
container(content)
.padding(pad)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::SurfaceContainerLow))),
..container::Style::default()
})
.into()
}
fn icon(name: &str) -> IconKind {
match name {
"download" => IconKind::Download,
"logs" => IconKind::Logs,
"settings" => IconKind::Settings,
_ => IconKind::Instances,
}
}
/// 长文案块(D-M79-5 的取证):单行 + 裁切——不折行、不顶穿 56 的页头、不压 trailing。
/// 矩阵图全用短文案看不出这个坑,真实实例名(`All the Mods 10 - ATM10 …`)一接就中招。
pub fn long_part<'a, M: Clone + 'a>(ui: &'a Ui, pressed: impl Fn() -> M) -> Element<'a, M> {
const LONG: &str = "All the Mods 10 - ATM10 - 1.21.1 - NeoForge";
let bar = app_bar(
ui,
AppBar::new(LONG)
.subtitle(LONG)
.leading(AppBarAction::inert(icon("instances")))
.trailing(AppBarAction::new(icon("settings"), pressed()))
.trailing(AppBarAction::new(icon("logs"), pressed())),
);
let row = |lines: usize| {
let mut item = ListItem::new(LONG, pressed())
.leading(icon("instances"))
.trailing(icon("settings"));
if lines >= 2 {
item = item.supporting(LONG);
}
if lines >= 3 {
item = item.overline(LONG);
}
container(list_item(ui, item.selected()))
.width(Length::Fixed(ui.metrics().nav_drawer_width))
};
let pane = navigation(
ui,
Navigation::drawer().group(
NavGroup::titled(LONG)
.item(NavItem::new(LONG, icon("instances"), pressed()).selected())
.item(NavItem::new("Settings", icon("settings"), pressed())),
),
);
// Tabs:长标签不折行,tab 自己变宽(横向滚动兜住溢出)
let tabs_row = tabs(
ui,
Tabs::primary()
.tab(Tab::new(LONG, pressed()).selected())
.tab(Tab::new("Settings", pressed())),
);
column![
note(
ui,
"长文案(D-M79-5):单行 + 裁切 —— 不折行、页头仍 56、不压 trailing"
),
plate(ui, ui.space(Space::X2), bar),
column![row(1), row(2), row(3)].spacing(ui.space(Space::X4)),
tabs_row,
container(container(pane).height(Length::Fixed(ui.space(Space::X8) * 6.0)))
.width(Length::Fixed(ui.metrics().nav_drawer_width)),
]
.spacing(ui.space(Space::X2))
.into()
}
@@ -0,0 +1,80 @@
//! **导航类家族**(`research/12b` §4/§6/§16 + `research/15` §21):四件组件,一个容器。
//!
//! | 组件 | 入口 | 参考章节 | 关键几何 |
//! |---|---|---|---|
//! | ListItem | [`list_item::list_item`] | 12b §4 | 行高 44 / 60 / 76(A1 56/72/88,登记偏离) |
//! | Tabs | [`tabs::tabs`] | 12b §6 | 容器 48、Primary 指示条 3、Secondary 线 1 |
//! | NavigationDrawer | [`drawer::navigation`] | 15 §21 + D33 | **同一个容器**:rail 88 ⇄ drawer 240 |
//! | AppBar small | [`app_bar::app_bar`] | 12b §16 | 页头 56、标题 TitleLarge、滚动换色 + L2 |
//!
//! ## 三条纪律(与另外四个家族同源)
//!
//! 1. **对消息类型泛型**,不 import `crate::app::Message`(契约 §2);`Ui` 一律 `&'a Ui` 传入。
//! 2. **状态层只走** [`crate::app::components::interaction`]:α 与禁用色一处求值;
//! 禁用是**修饰器**(`.disabled()` 只清消息,见 `selection/chip.rs` 复审 P2-1)。
//! 3. **精确尺寸只走** [`crate::app::components::layout`]:图标一律 `layout::fixed`(Q9),
//! 居中交给父级 `align_x`(Q8:`center_x(Fill)` 会把 56 宽药丸撑成 88)。
//!
//! ## 禁用内容角色**按变体**(不许抄默认值)
//!
//! | 组件 | 禁用内容角色 | 出处 |
//! |---|---|---|
//! | ListItem | **`OnSurface`**@38%(不是 `OnSurfaceVariant`) | `[源]` `ListTokens.ListItemDisabledLabel/LeadingIcon/TrailingIconColor = OnSurface` + `…Opacity = 0.38f` |
//! | Drawer / Rail 项 | `OnSurface`@38% | `[估]`(A1 无 disabled token,与 ListItem 同表面语境) |
//! | Tabs | `OnSurface`@38% | `[估]`(同上;A1 的 Tabs 两套 token 只有 Active/Inactive) |
//! | AppBar | — (本轮页头不带禁用动作) | — |
//!
//! ## Q21:要给居中就**两轴都显式给**(第三轮 review 的规则化)
//!
//! `Container` 的 `horizontal_alignment` 默认 `Left`、`vertical_alignment` 默认 `Top`
//! (`iced_widget-0.14.2/src/container.rs`)。只给 `align_y` 会让内容贴左缘——AppBar 的 40×40
//! 动作按钮里 24px 图标曾因此左内缩 4 / 右内缩 20(视觉 r1 实测前导墨迹 `38..58`,应 `44..68`),
//! 而且 hover 的圆形状态层会露在图标一侧。
//!
//! 本目录的复查结论(逐处):
//!
//! | 位置 | 需要 | 写法 |
//! |---|---|---|
//! | `app_bar::action` 命中区里的图标 | 两轴居中 | `align_x` + `align_y` |
//! | `app_bar` 页头内容行 | 垂直居中(水平由 `Fill` 决定) | `.align_y(Center)` |
//! | `tabs::tab_cell` 的内容 | 两轴居中 | `align_x` + `align_y` |
//! | `drawer/item` 的药丸 | 两轴居中 | `align_x` + `align_y` |
//! | `list_item` 的文本块 / `drawer` 的分组标题 | **左上起排**(`Start` 是对的,不需要居中) | 不设对齐,靠 `padding` 定起点 |
//!
//! 回归钉子:`navigation::probe` 的 appbar 断言量「左内缩 == 右内缩 ±2」——`align_x` 再漏一次就红。
//!
//! ## 单行约束(偏离 D-M79-5)
//!
//! 四件的文本一律 `Wrapping::None` + 外层 `clip(true)`:容器高度都是固定值(44/60/76、48/64、56),
//! `Text` 默认 `Wrapping::Word` 会把长实例名折成 2~3 行,顶穿页头、压住 trailing。官方要的是
//! **单行 ellipsis**,iced 没有 ellipsis(`inputs/menu.rs` 的 D19 先例)→ 我们单行 + 超长直接切。
//! 取证:`ULL_NAV_PART=long` 出图 + `navigation::probe::long` 量「行高不变 / 页头仍 56 / 标题不压动作」。
//!
//! ## 令牌缺口(G 编号,续 `research/15` §25 的 G13;G16~G20 已被其他家族占用)
//!
//! - **G22** Tabs 容器高 48(带图标 64)——`LayoutMetrics` 无此档;
//! - **G23** Tabs 指示条高 3 / `TabMinWidth` 90(后者 iced 无法表达,登记为限制);
//! - **G24** drawer 项高 56——与 `LM.nav_item_height`(44) 的差异按 A1 取 56,见 [`geometry`];
//! - **G25** ListItem 两/三行高(60 / 76)——`G4` 要求的 `row_height_1/2/3` 仍缺,本文件按
//! `LM.row_height` + 每行 16 派生(A1 delta 相同)。
//!
//! ## 视觉证据(D30)
//!
//! `gallery/navigation.rs` 把四个组件摆成状态矩阵;`ULL_GALLERY_SECTION=navigation` 只渲染本段,
//! `ULL_NAV_PART=<list|tabs|drawer|appbar|all>` 再切成单块出图(1180×760 一屏装不下四块矩阵)。
//! 渲染层断言在 [`probe`](读 `ULL_NAV_DIR` 下的真截图量像素),纯函数断言在各组件文件的 `tests`。
pub mod app_bar;
pub mod drawer;
pub mod geometry;
pub mod list_item;
pub mod matrix;
#[cfg(test)]
mod probe;
pub mod tabs;
pub use app_bar::{app_bar, AppBar, AppBarAction};
pub use drawer::{navigation, NavGroup, NavItem, Navigation};
pub use geometry::NavLayout;
pub use list_item::{list_item, ListItem};
pub use tabs::{tabs, Tab, Tabs, TabsVariant};
@@ -0,0 +1,393 @@
//! 渲染层几何断言:**对着真截图量像素**(M68 的教训——纯函数返回对,图可能是错的)。
//!
//! ## 为什么自带一份最小读图器
//!
//! `gallery::scan` 是 `pub(super)`(私有于 `gallery`),而 `gallery/mod.rs` 不在本 mission 的 scope 里,
//! 所以这里自带 ~60 行读图器(读 PNG / 取点 / 连通块 / 横带墨迹范围),判据全在本文件。
//! 与 `inputs/probe.rs` 同一个处置(已作为 finding 报给 tower:建议把 `gallery::scan` 提升为 `pub(crate)`)。
//!
//! ## 怎么跑(worktree 根目录)
//!
//! ```text
//! # ① 出 4 张图(每块一条命令,块选择器见 gallery/navigation.rs 的模块文档)
//! ULL_GALLERY_SECTION=navigation ULL_NAV_PART=list ./target/debug/unlucky-launcher-iced.exe \
//! --software-renderer --screen=gallery --screenshot target/m79/list-light.png --screenshot-theme=light
//! # ② 量图
//! ULL_NAV_DIR=target/m79 cargo test -p unlucky-app-iced navigation::probe
//! ```
//!
//! 没给 `ULL_NAV_DIR` 就**大声跳过**(不静默通过),与 `gallery::probe` 的口径一致。
//!
//! ## 量什么(每条都对着一个「已经画错过」的形态)
//!
//! | 断言 | 抓的错误 |
//! |---|---|
//! | ListItem 三行高 **44 / 60 / 76** | 抄 A1 的 56/72/88、或用内容撑高(行高被文本吃掉) |
//! | ListItem 禁用内容 = `OnSurface`@38% | 走 `disabled_colors()` 的默认 `OnSurfaceVariant` |
//! | Primary 指示条宽 = 选中项文本宽 + 2×16 | 条写成 `Fill`(= 整行宽,Q8 的另一面)或写死 |
//! | drawer **240** / rail **88**,药丸内缩 **12** / **16** | 药丸不贴边(Q8)、或两套组件各写死一个宽度 |
//! | AppBar **56** 高(静息 + 滚动) | 页头被内容撑高、或按 A1 改回 64 |
use std::path::{Path, PathBuf};
use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::spacing::Space;
use crate::app::components::interaction;
mod divider;
mod long;
mod scan;
use scan::{rgb, ui as ui_of, BBox, Rgb, Shot};
/// 有这张图就读,没有就 `None`(深色档的可选覆盖用)。
fn shot_opt(dir: &Path, part: &str, flavor: SchemeFlavor) -> Option<Shot> {
let slug = match flavor {
SchemeFlavor::Light => "light",
SchemeFlavor::Dark => "dark",
};
let path = dir.join(format!("{part}-{slug}.png"));
path.exists().then(|| Shot::load(&path))
}
/// `ULL_NAV_DIR` → 截图目录(相对路径按**工作区根**解释:`cargo test` 的 cwd 是 crate 目录)。
fn shot_dir() -> Option<PathBuf> {
let raw = std::env::var_os("ULL_NAV_DIR")?;
let path = PathBuf::from(raw);
if path.is_absolute() || path.exists() {
return Some(path);
}
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
Some(root.join(path))
}
macro_rules! require_dir {
() => {
match shot_dir() {
Some(dir) => dir,
None => {
eprintln!(
"[navigation::probe] SKIPPED:未设置 ULL_NAV_DIR。这是**渲染层**断言,\
需要先按本文件模块文档出 4 张图(target/m79/*.png)。"
);
return;
}
}
};
}
fn shot(dir: &Path, part: &str, flavor: SchemeFlavor) -> Shot {
let slug = match flavor {
SchemeFlavor::Light => "light",
SchemeFlavor::Dark => "dark",
};
Shot::load(&dir.join(format!("{part}-{slug}.png")))
}
/// ListItem:三行高 **44 / 60 / 76**(选中行的 `SecondaryContainer` 底就是行高)。
#[test]
fn list_item_rows_are_44_60_76() {
let dir = require_dir!();
let ui = ui_of(SchemeFlavor::Light);
let shot = shot(&dir, "list", SchemeFlavor::Light);
// 矩阵格宽 240(`nav_drawer_width`):选中列的三块填充就是三条行
let selected = rgb(ui.color(Role::SecondaryContainer));
let mut rows: Vec<BBox> = shot
.blocks(selected)
.into_iter()
.filter(|b| (238..=242).contains(&b.width()) && b.height() > 30)
.collect();
rows.sort_by_key(|b| b.top);
assert_eq!(
rows.len(),
3,
"选中列应有 3 条(一 / 两 / 三行),实际 {} —— 图里格子宽度或选中底丢了",
rows.len()
);
let heights: Vec<usize> = rows.iter().map(BBox::height).collect();
// 边缘那一行是抗锯齿(半覆盖)→ 实测常常少 1px;判 43..=44 / 59..=60 / 75..=76,
// 而 A1 的 56/72/88 仍然落区间外(这条照样能被打红)。
for (measured, expected) in heights.iter().zip([44usize, 60, 76]) {
assert!(
(expected - 1..=expected).contains(measured),
"12b §4.1/4.2 的项目口径是 44/60/76(A1 是 56/72/88);实测 {heights:?}"
);
}
// 禁用内容角色 = `OnSurface`@38%(A1 ListTokens),不是默认的 `OnSurfaceVariant`@38%。
// 判据:禁用格里**最深**的像素就是核心笔画,它必须精确等于 A1 的那个混合色。
let cell = rows[0].width() + 8; // 格宽 240 + 列间距 8
let disabled = BBox {
left: rows[0].left + cell * 2,
right: (rows[0].left + cell * 2 + rows[0].width() - 1).min(shot.width - 1),
top: rows[0].top,
bottom: rows[2].bottom,
};
let darkest = shot.darkest(&disabled).expect("禁用格里应有墨迹");
let right = rgb(interaction::disabled_content_over_role(
&ui,
Role::OnSurface,
false,
));
let wrong = rgb(interaction::disabled_content_over_role(
&ui,
Role::OnSurfaceVariant,
false,
));
let near = |a: Rgb, b: Rgb| {
a.iter()
.zip(b)
.all(|(x, y)| x.abs_diff(y) <= scan::TOLERANCE)
};
assert!(
near(darkest, right),
"禁用内容最深像素是 {darkest:?},A1 的 OnSurface@38% 是 {right:?}(用 OnSurfaceVariant 会得到 {wrong:?})"
);
assert!(
!near(darkest, wrong),
"禁用内容用了 OnSurfaceVariant@38%({wrong:?})而不是 A1 的 OnSurface@38%({right:?})"
);
eprintln!(
"[navigation::probe] list: 行高 {heights:?},禁用最深像素 {darkest:?}(A1 = {right:?})"
);
}
/// Tabs:Primary 指示条宽 = **选中项**文本宽 + 2×16(短/长文案各选中一次 → 两条宽度必须不同)。
#[test]
fn tabs_indicator_tracks_the_selected_tab() {
let dir = require_dir!();
let ui = ui_of(SchemeFlavor::Light);
let shot = shot(&dir, "tabs", SchemeFlavor::Light);
let primary = rgb(ui.color(Role::Primary));
let surface = rgb(ui.color(Role::Surface));
// 指示条:3px 高、宽 ≥ 40 的 `Primary` 块(选中项的标签是同色,但笔画块远小于 40 宽)
let mut bars: Vec<BBox> = shot
.blocks(primary)
.into_iter()
.filter(|b| b.height() <= 4 && b.width() >= 40)
.collect();
bars.sort_by_key(|b| b.top);
assert!(
bars.len() >= 2,
"至少应有 2 条 Primary 指示条(短文案选中一行 + 长文案选中一行),实际 {}",
bars.len()
);
// 前两条 = 两行 Primary(后面还有「五态」那一行的选中条,不参与「跟随宽度」的判定)
bars.truncate(2);
let mut measured = Vec::new();
for bar in &bars {
// 该行的标签墨迹:指示条上方 2..32 像素、**指示条自身跨度内**与底色不同的像素范围
let span = shot
.band_ink(
surface,
bar.left,
bar.right,
bar.top.saturating_sub(32),
bar.top.saturating_sub(2),
)
.expect("指示条上方应能找到选中标签的墨迹");
let ink = span.1 - span.0 + 1;
let expected = ink + 32; // 2 × pad(16)
assert!(
bar.width() >= ink,
"指示条 {} 比标签墨迹 {ink} 还窄",
bar.width()
);
assert!(
bar.width() <= expected + 12,
"指示条宽 {} 远大于「标签墨迹 {ink} + 2×16 = {expected}」——条被写成了整行宽(Q8)",
bar.width()
);
assert!(
bar.width() + 12 >= expected,
"指示条宽 {} 小于「标签墨迹 {ink} + 2×16 = {expected}」——条没有覆盖内边距",
bar.width()
);
measured.push((bar.width(), ink));
}
// 6.6:Secondary 的分隔线必须是**一条连续直线**(逐 tab 分段画会在相邻段之间留缝)。
// 判据与锚定规则全在 `probe/divider.rs`(含「无论红绿都打印锚定行」)。
divider::check(&shot, &ui, "light");
if let Some(dark) = shot_opt(&dir, "tabs", SchemeFlavor::Dark) {
divider::check(&dark, &ui_of(SchemeFlavor::Dark), "dark");
}
let (short_bar, long_bar) = (measured[0].0, measured[1].0);
assert!(
long_bar > short_bar + 20,
"两行的指示条宽度必须随选中项变化(短文案 {short_bar} / 长文案 {long_bar})——\
写死或 Fill 的条会一样宽"
);
eprintln!("[navigation::probe] tabs: 指示条 {measured:?}(宽, 标签墨迹宽)");
}
/// Drawer:**同一个容器两个宽度** —— rail 88 / drawer 240,药丸 56×32 / 216×56,内缩 16 / 12(D33)。
#[test]
fn drawer_is_240_and_rail_is_88_with_inset_pills() {
let dir = require_dir!();
let ui = ui_of(SchemeFlavor::Light);
let shot = shot(&dir, "drawer", SchemeFlavor::Light);
let pad = Space::X2.dp();
let plate = rgb(ui.color(Role::SurfaceContainerLow));
let mut plates: Vec<BBox> = shot
.blocks(plate)
.into_iter()
.filter(|b| b.height() > 150)
.collect();
plates.sort_by_key(|b| b.left);
assert_eq!(
plates.len(),
2,
"应有 2 块取景板(rail 与 drawer),实际 {}",
plates.len()
);
let (rail_plate, drawer_plate) = (plates[0], plates[1]);
// 取景板比导航容器大一圈(左右各 pad):板宽 − 2×pad 才是容器宽
let rail = rail_plate.width() as f32 - pad * 2.0;
let drawer = drawer_plate.width() as f32 - pad * 2.0;
assert_eq!(rail, 88.0, "LayoutMetrics::nav_rail_width");
assert_eq!(drawer, 240.0, "LayoutMetrics::nav_drawer_width");
let pill = rgb(ui.color(Role::SecondaryContainer));
let mut pills: Vec<BBox> = shot
.blocks(pill)
.into_iter()
.filter(|b| b.width() > 40 && b.height() > 20)
.collect();
pills.sort_by_key(|b| b.left);
assert_eq!(
pills.len(),
2,
"rail 与 drawer 各一个选中药丸,实际 {}",
pills.len()
);
let (rail_pill, drawer_pill) = (pills[0], pills[1]);
// rail:药丸 56×32,左右各缩 16(88 − 2×16 = 56,M68 的结论)
// 边缘抗锯齿可能少 1px(32 → 31);判 ±1 区间,216/56 的 drawer 药丸同理
assert!(
(55..=56).contains(&rail_pill.width()) && (31..=32).contains(&rail_pill.height()),
"12b §5.4:rail 药丸应是 56×32,实测 {}×{}",
rail_pill.width(),
rail_pill.height()
);
assert_eq!(
rail_pill.left - (rail_plate.left + pad as usize),
16,
"rail 药丸左内缩必须是 16(Q8 回归时这里是 0,药丸会贴边)"
);
// drawer:药丸 216×56,左右各缩 12(A1 `NavigationDrawerItemDefaults.ItemPadding = 12`)
assert!(
(215..=216).contains(&drawer_pill.width()) && (55..=56).contains(&drawer_pill.height()),
"21.5:drawer 药丸应是 216×56(240 − 2×12),实测 {}×{}",
drawer_pill.width(),
drawer_pill.height()
);
assert_eq!(
drawer_plate.width() - drawer_pill.width(),
24 + pad as usize * 2,
"药丸两侧内缩之和必须是 24(12 + 12),再加取景板的两圈 pad"
);
assert_eq!(
drawer_pill.left - (drawer_plate.left + pad as usize),
12,
"ItemPadding"
);
// 圆角语言不变:两条药丸都居中(左右内缩相等)
assert_eq!(
rail_plate.right - pad as usize - rail_pill.right,
rail_pill.left - (rail_plate.left + pad as usize),
"rail 药丸必须水平居中(两侧内缩相等)"
);
eprintln!(
"[navigation::probe] drawer: rail {rail} / drawer {drawer},药丸 {}×{} / {}×{},内缩 {} / {}",
rail_pill.width(),
rail_pill.height(),
drawer_pill.width(),
drawer_pill.height(),
rail_pill.left - (rail_plate.left + pad as usize),
drawer_pill.left - (drawer_plate.left + pad as usize)
);
}
/// AppBar:静息 56 高(`Surface`)、滚动 56 高(`SurfaceContainer`)——高度不随滚动变化。
#[test]
fn app_bar_is_56_high_in_both_states() {
let dir = require_dir!();
let ui = ui_of(SchemeFlavor::Light);
let shot = shot(&dir, "appbar", SchemeFlavor::Light);
let resting = rgb(ui.color(Role::Surface));
let scrolled = rgb(ui.color(Role::SurfaceContainer));
let mut measured = Vec::new();
for (name, color) in [("静息", resting), ("滚动", scrolled)] {
let bars: Vec<BBox> = shot
.blocks(color)
.into_iter()
.filter(|b| b.width() > 300 && (54..=58).contains(&b.height()))
.collect();
assert_eq!(
bars.len(),
1,
"{name} AppBar 应恰好一条(56 高、>300 宽),实际 {} 条",
bars.len()
);
measured.push((name, bars[0].height()));
}
// 两条同高:滚动只换容器色与高程,不改高度(A1 只有 `OnScrollContainerColor` / `OnScrollContainerElevation`)
// 滚动态带 L2 阴影,阴影会把边缘那一行染成接近容器的颜色 → 允许差 1px
assert!(
(measured[0].1 as i32 - measured[1].1 as i32).abs() <= 1,
"静息与滚动必须同高(滚动不改高度),实测 {measured:?}"
);
// 抗锯齿让边缘行可能差 1px:判 55..=57;A1 的 64 必须落区间外
assert!(
(55..=57).contains(&measured[0].1),
"AppBar 高度应是 56(LM.page_header_height),实测 {measured:?}"
);
assert!(
!(63..=65).contains(&measured[0].1),
"64 是 A1 AppBarSmallTokens.ContainerHeight;本项目按骨架令牌取 56(D-M79-2)"
);
// Q21:动作图标必须在 40×40 命中区正中出现(`Container` 默认 Left/Top,
// 只给 `align_y` 会贴左缘——视觉 r1 实测前导墨迹 `38..58`,应 `44..68`)。
// 判据:左内缩(bar.left → 前导墨迹左缘)== 右内缩(尾随墨迹右缘 → bar.right),±2px。
let bars = shot
.blocks(resting)
.into_iter()
.filter(|b| b.width() > 300 && (54..=58).contains(&b.height()))
.collect::<Vec<_>>();
let bar = bars[0];
let leading_ink = rgb(ui.color(Role::OnSurface));
let trailing_ink = rgb(ui.color(Role::OnSurfaceVariant));
let leading = shot
.band_extent(leading_ink, bar.top, bar.bottom, bar.left, bar.left + 48)
.expect("页头左侧应有前导图标墨迹");
let trailing = shot
.band_extent(
trailing_ink,
bar.top,
bar.bottom,
bar.right.saturating_sub(48),
bar.right,
)
.expect("页头右侧应有尾随图标墨迹");
let left_inset = leading.0 as i32 - bar.left as i32;
let right_inset = bar.right as i32 - trailing.1 as i32;
assert!(
(left_inset - right_inset).abs() <= 2,
"动作图标不在命中区正中:左内缩 {left_inset} / 右内缩 {right_inset}(Q21:`align_x` 漏给会差 8px)"
);
eprintln!(
"[navigation::probe] appbar: 高度 {measured:?},图标内缩 左 {left_inset} / 右 {right_inset}"
);
}
@@ -0,0 +1,49 @@
//! 分隔线守卫(6.6):**Secondary 的分隔线必须是一条连续直线**。
//!
//! 逐 tab 分段画会在相邻段之间留缝(视觉 r1 P2-2 实测 `25..101 | 103..191 | 193..265`,
//! 交界像素偏亮)。这条守卫被复审三轮打过三次,每次都是「假绿」,所以下面把**每个判据
//! 挡的是哪一次假通过**写清楚——改之前先读一遍:
//!
//! | 判据 | 挡的假通过 |
//! |---|---|
//! | ① 锚定「细 + 近乎实心 + 薄 + 低对比 + 跨度 ≥200」的一行(见 `scan::Shot::find_thin_line`) | 五态行的 40px 高底色带、`Primary` 指示条顶替分隔线 |
//! | ② 线色必须是 `Surface → SurfaceVariant` 的插值(覆盖率 30%~105%) | 更靠上的同类细线顶替:`OutlineVariant` 组间线(t 出界)、hover 8% 带(三通道 t 散开) |
//! | ③ 参考色取**该行墨迹众数色**(见 `scan::Shot::line_segments`) | 缺口恰落在跨度中点时,参考色退化成页面底色 → 恒 1 段 |
//! | ④ 在该行**整段墨迹跨度**上数段(不是某次 run 的窗口) | 缺口在窗口外看不见、在窗口内被「跳过去重新起跑」滑过 |
//!
//! ## 锚定行**无条件打印**
//!
//! 不管红绿都往 stderr 打一行 `[probe] divider anchored at y=… span=x..x color=(r,g,b) segments=N`。
//! 之前绿的时候什么都不说,于是「锚错行」是静默的——不看断言在量什么,等于没有断言。
//!
//! ## 深浅两档
//!
//! 浅色图必跑;深色图(`tabs-dark.png`)**存在就跑**(深色的 divider 对比更低,更值得测)。
//! 没有深色图时不算失败,但会在输出里留一行 `dark 档跳过`。
use unlucky_design::color::Role;
use crate::theme::Ui;
use super::scan::{rgb, Shot};
/// 在 `shot` 上验证分隔线是一条直线;`label` 只用于日志(`light` / `dark`)。
pub(super) fn check(shot: &Shot, ui: &Ui, label: &str) {
let surface = rgb(ui.color(Role::Surface));
let variant = rgb(ui.color(Role::SurfaceVariant));
let (y, x0, x1, color) = shot
.find_thin_line(surface, variant, 200, 120)
.unwrap_or_else(|| panic!("[{label}] 找不到 Secondary 那一行细线(锚定失败)"));
let segments = shot.line_segments(y, x0, x1);
eprintln!(
"[probe] divider anchored at y={y} span=x{x0}..{x1} color={color:?} segments={segments} \
({label}, {shot_width}x{shot_height})",
shot_width = shot.width,
shot_height = shot.height,
);
assert_eq!(
segments, 1,
"[{label}] Secondary 分隔线应是一条直线,实测 {segments} 段(量到 y={y}, x{x0}..{x1})——\
分段画会在交界处留缝"
);
}
@@ -0,0 +1,98 @@
//! 长文案的渲染层断言(**D-M79-5**:我们的单行约束是否真的成立)。
//!
//! 矩阵图全用短文案("Instances" / "Instance details"),`Text` 默认 `Wrapping::Word` 的坑
//! 在图上根本看不出来——页面一接真实实例名(`All the Mods 10 - ATM10 - 1.21.1 - NeoForge`)
//! 就会折成 2~3 行、顶穿 56 的页头、压住 trailing 动作。所以这里用 `ULL_NAV_PART=long`
//! 那张图单独量三件事:
//!
//! | 断言 | 抓的错误 |
//! |---|---|
//! | ListItem 三行高仍 44/60/76 | 长文本折行把行撑高 / 被固定行高裁掉 |
//! | AppBar 仍 56 高 | 标题折行顶穿页头 |
//! | 标题墨迹右缘 < trailing 图标左缘 | 长标题覆盖到动作按钮上 |
//!
//! 跑法见 `navigation/probe.rs` 的模块文档(`ULL_NAV_DIR=target/m79`,图名 `long-light.png`)。
use unlucky_design::color::{Role, SchemeFlavor};
use super::scan::{rgb, ui};
use super::{shot, shot_dir};
#[test]
fn long_text_stays_single_line() {
let Some(dir) = shot_dir() else {
eprintln!(
"[navigation::probe::long] SKIPPED:未设置 ULL_NAV_DIR(需要先出 long-light.png)"
);
return;
};
let shot = shot(&dir, "long", SchemeFlavor::Light);
let ui = ui(SchemeFlavor::Light);
// ---- ① ListItem:长实例名不折行 → 行高仍是 44 / 60 / 76 ----
let selected = rgb(ui.color(Role::SecondaryContainer));
let mut rows = shot
.blocks(selected)
.into_iter()
.filter(|b| (238..=242).contains(&b.width()) && b.height() > 30)
.collect::<Vec<_>>();
rows.sort_by_key(|b| b.top);
assert_eq!(
rows.len(),
3,
"长文案块应有 3 条 ListItem(实 {})",
rows.len()
);
let heights = rows.iter().map(|b| b.height()).collect::<Vec<_>>();
for (measured, expected) in heights.iter().zip([44usize, 60, 76]) {
assert!(
(expected - 1..=expected).contains(measured),
"长文案把行高撑成 {heights:?}(应为 44/60/76 ±1)——说明折行了"
);
}
// ---- ② AppBar:长标题不顶穿页头(仍 56 高)----
let bar = shot
.blocks(rgb(ui.color(Role::Surface)))
.into_iter()
.find(|b| b.width() > 300 && (54..=58).contains(&b.height()))
.expect("长文案块应有一条 56 高的页头");
assert!(
(55..=57).contains(&bar.height()),
"长标题把页头撑成 {} 高(应 56)——说明折行了",
bar.height()
);
// ---- ③ 标题不许压到 trailing 动作上 ----
let title_ink = shot
.band_extent(
rgb(ui.color(Role::OnSurface)),
bar.top,
bar.bottom,
bar.left + 40,
bar.right.saturating_sub(90),
)
.expect("页头里应有标题墨迹");
let trailing = shot
.band_extent(
rgb(ui.color(Role::OnSurfaceVariant)),
bar.top,
bar.bottom,
bar.right.saturating_sub(90),
bar.right,
)
.expect("页头右侧应有 trailing 图标墨迹");
assert!(
title_ink.1 + 2 < trailing.0,
"长标题墨迹右缘 {} 压到 trailing 图标左缘 {} 上了(应被裁在文本区里)",
title_ink.1,
trailing.0
);
eprintln!(
"[navigation::probe] long: 行高 {heights:?},页头 {} 高,标题右缘 {} < trailing {}",
bar.height(),
title_ink.1,
trailing.0
);
}
@@ -0,0 +1,379 @@
//! `probe` 的读图器(`probe.rs` 拆出来的:单文件 ≤400 行,AGENTS.md §3-1)。
//!
//! 只做三件事:读 PNG、找同色的连通块(`BBox`)、量一条横带里某颜色的墨迹范围
//! 与「一条细线」。判据在 `probe.rs` / `probe/divider.rs` / `probe/long.rs`。
//!
//! ## 已知边界(分隔线守卫,写在这里供以后改的人查)
//!
//! 1. **≤25% 覆盖的淡线找不到** → 表现是 **loud red**(`find_thin_line` 返回 `None`,
//! `divider::check` 直接 panic「锚定失败」),**不是假绿**。原因:`find_thin_line` 的
//! `TOL=6` 是单通道可辨差下限,40% 覆盖行实测 `(9,6,4)` 刚好在线上,25% 覆盖行只有
//! `(6,4,2)`,会落到判据外——但相邻那行是满覆盖(`(25,17,10)`),所以真实渲染的画法下
//! 行锚定不会丢;只有「整条线都画在半像素上」才会既找不到又看起来像没画。
//! 2. **身份校验绑定了 `SurfaceVariant`**:`divider::check` 用
//! `Surface → SurfaceVariant` 的插值做身份校验。若哪天 Secondary 分隔线换了角色色
//! (比如改回 `OutlineVariant`),这里会 loud red——届时改 `divider.rs` 里那一行 `Role`。
//! 3. **只认「一条横线」**:竖向分隔线(本项目不用)与圆角/虚线画法不在判据内。
//! 4. **深浅两档**:浅色图必跑;深色图存在就跑(`tabs-dark.png`),深色 divider 对比更低
//! (实测线色 `(46,60,70)`,与深色 Surface 的差比浅色档小),更值得测。
use std::path::Path;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::{Families, Ui};
/// 字体族(与 `gallery::probe` 同一套)。
pub(super) const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 颜色比对容差。
///
/// **3 而不是 10**:本家族要区分的几个容器色本身很接近(light 下
/// `SurfaceContainerLow (235,245,255)` vs `SurfaceContainer (227,239,251)` 只差 8/6/4),
/// 容差 10 会把「取景板」和「滚动后的 AppBar」连成一块,量出来的高度就不对了。
/// 实色区域是精确值,3 足够吸收抗锯齿的取整误差。
pub(super) const TOLERANCE: u8 = 3;
pub(super) type Rgb = [u8; 3];
pub(super) fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
}
pub(super) fn rgb(color: iced::Color) -> Rgb {
[
(color.r * 255.0).round() as u8,
(color.g * 255.0).round() as u8,
(color.b * 255.0).round() as u8,
]
}
pub(super) fn matches(pixel: Rgb, expected: Rgb) -> bool {
// **每个通道**都在容差内;`differs` 是它的否定(任一通道超差即「不同」)
!differs(pixel, expected, TOLERANCE)
}
/// 两个颜色在**每个通道**上都不超过 `tol`。
pub(super) fn differs(pixel: Rgb, expected: Rgb, tol: u8) -> bool {
pixel.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > tol)
}
/// 一个外接框(闭区间)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct BBox {
pub(super) left: usize,
pub(super) right: usize,
pub(super) top: usize,
pub(super) bottom: usize,
}
impl BBox {
pub(super) fn width(&self) -> usize {
self.right - self.left + 1
}
pub(super) fn height(&self) -> usize {
self.bottom - self.top + 1
}
}
/// 一张图的像素访问器。
pub(super) struct Shot {
pub(super) width: usize,
pub(super) height: usize,
pixels: Vec<Rgb>,
}
impl Shot {
pub(super) fn load(path: &Path) -> Self {
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("读不动 {path:?}:{e}"));
let image = image::load_from_memory(&bytes)
.unwrap_or_else(|e| panic!("解 PNG 失败 {path:?}:{e}"))
.to_rgb8();
assert_eq!(
(image.width() as usize, image.height() as usize),
(1180, 760),
"验收图必须是 D30 的 1180×760 @100% DPI"
);
Self {
width: image.width() as usize,
height: image.height() as usize,
pixels: image.pixels().map(|p| p.0).collect(),
}
}
pub(super) fn at(&self, x: usize, y: usize) -> Rgb {
self.pixels[y * self.width + x]
}
/// 某种颜色的**全部**连通块(面积 ≥ 24;文字笔画太碎的块直接丢掉)。
pub(super) fn blocks(&self, color: Rgb) -> Vec<BBox> {
let mut seen = vec![false; self.width * self.height];
let mut found = Vec::new();
for y in 0..self.height {
for x in 0..self.width {
if seen[y * self.width + x] || !matches(self.at(x, y), color) {
continue;
}
let mut stack = vec![(x, y)];
seen[y * self.width + x] = true;
let mut bbox = BBox {
left: x,
right: x,
top: y,
bottom: y,
};
while let Some((cx, cy)) = stack.pop() {
bbox.left = bbox.left.min(cx);
bbox.right = bbox.right.max(cx);
bbox.top = bbox.top.min(cy);
bbox.bottom = bbox.bottom.max(cy);
for (nx, ny) in [
(cx + 1, cy),
(cx.saturating_sub(1), cy),
(cx, cy + 1),
(cx, cy.saturating_sub(1)),
] {
if nx < self.width
&& ny < self.height
&& !seen[ny * self.width + nx]
&& matches(self.at(nx, ny), color)
{
seen[ny * self.width + nx] = true;
stack.push((nx, ny));
}
}
}
if bbox.width() * bbox.height() >= 24 {
found.push(bbox);
}
}
}
found
}
/// 矩形内**最深**的像素(禁用的核心笔画就是精确的混合色,用它判角色最稳)。
pub(super) fn darkest(&self, bbox: &BBox) -> Option<Rgb> {
let mut best: Option<(u32, Rgb)> = None;
for y in bbox.top..=bbox.bottom.min(self.height - 1) {
for x in bbox.left..=bbox.right.min(self.width - 1) {
let pixel = self.at(x, y);
let sum: u32 = pixel.iter().map(|c| u32::from(*c)).sum();
if best.is_none_or(|(current, _)| sum < current) {
best = Some((sum, pixel));
}
}
}
best.map(|(_, pixel)| pixel)
}
/// 矩形里某颜色墨迹的左右范围(量图标/文字位置用)。
pub(super) fn band_extent(
&self,
color: Rgb,
y0: usize,
y1: usize,
x0: usize,
x1: usize,
) -> Option<(usize, usize)> {
let mut span: Option<(usize, usize)> = None;
for y in y0..=y1.min(self.height - 1) {
for x in x0..=x1.min(self.width - 1) {
if matches(self.at(x, y), color) {
span = Some(match span {
Some((l, r)) => (l.min(x), r.max(x)),
None => (x, x),
});
}
}
}
span
}
/// 找「一条**细、低对比、近乎实心**的长横线」,返回 `(y, x0, x1)`:
/// `y` 是线最实的那一行,`x0..x1` 是**该行整段墨迹的跨度**(不是某一次连续 run 的窗口)。
///
/// 四条判据,每条都对应一次真实的假通过(视觉 r1 P2-2 复核时被点出来的):
///
/// 1. **跨度 ≥ `min_len`**(200):`Secondary` 分隔线整行 242px;标题/标签行凑不满,能跳过它们。
/// 2. **近乎实心**(墨迹数 ≥ 跨度 × 4/5):逐 tab 分段时中间是真缺口,但只有 1~2px,仍算实心
/// ——**必须先能找到这一行**,再由 [`Shot::line_segments`] 把缺口数成多段。
/// (早先「凑满 150px 就 return,然后在窗口里数段」的写法会滑过缺口,永远数到 1。)
/// 3. **薄**(跨中点纵向墨迹 ≤ 3 行):五态行里的 hover 底色是 40px 高的带,被这条排除
/// (早先的写法会把那条 224px 的带选中,量到完全不同的元素)。
/// 4. **低对比**(线色与底色的单通道最大差 ≤ `max_contrast`):`Primary` 指示条是高饱和色,被排除。
///
/// `TOL` 的口径:**单通道**最大可辨差,取 6。40% 覆盖的抗锯齿行实测单通道差 `(9,6,4)`,
/// 6 能看见它、又挡住 ±3 的渲染噪声;覆盖再低的行会落到判据外,但**相邻那行是满覆盖**
/// (实测线色差 25/17/10),所以行的锚定不会丢。
pub(super) fn find_thin_line(
&self,
background: Rgb,
expected: Rgb,
min_len: usize,
max_contrast: u8,
) -> Option<(usize, usize, usize, Rgb)> {
const TOL: u8 = 6;
let is_ink = |pixel: Rgb| differs(pixel, background, TOL);
let contrast = |pixel: Rgb| {
pixel
.iter()
.zip(background)
.map(|(p, q)| p.abs_diff(q))
.max()
.unwrap_or(0)
};
for y in 0..self.height {
let ink: Vec<usize> = (0..self.width).filter(|x| is_ink(self.at(*x, y))).collect();
let (Some(&first), Some(&last)) = (ink.first(), ink.last()) else {
continue;
};
let len = last - first + 1;
if len < min_len || ink.len() * 5 < len * 4 {
continue;
}
// 薄:跨中点纵向墨迹 ≤ 3 行
let mid = (first + last) / 2;
let mut top = y;
while top > 0 && is_ink(self.at(mid, top - 1)) {
top -= 1;
}
let mut bottom = y;
while bottom + 1 < self.height && is_ink(self.at(mid, bottom + 1)) {
bottom += 1;
}
if bottom - top + 1 > 3 {
continue;
}
// 低对比:取这条细带里最实的一行(满覆盖),跨段与线色都按那一行算
let best = (top..=bottom)
.max_by_key(|candidate| {
(first..=last)
.filter(|x| is_ink(self.at(*x, *candidate)))
.count()
})
.unwrap_or(y);
let line = self.modal_color(best, first, last);
let c = contrast(line);
if c > max_contrast || c <= TOL {
continue;
}
// **身份校验**:线色必须落在 `底色 → 预期角色色` 的插值线上(覆盖率 30%~105%)。
// 只靠「跨度/薄/低对比」挡不住「更靠上的另一条同类细线」(复审 r3 的
// `decoy_outline_gap` / `decoy_hover_gap` 两个反例都因此锚错行)。
let Some(coverage) = Self::blend_coverage(line, background, expected) else {
eprintln!(
"[probe] divider skip y={best} color={line:?}(不是预期角色色的插值,拒绝)"
);
continue;
};
if !(0.30..=1.05).contains(&coverage) {
eprintln!(
"[probe] divider skip y={best} color={line:?}(覆盖率 {coverage:.2} 出界,拒绝)"
);
continue;
}
return Some((best, first, last, line));
}
None
}
/// `y` 行 `[x0, x1]` 上「按该行**墨迹众数色**」数的连通段数(分隔线断没断)。
///
/// 参考色为什么不能取「跨度中点那一个像素」:缺口恰落在中点时,参考色变成页面底色,
/// 数出来的是「底色有几段」→ 恒为 1 → **假绿**(复审 r3 的 `gap_at_mid` 反例)。
/// 众数色在整段跨度里天然是线自己的颜色(242px 的线 vs ≤4px 的缺口)。
pub(super) fn line_segments(&self, y: usize, x0: usize, x1: usize) -> usize {
let line = self.modal_color(y, x0, x1);
let mut segments = 0;
let mut in_run = false;
for x in x0..=x1 {
let on = matches(self.at(x, y), line);
if on && !in_run {
segments += 1;
in_run = true;
} else if !on {
in_run = false;
}
}
segments
}
/// `y` 行 `[x0, x1]` 里出现次数最多的颜色(分隔线的线色)。
pub(super) fn modal_color(&self, y: usize, x0: usize, x1: usize) -> Rgb {
let mut counts: std::collections::HashMap<Rgb, usize> = std::collections::HashMap::new();
for x in x0..=x1.min(self.width - 1) {
*counts.entry(self.at(x, y)).or_insert(0) += 1;
}
counts
.into_iter()
.max_by_key(|(_, n)| *n)
.map(|(c, _)| c)
.unwrap_or(self.at(x0.min(self.width - 1), y))
}
/// `pixel` 是否落在 `from → to` 的插值线上(覆盖率 t),返回三个通道一致时的平均 t。
///
/// 用来给「这条线是不是某个角色色」做**身份校验**:比「单通道差 ≤N」稳得多——
/// 实测 `OutlineVariant (200,212,224)` → t=(1.84,2.24,3.1) 出界;
/// hover 8% `(228,233,238)` → t=(0.72,1.0,1.7) 三通道散开 0.98;两者都被拒。
/// 而真分隔线满覆盖 `(221,233,245)` → t=(1,1,1);40% 覆盖的抗锯齿行 → t≈(0.36,0.35,0.40) 被接受。
///
/// 只统计 `|to - from| > 4` 的通道(差太小的通道不携带信息,噪声会把 t 带偏)。
pub(super) fn blend_coverage(pixel: Rgb, from: Rgb, to: Rgb) -> Option<f32> {
let mut ts = Vec::new();
for ((p, f), t) in pixel.iter().zip(from).zip(to) {
let delta = f32::from(t) - f32::from(f);
if delta.abs() <= 4.0 {
continue;
}
ts.push((f32::from(*p) - f32::from(f)) / delta);
}
if ts.is_empty() {
return None;
}
let max = ts.iter().cloned().fold(f32::MIN, f32::max);
let min = ts.iter().cloned().fold(f32::MAX, f32::min);
if max - min > 0.25 {
return None;
}
Some(ts.iter().sum::<f32>() / ts.len() as f32)
}
/// 矩形内「与底色不同」的像素的左右范围(量文本墨迹用)。
///
/// 为什么不按精确文字色找:小字号抗锯齿后几乎没有像素落在**精确**色上(容差 3 时实测
/// 「Logs」只剩 12px)。改成「与底色不同即墨迹」,既与字色无关,也不会被状态层带偏。
pub(super) fn band_ink(
&self,
background: Rgb,
x0: usize,
x1: usize,
y0: usize,
y1: usize,
) -> Option<(usize, usize)> {
let mut span: Option<(usize, usize)> = None;
for y in y0..=y1.min(self.height - 1) {
for x in x0..=x1.min(self.width - 1) {
let pixel = self.at(x, y);
let differs = pixel
.iter()
.zip(background)
.any(|(a, b)| a.abs_diff(b) > 12);
if differs {
span = Some(match span {
Some((l, r)) => (l.min(x), r.max(x)),
None => (x, x),
});
}
}
}
span
}
}
@@ -0,0 +1,359 @@
//! Tabs(`research/12b` §6):**Primary**(下方 3px 指示条)与 **Secondary**(1px 分隔线)。
//!
//! ## 参考值(`[源]` = A1,本轮 `javap` 实读本机 AAR)
//!
//! ```text
//! PrimaryNavigationTabTokens: ContainerHeight 48、IconAndLabelTextContainerHeight 64、
//! ActiveIndicatorHeight 3、ActiveIndicatorShape RoundedCornerShape(3dp)、
//! IconSize 24、ContainerShape CornerNone、LabelTextFont TitleSmall
//! ActiveIndicatorColor=Primary;Active*/Inactive* 各态内容色(见下表)
//! SecondaryNavigationTabTokens: ContainerHeight 48、DividerHeight、DividerColor=SurfaceVariant、
//! ActiveLabelTextColor=OnSurface、InactiveLabelTextColor=OnSurfaceVariant
//! ```
//!
//! | 项 | Primary | Secondary |
//! |---|---|---|
//! | 选中内容色 | **`Primary`** | **`OnSurface`**(不是 Primary——6.5 明写) |
//! | 未选内容色 | `OnSurfaceVariant` | `OnSurfaceVariant` |
//! | hover / focus / pressed | 未选项提到 **`OnSurface`**,选中项保持不变 | 同左 |
//! | 底部条 | 仅选中项:**3px `Primary`**,宽度**跟随该 tab** | 所有项:**1px `SurfaceVariant`**(连成一条线) |
//!
//! 尺寸/圆角/线厚全部来自 [`TabsGeometry`](**G22/G23**:`LayoutMetrics` 没有 Tabs 档)。
//!
//! ## 指示条怎么做到「宽度跟随选中项」
//!
//! iced 没有「取到渲染后文本宽度」的公开 API,所以这里用 [`stack`] 的**分层语义**:
//!
//! 1. `Stack` 的**底层**是 tab 自己的内容容器(`Length::Shrink` → 宽度 = 文本实测宽 + 2×16);
//! 2. `Stack` 的**上层**是 `Length::Fill` 的指示条。`Stack::layout` 先按**底层**定尺寸,再用
//! 「已知尺寸」的 `Limits` 布局其余层(`iced_widget-0.14.2/src/stack.rs` 的
//! `size = limits.resolve(self.width, self.height, base.size())`)——于是 Fill 的指示条
//! 拿到的正好是「这个 tab 的宽」,**不是**整行宽。
//!
//! 这也顺带避开 Q8 的另一面:把条写成 `center_x(Fill)` 会把它撑成整行。
//!
//! ## 横向滚动
//!
//! 整行外面套 `scrollable`(`Direction::Horizontal`,滚动条 `Scrollbar::hidden()`):
//! tab 宽度因此是**内容宽**而不是「行宽 ÷ n」——官方 `ScrollableTabRow` 的口径。
//! `TabMinWidth = 90` **本轮不施加**(iced 无 min-width,登记在 G23)。
use iced::widget::text::{LineHeight, Wrapping};
use iced::widget::{button, container, scrollable, space, text, Row, Stack};
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::{self, Interaction};
use super::super::layout;
use super::geometry::{TabsGeometry, TABS_ICON_HEIGHT};
/// 两种 Tabs(`research/12b` §6;M3 的 `PrimaryTabRow` / `SecondaryTabRow`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TabsVariant {
/// 主标签页:选中项下方 3px `Primary` 指示条。
Primary,
/// 次标签页:1px `SurfaceVariant` 分隔线 + 选中项 `OnSurface` 文字。
Secondary,
}
impl TabsVariant {
/// 两种变体(gallery 状态矩阵按这个次序出图)。
pub const ALL: [TabsVariant; 2] = [TabsVariant::Primary, TabsVariant::Secondary];
/// 稳定名字(截图文件名 / 图例用)。
pub const fn name(self) -> &'static str {
match self {
TabsVariant::Primary => "primary",
TabsVariant::Secondary => "secondary",
}
}
/// 是否是 Primary(指示条 3px)。
pub const fn is_primary(self) -> bool {
matches!(self, TabsVariant::Primary)
}
}
/// 一个标签页。
#[derive(Debug, Clone)]
pub struct Tab<'a, M> {
label: &'a str,
icon: Option<IconKind>,
selected: bool,
on_press: Option<M>,
state: Option<Interaction>,
}
impl<'a, M> Tab<'a, M> {
/// 一个未选中的 tab。
pub fn new(label: &'a str, on_press: M) -> Self {
Self {
label,
icon: None,
selected: false,
on_press: Some(on_press),
state: None,
}
}
/// 选中(内容色换成激活档 + 画指示条)。
pub fn selected(mut self) -> Self {
self.selected = true;
self
}
/// 前置图标(24,`IconSize::Toolbar`;带图标的容器高官方是 64,本轮只做文字档)。
pub fn icon(mut self, kind: IconKind) -> Self {
self.icon = Some(kind);
self
}
/// 强制视觉态(gallery 取证;不动状态机)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
/// 转禁用:**只清消息**(修饰器,不是构造函数)。
pub fn disabled(mut self) -> Self {
self.on_press = None;
self
}
}
/// 一行标签页。
#[derive(Debug, Clone)]
pub struct Tabs<'a, M> {
variant: TabsVariant,
tabs: Vec<Tab<'a, M>>,
}
impl<'a, M> Tabs<'a, M> {
/// Primary tabs(3px 指示条)。
pub fn primary() -> Self {
Self {
variant: TabsVariant::Primary,
tabs: Vec::new(),
}
}
/// Secondary tabs(1px 分隔线)。
pub fn secondary() -> Self {
Self {
variant: TabsVariant::Secondary,
tabs: Vec::new(),
}
}
/// 追加一个 tab。
pub fn tab(mut self, tab: Tab<'a, M>) -> Self {
self.tabs.push(tab);
self
}
}
/// 一个 tab 的内容色(A1 的 `Active*` / `Inactive*` / 各态表,见模块文档)。
fn content_color(ui: &Ui, primary: bool, state: Interaction, selected: bool) -> Color {
if state.is_disabled() {
// A1 的 Tabs 没有 Disabled* token(实读:Primary/Secondary 两套都只有 Active/Inactive × 四态),
// 按 ListItem 的 38% 口径处理 —— 登记 `[估]`。
return interaction::disabled_content_over_role(ui, Role::OnSurface, false);
}
if selected {
// 6.5:Primary 选中 `Primary`;Secondary 选中 `OnSurface`
return if primary {
ui.color(Role::Primary)
} else {
ui.color(Role::OnSurface)
};
}
match state {
// 未选中静息 `OnSurfaceVariant`;hover/focus/pressed 提到 `OnSurface`(A1 的 Inactive* 四态)
Interaction::Enabled => ui.color(Role::OnSurfaceVariant),
_ => ui.color(Role::OnSurface),
}
}
/// Primary 指示条的颜色(只有选中项有;禁用项降档,否则「点不动却亮着」)。
fn bar_color(ui: &Ui, state: Interaction, selected: bool) -> Option<Color> {
if !selected {
return None;
}
if state.is_disabled() {
return Some(interaction::disabled_content_over_role(
ui,
Role::OnSurface,
false,
));
}
Some(ui.color(Role::Primary))
}
/// 6.6:Secondary 的分隔线色 = **`SurfaceVariant`**(不是 `OutlineVariant`——那是 §22 的分隔线)。
///
/// 色值只在这里出现一次:整行那条线与逐 tab 的实现都从这里取。
fn secondary_line_color(ui: &Ui) -> Color {
ui.color(Role::SurfaceVariant)
}
/// 一行标签页(自带横向滚动)。
pub fn tabs<'a, M: Clone + 'a>(ui: &'a Ui, tabs: Tabs<'a, M>) -> Element<'a, M> {
let geometry = TabsGeometry::of(ui);
let primary = tabs.variant.is_primary();
// 6.1:带图标时容器高 **64**(`IconAndLabelTextContainerHeight`),纯文字 48
let height = if tabs.tabs.iter().any(|tab| tab.icon.is_some()) {
TABS_ICON_HEIGHT
} else {
geometry.height
};
let mut row = Row::new()
.height(Length::Fixed(height))
.align_y(Alignment::Center);
for tab in tabs.tabs {
row = row.push(tab_cell(ui, tab, geometry, primary, height));
}
let body: Element<'a, M> = if primary {
row.into()
} else {
// A1 的 Secondary 分隔线是**整行一条直线**,不是每个 tab 一段:
// 逐段画会在相邻段之间留缝(视觉 r1 P2-2)。这里用 `Stack` 把一条 Fill 线压在整行底下,
// 底座是 tab 行本身(Shrink → 内容宽),所以线长 = tab 行总宽,横向滚动时跟着走。
let line = container(space::vertical())
.width(Length::Fill)
.height(Length::Fixed(geometry.secondary_bar))
.style(move |_theme| container::Style {
background: Some(Background::Color(secondary_line_color(ui))),
..container::Style::default()
});
Stack::new()
.push(container(row))
.push(
container(line)
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::End),
)
.into()
};
scrollable(body)
.direction(scrollable::Direction::Horizontal(
scrollable::Scrollbar::hidden(),
))
.height(Length::Fixed(height))
.into()
}
/// 一个 tab 单元(内容 + 底部条)。
fn tab_cell<'a, M: Clone + 'a>(
ui: &'a Ui,
tab: Tab<'a, M>,
geometry: TabsGeometry,
primary: bool,
height: f32,
) -> Element<'a, M> {
let forced = tab.state.or(if tab.on_press.is_none() {
Some(Interaction::Disabled)
} else {
None
});
let state = forced.unwrap_or(Interaction::Enabled);
let selected = tab.selected;
let color = content_color(ui, primary, state, selected);
let style = ui.style(TypeRole::TitleSmall);
let label = text(tab.label)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
// 单行(容器高固定 48/64):长标签让 tab 变宽 + 横向滚动,不折行(偏离 D-M79-5)
.wrapping(Wrapping::None)
.font(ui.font_of(style))
.color(color);
let mut content = Row::new().align_y(Alignment::Center).spacing(geometry.pad);
if let Some(kind) = tab.icon {
content = content.push(layout::fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(kind, color),
));
}
content = content.push(label);
let bar_height = geometry.bar(primary);
// 底层:内容自己决定宽度(Shrink),底部留出条的高度 —— 未选中的 Primary tab 也占同样的高
let base = container(content)
.height(Length::Fixed(height))
// Q21:要给居中就两轴都显式给(`Container` 默认 Left/Top)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.padding(Padding {
top: 0.0,
right: geometry.pad,
bottom: bar_height,
left: geometry.pad,
});
// Primary:逐 tab 画条(要跟随该 tab 的宽度);Secondary:整行一条,在 `tabs()` 里画
let bar = if primary {
bar_color(ui, state, selected)
} else {
None
};
let radius = geometry.indicator_radius();
let mut stack = Stack::new().push(base);
if let Some(color) = bar {
// 上层:Fill —— `Stack` 先按底层定尺寸,所以这里的 Fill = 这个 tab 的宽(不是整行)
stack = stack.push(
container(
container(space::vertical())
.width(Length::Fill)
.height(Length::Fixed(bar_height))
.style(move |_theme| container::Style {
background: Some(Background::Color(color)),
border: Border {
radius,
..Border::default()
},
..container::Style::default()
}),
)
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::End),
);
}
let fill = interaction::overlay_color(ui, state, Role::OnSurface, Role::Surface);
let button = button(stack)
.height(Length::Fixed(height))
.padding(0)
.style(move |_theme, _status| button::Style {
background: fill.map(Background::Color),
text_color: color,
// ContainerShape = CornerNone(tab 本身是直角;圆角只在指示条上)
border: Border {
radius: ui.radius_dp(ShapeScale::None),
..Border::default()
},
shadow: Shadow::default(),
snap: true,
});
match tab.on_press {
Some(message) => button.on_press(message).into(),
None => button.into(),
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,150 @@
//! `tabs` 的单元测试(split 拆出来的:单文件 ≤400 行,AGENTS.md §3-1)。
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::Families;
use super::super::super::interaction;
fn ui() -> Ui {
Ui::with_platform(
SchemeFlavor::Light,
Platform::Windows,
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
)
}
/// 6.5:选中色 Primary 用 `Primary`,Secondary 用 `OnSurface`(**不是** Primary)。
#[test]
fn active_colors_follow_the_variant() {
let ui = ui();
assert_eq!(
content_color(&ui, true, Interaction::Enabled, true),
ui.color(Role::Primary)
);
assert_eq!(
content_color(&ui, false, Interaction::Enabled, true),
ui.color(Role::OnSurface),
"6.5:Secondary 的选中项是 OnSurface"
);
assert_ne!(
content_color(&ui, false, Interaction::Enabled, true),
ui.color(Role::Primary),
"把 Secondary 的选中色写成 Primary 是最常见的串线"
);
}
/// 未选中静息 `OnSurfaceVariant`;hover / focus / pressed 提到 `OnSurface`。
#[test]
fn inactive_states_lift_to_on_surface() {
let ui = ui();
for primary in [true, false] {
assert_eq!(
content_color(&ui, primary, Interaction::Enabled, false),
ui.color(Role::OnSurfaceVariant)
);
for state in [
Interaction::Hovered,
Interaction::Focused,
Interaction::Pressed,
] {
assert_eq!(
content_color(&ui, primary, state, false),
ui.color(Role::OnSurface),
"{state:?} 必须提到 OnSurface"
);
}
}
}
/// 6.2:Primary 只有选中项有 3px `Primary` 条;禁用项降档。
#[test]
fn primary_bar_only_on_the_selected_tab() {
let ui = ui();
assert_eq!(bar_color(&ui, Interaction::Enabled, false), None);
assert_eq!(
bar_color(&ui, Interaction::Enabled, true),
Some(ui.color(Role::Primary))
);
assert_eq!(
bar_color(&ui, Interaction::Disabled, true),
Some(interaction::disabled_content_over_role(
&ui,
Role::OnSurface,
false
)),
"禁用项「点不动却亮着」是错的"
);
}
/// 6.6:Secondary 的分隔线整行一条,颜色 `SurfaceVariant`(不是 §22 的 `OutlineVariant`)。
#[test]
fn secondary_line_is_surface_variant() {
let ui = ui();
assert_eq!(secondary_line_color(&ui), ui.color(Role::SurfaceVariant));
assert_ne!(
secondary_line_color(&ui),
ui.color(Role::OutlineVariant),
"OutlineVariant 是 §22 分隔线的角色,Tabs 不用它"
);
}
/// 禁用是**修饰器**:只清消息,label / 选中态 / 图标全部保留。
#[test]
fn disabled_only_clears_the_message() {
let tab = Tab::new("Mods", ())
.icon(unlucky_design::icons::IconKind::Settings)
.selected()
.disabled();
assert!(tab.on_press.is_none());
assert!(tab.icon.is_some());
assert!(tab.selected);
assert_eq!(tab.label, "Mods");
}
/// 指示条宽度 = tab 宽 = 文本宽 + 2×16(这条是「跟随选中项宽度」的公式面)。
#[test]
fn indicator_width_is_the_tab_width() {
let geometry = TabsGeometry::of(&ui());
let tab_width = |text_width: f32| text_width + geometry.pad * 2.0;
assert_eq!(tab_width(0.0), 32.0, "空文本 = 2×16");
// 两段不同长度的文案 ⇒ 指示条宽度必然不同(渲染层断言用它打红)
let short = tab_width(40.0);
let long = tab_width(120.0);
assert_eq!(long - short, 80.0);
assert!(long > short);
}
/// 6.1:带图标的 Tabs 容器高 64(`IconAndLabelTextContainerHeight`),纯文字 48。
#[test]
fn icon_tabs_use_the_taller_container() {
let ui = ui();
assert_eq!(TABS_ICON_HEIGHT, 64.0);
assert!(TABS_ICON_HEIGHT > TabsGeometry::of(&ui).height);
}
/// 3 个 tab 的树能建出来(含选中 / 禁用 / 带图标三条路径)。
#[test]
fn view_builds_a_tree() {
let ui = ui();
let _: Element<'_, ()> = tabs(
&ui,
Tabs::primary()
.tab(Tab::new("General", ()).selected())
.tab(Tab::new("Mods", ()).icon(unlucky_design::icons::IconKind::Settings))
.tab(Tab::new("Logs", ()).disabled()),
);
let _: Element<'_, ()> = tabs(
&ui,
Tabs::secondary()
.tab(Tab::new("Release", ()).selected())
.tab(Tab::new("Snapshot", ())),
);
}
@@ -0,0 +1,186 @@
//! Checkbox(`research/15` §18):20×20 / 圆角 2 / 描边向内收的 2px 框 / 勾形 12dp 方头 /
//! indeterminate 形变为居中短横 / Ø40 状态层 / 48 命中区。
//!
//! ## 三条容易画错的(每条都有对应的像素断言或单测)
//!
//! 1. **描边向内**:外缘恒 20(§18.3)。iced 的 quad 渲染器把描边路径**向内缩半个线宽**
//! (`iced_tiny_skia::engine::draw_quad`:`border_bounds + border_width / 2`),与 Compose
//! `drawBox` 的 `topLeft = stroke/2, size = 20 − stroke` 同一口径,所以选中/未选中外缘同尺寸。
//! 2. **勾不是图标**:勾形按路径比例画(`shell::glyph`),代码里**没有第二个 `IconKind`**(§18.6)。
//! 3. **状态层是 Ø40 的圆**、命中区是 48:两者不同尺寸,所以外壳是透明的 48 按钮,
//! 里面套一个 40 的圆容器,再套 20 的方框(见 `shell` 的模块文档)。
use iced::widget::{container, row, text};
use iced::{Alignment, Element, Length, Padding};
use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::super::interaction::Interaction;
use super::geometry::ControlGeometry;
use super::paint::{self, Frame};
use super::shell::{self, StateSource};
/// 复选框的三种取值(§18.6:indeterminate 不是第四种「选中」,是「部分选中」)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckValue {
/// 未选中:透明内部 + 2px `OnSurfaceVariant` 描边。
Unchecked,
/// 选中:`Primary` 实心 + 勾。
Checked,
/// 部分选中:**同样是实心**,勾形变成一条居中短横(不换图标)。
Indeterminate,
}
impl CheckValue {
/// 框是否**填充**(选中与 indeterminate 都填,后者只是把勾换成短横)。
pub const fn filled(self) -> bool {
!matches!(self, CheckValue::Unchecked)
}
/// 是否要画短横而不是勾。
pub const fn indeterminate(self) -> bool {
matches!(self, CheckValue::Indeterminate)
}
}
/// 一个复选框(框 + 可选标签;`on_toggle` 为 `None` = 禁用)。
#[derive(Debug, Clone)]
pub struct Checkbox<'a, M> {
label: &'a str,
value: CheckValue,
on_toggle: Option<M>,
error: bool,
state: Option<Interaction>,
}
impl<'a, M> Checkbox<'a, M> {
/// 一个二态复选框。
pub fn new(label: &'a str, selected: bool, on_toggle: M) -> Self {
Self::tri(
label,
if selected {
CheckValue::Checked
} else {
CheckValue::Unchecked
},
on_toggle,
)
}
/// 三态复选框(`indeterminate` 由调用方给值)。
pub fn tri(label: &'a str, value: CheckValue, on_toggle: M) -> Self {
Self {
label,
value,
on_toggle: Some(on_toggle),
error: false,
state: None,
}
}
/// 禁用的复选框(无消息;禁用不是「另一种颜色」,见 `paint::checkbox` 的禁用分支)。
pub fn disabled(label: &'a str, value: CheckValue) -> Self {
Self {
label,
value,
on_toggle: None,
error: false,
state: None,
}
}
/// 校验失败态(§18.7:未选中描边 `Error`,选中填 `Error` + 勾 `OnError`)。
pub fn error(mut self) -> Self {
self.error = true;
self
}
/// 强制视觉态(`--screen=gallery` 取证用;运行期不要调)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
}
/// 画一个复选框(框 + 标签)。
pub fn checkbox<'a, M: Clone + 'a>(ui: &'a Ui, spec: Checkbox<'a, M>) -> Element<'a, M> {
let geometry = ControlGeometry::checkbox(ui);
let filled = spec.value.filled();
let indeterminate = spec.value.indeterminate();
let error = spec.error;
let radius = geometry.radius;
let stroke = geometry.stroke;
let source = StateSource::new(spec.state, spec.on_toggle.is_some());
// 勾的颜色与 hover/pressed 无关(A1:`SelectedHover/Focus/PressedIconColor` 都是 `OnPrimary`),
// 只随「选中 + 禁用」变 —— 所以这里用**静态**态求值(SVG 的颜色在构建期就要定)。
let at_rest = paint::checkbox(ui, filled, error, source.get(), radius, stroke);
let glyph: Element<'a, M> = if filled {
shell::square(
geometry.side,
shell::glyph(geometry, indeterminate, at_rest.glyph),
)
} else {
iced::widget::Space::new().into()
};
// 20 方框:填/描边(跟态走)+ 勾
let boxed = container(glyph)
.width(Length::Fixed(geometry.side))
.height(Length::Fixed(geometry.side))
.style(source.container_style(move |state| {
paint::checkbox(ui, filled, error, state, radius, stroke)
.frame
.container_style()
}));
// Ø40 状态层:圆(半径 = 直径/2,`ShapeScale::Full` 的等价写法)
let layer_side = geometry.state_layer;
let layer = container(boxed)
.width(Length::Fixed(layer_side))
.height(Length::Fixed(layer_side))
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(source.container_style(move |state| {
Frame {
fill: paint::state_layer(ui, state, paint::checkbox_layer_role(filled, error)),
outline: None,
stroke: 0.0,
radius: layer_side / 2.0,
}
.container_style()
}));
let mut content = row![layer].align_y(Alignment::Center).spacing(geometry.gap);
if !spec.label.is_empty() {
let style = ui.style(TypeRole::BodyLarge);
content = content.push(
text(spec.label)
.size(style.size_px)
.font(ui.font_of(style))
.color(paint::label(ui, source.get())),
);
}
// 命中区 48:方框 20 + 状态层 40 都在里面垂直居中;
// 左右各留 `hit_padding` 透明内边距,无标签时命中区也是 48×48(复审 P2-2)
let pad = geometry.hit_padding();
let body = container(content)
.height(Length::Fill)
.align_y(Alignment::Center)
.padding(Padding {
left: pad,
right: pad,
..Padding::default()
});
shell::shell(
&source,
Length::Shrink,
Length::Fixed(geometry.hit),
spec.on_toggle,
body.into(),
)
}
@@ -0,0 +1,187 @@
//! Chip(`research/12b` §13):高 **32** / 圆角 **8** / 水平内边距 **8** / 元素间距 **8** /
//! 图标 **18**(`IconSize::Button` = 官方 `AssistChipTokens.IconSize`)/ 描边 1px `OutlineVariant`。
//!
//! ## 三类(§13 的 Assist / Filter / Input)
//!
//! | 类 | 选中态 | 容器 | 文字 | 前置图标 |
//! |---|---|---|---|---|
//! | Assist | **无**(一次动作,不是开关) | 透明 + 1px 描边 | `OnSurface` | `Primary` |
//! | Filter | 有 | 未选透明+描边;选中 `SecondaryContainer` **无描边** | `OnSurfaceVariant` / `OnSecondaryContainer` | 选中态才给勾 |
//! | Input | 有 | 同 Filter | 同 Filter | 可给 + 后置关闭图标 |
//!
//! ## 为什么 Chip 不用 `selection::shell` 那条透明外壳
//!
//! M3 的 chip **没有圆形状态层**:hover / pressed 就是把状态层叠在**自己的容器**上,
//! 而容器尺寸 = 命中区尺寸。所以 chip 直接走 `button` 的样式闭包(与按钮家族同一条产线),
//! 不需要「48 外壳 + 40 圆」那套。
use iced::widget::{button, container, row, text};
use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow};
use unlucky_design::icons::IconKind;
use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::Interaction;
use super::geometry::ChipGeometry;
use super::paint::{self, ChipVariant};
/// 一个 chip(可带前后图标;`on_press` 为 `None` = 禁用)。
#[derive(Debug, Clone)]
pub struct Chip<'a, M> {
label: &'a str,
variant: ChipVariant,
selected: bool,
leading: Option<IconKind>,
trailing: Option<IconKind>,
on_press: Option<M>,
state: Option<Interaction>,
}
impl<'a, M> Chip<'a, M> {
/// Assist chip:辅助动作,**没有**选中态。
pub fn assist(label: &'a str, on_press: M) -> Self {
Self::new(label, ChipVariant::Assist, false, on_press)
}
/// Filter chip:筛选开关(`selected` 决定容器是否 `SecondaryContainer`)。
pub fn filter(label: &'a str, selected: bool, on_press: M) -> Self {
Self::new(label, ChipVariant::Filter, selected, on_press)
}
/// Input chip:用户输入的实体(可选中 + 可带关闭图标)。
pub fn input(label: &'a str, on_press: M) -> Self {
Self::new(label, ChipVariant::Input, false, on_press)
}
/// 转成禁用:**只清消息**,图标 / 选中态 / 状态层全部保留(复审 P2-1)。
///
/// 官方禁用态是「容器/描边 12%、文字与**图标** 38%」——只降**不删**。早先的
/// `Chip::disabled(label, variant)` 把 leading / trailing / selected 一起丢掉,实测
/// assist 78→54、filter·on 74→50(连填充和勾都没了),禁用列里 `filter·on` 与 `filter`
/// 逐像素同形,看不出选中与否。
pub fn disabled(mut self) -> Self {
self.on_press = None;
self
}
fn new(label: &'a str, variant: ChipVariant, selected: bool, on_press: M) -> Self {
Self {
label,
variant,
selected,
leading: None,
trailing: None,
on_press: Some(on_press),
state: None,
}
}
/// 前置图标(Assist / Filter 用)。
pub fn leading(mut self, kind: IconKind) -> Self {
self.leading = Some(kind);
self
}
/// 后置图标(Input chip 的关闭键)。
pub fn trailing(mut self, kind: IconKind) -> Self {
self.trailing = Some(kind);
self
}
/// 强制视觉态(gallery 取证用;运行期不要调)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
}
/// 画一个 chip。
pub fn chip<'a, M: Clone + 'a>(ui: &'a Ui, spec: Chip<'a, M>) -> Element<'a, M> {
let geometry = ChipGeometry::of(ui);
let variant = spec.variant;
let selected = spec.selected;
let disabled = spec.on_press.is_none();
let forced = spec.state;
// 文字/图标色只看「选中 + 禁用」(A1:Filter/Input 的 label 色在 hover/focus/pressed 不变),
// 所以这里静态求值;容器(含状态层)在样式闭包里跟态走。
let at_rest = paint::chip(
ui,
variant,
selected,
if disabled {
Interaction::Disabled
} else {
forced.unwrap_or(Interaction::Enabled)
},
geometry.radius,
);
let mut content = row![].align_y(Alignment::Center).spacing(geometry.gap);
if let Some(kind) = spec.leading {
// Q9:图标一律经固定尺寸容器;盒 18 ≠ RENDER_PX(24),所以必须走 `view_at` 按 18 烘焙,
// 否则线宽会被布局缩水(复审 r1 P2-2)。
content = content.push(super::super::layout::centered_fixed(
geometry.icon,
geometry.icon,
icons::view_at::<M>(kind, at_rest.icon, geometry.icon),
));
}
let style = ui.style(TypeRole::LabelLarge);
content = content.push(
text(spec.label)
.size(style.size_px)
.font(ui.font_of(ui.emphasized(TypeRole::LabelLarge)))
.color(at_rest.label),
);
if let Some(kind) = spec.trailing {
content = content.push(super::super::layout::centered_fixed(
geometry.icon,
geometry.icon,
icons::view_at::<M>(kind, at_rest.icon, geometry.icon),
));
}
// 垂直居中必须在内容侧做(`button` 把内容放左上角)
let body = container(content)
.height(Length::Fill)
.align_y(Alignment::Center);
let element = button(body)
.height(Length::Fixed(geometry.height))
.padding(Padding {
top: 0.0,
right: geometry.pad,
bottom: 0.0,
left: geometry.pad,
})
.style(move |_theme, status| {
let state = forced.unwrap_or_else(|| Interaction::from_status(status));
let paint = paint::chip(ui, variant, selected, state, geometry.radius);
button::Style {
background: paint.frame.fill.map(Background::Color),
text_color: paint.label,
border: Border {
color: paint.frame.outline.unwrap_or(iced::Color::TRANSPARENT),
width: if paint.frame.outline.is_some() {
paint.frame.stroke
} else {
0.0
},
radius: iced::border::Radius::new(paint.frame.radius),
},
shadow: Shadow::default(),
snap: true,
}
});
match spec.on_press {
Some(message) => element.on_press(message).into(),
None => element.into(),
}
}
@@ -0,0 +1,259 @@
//! 选择类家族的**几何**:值要么来自令牌,要么在此登记 G 编号(不散落在组件里)。
//!
//! ## 参考值 → 落点
//!
//! | # | 量 | 参考值 | 来源 | 落点 |
//! |---|---|---|---|---|
//! | 18.1 | Checkbox 外框 / 圆角 | **20×20** / **2** | `[源]` `Checkbox.kt:737-738`(`COMPONENT_WIDTH/HEIGHT=18` 是死值,见 §26.2) | `Component::Checkbox.metrics()` + `ComponentRadius::Micro` |
//! | 18.2 | Checkbox 描边 | **2**(选中时 0) | `[源]` `UnselectedOutlineWidth` | `ComponentMetrics::CHECKBOX.stroke` |
//! | 18.5 | Checkbox 状态层 | **Ø40** | `[源]` `StateLayerSize` | `ComponentMetrics::CHECKBOX.state_layer` |
//! | 18.9 | 命中区 / 标签间距 | **48** / **8** | `[源]` `minimumInteractiveComponentSize` / 间距 `[估]` | `LayoutMetrics::touch_target` / `metrics.gap` |
//! | 19.1 | Radio 外圈 / 环线 | **20** / **2** | `[源]` `IconSize` / `RadioStrokeWidth` | `Component::Radio.metrics()` |
//! | 19.2 | Radio 内点 | **10**(不是 12) | `[源]` `DotSize=12`,绘制半径 12/2−2/2=5 | `ComponentMetrics::RADIO.inner_dot` |
//! | 10.1 | Switch 轨道 | **52×32**,圆角 full(16) | `[源]` `TrackWidth/TrackHeight/TrackShape` | [`SwitchGeometry`](**G16**) |
//! | 10.2 | Switch 滑块 | 选中 **24** / 未选 **16** / 按下 **28** | `[源]` `Selected/Unselected/PressedHandleWidth` | [`SwitchGeometry`](**G16**) |
//! | 13.1 | Chip 高 / 圆角 | **32** / **8** | `[源]` `ContainerHeight` / `ContainerShape=CornerSmall` | `Space::X8` + `Component::Chip.spec().shape` |
//!
//! **G16 登记**(续 `research/15` §25 的 G13):`ComponentMetrics` 只有
//! `CHECKBOX/RADIO/SEARCH_BAR/NAVIGATION_DRAWER` 四组,**Switch 与 Chip 没有落点**。
//! 本文件把它们的量收成命名常量(每个都带 A1 出处),等令牌层补
//! `ComponentMetrics::SWITCH/CHIP` 后整段删除、改走 `Component::Switch.metrics()`。
//!
//! **G14 已收口(M86)**:Chip 图标取 `IconSize::Button` = **18**(AAR `AssistChipTokens.IconSize =
//! 18.0d`,与 12b 13.4 同值),不再是偏离。Switch 滑块内的图标是**另一个值 16**
//! (`SwitchTokens.SelectedIconSize / UnselectedIconSize = 16.0d`),所以走 `IconSize::SwitchThumb`:
//! 两者在官方就是两个 token,别为了省一个档位把它们合成一个。
use unlucky_design::component_map::{Component, ComponentMetrics};
use unlucky_design::icons::IconSize;
use unlucky_design::spacing::Space;
use crate::theme::Ui;
/// Switch 轨道宽(`SwitchTokens.TrackWidth = 52.0`,**G16**:令牌层无此档)。
const SWITCH_TRACK_WIDTH: f32 = 52.0;
/// Switch 轨道描边(`SwitchTokens.TrackOutlineWidth = 2.0`,**G16**)。
const SWITCH_TRACK_STROKE: f32 = 2.0;
/// Switch 选中态滑块(`SwitchTokens.SelectedHandleWidth = 24.0`,**G16**)。
const SWITCH_THUMB_SELECTED: f32 = 24.0;
/// Switch 未选中态滑块(`SwitchTokens.UnselectedHandleWidth = 16.0`,**G16**)。
const SWITCH_THUMB_UNSELECTED: f32 = 16.0;
/// Switch 按下态滑块(`SwitchTokens.PressedHandleWidth = 28.0`,**G16**)。
const SWITCH_THUMB_PRESSED: f32 = 28.0;
/// Switch 状态层(`SwitchTokens.StateLayerSize = 40.0`,**G16**;与 Checkbox 同值)。
const SWITCH_STATE_LAYER: f32 = 40.0;
/// Checkbox / Radio 共用的方框几何(两控件同尺寸、不同形状语言,§18 / §19)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ControlGeometry {
/// 可见外框边长(20)。
pub side: f32,
/// 描边 / 环线宽度(2;**禁用态不变细**,§18.8)。
pub stroke: f32,
/// 状态层圆的直径(40)。
pub state_layer: f32,
/// 最小命中区(48,`LayoutMetrics::touch_target`)。
pub hit: f32,
/// 框 ↔ 标签间距(8,团队口径 `[估]`)。
pub gap: f32,
/// 圆角半径:Checkbox = 2(`ComponentRadius::Micro`),Radio = 10(`ShapeScale::Full` 的真圆)。
pub radius: f32,
/// Radio 内点直径(10);Checkbox 为 0(没有内点)。
pub inner_dot: f32,
}
impl ControlGeometry {
/// Checkbox:20×20 / 圆角 2 / 描边 2 / 状态层 Ø40(全部 `[源]`,见模块表)。
pub fn checkbox(ui: &Ui) -> Self {
let metrics = metrics(Component::Checkbox);
Self {
side: metrics.width,
stroke: metrics.stroke,
state_layer: metrics.state_layer,
hit: ui.metrics().touch_target,
gap: metrics.gap,
// 微控件层(D32):2dp 不在 `ShapeScale` 刻度里,只能走 `ComponentRadius`。
// 传 20 拿不到值说明有人把它挪到容器层了——那是 D32 违规,`unwrap_or` 会悄悄
// 变成直角,所以这里用 `expect` 直接炸(比图画错好查)。
radius: metrics
.radius
.expect("Checkbox 的圆角必须在微控件层(ComponentSpec::micro_radius)")
.radius_px(metrics.width)
.expect("Checkbox 20dp ≤ 微控件上限 24dp"),
// Checkbox 没有内点(内点是 radio 的语言,§19.6 明令两者不许混用)
inner_dot: metrics.inner_dot,
}
}
/// Radio:20 / 环线 2 / 内点 10 / 状态层 Ø40;圆角是**真圆**(`ShapeScale::Full`)。
pub fn radio(ui: &Ui) -> Self {
let metrics = metrics(Component::Radio);
Self {
side: metrics.width,
stroke: metrics.stroke,
state_layer: metrics.state_layer,
hit: ui.metrics().touch_target,
gap: metrics.gap,
radius: Component::Radio.spec().shape.radius_px(metrics.width),
// 内点 **10 不是 12**(§19.2:12 是含描边前的名义值,绘制半径 = 12/2 − 2/2 = 5)
inner_dot: metrics.inner_dot,
}
}
/// 命中区比可见状态层宽出来的部分(左右**各一份**):`(48 − 40) / 2 = 4`。
///
/// 无标签时行宽只有状态层的 40dp,命中区就少 8dp;A1 的
/// `minimumInteractiveComponentSize` 是 **48**,所以 view 侧左右各留这段透明内边距
/// (复审 P2-2)。有标签时行宽本来 ≥48,这段内边距不改变结论,只是保持两处口径一致。
pub fn hit_padding(&self) -> f32 {
(self.hit - self.state_layer) / 2.0
}
/// 勾形横向端点跨度(20 画布内 0.2→0.8):`[源]` `Checkbox.kt:496-514` 的路径比例。
///
/// 这是 research/15 §25 明确允许的「组件级常量」(路径比例 0.2/0.4/0.5/0.7/0.8 不进令牌层)。
/// 只在取证/测试里用(生产路径由 `shell::glyph` 直接按比例构路径)。
#[cfg(test)]
pub fn check_span(&self) -> f32 {
self.side * (0.8 - 0.2)
}
/// 方头(`StrokeCap.Square`)渲染后墨迹相对端点跨度的**外扩量**:两侧各 `1/√2 × 线宽`。
///
/// 只用于把「参考值 12 是端点跨度、实测是墨迹跨度」这件事写进断言:勾的两端是 45° 斜线,
/// 方头沿斜向外扩,投影到 x 轴正好是 `stroke / √2`(每端)。`#[cfg(test)]`:同 `check_span`。
#[cfg(test)]
pub fn square_cap_bleed(&self) -> f32 {
self.stroke * std::f32::consts::FRAC_1_SQRT_2
}
}
/// Switch 的几何(`research/12b` §10;数值标 **G16** 的部分令牌层暂无档位)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SwitchGeometry {
/// 轨道宽(52)。
pub track_width: f32,
/// 轨道高(32 = `Space::X8`)。
pub track_height: f32,
/// 轨道描边(2,仅未选中态画)。
pub track_stroke: f32,
/// 滑块:选中 / 未选中 / 按下三档(24 / 16 / 28)。
pub thumb_selected: f32,
/// 未选中滑块(16)。
pub thumb_unselected: f32,
/// 按下滑块(28)。
pub thumb_pressed: f32,
/// 滑块内图标(16 = `IconSize::SwitchThumb`;AAR `SwitchTokens.SelectedIconSize /
/// UnselectedIconSize = 16.0d`。**不是按钮的 18**)。
pub icon: f32,
/// 状态层直径(40)。
pub state_layer: f32,
/// 最小命中区(48)。
pub hit: f32,
}
impl SwitchGeometry {
/// 从令牌 + G16 常量求值。
pub fn of(ui: &Ui) -> Self {
Self {
track_width: SWITCH_TRACK_WIDTH,
track_height: ui.space(Space::X8),
track_stroke: SWITCH_TRACK_STROKE,
thumb_selected: SWITCH_THUMB_SELECTED,
thumb_unselected: SWITCH_THUMB_UNSELECTED,
thumb_pressed: SWITCH_THUMB_PRESSED,
icon: IconSize::SwitchThumb.px(),
state_layer: SWITCH_STATE_LAYER,
hit: ui.metrics().touch_target,
}
}
/// 滑块直径:按下态胀到 28,其余按选中与否取 24 / 16(§10.2)。
pub fn thumb(&self, selected: bool, pressed: bool) -> f32 {
if pressed {
self.thumb_pressed
} else if selected {
self.thumb_selected
} else {
self.thumb_unselected
}
}
/// 滑块离轨道边的内距 = `(轨道高 − 滑块) / 2`(三个档位都成立:32−24=8→4,32−16=16→8,32−28=4→2)。
pub fn thumb_inset(&self, thumb: f32) -> f32 {
(self.track_height - thumb) / 2.0
}
/// 状态层相对轨道的横向外扩:状态层比轨道高,未选中时滑块又靠左,
/// 所以状态层会**超出轨道左侧**——绘制区因此要多留这一段(见 [`Self::layer_offset`])。
pub fn bleed(&self) -> f32 {
(self.state_layer - self.track_height) / 2.0
}
/// 滑块圆心到状态层左缘的距离(= 状态层半径)。
pub fn layer_radius(&self) -> f32 {
self.state_layer / 2.0
}
/// 滑块圆心离轨道左缘的距离:未选中 = 内距 + 半滑块;选中 = 轨道宽 − 内距 − 半滑块。
pub fn thumb_center(&self, selected: bool, thumb: f32) -> f32 {
let inset = self.thumb_inset(thumb);
if selected {
self.track_width - inset - thumb / 2.0
} else {
inset + thumb / 2.0
}
}
/// 状态层左缘相对**绘制区**的位置:绘制区左缘 = 轨道左缘 − [`Self::bleed`]。
pub fn layer_offset(&self, selected: bool, thumb: f32) -> f32 {
self.thumb_center(selected, thumb) + self.bleed() - self.layer_radius()
}
/// 绘制区宽(轨道 + 两侧各一段状态层外扩)。
pub fn draw_width(&self) -> f32 {
self.track_width + self.bleed() * 2.0
}
/// 绘制区高(= 状态层直径,轨道在中间垂直居中)。
pub fn draw_height(&self) -> f32 {
self.state_layer
}
}
/// Chip 的几何(`research/12b` §13)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ChipGeometry {
/// 容器高(32 = `Space::X8`,`AssistChipTokens.ContainerHeight`)。
pub height: f32,
/// 圆角(8 = `Component::Chip.spec().shape = ShapeScale::Small`)。
pub radius: f32,
/// 水平内边距(8 = `Space::X2`,`Chip.kt HorizontalElementsPadding`)。
pub pad: f32,
/// 元素间距(8,同上)。
pub gap: f32,
/// 图标边长(18 = `IconSize::Button`;AAR `AssistChipTokens.IconSize = 18.0d`,12b 13.4 同值)。
pub icon: f32,
}
impl ChipGeometry {
/// 从令牌求值(`shape` 档位来自组件表,半径按边长折算)。
pub fn of(ui: &Ui) -> Self {
let height = ui.space(Space::X8);
let shape = Component::Chip.spec().shape;
Self {
height,
radius: shape.radius_px(height),
pad: ui.space(Space::X2),
gap: ui.space(Space::X2),
icon: IconSize::Button.px(),
}
}
}
/// 组件表里的微度量;缺组 = 令牌层还没建这一档(G16 的判据)。
fn metrics(component: Component) -> ComponentMetrics {
component
.metrics()
.unwrap_or_else(|| panic!("{component:?} 缺 ComponentMetrics(G10 的消费入口)"))
}
Loaded 100 of 354 files, more files were not shown because too many files have changed in this diff. Show more