9 Commits
Author SHA1 Message Date
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
37 changed files with 6646 additions and 49 deletions

No files matched your search

+80
View File
@@ -90,3 +90,83 @@ jobs:
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- run: cargo build --workspace --release - 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
+86 -7
View File
@@ -1,6 +1,6 @@
name: Release name: Release
# 发布流水线:tag `v*` → 三平台(四槽位)矩阵构建 → 单文件产物 + SHA256SUMS # 发布流水线:tag `v*` → 四平台(六槽位)矩阵构建 → 单文件产物 + SHA256SUMS
# → 上传 artifact + 创建 GitHub Release(pre-release 默认开)。同一 tag 重跑幂等: # → 上传 artifact + 创建 GitHub Release(pre-release 默认开)。同一 tag 重跑幂等:
# Release 已存在时覆盖资产与说明,不再 `gh release create`(见 release job 末步); # Release 已存在时覆盖资产与说明,不再 `gh release create`(见 release job 末步);
# 重跑**不碰** pre-release 标记,该标记只在 `inputs.prerelease` 显式给出时才被断言。 # 重跑**不碰** pre-release 标记,该标记只在 `inputs.prerelease` 显式给出时才被断言。
@@ -42,26 +42,56 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
# Windows:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包, # Windows x64:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包,
# 双击即用的单文件形态;UCRT 本身就是 Win10+ 的系统组件。 # 双击即用的单文件形态;UCRT 本身就是 Win10+ 的系统组件。
# 产物根 target/release;自检与打包都从 matrix.build_dir 取路径(不硬编码)。
- slot: windows-x64 - slot: windows-x64
os: windows-latest 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 rustflags: -C target-feature=+crt-static
# Linux:固定 ubuntu-22.04(glibc 2.35 地板)而不是 latest, # Linux:固定 ubuntu-22.04(glibc 2.35 地板)而不是 latest,
# 否则产物的 glibc 底线会跟着 runner 漂移(24.04 = 2.39)。 # 否则产物的 glibc 底线会跟着 runner 漂移(24.04 的 glibc 明显更高)。
- slot: linux-x64 - slot: linux-x64
os: ubuntu-22.04 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 已下线)。 # macOS:latest = arm64;Intel x64 用 macos-15-intel(macos-13 已下线)。
- slot: macos-arm64 - slot: macos-arm64
os: macos-latest os: macos-latest
build_dir: target/release
- slot: macos-x64 - slot: macos-x64
os: macos-15-intel os: macos-15-intel
build_dir: target/release
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Linux 图形栈构建依赖(winit / femtovg 编译期需要) - name: Linux 图形栈构建依赖(winit / femtovg 编译期需要)
if: runner.os == 'Linux' if: runner.os == 'Linux'
shell: bash
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y --no-install-recommends \ sudo apt-get install -y --no-install-recommends \
@@ -71,14 +101,56 @@ jobs:
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2 - 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 - name: cargo build --release --locked
shell: bash
env: env:
RUSTFLAGS: ${{ matrix.rustflags }} 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) - name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
if: runner.os == 'Windows' if: runner.os == 'Windows'
shell: bash shell: bash
env:
# 跨槽位的关键点:产物在 target/<triple>/release 而不是 target/release。
EXE: ${{ matrix.build_dir }}/unlucky-launcher.exe
run: | run: |
set -euo pipefail set -euo pipefail
# +crt-static 是否真的生效,只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 说明还得装 # +crt-static 是否真的生效,只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 说明还得装
@@ -89,6 +161,7 @@ jobs:
# ② find 兜底:Program Files 与 Program Files (x86) 两个 VS 根都扫(Build Tools 落在 x86 根); # ② find 兜底:Program Files 与 Program Files (x86) 两个 VS 根都扫(Build Tools 落在 x86 根);
# ③ PATH:镜像若已把工具链预置进 PATH 则直接可用。 # ③ PATH:镜像若已把工具链预置进 PATH 则直接可用。
# PATH 排最后是刻意的:PATH 最不可控,官方定位器优先。 # PATH 排最后是刻意的:PATH 最不可控,官方定位器优先。
# Hostx64 的 dumpbin 读 ARM64 PE 的导入表没问题,所以 arm64 槽位共用同一条链。
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe" VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
DUMPBIN="" DUMPBIN=""
VIA="" VIA=""
@@ -133,7 +206,8 @@ jobs:
exit 1 exit 1
fi fi
echo "dumpbin: $DUMPBIN(定位自 $VIA)" echo "dumpbin: $DUMPBIN(定位自 $VIA)"
"$DUMPBIN" -nologo -dependents target/release/unlucky-launcher.exe | tee dependents.txt ls -l "$EXE"
"$DUMPBIN" -nologo -dependents "$EXE" | tee dependents.txt
if grep -Eiq 'VCRUNTIME|MSVCP|MSVCR|ucrtbase\.dll' dependents.txt; then if grep -Eiq 'VCRUNTIME|MSVCP|MSVCR|ucrtbase\.dll' dependents.txt; then
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包" echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"
exit 1 exit 1
@@ -145,11 +219,15 @@ jobs:
# 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。 # 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。
REF_TYPE: ${{ github.ref_type }} REF_TYPE: ${{ github.ref_type }}
TAG: ${{ github.ref_name }} TAG: ${{ github.ref_name }}
SLOT: ${{ matrix.slot }}
BUILD_DIR: ${{ matrix.build_dir }}
run: | run: |
set -euo pipefail set -euo pipefail
# git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。 # git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。
PY=$(command -v python3 || command -v 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 if [ "$REF_TYPE" = "tag" ]; then
args+=(--version "$TAG") # 与 Cargo.toml 版本交叉校验 args+=(--version "$TAG") # 与 Cargo.toml 版本交叉校验
fi fi
@@ -202,7 +280,8 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
ls -l dist ls -l dist
for slot in windows-x64 linux-x64 macos-arm64 macos-x64; do # 六槽位全齐才发版:缺任意一个就 fail(宁可不出 Release,也不出少一档的 Release)。
for slot in windows-x64 windows-arm64 linux-x64 linux-arm64 macos-arm64 macos-x64; do
if ! compgen -G "dist/*-$slot*" >/dev/null; then if ! compgen -G "dist/*-$slot*" >/dev/null; then
echo "::error::矩阵缺产物:$slot" echo "::error::矩阵缺产物:$slot"
exit 1 exit 1
Generated
+4
View File
@@ -6070,6 +6070,10 @@ dependencies = [
"zip", "zip",
] ]
[[package]]
name = "unlucky-design"
version = "0.1.0"
[[package]] [[package]]
name = "unsafe-libyaml" name = "unsafe-libyaml"
version = "0.2.11" version = "0.2.11"
+7 -1
View File
@@ -1,6 +1,12 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["crates/core", "crates/agent", "crates/app", "crates/cli"] members = [
"crates/core",
"crates/agent",
"crates/app",
"crates/cli",
"crates/design",
]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.1.0"
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "unlucky-design"
description = "Unlucky-Launcher 设计令牌:M3 Expressive 色彩/排版/形状/高程/动效/图标,纯数据 + 纯函数,零 UI 依赖"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
rust-version.workspace = true
publish.workspace = true
# 零 UI 依赖是硬约束(D3 纪律 + M61):本 crate 是渲染无关的**数据与纯函数**,
# 不得出现 iced / slint / winit / wgpu / gtk / egui / tauri 等 UI 或渲染框架,
# 也不引入任何第三方 crate(连 serde 都不加——序列化由消费方适配层自己做)。
# 证据命令:cargo tree -p unlucky-design --edges normal
[dependencies]
[lints]
workspace = true
+253
View File
@@ -0,0 +1,253 @@
//! # 色彩令牌(M3 color roles)
//!
//! 49 个 M3 语义色角色 × light/dark 两套,全部由**一个种子色**派生([`Palettes`]),
//! 再按官方 tone 映射表解析成具体色值([`Scheme`])。
//!
//! ```no_run
//! use unlucky_design::color::{Color, Role, Scheme, SchemeFlavor};
//!
//! let light = Scheme::baseline(SchemeFlavor::Light);
//! let surface = light.get(Role::Surface);
//! let on_surface = light.get(Role::OnSurface);
//! assert!(on_surface.contrast_ratio(surface) > 4.5);
//!
//! // 用户自定义强调色:表面中性色不动,只换强调族。
//! let custom = Scheme::from_accent(SchemeFlavor::Light, Color::from_hex("#006A5C").unwrap());
//! assert_eq!(custom.get(Role::Surface), surface);
//! ```
//!
//! ## 口径声明:这是 M3 **近似**,不是标准 HCT 动态取色
//!
//! 标准 M3 的调色板建在 **HCT**(CAM16 色相/彩度 + CIELAB `L*`)上。本 crate 不实现 CAM16,
//! 用的是 **OKLCh**(色相/彩度)+ **CIELAB `L*`**(tone 轴)。具体差异:
//!
//! | 轴 | 标准 M3 | 本实现 | 影响 |
//! |---|---|---|---|
//! | tone | CIELAB `L*` | CIELAB `L*`(二分反解 OKLab 明度,误差 ≤1 tone) | **无偏差**:tone 定义严格对齐 |
//! | 色相 | CAM16 hue | OKLCh hue(种子色相原样保留) | 绝对色相角不同,但「同族色相恒定」这个性质成立;派生色(tertiary = +60°)偏移量与 M3 不完全相同 |
//! | 彩度 | CAM16 chroma(如 primary 36 / secondary 16 / neutral 6) | OKLCh chroma(标定值见 [`palettes`]) | 数值量纲不同,语义等价;饱和度观感与官方色板会有可见差别(不是抄错,是量纲换算不通用) |
//! | gamut | CAM16 gamut mapping(按色相压缩彩度) | 按 tone 二分压 OKLCh 彩度进 sRGB | 极端 tone(5/95+)的饱和度可能比官方色板略低 |
//! | 动态色 | Material You 从壁纸取 5 组种子 | 只用**一个**种子 + 固定派生规则 | 没有「多色壁纸取色」能力;本启动器用不到(壁纸取色是安卓端特性) |
//! | 对比度模式 | Dynamic Contrast 三档(standard/medium/high) | 只有 standard | 不提供高对比方案;WCAG AA 在 standard 下已满足(见 [`super::color::contrast`] 与测试) |
//!
//! 结论:**不许对外宣称「标准 M3 动态取色」**。要对比官方色板时按上表解释差异,
//! 不要试图逐值对齐 material-color-utilities 的输出(那需要 CAM16 + HCT,是另一个量级的实现)。
//!
//! ## 对比度纪律(WCAG AA)
//!
//! - 所有 `on-*` 与其承载色(`*-container`、`primary`、`surface`)都满足 **≥ 4.5:1**;
//! 这一条由 `tests/contrast_aa.rs` 全表验证,且因为 tone 轴固定,**换任何种子色都成立**
//! (WCAG 对比度只取决于相对亮度,而 tone 直接钉住 L*)。
//! - 例外(显式登记,非缺陷):
//! - [`Role::Outline`] 对 [`Role::Surface`]:M3 定义的**非文字**元素(输入框描边),
//! 目标 3:1,实测 light 4.29:1 / dark 6.5:1;
//! - [`Role::OutlineVariant`] 对 [`Role::Surface`]:纯装饰分隔线,M3 不要求对比度下限;
//! - [`Role::SurfaceVariant`]:它是**容器**色(不是前景),不参与对比度门禁。
//! - 禁用态按 M3 的 alpha 叠加([`state::DISABLED_CONTENT_ALPHA`]),刻意低于 AA。
pub mod palettes;
pub mod roles;
pub mod scheme;
pub mod space;
pub mod state;
pub mod tonal;
pub use palettes::{PaletteSlot, Palettes, BASELINE_SEED};
pub use roles::Role;
pub use scheme::{Scheme, SchemeFlavor, SCRIM_ALPHA, SHADOW_ALPHA};
pub use state::StateLayer;
pub use tonal::TonalPalette;
/// sRGB 颜色(8 bit/通道 + alpha),刻意不用第三方颜色类型:本 crate 零依赖。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Color {
/// 红通道。
pub r: u8,
/// 绿通道。
pub g: u8,
/// 蓝通道。
pub b: u8,
/// alpha(0 = 全透明,255 = 不透明)。
pub a: u8,
}
impl Color {
/// 不透明颜色。
pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b, a: 255 }
}
/// 带 alpha 的颜色(`a` 为 0..=255)。
pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
Self { r, g, b, a }
}
/// 纯黑。
pub const BLACK: Color = Color::rgb(0, 0, 0);
/// 纯白。
pub const WHITE: Color = Color::rgb(255, 255, 255);
/// 从 `#RRGGBB` / `#RGB` / `#AARRGGBB` 解析(不合法返回 `None`)。
///
/// 只给配置读取与测试用——UI 代码里**不许出现色字面量**,一律走 [`Role`]。
pub fn from_hex(text: &str) -> Option<Self> {
let hex = text.trim().trim_start_matches('#');
let parse = |s: &str| u8::from_str_radix(s, 16).ok();
match hex.len() {
3 => {
let d = |i: usize| {
let v = parse(&hex[i..i + 1])?;
Some(v * 17)
};
Some(Self::rgb(d(0)?, d(1)?, d(2)?))
}
6 => Some(Self::rgb(
parse(&hex[0..2])?,
parse(&hex[2..4])?,
parse(&hex[4..6])?,
)),
8 => Some(Self::rgba(
parse(&hex[2..4])?,
parse(&hex[4..6])?,
parse(&hex[6..8])?,
parse(&hex[0..2])?,
)),
_ => None,
}
}
/// 不透明部分转 `#RRGGBB`(alpha < 255 时附 `#AARRGGBB` 形式)。
pub fn to_hex(self) -> String {
if self.a == 255 {
format!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b)
} else {
format!("#{:02X}{:02X}{:02X}{:02X}", self.a, self.r, self.g, self.b)
}
}
/// 改 alpha(`alpha` 0..1,越界夹取)。
pub fn with_alpha(self, alpha: f32) -> Self {
Self {
a: (alpha.clamp(0.0, 1.0) * 255.0).round() as u8,
..self
}
}
/// alpha 归一化到 0..1。
pub fn alpha(self) -> f32 {
f32::from(self.a) / 255.0
}
/// 是否完全不透明。
pub fn is_opaque(self) -> bool {
self.a == 255
}
/// WCAG 相对亮度(0..1,按 sRGB→XYZ 的 Y 行计算)。
pub fn relative_luminance(self) -> f32 {
let lin = |c: u8| space::srgb_to_linear(f32::from(c) / 255.0);
space::luminance([lin(self.r), lin(self.g), lin(self.b)])
}
/// CIELAB `L*`(0..100)——这就是 M3 的 **tone**。
///
/// 调色板按这条轴生成,验收也按这条轴断言;想知道某个色值「是几号 tone」就用它。
pub fn lstar(self) -> f32 {
space::lstar_from_luminance(self.relative_luminance())
}
/// WCAG 对比度(1..21)。`self` 与 `other` 都不透明时才严格;带 alpha 时先算「不计 alpha」的值。
pub fn contrast_ratio(self, other: Self) -> f32 {
let a = self.relative_luminance();
let b = other.relative_luminance();
let (hi, lo) = if a > b { (a, b) } else { (b, a) };
(hi + 0.05) / (lo + 0.05)
}
/// alpha 合成:`self` 叠在 `bg` 之上,返回不透明结果。
///
/// 这是 M3 状态层/禁用态/遮罩的唯一合成口径(非预乘 alpha)。
pub fn over(self, bg: Self) -> Self {
let sa = self.alpha();
if sa >= 1.0 {
return self;
}
let blend = |fg: u8, b: u8| {
let v = f32::from(fg) * sa + f32::from(b) * (1.0 - sa);
v.round().clamp(0.0, 255.0) as u8
};
let a = sa + bg.alpha() * (1.0 - sa);
Self {
r: blend(self.r, bg.r),
g: blend(self.g, bg.g),
b: blend(self.b, bg.b),
a: (a * 255.0).round().clamp(0.0, 255.0) as u8,
}
}
/// 两色线性插值(`t` 0..1),用于动画中间帧(GPU 路径的 shape morph 用得上)。
pub fn lerp(self, other: Self, t: f32) -> Self {
let t = t.clamp(0.0, 1.0);
let mix = |a: u8, b: u8| {
(f32::from(a) + (f32::from(b) - f32::from(a)) * t)
.round()
.clamp(0.0, 255.0) as u8
};
Self {
r: mix(self.r, other.r),
g: mix(self.g, other.g),
b: mix(self.b, other.b),
a: mix(self.a, other.a),
}
}
/// 从线性 RGB(0..1,可越界)构造:先夹取再 sRGB 编码。
fn from_linear_f32(rgb: [f32; 3]) -> Self {
let enc = |c: f32| {
(space::linear_to_srgb(c.clamp(0.0, 1.0)) * 255.0)
.round()
.clamp(0.0, 255.0) as u8
};
Self::rgb(enc(rgb[0]), enc(rgb[1]), enc(rgb[2]))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_roundtrip() {
let c = Color::from_hex("#6750A4").expect("合法 hex");
assert_eq!(c, Color::rgb(0x67, 0x50, 0xA4));
assert_eq!(c.to_hex(), "#6750A4");
assert_eq!(Color::from_hex("6750a4"), Some(c));
assert_eq!(Color::from_hex("#FFF"), Some(Color::WHITE));
assert_eq!(Color::from_hex("#80FF0000").map(|c| c.a), Some(0x80));
assert_eq!(Color::from_hex("#12345"), None);
assert_eq!(Color::from_hex("zzzzzz"), None);
}
#[test]
fn contrast_extremes() {
assert!((Color::BLACK.contrast_ratio(Color::WHITE) - 21.0).abs() < 0.01);
assert!((Color::WHITE.contrast_ratio(Color::WHITE) - 1.0).abs() < 0.001);
}
#[test]
fn over_composites_correctly() {
let black_50 = Color::BLACK.with_alpha(0.5);
let got = black_50.over(Color::WHITE);
// with_alpha 量化到 128/255 = 0.50196,所以结果落在 127(不是 128)。
assert_eq!((got.r, got.a), (127, 255));
}
#[test]
fn lerp_hits_endpoints() {
let a = Color::rgb(0, 0, 0);
let b = Color::rgb(255, 255, 255);
assert_eq!(a.lerp(b, 0.0), a);
assert_eq!(a.lerp(b, 1.0), b);
assert_eq!(a.lerp(b, 0.5).r, 128);
}
}
+161
View File
@@ -0,0 +1,161 @@
//! 六条 tone 调色板的集合(M3 scheme 的原料)。
//!
//! 角色 → 调色板槽位 → tone 的三段映射在 [`super::scheme`];这里只负责「种子 → 六条调色板」。
use super::tonal::TonalPalette;
use super::Color;
/// M3 baseline 种子色(与 material-color-utilities 的默认基线同值)。
pub const BASELINE_SEED: Color = Color::rgb(0x67, 0x50, 0xA4);
/// 调色板槽位(M3 的六条 tonal palette)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaletteSlot {
/// 主调色板(种子色相)。
Primary,
/// 次调色板(同色相、低彩度)。
Secondary,
/// 第三调色板(种子色相 +60°,中彩度)。
Tertiary,
/// 中性调色板(近零彩度的表面色)。
Neutral,
/// 中性变体调色板(比中性略彩色,做分隔与次级容器)。
NeutralVariant,
/// 错误调色板(固定红相)。
Error,
}
// 下面几个常数是**我们的标定值**(OKLCh 彩度单位),不是 M3 的 CAM16 chroma:
// CAM16 的 chroma 36/16/24/6/8 量纲与本实现不同,直接照抄会得到过饱和的色板。
/// 次色的彩度上限。
const SECONDARY_CHROMA: f32 = 0.055;
/// 第三色的彩度上限。
const TERTIARY_CHROMA: f32 = 0.085;
/// 中性的彩度上限(几乎是灰)。
const NEUTRAL_CHROMA: f32 = 0.008;
/// 中性变体的彩度上限。
const NEUTRAL_VARIANT_CHROMA: f32 = 0.016;
/// 错误色的色相(OKLCh 红相)。
const ERROR_HUE_DEG: f32 = 25.0;
/// 错误色的彩度上限。
const ERROR_CHROMA: f32 = 0.18;
/// 第三色调色板的色相偏移。
const TERTIARY_HUE_SHIFT_DEG: f32 = 60.0;
/// 一套完整的六条调色板。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Palettes {
/// 主要调色板。
pub primary: TonalPalette,
/// 次要调色板。
pub secondary: TonalPalette,
/// 第三调色板。
pub tertiary: TonalPalette,
/// 中性调色板。
pub neutral: TonalPalette,
/// 中性变体调色板。
pub neutral_variant: TonalPalette,
/// 错误调色板。
pub error: TonalPalette,
/// 彩度压缩的**共享基准**(取建方案时的 primary 彩度,强调色覆盖时**不变**)。
///
/// 为什么单独存一份:`from_accent` 换掉 primary 后,中性表面不该跟着变色调
/// (否则「换强调色不动底色」这条语义就破了),而彩度压缩又必须用同一个基准才能
/// 保证 primary/secondary 之间的相对灰度——两者靠这个字段同时成立。
reference_chroma: f32,
}
impl Palettes {
/// 出厂基线(种子 `#6750A4`,等价 M3 baseline)。
pub fn baseline() -> Self {
Self::from_seed(BASELINE_SEED)
}
/// 从一个种子色派生全套调色板。
pub fn from_seed(seed: Color) -> Self {
let primary = TonalPalette::from_color(seed);
Self {
primary,
secondary: primary.limited_chroma(SECONDARY_CHROMA),
tertiary: primary
.rotated(TERTIARY_HUE_SHIFT_DEG)
.limited_chroma(TERTIARY_CHROMA),
neutral: primary.with_chroma(NEUTRAL_CHROMA),
neutral_variant: primary.with_chroma(NEUTRAL_VARIANT_CHROMA),
error: TonalPalette::new(ERROR_HUE_DEG, ERROR_CHROMA),
reference_chroma: primary.chroma(),
}
}
/// 只换强调色:primary/secondary/tertiary 跟着新强调色走,**表面中性色保持不变**。
///
/// 这是「用户自定义强调色」的正确语义——换强调色不该让整个界面底色变脏;
/// 也因为 tone 轴固定,任何色相都能自动满足 WCAG AA(不需要再算一遍对比度)。
/// `reference_chroma` 也保持原值,所以中性色与 baseline 逐像素一致(有测试钉住)。
pub fn with_accent(self, accent: Color) -> Self {
let accent_primary = TonalPalette::from_color(accent);
Self {
primary: accent_primary,
secondary: accent_primary.limited_chroma(SECONDARY_CHROMA),
tertiary: accent_primary
.rotated(TERTIARY_HUE_SHIFT_DEG)
.limited_chroma(TERTIARY_CHROMA),
..self
}
}
/// 按槽位取调色板。
pub fn slot(&self, slot: PaletteSlot) -> TonalPalette {
match slot {
PaletteSlot::Primary => self.primary,
PaletteSlot::Secondary => self.secondary,
PaletteSlot::Tertiary => self.tertiary,
PaletteSlot::Neutral => self.neutral,
PaletteSlot::NeutralVariant => self.neutral_variant,
PaletteSlot::Error => self.error,
}
}
/// 方案级取色:以 [`Palettes::reference_chroma`] 为**共享基准**压所有调色板(见
/// [`TonalPalette::tone_scaled`]),保证同 tone 下 primary/secondary/tertiary 不会撞色,
/// 且换强调色时中性表面逐像素不变。
pub fn tone(&self, slot: PaletteSlot, tone: f32) -> Color {
self.slot(slot).tone_scaled(tone, self.reference_chroma)
}
/// 彩度压缩的共享基准(调试/测试用)。
pub fn reference_chroma(&self) -> f32 {
self.reference_chroma
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn baseline_seed_is_the_primary_palette_hue() {
let palettes = Palettes::baseline();
let seed_lch = super::super::space::Oklch::from_color(BASELINE_SEED);
let delta = (palettes.primary.hue_deg() - seed_lch.hue_deg).abs();
assert!(delta < 0.01, "primary 调色板应保持种子色相,偏差 {delta}°");
}
#[test]
fn accent_override_keeps_surfaces() {
let base = Palettes::baseline();
let accent = Color::rgb(0x00, 0x6A, 0x5C);
let swapped = base.with_accent(accent);
assert_eq!(swapped.neutral, base.neutral);
assert_eq!(swapped.neutral_variant, base.neutral_variant);
assert_eq!(swapped.error, base.error);
assert_ne!(swapped.primary.hue_deg(), base.primary.hue_deg());
}
#[test]
fn neutral_variant_is_less_chromatic_than_primary() {
let palettes = Palettes::baseline();
assert!(palettes.neutral.chroma() < palettes.neutral_variant.chroma());
assert!(palettes.neutral_variant.chroma() < palettes.secondary.chroma());
}
}
+276
View File
@@ -0,0 +1,276 @@
//! M3 语义色角色:`Role` 枚举是**唯一的名字清单**(49 个,M3 基础集全覆盖)。
//!
//! 后续 iced 页面不许另起名字(如 `bg-card` / `fg-primary`)——Fluent 时代的旧名到此为止,
//! 要表达「卡片底」就用 [`Role::SurfaceContainerLow`],要表达「次要文字」就用
//! [`Role::OnSurfaceVariant`](对应关系见 `DevDocs/SubAgentsTalk/design-令牌约定.md`)。
/// M3 语义色角色(49 个)。
///
/// 顺序即 [`Role::ALL`] 的顺序,也是 [`super::Scheme`] 内部数组的下标:
/// **只允许在末尾追加**,插到中间会静默改变已存盘的颜色映射。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Role {
/// 主色:主操作填充、选中指示。
Primary,
/// 主色之上的前景(文字/图标)。
OnPrimary,
/// 主色容器:次级强调块、选中片。
PrimaryContainer,
/// 主色容器之上的前景。
OnPrimaryContainer,
/// 次色:低频强调、图表。
Secondary,
/// 次色之上的前景。
OnSecondary,
/// 次色容器。
SecondaryContainer,
/// 次色容器之上的前景。
OnSecondaryContainer,
/// 第三色:与主/次形成对比的装饰强调。
Tertiary,
/// 第三色之上的前景。
OnTertiary,
/// 第三色容器。
TertiaryContainer,
/// 第三色容器之上的前景。
OnTertiaryContainer,
/// 错误色:错误文字、危险操作填充。
Error,
/// 错误色之上的前景。
OnError,
/// 错误容器:错误提示底。
ErrorContainer,
/// 错误容器之上的前景。
OnErrorContainer,
/// 表面:窗口/内容底色(M3 最新口径里取代 background)。
Surface,
/// 表面之上的主体文字。
OnSurface,
/// 表面变体:中性分隔/次级容器(**不是**文字色)。
SurfaceVariant,
/// 表面变体之上的次级文字/图标。
OnSurfaceVariant,
/// 背景(M3 已并入 surface,保留以兼容旧值)。
Background,
/// 背景之上的文本(同上,保留兼容)。
OnBackground,
/// 变暗的表面(模态背后的压暗层)。
SurfaceDim,
/// 变亮的表面(深色下的抬高底)。
SurfaceBright,
/// 最低层容器(比 surface 更低)。
SurfaceContainerLowest,
/// 低层容器:卡片。
SurfaceContainerLow,
/// 标准容器:卡片/面板。
SurfaceContainer,
/// 高层容器:浮层。
SurfaceContainerHigh,
/// 最高层容器:对话框/菜单。
SurfaceContainerHighest,
/// 描边:输入框边框等(非文字,3:1 即可)。
Outline,
/// 弱描边:分隔线(纯装饰)。
OutlineVariant,
/// 反相表面:Snackbar / Tooltip 底。
InverseSurface,
/// 反相表面之上的文字。
InverseOnSurface,
/// 反相表面之上的强调。
InversePrimary,
/// 阴影色(通常带 alpha,见 [`super::Scheme::shadow`])。
Shadow,
/// 遮罩色(模态背景,通常带 alpha)。
Scrim,
/// 表面着色(M3 旧规范的 elevation tint,现已被 surface container 取代)。
SurfaceTint,
/// 固定主色(深浅模式同值,用于需要跨模式稳定的块)。
PrimaryFixed,
/// 固定主色的暗档。
PrimaryFixedDim,
/// 固定主色之上的前景。
OnPrimaryFixed,
/// 固定主色暗档之上的前景。
OnPrimaryFixedVariant,
/// 固定次色。
SecondaryFixed,
/// 固定次色的暗档。
SecondaryFixedDim,
/// 固定次色之上的前景。
OnSecondaryFixed,
/// 固定次色暗档之上的前景。
OnSecondaryFixedVariant,
/// 固定第三色。
TertiaryFixed,
/// 固定第三色的暗档。
TertiaryFixedDim,
/// 固定第三色之上的前景。
OnTertiaryFixed,
/// 固定第三色暗档之上的前景。
OnTertiaryFixedVariant,
}
impl Role {
/// 全部 49 个角色,按 `Role as usize` 顺序排列。
pub const ALL: [Role; 49] = [
Role::Primary,
Role::OnPrimary,
Role::PrimaryContainer,
Role::OnPrimaryContainer,
Role::Secondary,
Role::OnSecondary,
Role::SecondaryContainer,
Role::OnSecondaryContainer,
Role::Tertiary,
Role::OnTertiary,
Role::TertiaryContainer,
Role::OnTertiaryContainer,
Role::Error,
Role::OnError,
Role::ErrorContainer,
Role::OnErrorContainer,
Role::Surface,
Role::OnSurface,
Role::SurfaceVariant,
Role::OnSurfaceVariant,
Role::Background,
Role::OnBackground,
Role::SurfaceDim,
Role::SurfaceBright,
Role::SurfaceContainerLowest,
Role::SurfaceContainerLow,
Role::SurfaceContainer,
Role::SurfaceContainerHigh,
Role::SurfaceContainerHighest,
Role::Outline,
Role::OutlineVariant,
Role::InverseSurface,
Role::InverseOnSurface,
Role::InversePrimary,
Role::Shadow,
Role::Scrim,
Role::SurfaceTint,
Role::PrimaryFixed,
Role::PrimaryFixedDim,
Role::OnPrimaryFixed,
Role::OnPrimaryFixedVariant,
Role::SecondaryFixed,
Role::SecondaryFixedDim,
Role::OnSecondaryFixed,
Role::OnSecondaryFixedVariant,
Role::TertiaryFixed,
Role::TertiaryFixedDim,
Role::OnTertiaryFixed,
Role::OnTertiaryFixedVariant,
];
/// 稳定字符串名(kebab-case),用于日志、配置、快照对拍与文档表格。
pub const fn name(self) -> &'static str {
match self {
Role::Primary => "primary",
Role::OnPrimary => "on-primary",
Role::PrimaryContainer => "primary-container",
Role::OnPrimaryContainer => "on-primary-container",
Role::Secondary => "secondary",
Role::OnSecondary => "on-secondary",
Role::SecondaryContainer => "secondary-container",
Role::OnSecondaryContainer => "on-secondary-container",
Role::Tertiary => "tertiary",
Role::OnTertiary => "on-tertiary",
Role::TertiaryContainer => "tertiary-container",
Role::OnTertiaryContainer => "on-tertiary-container",
Role::Error => "error",
Role::OnError => "on-error",
Role::ErrorContainer => "error-container",
Role::OnErrorContainer => "on-error-container",
Role::Surface => "surface",
Role::OnSurface => "on-surface",
Role::SurfaceVariant => "surface-variant",
Role::OnSurfaceVariant => "on-surface-variant",
Role::Background => "background",
Role::OnBackground => "on-background",
Role::SurfaceDim => "surface-dim",
Role::SurfaceBright => "surface-bright",
Role::SurfaceContainerLowest => "surface-container-lowest",
Role::SurfaceContainerLow => "surface-container-low",
Role::SurfaceContainer => "surface-container",
Role::SurfaceContainerHigh => "surface-container-high",
Role::SurfaceContainerHighest => "surface-container-highest",
Role::Outline => "outline",
Role::OutlineVariant => "outline-variant",
Role::InverseSurface => "inverse-surface",
Role::InverseOnSurface => "inverse-on-surface",
Role::InversePrimary => "inverse-primary",
Role::Shadow => "shadow",
Role::Scrim => "scrim",
Role::SurfaceTint => "surface-tint",
Role::PrimaryFixed => "primary-fixed",
Role::PrimaryFixedDim => "primary-fixed-dim",
Role::OnPrimaryFixed => "on-primary-fixed",
Role::OnPrimaryFixedVariant => "on-primary-fixed-variant",
Role::SecondaryFixed => "secondary-fixed",
Role::SecondaryFixedDim => "secondary-fixed-dim",
Role::OnSecondaryFixed => "on-secondary-fixed",
Role::OnSecondaryFixedVariant => "on-secondary-fixed-variant",
Role::TertiaryFixed => "tertiary-fixed",
Role::TertiaryFixedDim => "tertiary-fixed-dim",
Role::OnTertiaryFixed => "on-tertiary-fixed",
Role::OnTertiaryFixedVariant => "on-tertiary-fixed-variant",
}
}
/// 该角色是否是「文字/图标前景色」(`on-*` 与 `inverse-on-surface` 一类)。
///
/// 用于对比度门禁区分两类要求:前景对容器 ≥ 4.5:1(WCAG AA 正文),
/// 非文字元素 ≥ 3:1。
pub const fn is_foreground(self) -> bool {
matches!(
self,
Role::OnPrimary
| Role::OnPrimaryContainer
| Role::OnSecondary
| Role::OnSecondaryContainer
| Role::OnTertiary
| Role::OnTertiaryContainer
| Role::OnError
| Role::OnErrorContainer
| Role::OnSurface
| Role::OnSurfaceVariant
| Role::OnBackground
| Role::InverseOnSurface
| Role::InversePrimary
| Role::OnPrimaryFixed
| Role::OnPrimaryFixedVariant
| Role::OnSecondaryFixed
| Role::OnSecondaryFixedVariant
| Role::OnTertiaryFixed
| Role::OnTertiaryFixedVariant
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_is_in_enum_order() {
for (i, role) in Role::ALL.iter().enumerate() {
assert_eq!(*role as usize, i, "Role::ALL 顺序必须等于枚举声明顺序");
}
}
#[test]
fn names_are_unique_and_kebab() {
let mut seen = std::collections::BTreeSet::new();
for role in Role::ALL {
let name = role.name();
assert!(seen.insert(name), "角色名重复:{name}");
assert!(
name.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
"角色名必须是 kebab-case:{name}"
);
}
}
}
+313
View File
@@ -0,0 +1,313 @@
//! 一套完整的 M3 语义色方案(`Scheme`):角色 → 调色板槽位 → tone 的三段映射。
//!
//! tone 表取自 M3 基础方案的官方映射(light/dark 各一列)。固定角色
//! (`*-fixed*`)深浅同值,因为它们的用途就是「跨模式稳定」。
use super::palettes::{PaletteSlot, Palettes};
use super::roles::Role;
use super::Color;
/// 深浅模式。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SchemeFlavor {
/// 浅色(亮底暗字)。
Light,
/// 深色(暗底亮字)。
Dark,
}
impl SchemeFlavor {
/// 是否深色模式。
pub const fn is_dark(self) -> bool {
matches!(self, SchemeFlavor::Dark)
}
/// 反过来那一个。
pub const fn toggled(self) -> Self {
match self {
SchemeFlavor::Light => SchemeFlavor::Dark,
SchemeFlavor::Dark => SchemeFlavor::Light,
}
}
/// 稳定字符串名(`light` / `dark`),写配置与日志用。
pub const fn name(self) -> &'static str {
match self {
SchemeFlavor::Light => "light",
SchemeFlavor::Dark => "dark",
}
}
}
/// 阴影色透明度(M3 阴影 = 纯黑 + 这个 alpha;绘制阴影时叠在背景上)。
pub const SHADOW_ALPHA: f32 = 0.20;
/// 遮罩色透明度(模态背景压暗层)。
pub const SCRIM_ALPHA: f32 = 0.32;
/// 一条「角色 → 槽位 + tone」映射。
struct ToneRow {
role: Role,
slot: PaletteSlot,
light: f32,
dark: f32,
}
/// 49 个角色的 tone 映射表,**下标必须等于 `Role as usize`**(由测试钉死)。
const TONE_TABLE: [ToneRow; 49] = [
row(Role::Primary, PaletteSlot::Primary, 40.0, 80.0),
row(Role::OnPrimary, PaletteSlot::Primary, 100.0, 20.0),
row(Role::PrimaryContainer, PaletteSlot::Primary, 90.0, 30.0),
row(Role::OnPrimaryContainer, PaletteSlot::Primary, 10.0, 90.0),
row(Role::Secondary, PaletteSlot::Secondary, 40.0, 80.0),
row(Role::OnSecondary, PaletteSlot::Secondary, 100.0, 20.0),
row(Role::SecondaryContainer, PaletteSlot::Secondary, 90.0, 30.0),
row(
Role::OnSecondaryContainer,
PaletteSlot::Secondary,
10.0,
90.0,
),
row(Role::Tertiary, PaletteSlot::Tertiary, 40.0, 80.0),
row(Role::OnTertiary, PaletteSlot::Tertiary, 100.0, 20.0),
row(Role::TertiaryContainer, PaletteSlot::Tertiary, 90.0, 30.0),
row(Role::OnTertiaryContainer, PaletteSlot::Tertiary, 10.0, 90.0),
row(Role::Error, PaletteSlot::Error, 40.0, 80.0),
row(Role::OnError, PaletteSlot::Error, 100.0, 20.0),
row(Role::ErrorContainer, PaletteSlot::Error, 90.0, 30.0),
row(Role::OnErrorContainer, PaletteSlot::Error, 10.0, 90.0),
row(Role::Surface, PaletteSlot::Neutral, 98.0, 6.0),
row(Role::OnSurface, PaletteSlot::Neutral, 10.0, 90.0),
row(
Role::SurfaceVariant,
PaletteSlot::NeutralVariant,
90.0,
30.0,
),
row(
Role::OnSurfaceVariant,
PaletteSlot::NeutralVariant,
30.0,
80.0,
),
row(Role::Background, PaletteSlot::Neutral, 99.0, 10.0),
row(Role::OnBackground, PaletteSlot::Neutral, 10.0, 90.0),
row(Role::SurfaceDim, PaletteSlot::Neutral, 87.0, 6.0),
row(Role::SurfaceBright, PaletteSlot::Neutral, 98.0, 24.0),
row(
Role::SurfaceContainerLowest,
PaletteSlot::Neutral,
100.0,
4.0,
),
row(Role::SurfaceContainerLow, PaletteSlot::Neutral, 96.0, 10.0),
row(Role::SurfaceContainer, PaletteSlot::Neutral, 94.0, 12.0),
row(Role::SurfaceContainerHigh, PaletteSlot::Neutral, 92.0, 17.0),
row(
Role::SurfaceContainerHighest,
PaletteSlot::Neutral,
90.0,
22.0,
),
row(Role::Outline, PaletteSlot::NeutralVariant, 50.0, 60.0),
row(
Role::OutlineVariant,
PaletteSlot::NeutralVariant,
80.0,
30.0,
),
row(Role::InverseSurface, PaletteSlot::Neutral, 20.0, 90.0),
row(Role::InverseOnSurface, PaletteSlot::Neutral, 95.0, 20.0),
row(Role::InversePrimary, PaletteSlot::Primary, 80.0, 40.0),
row(Role::Shadow, PaletteSlot::Neutral, 0.0, 0.0),
row(Role::Scrim, PaletteSlot::Neutral, 0.0, 0.0),
row(Role::SurfaceTint, PaletteSlot::Primary, 40.0, 80.0),
row(Role::PrimaryFixed, PaletteSlot::Primary, 90.0, 90.0),
row(Role::PrimaryFixedDim, PaletteSlot::Primary, 80.0, 80.0),
row(Role::OnPrimaryFixed, PaletteSlot::Primary, 10.0, 10.0),
row(
Role::OnPrimaryFixedVariant,
PaletteSlot::Primary,
30.0,
30.0,
),
row(Role::SecondaryFixed, PaletteSlot::Secondary, 90.0, 90.0),
row(Role::SecondaryFixedDim, PaletteSlot::Secondary, 80.0, 80.0),
row(Role::OnSecondaryFixed, PaletteSlot::Secondary, 10.0, 10.0),
row(
Role::OnSecondaryFixedVariant,
PaletteSlot::Secondary,
30.0,
30.0,
),
row(Role::TertiaryFixed, PaletteSlot::Tertiary, 90.0, 90.0),
row(Role::TertiaryFixedDim, PaletteSlot::Tertiary, 80.0, 80.0),
row(Role::OnTertiaryFixed, PaletteSlot::Tertiary, 10.0, 10.0),
row(
Role::OnTertiaryFixedVariant,
PaletteSlot::Tertiary,
30.0,
30.0,
),
];
/// `const` 友好的行构造器(数组初始化里不能调用非 const fn 之外的东西)。
const fn row(role: Role, slot: PaletteSlot, light: f32, dark: f32) -> ToneRow {
ToneRow {
role,
slot,
light,
dark,
}
}
/// 一套语义色(49 个角色,全部已解析成具体颜色)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Scheme {
flavor: SchemeFlavor,
colors: [Color; 49],
}
impl Scheme {
/// 出厂基线方案(种子 `#6750A4`)。
pub fn baseline(flavor: SchemeFlavor) -> Self {
Self::from_palettes(&Palettes::baseline(), flavor)
}
/// 指定种子色的方案(表面色与强调色同源)。
pub fn from_seed(flavor: SchemeFlavor, seed: Color) -> Self {
Self::from_palettes(&Palettes::from_seed(seed), flavor)
}
/// 只覆盖强调色(表面中性色仍走基线,见 [`Palettes::with_accent`])。
pub fn from_accent(flavor: SchemeFlavor, accent: Color) -> Self {
Self::from_palettes(&Palettes::baseline().with_accent(accent), flavor)
}
/// 直接从调色板集合构建(高级入口:整仓测试与自定义 scheme 用)。
pub fn from_palettes(palettes: &Palettes, flavor: SchemeFlavor) -> Self {
let colors = std::array::from_fn(|i| {
let entry = &TONE_TABLE[i];
let tone = match flavor {
SchemeFlavor::Light => entry.light,
SchemeFlavor::Dark => entry.dark,
};
palettes.tone(entry.slot, tone)
});
Self { flavor, colors }
}
/// 该角色在指定模式下的 tone 值(M3 语义;文档表格、调试与测试用)。
pub fn tone_of(role: Role, flavor: SchemeFlavor) -> f32 {
let entry = &TONE_TABLE[role as usize];
debug_assert_eq!(entry.role, role, "TONE_TABLE 下标必须等于角色序号");
match flavor {
SchemeFlavor::Light => entry.light,
SchemeFlavor::Dark => entry.dark,
}
}
/// 取角色色值。
pub fn get(&self, role: Role) -> Color {
self.colors[role as usize]
}
/// 当前模式。
pub fn flavor(&self) -> SchemeFlavor {
self.flavor
}
/// 按角色顺序遍历(`for (role, color) in scheme.iter()`)。
pub fn iter(&self) -> impl Iterator<Item = (Role, Color)> + '_ {
Role::ALL.iter().copied().map(|role| (role, self.get(role)))
}
/// 阴影色(纯黑 + [`SHADOW_ALPHA`])。
pub fn shadow(&self) -> Color {
self.get(Role::Shadow).with_alpha(SHADOW_ALPHA)
}
/// 遮罩色(纯黑 + [`SCRIM_ALPHA`])。
pub fn scrim(&self) -> Color {
self.get(Role::Scrim).with_alpha(SCRIM_ALPHA)
}
/// 两个角色的 WCAG 对比度(1..21)。
pub fn contrast(&self, fg: Role, bg: Role) -> f32 {
self.get(fg).contrast_ratio(self.get(bg))
}
}
impl std::ops::Index<Role> for Scheme {
type Output = Color;
fn index(&self, role: Role) -> &Color {
&self.colors[role as usize]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tone_table_matches_role_order() {
for (i, entry) in TONE_TABLE.iter().enumerate() {
assert_eq!(
entry.role as usize,
i,
"TONE_TABLE 第 {i} 行是 {},顺序与 Role 枚举不一致",
entry.role.name()
);
}
}
#[test]
fn fixed_roles_are_identical_across_flavors() {
let light = Scheme::baseline(SchemeFlavor::Light);
let dark = Scheme::baseline(SchemeFlavor::Dark);
for role in [
Role::PrimaryFixed,
Role::PrimaryFixedDim,
Role::OnPrimaryFixed,
Role::OnPrimaryFixedVariant,
Role::SecondaryFixed,
Role::TertiaryFixedDim,
] {
assert_eq!(
light.get(role),
dark.get(role),
"{} 应跨模式同值",
role.name()
);
}
}
#[test]
fn light_and_dark_differ_on_text_pairs() {
let light = Scheme::baseline(SchemeFlavor::Light);
let dark = Scheme::baseline(SchemeFlavor::Dark);
assert_ne!(light.get(Role::OnSurface), dark.get(Role::OnSurface));
assert!(light.get(Role::Surface).relative_luminance() > 0.8);
assert!(dark.get(Role::Surface).relative_luminance() < 0.05);
}
#[test]
fn shadow_and_scrim_carry_alpha() {
let scheme = Scheme::baseline(SchemeFlavor::Light);
assert_eq!(scheme.shadow().a, (SHADOW_ALPHA * 255.0).round() as u8);
assert_eq!(scheme.scrim().a, (SCRIM_ALPHA * 255.0).round() as u8);
}
#[test]
fn accent_override_changes_primary_only() {
let base = Scheme::baseline(SchemeFlavor::Light);
let teal = Scheme::from_accent(SchemeFlavor::Light, Color::rgb(0x00, 0x6A, 0x5C));
assert_ne!(base.get(Role::Primary), teal.get(Role::Primary));
assert_eq!(base.get(Role::Surface), teal.get(Role::Surface));
assert_eq!(
base.get(Role::Outline),
teal.get(Role::Outline),
"强调色覆盖不该动中性描边"
);
}
}
+198
View File
@@ -0,0 +1,198 @@
//! 色彩空间转换:sRGB ↔ 线性 RGB ↔ CIELAB(tone 轴)+ OKLab/OKLCh(色相/彩度轴)。
//!
//! 这里只放**纯数学**,不认识任何语义色角色。口径说明见 [`super`] 的模块文档。
use super::Color;
/// gamut 判定容差:线性 RGB 允许露出这么点负值/溢出(浮点误差),别因此误判「越界」。
const GAMUT_EPS: f32 = 1.0e-4;
/// sRGB 里能取到的 OKLCh 彩度上限(超过它没有意义,二分搜索的初始上界)。
pub(crate) const GAMUT_CHROMA_CAP: f32 = 0.5;
/// sRGB 编码值(0..1)→ 线性光强。
pub(crate) fn srgb_to_linear(c: f32) -> f32 {
if c <= 0.040_45 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
/// 线性光强 → sRGB 编码值(0..1),结果不夹取,越界由调用方处理。
pub(crate) fn linear_to_srgb(c: f32) -> f32 {
if c <= 0.003_130_8 {
c * 12.92
} else {
1.055 * c.powf(1.0 / 2.4) - 0.055
}
}
/// 线性 RGB → CIE XYZ(D65 白点,sRGB 原色)。
pub(crate) fn linear_to_xyz(rgb: [f32; 3]) -> [f32; 3] {
let [r, g, b] = rgb;
[
0.412_456_4 * r + 0.357_576_1 * g + 0.180_437_5 * b,
0.212_672_9 * r + 0.715_152_2 * g + 0.072_175_0 * b,
0.019_333_9 * r + 0.119_192 * g + 0.950_304_1 * b,
]
}
/// 相对亮度 Y(WCAG 与 CIELAB 共用这条 Y,白点 1.0)。
pub(crate) fn luminance(rgb: [f32; 3]) -> f32 {
linear_to_xyz(rgb)[1]
}
/// CIELAB `L*`(M3 的 **tone** 就是这个轴,0 = 黑 / 100 = 白)。
pub(crate) fn lstar_from_luminance(y: f32) -> f32 {
let t = y.max(0.0);
if t > 0.008_856 {
116.0 * t.cbrt() - 16.0
} else {
903.296_3 * t
}
}
/// `L*` → 相对亮度 Y([`lstar_from_luminance`] 的逆)。
pub(crate) fn luminance_from_lstar(lstar: f32) -> f32 {
let l = lstar.clamp(0.0, 100.0);
if l > 8.0 {
((l + 16.0) / 116.0).powi(3)
} else {
l / 903.296_3
}
}
/// 线性 RGB → OKLab。
pub(crate) fn linear_to_oklab(rgb: [f32; 3]) -> [f32; 3] {
let [r, g, b] = rgb;
let l = 0.412_221_47 * r + 0.536_332_55 * g + 0.051_445_99 * b;
let m = 0.211_903_5 * r + 0.680_699_5 * g + 0.107_396_96 * b;
let s = 0.088_302_46 * r + 0.281_718_84 * g + 0.629_978_7 * b;
let (l_, m_, s_) = (l.cbrt(), m.cbrt(), s.cbrt());
[
0.210_454_26 * l_ + 0.793_617_8 * m_ - 0.004_072_047 * s_,
1.977_998_5 * l_ - 2.428_592_2 * m_ + 0.450_593_7 * s_,
0.025_904_037 * l_ + 0.782_771_77 * m_ - 0.808_675_77 * s_,
]
}
/// OKLab → 线性 RGB(可能越界,负值表示超出 sRGB gamut)。
pub(crate) fn oklab_to_linear(lab: [f32; 3]) -> [f32; 3] {
let [l, a, b] = lab;
let l_ = l + 0.396_337_78 * a + 0.215_803_76 * b;
let m_ = l - 0.105_561_346 * a - 0.063_854_17 * b;
let s_ = l - 0.089_484_18 * a - 1.291_485_5 * b;
let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
[
4.076_741_7 * l3 - 3.307_711_6 * m3 + 0.230_969_94 * s3,
-1.268_438 * l3 + 2.609_757_4 * m3 - 0.341_319_38 * s3,
-0.004_196_086_3 * l3 - 0.703_418_6 * m3 + 1.707_614_7 * s3,
]
}
/// OKLCh → 线性 RGB。
pub(crate) fn oklch_to_linear(l: f32, chroma: f32, hue_deg: f32) -> [f32; 3] {
let h = hue_deg.to_radians();
oklab_to_linear([l, chroma * h.cos(), chroma * h.sin()])
}
/// 线性 RGB 是否落在 sRGB 立方体内([`GAMUT_EPS`] 容差)。
pub(crate) fn in_gamut(rgb: [f32; 3]) -> bool {
rgb.iter()
.all(|c| *c >= -GAMUT_EPS && *c <= 1.0 + GAMUT_EPS)
}
/// 求「在保证 `L* = lstar` 的前提下,OKLab 明度该取多少」。
///
/// OKLab 的 L 不是 Y 的纯函数(彩色会偏离),所以给定 tone 与色相/彩度时用二分反解,
/// 让最终颜色的 **CIELAB L\* 精确等于 tone**——M3 的 tone 定义在 L\* 上,这一点必须对齐。
pub(crate) fn oklab_lightness_for_tone(chroma: f32, hue_deg: f32, lstar: f32) -> f32 {
let target = luminance_from_lstar(lstar);
let mut lo = 0.0_f32;
let mut hi = 1.0_f32;
for _ in 0..32 {
let mid = 0.5 * (lo + hi);
if luminance(oklch_to_linear(mid, chroma, hue_deg)) < target {
lo = mid;
} else {
hi = mid;
}
}
0.5 * (lo + hi)
}
/// 在该 tone / 色相下,把彩度压进 sRGB gamut 后能取到的最大值(上限 `chroma_max`)。
pub(crate) fn chroma_in_gamut(lstar: f32, hue_deg: f32, chroma_max: f32) -> f32 {
let hi0 = chroma_max.max(0.0);
if in_gamut(oklch_to_linear(
oklab_lightness_for_tone(hi0, hue_deg, lstar),
hi0,
hue_deg,
)) {
return hi0;
}
let mut lo = 0.0_f32;
let mut hi = hi0;
for _ in 0..24 {
let mid = 0.5 * (lo + hi);
let ok = in_gamut(oklch_to_linear(
oklab_lightness_for_tone(mid, hue_deg, lstar),
mid,
hue_deg,
));
if ok {
lo = mid;
} else {
hi = mid;
}
}
lo
}
/// 该 tone / 色相下 sRGB 能表达的最大彩度(P3.2 的「按比例压彩度」用它做基准)。
pub(crate) fn max_chroma(lstar: f32, hue_deg: f32) -> f32 {
chroma_in_gamut(lstar, hue_deg, GAMUT_CHROMA_CAP)
}
/// OKLCh 坐标(色相用度,0..360)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Oklch {
/// 明度 0..1(中性灰时 ≈ 相对亮度的立方根)。
pub lightness: f32,
/// 彩度(0 = 中性灰)。
pub chroma: f32,
/// 色相角,单位度,0..360。
pub hue_deg: f32,
}
impl Oklch {
/// 从一个 sRGB 颜色取色相/彩度(用作调色板的种子)。
pub fn from_color(color: Color) -> Self {
let lin = color.to_linear_f32();
let [l, a, b] = linear_to_oklab(lin);
let mut hue = b.atan2(a).to_degrees();
if hue < 0.0 {
hue += 360.0;
}
Self {
lightness: l,
chroma: (a * a + b * b).sqrt(),
hue_deg: hue,
}
}
/// 在色相环上旋转(用于派生 tertiary 这类同族但不同色的调色板)。
pub fn rotated(mut self, deg: f32) -> Self {
self.hue_deg = (self.hue_deg + deg).rem_euclid(360.0);
self
}
}
impl Color {
/// 线性光强三元组(0..1,越界不夹取)。
fn to_linear_f32(self) -> [f32; 3] {
let f = |c: u8| srgb_to_linear(f32::from(c) / 255.0);
[f(self.r), f(self.g), f(self.b)]
}
}
+96
View File
@@ -0,0 +1,96 @@
//! M3 状态层(state layer)与禁用态透明度。
//!
//! M3 的 hover/pressed 不是「另一种灰」,而是**内容色自身 + 固定 alpha** 叠在容器上——
//! 所以同一套令牌在任何底色上都成立,不必为每种底色各配一套(这条从 Fluent 时代沿用,
//! 见 `DevDocs/SubAgentsTalk/ui-integration-contract.md` §2 的三态叠加层思路)。
use super::Color;
/// 状态层的四种状态与对应 alpha(M3 规范值)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StateLayer {
/// 悬停:内容色 8%。
Hover,
/// 键盘焦点:内容色 10%。
Focus,
/// 按下:内容色 10%。
Pressed,
/// 拖拽中:内容色 16%。
Dragged,
}
impl StateLayer {
/// 叠加透明度(0..1)。
pub const fn alpha(self) -> f32 {
match self {
StateLayer::Hover => 0.08,
StateLayer::Focus | StateLayer::Pressed => 0.10,
StateLayer::Dragged => 0.16,
}
}
/// 把状态层叠到容器色上,得到**最终不透明色**。
///
/// `content` 一般是容器上的前景角色(例如 FilledButton 用 `on-primary`,
/// 中性表面上的按钮用 `on-surface`)。
pub fn over(self, content: Color, container: Color) -> Color {
content.with_alpha(self.alpha()).over(container)
}
}
/// 禁用态的透明度(M3):容器 12%、内容 38%。
///
/// 别用「灰化」近似——深色模式下灰化会降低对比度,alpha 叠加两种模式都成立。
pub const DISABLED_CONTAINER_ALPHA: f32 = 0.12;
/// 禁用态内容(文字/图标)透明度。
pub const DISABLED_CONTENT_ALPHA: f32 = 0.38;
/// 禁用态容器色:`on_surface` 12% 叠在 `surface` 上。
pub fn disabled_container(scheme: &super::Scheme) -> Color {
scheme
.get(super::Role::OnSurface)
.with_alpha(DISABLED_CONTAINER_ALPHA)
.over(scheme.get(super::Role::Surface))
}
/// 禁用态内容色:`on_surface` 38% 叠在容器上。
pub fn disabled_content(scheme: &super::Scheme, container: Color) -> Color {
scheme
.get(super::Role::OnSurface)
.with_alpha(DISABLED_CONTENT_ALPHA)
.over(container)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::{Role, Scheme, SchemeFlavor};
#[test]
fn hover_is_lighter_than_pressed_on_dark_container() {
let scheme = Scheme::baseline(SchemeFlavor::Dark);
let container = scheme.get(Role::SurfaceContainer);
let content = scheme.get(Role::OnSurface);
let hover = StateLayer::Hover.over(content, container);
let pressed = StateLayer::Pressed.over(content, container);
assert_eq!(hover.a, 255);
assert!(pressed.relative_luminance() > hover.relative_luminance());
}
#[test]
fn state_layer_alpha_matches_m3() {
assert!((StateLayer::Hover.alpha() - 0.08).abs() < f32::EPSILON);
assert!((StateLayer::Dragged.alpha() - 0.16).abs() < f32::EPSILON);
}
#[test]
fn disabled_content_stays_visible() {
let scheme = Scheme::baseline(SchemeFlavor::Light);
let container = disabled_container(&scheme);
let content = disabled_content(&scheme, container);
// 禁用态不追求 AA(规范就是这么定的),但必须仍可辨(> 1.5:1)。
let ratio = content.contrast_ratio(container);
assert!(ratio > 1.5, "禁用态对比度过低:{ratio}");
assert!(ratio < 4.5, "禁用态不该和正文一样醒目:{ratio}");
}
}
+163
View File
@@ -0,0 +1,163 @@
//! tone 调色板:一个种子色 → 一条「固定色相/彩度、tone 任取」的调色板。
//!
//! 这是 M3 tonal palette 的**近似实现**:色相恒定、按 tone 取色、按 tone 压彩度进 sRGB gamut。
//! 口径与已知偏差见 [`super`] 模块文档(OKLCh 而非 CAM16/HCT)。
use super::space::{self, Oklch};
use super::Color;
/// 一条 tone 调色板(色相固定 + 彩度上限)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TonalPalette {
hue_deg: f32,
chroma: f32,
}
impl TonalPalette {
/// 直接给色相(度)与彩度上限(OKLCh 单位,0 = 中性灰)建调色板。
pub fn new(hue_deg: f32, chroma: f32) -> Self {
Self {
hue_deg: hue_deg.rem_euclid(360.0),
chroma: chroma.max(0.0),
}
}
/// 从种子色取色相与彩度(等价 M3 的 `palette from seed`)。
pub fn from_color(seed: Color) -> Self {
let lch = Oklch::from_color(seed);
Self::new(lch.hue_deg, lch.chroma)
}
/// 固定色相,改彩度上限(用于把种子彩度压到 secondary/neutral 档)。
pub fn with_chroma(self, chroma: f32) -> Self {
Self::new(self.hue_deg, chroma)
}
/// 彩度不超过 `limit`(种子本身更灰时保持更灰,不硬拉饱和)。
pub fn limited_chroma(self, limit: f32) -> Self {
self.with_chroma(self.chroma.min(limit))
}
/// 旋转色相(derived hue)。
pub fn rotated(self, deg: f32) -> Self {
Self::new(self.hue_deg + deg, self.chroma)
}
/// 该调色板的色相(度)。
pub fn hue_deg(self) -> f32 {
self.hue_deg
}
/// 该调色板的彩度上限(OKLCh 单位)。
pub fn chroma(self) -> f32 {
self.chroma
}
/// 取某个 tone 的颜色(tone 0..100,超出夹取)。
///
/// 实现口径:CIELAB `L*` 精确等于 tone(M3 的 tone 定义),彩度按该 tone 的 gamut 上限
/// 二分裁剪——所以 tone 0 恒为纯黑、tone 100 恒为纯白,且同 tone 的 WCAG 对比度
/// 与色相无关(这是对比度门禁能跨种子稳定的原因)。
pub fn tone(self, tone: f32) -> Color {
self.tone_scaled(tone, self.chroma)
}
/// 带「共享彩度基准」的取色:**所有调色板按同一比例压彩度进 gamut**。
///
/// `reference_chroma` 一般是方案里最饱和那条调色板(primary)的彩度。压缩系数
/// `f = min(1, C_max(tone,hue) / reference)`,每条调色板取 `C_req × f`——
/// 这样 `primary` 与 `secondary` 在高 tone(tone 90/96 这类浅容器)上不会被**一起裁到**
/// 同一个 gamut 上限而撞色,`secondary` 永远比 `primary` 更灰(M3 的语义就靠这个)。
/// 单独用 [`TonalPalette::tone`] 时没有基准,等价于 `reference = 自身彩度`(直接裁剪)。
pub fn tone_scaled(self, tone: f32, reference_chroma: f32) -> Color {
let lstar = tone.clamp(0.0, 100.0);
// 端点直接给纯黑/纯白:近黑处的 gamut 容差会放进微小彩度,导致 tone 0 偏色。
if lstar <= 0.0 {
return Color::BLACK;
}
if lstar >= 100.0 {
return Color::WHITE;
}
let c_max = space::max_chroma(lstar, self.hue_deg);
let factor = if reference_chroma > 1.0e-6 {
(c_max / reference_chroma).min(1.0)
} else {
1.0
};
let chroma = (self.chroma * factor).min(c_max).max(0.0);
let lightness = space::oklab_lightness_for_tone(chroma, self.hue_deg, lstar);
Color::from_linear_f32(space::oklch_to_linear(lightness, chroma, self.hue_deg))
}
}
#[cfg(test)]
mod tests {
use super::*;
const SEED: Color = Color::rgb(0x67, 0x50, 0xA4);
fn lstar_of(color: Color) -> f32 {
color.lstar()
}
#[test]
fn tone_matches_lstar_within_one_unit() {
let palette = TonalPalette::from_color(SEED);
for tone in [
0.0_f32, 6.0, 10.0, 30.0, 40.0, 50.0, 80.0, 90.0, 95.0, 98.0, 100.0,
] {
let got = lstar_of(palette.tone(tone));
assert!(
(got - tone).abs() <= 1.0,
"tone {tone} 的 L* 是 {got},偏差超过 1(量化误差上限)"
);
}
}
#[test]
fn extremes_are_pure_black_and_white() {
let palette = TonalPalette::from_color(SEED);
assert_eq!(palette.tone(0.0), Color::rgb(0, 0, 0));
assert_eq!(palette.tone(100.0), Color::rgb(255, 255, 255));
assert_eq!(palette.tone(-5.0), Color::rgb(0, 0, 0));
assert_eq!(palette.tone(120.0), Color::rgb(255, 255, 255));
}
#[test]
fn seed_hue_is_preserved_across_tones() {
let palette = TonalPalette::from_color(SEED);
for tone in [30.0_f32, 50.0, 70.0] {
let lch = Oklch::from_color(palette.tone(tone));
let delta = (lch.hue_deg - palette.hue_deg()).abs();
let delta = delta.min(360.0 - delta);
assert!(delta < 3.0, "tone {tone} 色相偏移 {delta}° 过大");
}
}
#[test]
fn neutral_palette_is_gray() {
let neutral = TonalPalette::new(120.0, 0.0);
let c = neutral.tone(50.0);
let spread = f32::from(c.r.max(c.g).max(c.b) - c.r.min(c.g).min(c.b));
assert!(
spread <= 1.0,
"彩度 0 的调色板应当接近中性灰,实测通道差 {spread}"
);
}
#[test]
fn chroma_never_leaves_gamut() {
// 高彩度种子在极端 tone 上必须被裁到 gamut 内(不出现「负 RGB」削顶后的色带)。
let hot = TonalPalette::new(30.0, 0.4);
for tone in [5.0_f32, 20.0, 40.0, 60.0, 80.0, 95.0] {
let lch = Oklch::from_color(hot.tone(tone));
assert!(
lch.chroma <= 0.4 + 1.0e-3,
"tone {tone} 彩度 {} 超出请求上限",
lch.chroma
);
let got = lstar_of(hot.tone(tone));
assert!((got - tone).abs() <= 1.0, "tone {tone} 的 L* 是 {got}");
}
}
}
+272
View File
@@ -0,0 +1,272 @@
//! 组件 → 档位映射表:哪个组件用哪档形状 / 高程 / 字体 / 动效。
//!
//! 目的不是「规定 M3 官方长什么样」(官方只给组件规格,不给启动器里的档位表),而是
//! **给 iced 页面线一个不需要再谈判的默认值**:同一个组件在五个页面里长得一样,
//! 才有用户要的「UI 整体性」。
//!
//! 口径:
//! - 形状档位见 [`crate::shape::ShapeScale`],高程见 [`crate::elevation::Elevation`];
//! - `text` 是主要文字角色,`support` 是辅助文字(没有就 `None`);
//! - `motion` + `duration` 是状态过渡(hover/press/展开),不是页面切换;
//! - `morph` 是按下/选中时的形状形变(Expressive 特征),不需要就 `None`。
//!
//! **改这里的值 = 改全站观感**,必须同步 `DevDocs/SubAgentsTalk/design-令牌约定.md`;
//! 页面线不许在页面里覆盖这些档位。
mod table;
use crate::elevation::Elevation;
use crate::motion::{DurationToken, Easing};
use crate::shape::{ShapeMorph, ShapeScale};
use crate::typography::TypeRole;
/// 组件的排版 + 形状 + 动效档位。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ComponentSpec {
/// 该档位属于哪个组件(表自描述,便于测试与文档生成)。
pub component: Component,
/// 圆角档位。
pub shape: ShapeScale,
/// 高程级别(决定 surface container 角色与是否画阴影)。
pub elevation: Elevation,
/// 主要文字角色。
pub text: TypeRole,
/// 辅助文字角色。
pub support: Option<TypeRole>,
/// 状态过渡缓动。
pub motion: Easing,
/// 状态过渡时长。
pub duration: DurationToken,
/// 按下/选中时的形状形变。
pub morph: Option<ShapeMorph>,
}
/// 需要固定档位的组件清单(M53 页面骨架里实际用到的那些)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Component {
/// 顶部标题栏(第一层 chrome)。
TopAppBar,
/// 导航项(侧栏一行)。
NavigationItem,
/// 主操作按钮(实心强调色,每屏最多一个)。
PrimaryButton,
/// 次操作按钮(tonal)。
SecondaryButton,
/// 文本按钮(低权重)。
TextButton,
/// 图标按钮。
IconButton,
/// 描边卡片。
OutlinedCard,
/// 填充卡片(层级高于描边卡)。
FilledCard,
/// 列表行。
ListItem,
/// 文本输入框。
TextField,
/// 分段按钮(磁贴/列表切换、小中大三档)。
SegmentedButton,
/// 贴片/徽标。
Chip,
/// 开关。
Switch,
/// 滑块。
Slider,
/// 进度条。
ProgressIndicator,
/// 对话框。
Dialog,
/// 下拉菜单。
Menu,
/// 底部提示条。
Snackbar,
/// 悬浮提示。
Tooltip,
/// 空态(图标 + 标题 + 说明 + 标准按钮)。
EmptyState,
}
impl Component {
/// 全部组件。
pub const ALL: [Component; 20] = [
Component::TopAppBar,
Component::NavigationItem,
Component::PrimaryButton,
Component::SecondaryButton,
Component::TextButton,
Component::IconButton,
Component::OutlinedCard,
Component::FilledCard,
Component::ListItem,
Component::TextField,
Component::SegmentedButton,
Component::Chip,
Component::Switch,
Component::Slider,
Component::ProgressIndicator,
Component::Dialog,
Component::Menu,
Component::Snackbar,
Component::Tooltip,
Component::EmptyState,
];
/// 该组件的档位。
pub fn spec(self) -> ComponentSpec {
table::SPECS[self as usize]
}
/// 稳定字符串名(kebab-case)。
pub const fn name(self) -> &'static str {
match self {
Component::TopAppBar => "top-app-bar",
Component::NavigationItem => "navigation-item",
Component::PrimaryButton => "primary-button",
Component::SecondaryButton => "secondary-button",
Component::TextButton => "text-button",
Component::IconButton => "icon-button",
Component::OutlinedCard => "outlined-card",
Component::FilledCard => "filled-card",
Component::ListItem => "list-item",
Component::TextField => "text-field",
Component::SegmentedButton => "segmented-button",
Component::Chip => "chip",
Component::Switch => "switch",
Component::Slider => "slider",
Component::ProgressIndicator => "progress-indicator",
Component::Dialog => "dialog",
Component::Menu => "menu",
Component::Snackbar => "snackbar",
Component::Tooltip => "tooltip",
Component::EmptyState => "empty-state",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_matches_component_order() {
for (i, spec) in table::SPECS.iter().enumerate() {
assert_eq!(
spec.component as usize, i,
"SPECS 第 {i} 行顺序与枚举不一致"
);
}
}
#[test]
fn every_component_resolves() {
assert_eq!(Component::ALL.len(), table::SPECS.len());
for component in Component::ALL {
let spec = component.spec();
assert_eq!(spec.component, component);
assert!(spec.text.style().size_px >= 11.0);
}
}
#[test]
fn names_are_unique() {
let mut seen = std::collections::BTreeSet::new();
for component in Component::ALL {
assert!(
seen.insert(component.name()),
"组件名重复:{}",
component.name()
);
}
}
#[test]
fn elevation_matches_official_component_tokens() {
// A 级来源(androidx.compose.material3.tokens):
// FilledCardTokens/OutlinedCardTokens.ContainerElevation = Level0
// MenuTokens.ContainerElevation = Level2
// DialogTokens.ContainerElevation = Level3
// SnackbarTokens.ContainerElevation = Level3
// AppBarTokens = Level0、ListTokens.ItemContainerElevation = Level0
// 偏离只允许出现在有登记的地方(Tooltip 是 B 级:token 文件里没有 Elevation 行)。
for (component, expect) in [
(Component::FilledCard, Elevation::Level0),
(Component::OutlinedCard, Elevation::Level0),
(Component::Menu, Elevation::Level2),
(Component::Dialog, Elevation::Level3),
(Component::Snackbar, Elevation::Level3),
(Component::TopAppBar, Elevation::Level0),
(Component::ListItem, Elevation::Level0),
(Component::TextField, Elevation::Level0),
] {
assert_eq!(
component.spec().elevation,
expect,
"{} 的高程与官方组件令牌不一致",
component.name()
);
}
// R4「全屏都在飘」的守门:一屏常见元素里带阴影(>Level0)的只允许是浮层。
for component in Component::ALL {
if component.spec().elevation > Elevation::Level0 {
assert!(
matches!(
component,
Component::Menu
| Component::Dialog
| Component::Snackbar
| Component::Tooltip
),
"{} 不该有阴影(只有浮层允许)",
component.name()
);
}
}
}
#[test]
fn shapes_match_official_component_tokens() {
// 官方:卡片 CornerMedium(12)、对话框 CornerExtraLarge(28)、菜单 CornerExtraSmall(4)、
// 按钮/导航指示条 CornerFull、chip CornerSmall(8)。
assert_eq!(Component::FilledCard.spec().shape, ShapeScale::Medium);
assert_eq!(Component::OutlinedCard.spec().shape, ShapeScale::Medium);
assert_eq!(Component::Dialog.spec().shape, ShapeScale::ExtraLarge);
assert_eq!(Component::Menu.spec().shape, ShapeScale::ExtraSmall);
assert_eq!(Component::PrimaryButton.spec().shape, ShapeScale::Full);
assert_eq!(Component::Chip.spec().shape, ShapeScale::Small);
}
#[test]
fn only_primary_actions_use_filled_emphasis_and_one_dialog_layer() {
// 契约 §1:状态色不复用 accent、每屏最多一个实心 accent → 只有 primary-button 是
// 「实心强调」,其余按钮的形态由页面决定但不能是 Full + Level0 + Emphasized 的组合。
let primary = Component::PrimaryButton.spec();
assert_eq!(primary.shape, ShapeScale::Full);
assert_eq!(primary.text, TypeRole::LabelLarge);
// 浮层类必须比卡片层高。
assert!(Component::Dialog.spec().elevation > Component::OutlinedCard.spec().elevation);
assert!(Component::Menu.spec().elevation > Component::FilledCard.spec().elevation);
}
#[test]
fn morphs_always_expand_upwards() {
for component in Component::ALL {
if let Some(morph) = component.spec().morph {
assert_ne!(
morph.from,
morph.to,
"{} 的形变起止不能相同",
component.name()
);
// 方向必须「沿阶梯胀开」(morph 对 `Full` 也没问题,rank 把它排在末尾),
// 不比较像素跨度:`Full` 的半径依赖边长,用固定边长比会被 `None` 之外的大档拉爆。
assert!(
morph.from.rank() < morph.to.rank(),
"{} 的形变方向反了:{:?} → {:?}",
component.name(),
morph.from,
morph.to
);
}
}
}
}
+268
View File
@@ -0,0 +1,268 @@
//! 组件档位数据表(**下标必须等于 `Component as usize`**,由测试钉死)。
//!
//! 一行一个组件。改值就是在改全站观感——同步协作文档再改。
//!
//! **高程/形状取值来源(A 级)**:`androidx.compose.material3.tokens` 的各组件 `<X>Tokens.kt`
//! (FilledCard/OutlinedCard/ElevatedCard/Menu/Dialog/Snackbar/AppBar/List)。已登记的两处偏离:
//! - `Tooltip`:`PlainTooltipTokens.kt` 只有 Corner 行、**没有 Elevation 行**,Table 的 Level2 是
//! B 级(沿用 M3 旧规范「浮层 3dp」的直觉),有官方值后以官方为准;
//! - `ListItem`:官方 `ListTokens` 用 `CornerNone`(直角行),本项目取 `Small(8)`——列表行在本
//! 启动器里是**卡片内的独立可点块**(不是贴边通栏行),圆角与卡片同族更协调;偏差 8dp 已登记。
use super::{Component, ComponentSpec};
use crate::elevation::Elevation;
use crate::motion::{
DurationToken, Easing, SPRING_EXPRESSIVE_SPATIAL_DEFAULT, SPRING_EXPRESSIVE_SPATIAL_FAST,
};
use crate::shape::{ShapeMorph, ShapeScale};
use crate::typography::TypeRole;
/// 一个组件的档位行(`const fn` 便捷构造,字段名在 [`ComponentSpec`] 里)。
const fn spec(
component: Component,
shape: ShapeScale,
elevation: Elevation,
text: TypeRole,
support: Option<TypeRole>,
motion: Easing,
duration: DurationToken,
morph: Option<ShapeMorph>,
) -> ComponentSpec {
ComponentSpec {
component,
shape,
elevation,
text,
support,
motion,
duration,
morph,
}
}
/// 20 个组件的档位表。
pub(super) const SPECS: [ComponentSpec; 20] = [
spec(
Component::TopAppBar,
ShapeScale::None,
Elevation::Level0,
TypeRole::TitleLarge,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Medium2,
None,
),
spec(
Component::NavigationItem,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelLarge,
None,
Easing::Emphasized,
DurationToken::Medium2,
Some(ShapeMorph::new(
ShapeScale::Large,
ShapeScale::Full,
SPRING_EXPRESSIVE_SPATIAL_FAST,
)),
),
spec(
Component::PrimaryButton,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelLarge,
None,
Easing::Emphasized,
DurationToken::Short4,
None,
),
spec(
Component::SecondaryButton,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelLarge,
None,
Easing::Standard,
DurationToken::Short4,
None,
),
spec(
Component::TextButton,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelLarge,
None,
Easing::Standard,
DurationToken::Short4,
None,
),
spec(
Component::IconButton,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelMedium,
None,
Easing::Standard,
DurationToken::Short3,
Some(ShapeMorph::new(
ShapeScale::Medium,
ShapeScale::Full,
SPRING_EXPRESSIVE_SPATIAL_FAST,
)),
),
// FilledCardTokens.ContainerElevation = Level0(hover 才到 L1、dragged 到 L3)。
spec(
Component::OutlinedCard,
ShapeScale::Medium,
Elevation::Level0,
TypeRole::TitleMedium,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Medium1,
None,
),
spec(
Component::FilledCard,
ShapeScale::Medium,
Elevation::Level0,
TypeRole::TitleMedium,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Medium1,
Some(ShapeMorph::new(
ShapeScale::Medium,
ShapeScale::Large,
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
)),
),
spec(
Component::ListItem,
ShapeScale::Small,
Elevation::Level0,
TypeRole::BodyLarge,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Short4,
None,
),
spec(
Component::TextField,
ShapeScale::ExtraSmall,
Elevation::Level0,
TypeRole::BodyLarge,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Short4,
None,
),
spec(
Component::SegmentedButton,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelLarge,
None,
Easing::Emphasized,
DurationToken::Medium2,
None,
),
spec(
Component::Chip,
ShapeScale::Small,
Elevation::Level0,
TypeRole::LabelMedium,
None,
Easing::Standard,
DurationToken::Short3,
Some(ShapeMorph::new(
ShapeScale::ExtraSmall,
ShapeScale::Small,
SPRING_EXPRESSIVE_SPATIAL_FAST,
)),
),
spec(
Component::Switch,
ShapeScale::Full,
Elevation::Level0,
TypeRole::BodyMedium,
Some(TypeRole::BodySmall),
Easing::Emphasized,
DurationToken::Medium2,
None,
),
spec(
Component::Slider,
ShapeScale::Full,
Elevation::Level0,
TypeRole::BodyMedium,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Short2,
None,
),
spec(
Component::ProgressIndicator,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelMedium,
None,
Easing::Linear,
DurationToken::Long2,
None,
),
// DialogTokens.ContainerElevation = Level3,CornerExtraLarge = 28。
spec(
Component::Dialog,
ShapeScale::ExtraLarge,
Elevation::Level3,
TypeRole::HeadlineSmall,
Some(TypeRole::BodyMedium),
Easing::EmphasizedDecelerate,
DurationToken::Long4,
Some(ShapeMorph::new(
ShapeScale::Large,
ShapeScale::ExtraLarge,
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
)),
),
// MenuTokens:CornerExtraSmall(4) + Level2。
spec(
Component::Menu,
ShapeScale::ExtraSmall,
Elevation::Level2,
TypeRole::BodyMedium,
None,
Easing::StandardDecelerate,
DurationToken::Short4,
None,
),
spec(
Component::Snackbar,
ShapeScale::ExtraSmall,
Elevation::Level3,
TypeRole::BodyMedium,
None,
Easing::EmphasizedDecelerate,
DurationToken::Medium2,
None,
),
spec(
Component::Tooltip,
ShapeScale::ExtraSmall,
Elevation::Level2,
TypeRole::BodySmall,
None,
Easing::StandardDecelerate,
DurationToken::Short2,
None,
),
spec(
Component::EmptyState,
ShapeScale::Large,
Elevation::Level0,
TypeRole::HeadlineSmall,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Medium2,
None,
),
];
+226
View File
@@ -0,0 +1,226 @@
//! 高程令牌:elevation level、阴影参数、surface container 映射。
//!
//! M3 在 2024 之后改了「怎么表达高程」:**不再**往表面上叠半透明的 surface tint,
//! 而是用 `surface-container-*` 这一族中性容器色表达层级(GPU 路径再叠阴影做空间感)。
//! 本模块两种口径都给出,旧口径显式标为 deprecated,避免页面线混用。
use crate::color::Role;
/// M3 高程级别 0..5(对应 0 / 1 / 3 / 6 / 8 / 12 dp)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Elevation {
/// 0dp:贴底内容(窗口底、内联区)。
Level0,
/// 1dp:卡片、可抬起的列表项。
Level1,
/// 3dp:可交互卡片、导航栏。
Level2,
/// 6dp:FAB、侧栏、浮动工具条。
Level3,
/// 8dp:菜单、popover。
Level4,
/// 12dp:对话框、模态。
Level5,
}
impl Elevation {
/// 全部级别。
pub const ALL: [Elevation; 6] = [
Elevation::Level0,
Elevation::Level1,
Elevation::Level2,
Elevation::Level3,
Elevation::Level4,
Elevation::Level5,
];
/// 标称高度(dp)。
pub const fn dp(self) -> f32 {
match self {
Elevation::Level0 => 0.0,
Elevation::Level1 => 1.0,
Elevation::Level2 => 3.0,
Elevation::Level3 => 6.0,
Elevation::Level4 => 8.0,
Elevation::Level5 => 12.0,
}
}
/// 该级别应使用的表面容器色角色(M3 现行口径)。
///
/// 层级靠**换底色**表达,不靠 tint;浅深模式都成立。
pub const fn surface_role(self) -> Role {
match self {
Elevation::Level0 => Role::SurfaceContainerLowest,
Elevation::Level1 => Role::SurfaceContainerLow,
Elevation::Level2 => Role::SurfaceContainer,
Elevation::Level3 => Role::SurfaceContainerHigh,
Elevation::Level4 | Elevation::Level5 => Role::SurfaceContainerHighest,
}
}
/// 阴影参数(**占位值,待 M63 视觉规格** — 见下面的 TODO)。
///
/// TODO(M63): 取值待视觉规格。官方只给 dp 与设计稿示意,没有可移植的 blur/alpha 公式;
/// 这里的 blur/offset/alpha 是临时占位(按「blur ≈ dp × 1.3 + 1.5」凑的数),
/// M63 的玻璃规格表落地后以那张表为准,别在页面里引用这些绝对值。
pub const fn shadow(self) -> ShadowSpec {
match self {
Elevation::Level0 => ShadowSpec {
blur_px: 0.0,
offset_y_px: 0.0,
alpha: 0.0,
},
Elevation::Level1 => ShadowSpec {
blur_px: 3.0,
offset_y_px: 1.0,
alpha: 0.12,
},
Elevation::Level2 => ShadowSpec {
blur_px: 6.0,
offset_y_px: 2.0,
alpha: 0.14,
},
Elevation::Level3 => ShadowSpec {
blur_px: 9.0,
offset_y_px: 4.0,
alpha: 0.16,
},
Elevation::Level4 => ShadowSpec {
blur_px: 12.0,
offset_y_px: 6.0,
alpha: 0.18,
},
Elevation::Level5 => ShadowSpec {
blur_px: 17.0,
offset_y_px: 8.0,
alpha: 0.20,
},
}
}
/// 旧口径(M3 2022):把 `surface-tint` 按这个 alpha 叠在 `surface` 上表达高程。
///
/// **deprecated**——现行 M3 用 [`Elevation::surface_role`];保留只为读旧稿时能对上数,
/// 新代码不要用。
pub const fn deprecated_tint_alpha(self) -> f32 {
match self {
Elevation::Level0 => 0.0,
Elevation::Level1 => 0.05,
Elevation::Level2 => 0.08,
Elevation::Level3 => 0.11,
Elevation::Level4 => 0.12,
Elevation::Level5 => 0.14,
}
}
}
/// 一层阴影的绘制参数。
///
/// TODO(M63): 取值待视觉规格(这份结构只是「接口预留」,数值由 M63 的玻璃规格表填充)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ShadowSpec {
/// 高斯模糊半径(px)。
pub blur_px: f32,
/// 向下偏移(px)。
pub offset_y_px: f32,
/// 阴影色 alpha(叠在 [`crate::color::Scheme::shadow`] 之上)。
pub alpha: f32,
}
impl ShadowSpec {
/// 是否可见(0 表示这一级不画阴影)。
pub fn is_visible(&self) -> bool {
self.alpha > 0.0 && self.blur_px > 0.0
}
}
/// 三个「材质层」的语义(沿用 `ui-integration-contract.md` §2 的三层,映射到 M3 令牌)。
///
/// 页面线按层取色,不许自己决定「这一层比那一层深一点」。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MaterialLayer {
/// 底层(canvas):窗口底,永远不透明。
Canvas,
/// 卡片层:页面内容/设置分组/AI 面板。
Card,
/// 浮层:对话框、菜单、Toast。
Overlay,
}
impl MaterialLayer {
/// 该层的底色角色。
pub const fn surface_role(self) -> Role {
match self {
MaterialLayer::Canvas => Role::Surface,
MaterialLayer::Card => Role::SurfaceContainerLow,
MaterialLayer::Overlay => Role::SurfaceContainerHigh,
}
}
/// 该层的高程级别。
pub const fn elevation(self) -> Elevation {
match self {
MaterialLayer::Canvas => Elevation::Level0,
MaterialLayer::Card => Elevation::Level1,
MaterialLayer::Overlay => Elevation::Level3,
}
}
/// 该层是否允许半透明(D27:**只有 GPU 路径**才允许;CPU 路径一律纯色)。
pub const fn allows_translucency(self) -> bool {
matches!(self, MaterialLayer::Card | MaterialLayer::Overlay)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn elevation_dp_ladder() {
let ladder: Vec<f32> = Elevation::ALL.iter().map(|e| e.dp()).collect();
assert_eq!(ladder, vec![0.0, 1.0, 3.0, 6.0, 8.0, 12.0]);
}
#[test]
fn shadow_grows_with_elevation() {
let mut prev = Elevation::Level0.shadow();
for level in &Elevation::ALL[1..] {
let cur = level.shadow();
assert!(cur.blur_px > prev.blur_px, "{level:?} 的模糊应更大");
assert!(cur.offset_y_px >= prev.offset_y_px);
assert!(cur.alpha >= prev.alpha);
assert!(cur.is_visible());
prev = cur;
}
assert!(!Elevation::Level0.shadow().is_visible());
}
#[test]
fn higher_levels_use_higher_surface_containers() {
assert_eq!(
Elevation::Level5.surface_role(),
Role::SurfaceContainerHighest
);
assert_eq!(
Elevation::Level0.surface_role(),
Role::SurfaceContainerLowest
);
}
#[test]
fn material_layers_are_ordered() {
assert_eq!(MaterialLayer::Canvas.surface_role(), Role::Surface);
assert_eq!(
MaterialLayer::Card.surface_role(),
Role::SurfaceContainerLow
);
assert_eq!(
MaterialLayer::Overlay.surface_role(),
Role::SurfaceContainerHigh
);
assert!(!MaterialLayer::Canvas.allows_translucency());
assert!(MaterialLayer::Overlay.allows_translucency());
}
}
+184
View File
@@ -0,0 +1,184 @@
//! 图标几何数据:24 网格、1.5px 视觉描边的 SVG path 字符串。
//!
//! 源流:`DevDocs/ops/slint-reference/crates/app/ui/components/icons.slint`(M54 重画过一轮的
//! Fluent 2 线性几何)。命令集沿用 `M/L/H/V/A/C/Q/Z`,与 SVG 路径语法一致,可直接交给
//! 任何 SVG path 解析器(iced 侧是 `iced::widget::canvas` 的 `Path`/`lyon`)。
//!
//! 这里只有数据:不许在数据里写颜色、尺寸、dpi——那些由渲染层决定。
use super::{Icon, IconKind, IconPaint};
/// 全部图标几何,**下标必须等于 `IconKind as usize`**(由测试钉死)。
pub(super) const GLYPHS: [Icon; 28] = [
// 实例:2×2 圆角方块(原 Slint 版是 4 个 Rectangle 描边,这里折成一条 path)
Icon::data(
IconKind::Instances,
"M 5 3 H 8 A 2 2 0 0 1 10 5 V 8 A 2 2 0 0 1 8 10 H 5 A 2 2 0 0 1 3 8 V 5 A 2 2 0 0 1 5 3 Z \
M 16 3 H 19 A 2 2 0 0 1 21 5 V 8 A 2 2 0 0 1 19 10 H 16 A 2 2 0 0 1 14 8 V 5 A 2 2 0 0 1 16 3 Z \
M 5 14 H 8 A 2 2 0 0 1 10 16 V 19 A 2 2 0 0 1 8 21 H 5 A 2 2 0 0 1 3 19 V 16 A 2 2 0 0 1 5 14 Z \
M 16 14 H 19 A 2 2 0 0 1 21 16 V 19 A 2 2 0 0 1 19 21 H 16 A 2 2 0 0 1 14 19 V 16 A 2 2 0 0 1 16 14 Z",
IconPaint::Stroke,
),
// 下载:箭头入托盘
Icon::data(
IconKind::Download,
"M 12 3.5 V 15 M 7 10.5 L 12 15.5 L 17 10.5 M 4.5 20.5 H 19.5",
IconPaint::Stroke,
),
// 日志:卷角文档 + 两行正文(不是三横——三横读作「菜单」)
Icon::data(
IconKind::Logs,
"M 6 3.5 H 14 L 18.5 8 V 20.5 H 6 Z M 14 3.5 V 8 H 18.5 M 9 12.5 H 15.5 M 9 16.5 H 15.5",
IconPaint::Stroke,
),
// 设置:八齿齿轮 + 中心孔
Icon::data(
IconKind::Settings,
"M 18.89 14.11 L 21.51 13.34 L 21.51 10.66 L 18.89 9.89 A 7.2 7.2 0 0 1 18.36 8.62 \
L 19.67 6.22 L 17.78 4.33 L 15.38 5.64 A 7.2 7.2 0 0 1 14.11 5.11 L 13.34 2.49 L 10.66 2.49 \
L 9.89 5.11 A 7.2 7.2 0 0 1 8.62 5.64 L 6.22 4.33 L 4.33 6.22 L 5.64 8.62 A 7.2 7.2 0 0 1 5.11 9.89 \
L 2.49 10.66 L 2.49 13.34 L 5.11 14.11 A 7.2 7.2 0 0 1 5.64 15.38 L 4.33 17.78 L 6.22 19.67 \
L 8.62 18.36 A 7.2 7.2 0 0 1 9.89 18.89 L 10.66 21.51 L 13.34 21.51 L 14.11 18.89 A 7.2 7.2 0 0 1 15.38 18.36 \
L 17.78 19.67 L 19.67 17.78 L 18.36 15.38 A 7.2 7.2 0 0 1 18.89 14.11 Z \
M 8.8 12 A 3.2 3.2 0 1 0 15.2 12 A 3.2 3.2 0 1 0 8.8 12 Z",
IconPaint::Stroke,
),
// AI:四角星(凹边四角星,不是直线菱形)
Icon::data(
IconKind::Ai,
"M 12 3 Q 13.6 10.4 21 12 Q 13.6 13.6 12 21 Q 10.4 13.6 3 12 Q 10.4 10.4 12 3 Z",
IconPaint::Stroke,
),
// 播放 / 停止:实心(与文字并排时不至于太轻)
Icon::data(
IconKind::Play,
"M 7.5 4.5 L 19 12 L 7.5 19.5 Z",
IconPaint::Fill,
),
Icon::data(
IconKind::Stop,
"M 8 7 H 16 A 1 1 0 0 1 17 8 V 16 A 1 1 0 0 1 16 17 H 8 A 1 1 0 0 1 7 16 V 8 A 1 1 0 0 1 8 7 Z",
IconPaint::Fill,
),
// 搜索:放大镜
Icon::data(
IconKind::Search,
"M 10.5 4 A 6.5 6.5 0 1 0 10.5 17 A 6.5 6.5 0 1 0 10.5 4 Z M 15.4 15.4 L 20.5 20.5",
IconPaint::Stroke,
),
Icon::data(
IconKind::Plus,
"M 12 5 V 19 M 5 12 H 19",
IconPaint::Stroke,
),
Icon::data(
IconKind::Close,
"M 6.5 6.5 L 17.5 17.5 M 17.5 6.5 L 6.5 17.5",
IconPaint::Stroke,
),
Icon::data(IconKind::Minimize, "M 6 12 H 18", IconPaint::Stroke),
Icon::data(
IconKind::Maximize,
"M 7.5 5.5 H 16.5 A 2 2 0 0 1 18.5 7.5 V 16.5 A 2 2 0 0 1 16.5 18.5 H 7.5 \
A 2 2 0 0 1 5.5 16.5 V 7.5 A 2 2 0 0 1 7.5 5.5 Z",
IconPaint::Stroke,
),
Icon::data(
IconKind::ChevronLeft,
"M 14.5 5.5 L 8 12 L 14.5 18.5",
IconPaint::Stroke,
),
Icon::data(
IconKind::ChevronRight,
"M 9.5 5.5 L 16 12 L 9.5 18.5",
IconPaint::Stroke,
),
Icon::data(
IconKind::ChevronDown,
"M 5.5 9.5 L 12 16 L 18.5 9.5",
IconPaint::Stroke,
),
// 展开为独立页:双对角外箭头
Icon::data(
IconKind::Expand,
"M 14 4 H 20 V 10 M 20 4 L 13.5 10.5 M 10 20 H 4 V 14 M 4 20 L 10.5 13.5",
IconPaint::Stroke,
),
// 发送:纸飞机
Icon::data(
IconKind::Send,
"M 21 3 L 3 10.5 L 10.2 13.6 L 13.4 20.5 Z M 10.2 13.6 L 21 3",
IconPaint::Stroke,
),
Icon::data(
IconKind::Check,
"M 5 12.5 L 9.8 17.3 L 19 6.5",
IconPaint::Stroke,
),
Icon::data(
IconKind::Warn,
"M 12 4.5 L 20.5 19.5 H 3.5 Z M 12 9.5 V 14 M 12 16.6 V 17",
IconPaint::Stroke,
),
Icon::data(
IconKind::Error,
"M 12 4 A 8 8 0 1 0 12 20 A 8 8 0 1 0 12 4 Z M 12 7.5 V 13 M 12 16.6 V 17",
IconPaint::Stroke,
),
// 更多:三圆点(实心,原 Slint 版是三个 filled Rectangle)
Icon::data(
IconKind::More,
"M 7.5 12 A 1.5 1.5 0 1 0 4.5 12 A 1.5 1.5 0 1 0 7.5 12 Z \
M 13.5 12 A 1.5 1.5 0 1 0 10.5 12 A 1.5 1.5 0 1 0 13.5 12 Z \
M 19.5 12 A 1.5 1.5 0 1 0 16.5 12 A 1.5 1.5 0 1 0 19.5 12 Z",
IconPaint::Fill,
),
// 侧栏:面板框 + 左侧分栏线
Icon::data(
IconKind::Sidebar,
"M 6 5 H 18 A 2 2 0 0 1 20 7 V 17 A 2 2 0 0 1 18 19 H 6 A 2 2 0 0 1 4 17 V 7 \
A 2 2 0 0 1 6 5 Z M 9.5 5.5 V 18.5",
IconPaint::Stroke,
),
// 还原(已最大化时的标题栏按钮):前后两层方框
Icon::data(
IconKind::Restore,
"M 9 4.5 H 19.5 V 15 M 15 8.5 H 4.5 V 19.5 H 15 Z",
IconPaint::Stroke,
),
// 账户占位:头像剪影
Icon::data(
IconKind::User,
"M 12 5.3 A 3.7 3.7 0 1 0 12 12.7 A 3.7 3.7 0 1 0 12 5.3 Z \
M 4.5 20.5 C 4.5 16.9 8 15 12 15 C 16 15 19.5 16.9 19.5 20.5",
IconPaint::Stroke,
),
// ── M61 新增(追加在末尾,不动既有次序;iced 页面需要而 Slint 版没有的)──
// 刷新:顺时针单圈 + 右上角箭头
Icon::data(
IconKind::Refresh,
"M 20 12 A 8 8 0 1 1 12 4 C 14.2 4 16.3 4.9 17.8 6.4 L 20 8.5 \
M 20 4.5 V 8.5 H 16",
IconPaint::Stroke,
),
// 筛选:漏斗
Icon::data(
IconKind::Filter,
"M 4.5 5.5 H 19.5 L 14 12.5 V 19 L 10 16.5 V 12.5 Z",
IconPaint::Stroke,
),
// 目录:文件夹(实例目录 / 日志目录入口)
Icon::data(
IconKind::Folder,
"M 4 7.5 A 2 2 0 0 1 6 5.5 H 9.5 L 11.5 7.5 H 18 A 2 2 0 0 1 20 9.5 V 17 \
A 2 2 0 0 1 18 19 H 6 A 2 2 0 0 1 4 17 Z",
IconPaint::Stroke,
),
// 删除:垃圾桶
Icon::data(
IconKind::Trash,
"M 4.5 7 H 19.5 M 9.5 7 V 4.5 H 14.5 V 7 M 6.8 7 L 7.8 19.5 H 16.2 L 17.2 7 \
M 10.5 10.5 V 16 M 13.5 10.5 V 16",
IconPaint::Stroke,
),
];
+208
View File
@@ -0,0 +1,208 @@
//! `IconKind` 枚举:**API 已冻结,只允许在末尾追加**(序号即持久化 id,插到中间会静默换图)。
use super::glyphs::GLYPHS;
use super::Icon;
/// 图标种类。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub enum IconKind {
/// 实例(2×2 网格)。
Instances,
/// 下载。
Download,
/// 日志(文档 + 正文)。
Logs,
/// 设置(齿轮)。
Settings,
/// AI(四角星)。
Ai,
/// 播放(实心三角)。
Play,
/// 停止(实心圆角方)。
Stop,
/// 搜索(放大镜)。
Search,
/// 加号。
Plus,
/// 关闭(叉)。
Close,
/// 最小化(横线)。
Minimize,
/// 最大化(方框)。
Maximize,
/// 左尖角。
ChevronLeft,
/// 右尖角。
ChevronRight,
/// 下尖角。
ChevronDown,
/// 展开为独立页(双对角箭头)。
Expand,
/// 发送(纸飞机)。
Send,
/// 勾选。
Check,
/// 警告(三角感叹)。
Warn,
/// 错误(圆形感叹)。
Error,
/// 更多(三点,实心)。
More,
/// 侧栏开关。
Sidebar,
/// 还原窗口(双层方框)。
Restore,
/// 账户占位(头像剪影)。
User,
/// 刷新(M61 新增)。
Refresh,
/// 筛选(M61 新增)。
Filter,
/// 目录(M61 新增)。
Folder,
/// 删除(M61 新增)。
Trash,
}
impl IconKind {
/// 全部图标,按枚举声明顺序。
pub const ALL: [IconKind; 28] = [
IconKind::Instances,
IconKind::Download,
IconKind::Logs,
IconKind::Settings,
IconKind::Ai,
IconKind::Play,
IconKind::Stop,
IconKind::Search,
IconKind::Plus,
IconKind::Close,
IconKind::Minimize,
IconKind::Maximize,
IconKind::ChevronLeft,
IconKind::ChevronRight,
IconKind::ChevronDown,
IconKind::Expand,
IconKind::Send,
IconKind::Check,
IconKind::Warn,
IconKind::Error,
IconKind::More,
IconKind::Sidebar,
IconKind::Restore,
IconKind::User,
IconKind::Refresh,
IconKind::Filter,
IconKind::Folder,
IconKind::Trash,
];
/// 取几何数据。
pub fn icon(self) -> Icon {
GLYPHS[self as usize]
}
/// 稳定字符串名(kebab-case):写配置、日志、测试快照都用它,不要用 `Debug` 输出。
pub const fn name(self) -> &'static str {
match self {
IconKind::Instances => "instances",
IconKind::Download => "download",
IconKind::Logs => "logs",
IconKind::Settings => "settings",
IconKind::Ai => "ai",
IconKind::Play => "play",
IconKind::Stop => "stop",
IconKind::Search => "search",
IconKind::Plus => "plus",
IconKind::Close => "close",
IconKind::Minimize => "minimize",
IconKind::Maximize => "maximize",
IconKind::ChevronLeft => "chevron-left",
IconKind::ChevronRight => "chevron-right",
IconKind::ChevronDown => "chevron-down",
IconKind::Expand => "expand",
IconKind::Send => "send",
IconKind::Check => "check",
IconKind::Warn => "warn",
IconKind::Error => "error",
IconKind::More => "more",
IconKind::Sidebar => "sidebar",
IconKind::Restore => "restore",
IconKind::User => "user",
IconKind::Refresh => "refresh",
IconKind::Filter => "filter",
IconKind::Folder => "folder",
IconKind::Trash => "trash",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::icons::IconPaint;
#[test]
fn glyph_table_matches_enum_order() {
for (i, icon) in GLYPHS.iter().enumerate() {
assert_eq!(
icon.kind as usize,
i,
"GLYPHS 第 {i} 项是 {},与 IconKind 顺序不一致",
icon.kind.name()
);
}
}
#[test]
fn all_kinds_resolve() {
assert_eq!(IconKind::ALL.len(), GLYPHS.len());
for kind in IconKind::ALL {
let icon = kind.icon();
assert_eq!(icon.kind, kind);
assert!(!icon.path.is_empty());
}
}
#[test]
fn names_are_unique_and_kebab() {
let mut seen = std::collections::BTreeSet::new();
for kind in IconKind::ALL {
let name = kind.name();
assert!(seen.insert(name), "图标名重复:{name}");
assert!(name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
}
}
#[test]
fn migrated_kinds_keep_their_positions() {
// 前 24 个是 Slint 版迁移来的(次序必须原样),后 4 个是 M61 追加的。
assert_eq!(IconKind::ALL[0], IconKind::Instances);
assert_eq!(IconKind::ALL[4], IconKind::Ai);
assert_eq!(IconKind::ALL[20], IconKind::More);
assert_eq!(IconKind::ALL[23], IconKind::User);
assert_eq!(IconKind::ALL[24], IconKind::Refresh);
assert_eq!(IconKind::ALL[27], IconKind::Trash);
}
#[test]
fn only_solid_icons_are_filled() {
for kind in IconKind::ALL {
let icon = kind.icon();
let expect_fill = matches!(kind, IconKind::Play | IconKind::Stop | IconKind::More);
assert_eq!(
icon.paint,
if expect_fill {
IconPaint::Fill
} else {
IconPaint::Stroke
},
"{} 的 fill/stroke 判定不对",
kind.name()
);
}
}
}
+251
View File
@@ -0,0 +1,251 @@
//! 图标令牌:`IconKind`(一次冻结,只允许在末尾追加)+ 24 网格几何数据。
//!
//! ```no_run
//! use unlucky_design::icons::{IconKind, IconSize};
//!
//! let icon = IconKind::Settings.icon();
//! let w = icon.stroke_width(IconSize::Nav.px());
//! assert!((w - 1.8).abs() < 1e-6);
//! ```
//!
//! ## 几何口径(三线统一,M54 定稿)
//!
//! - **网格 24×24**([`VIEWBOX`]),内容留 2~3 单位边距;
//! - **视觉描边 1.5px**([`VISUAL_STROKE_PX`]):不管渲染多大,线宽看起来都是 1.5 逻辑像素;
//! - **圆头端点 + 圆角连接**(`stroke-linecap: round` / `stroke-linejoin: round`),
//! 适配层设这两个属性,数据里不表达;
//! - **实心图形才用 fill**(播放/停止/三点),其余一律描边,保证与文字并排时重量一致;
//! - 不用字体字符当图标(`+` `→` 一律禁止)。
//!
//! ## ⚠️ 描边语义差异:Slint 与 SVG **不是一回事**
//!
//! - Slint 的 `Path.stroke-width` 是**绝对像素**:只乘 DPI 缩放,**不乘 viewBox**。
//! 所以老代码写 `stroke-width: 1.5px` 意味着「渲染出来就是 1.5px」,viewBox 缩放对它无影响
//! (实证:femtovg 后端 `PathItemRenderer::render` 里的
//! `paint.set_line_width(stroke_width * scale_factor)`——只有 DPI 缩放因子,没有 viewBox 项)。
//! - SVG / iced canvas 的 `stroke-width` 是**用户单位**(viewBox 单位):渲染时会被
//! `render_px / VIEWBOX` 缩放。同样的 `1.5` 在 20px 渲染下只有 1.25px 视觉宽度。
//!
//! 因此 iced 侧必须**按 SVG 语义重算**:要保持 1.5px 视觉线宽,viewBox 里的 stroke-width 得取
//! `1.5 × 24 / render_px`(20px → 1.8、16px → 2.25、24px → 1.5)。[`Icon::stroke_width`] 就是这个
//! 换算的纯函数——**不要在适配层手抄 1.5 这个数**,那是 Slint 口径。
mod glyphs;
mod kinds;
pub use kinds::IconKind;
/// 图标网格边长(viewBox 宽高)。
pub const VIEWBOX: f32 = 24.0;
/// 视觉描边宽度(逻辑像素,与渲染尺寸无关)。
pub const VISUAL_STROKE_PX: f32 = 1.5;
/// 描边语义换算:给定渲染尺寸与目标视觉线宽,返回 **viewBox 单位**的 stroke-width。
///
/// `render_px <= 0` 时返回 `visual_px`(退化情形不产生除零/无穷大)。
pub fn stroke_width_for(render_px: f32, visual_px: f32) -> f32 {
if render_px <= 0.0 {
return visual_px;
}
visual_px * VIEWBOX / render_px
}
/// 反向换算:viewBox 单位的 stroke-width 在实际渲染尺寸下的视觉线宽。
pub fn visual_stroke_px(render_px: f32, stroke_width: f32) -> f32 {
stroke_width * render_px / VIEWBOX
}
/// 图标的绘制方式。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum IconPaint {
/// 只描边(默认,绝大多数图标)。
Stroke,
/// 填充(播放/停止/三点这类实心图形)。
Fill,
}
/// 图标的标准渲染尺寸(页面只从这几档里取,不许自定义零散值)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum IconSize {
/// 12px:行内小图标(徽标内)。
Inline,
/// 16px:按钮/标题栏按钮。
Button,
/// 20px:导航项、列表行。
Nav,
/// 24px:卡片头、工具栏。
Toolbar,
/// 32px:空态图标块。
Empty,
}
impl IconSize {
/// 像素值。
pub const fn px(self) -> f32 {
match self {
IconSize::Inline => 12.0,
IconSize::Button => 16.0,
IconSize::Nav => 20.0,
IconSize::Toolbar => 24.0,
IconSize::Empty => 32.0,
}
}
/// 全部档位,由小到大。
pub const ALL: [IconSize; 5] = [
IconSize::Inline,
IconSize::Button,
IconSize::Nav,
IconSize::Toolbar,
IconSize::Empty,
];
}
/// 一个图标的几何数据。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Icon {
/// 图标种类。
pub kind: IconKind,
/// SVG path 数据(viewBox `0 0 24 24`,可能含多个子路径)。
pub path: &'static str,
/// 描边还是填充。
pub paint: IconPaint,
}
impl Icon {
/// 表项构造器(只在 [`glyphs`] 的数据表里用)。
pub(super) const fn data(kind: IconKind, path: &'static str, paint: IconPaint) -> Self {
Self { kind, path, paint }
}
/// 该图标在 `render_px` 下的 **viewBox 单位** stroke-width([`IconPaint::Fill`] 返回 0)。
pub fn stroke_width(self, render_px: f32) -> f32 {
match self.paint {
IconPaint::Fill => 0.0,
IconPaint::Stroke => stroke_width_for(render_px, VISUAL_STROKE_PX),
}
}
/// 是否实心填充。
pub fn is_filled(self) -> bool {
self.paint == IconPaint::Fill
}
/// 路径里是否含曲线命令(适配层可选:纯直线路径走不走曲线细分)。
pub fn has_curves(self) -> bool {
self.path
.bytes()
.any(|b| matches!(b, b'C' | b'Q' | b'A' | b'c' | b'q' | b'a'))
}
/// 子路径个数(`M` 命令计数):适配层可以据此一次性分配 `lyon` 的缓冲区。
pub fn subpath_count(self) -> usize {
self.path.matches('M').count() + self.path.matches('m').count()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paths_use_only_svg_commands() {
const ALLOWED: &str = "MmLlHhVvAaCcQqZz0123456789 .,-";
for kind in IconKind::ALL {
let path = kind.icon().path;
assert!(path.starts_with('M'), "{} 的路径必须以 M 开头", kind.name());
for byte in path.bytes() {
assert!(
byte.is_ascii(),
"{} 的路径含非 ASCII(不许用字体字符/中文)",
kind.name()
);
}
for ch in path.chars() {
assert!(
ALLOWED.contains(ch),
"{} 的路径含非法字符 {ch:?}",
kind.name()
);
}
// 不许出现科学计数法(能解析但可读性差、易手误)。
assert!(
!path.contains('e') && !path.contains('E'),
"{} 用了科学计数法",
kind.name()
);
assert!(
path.matches(' ').count() >= 2,
"{} 的坐标分隔异常",
kind.name()
);
}
}
#[test]
fn coordinates_stay_inside_the_grid() {
for kind in IconKind::ALL {
for token in kind.icon().path.split([' ', ',']) {
if let Ok(value) = token.parse::<f32>() {
assert!(
(-1.0..=VIEWBOX + 1.0).contains(&value),
"{} 出现 {value}:超出 24 网格(弧半径/控制点也受这条约束)",
kind.name()
);
}
}
}
}
#[test]
fn subpaths_are_counted() {
assert_eq!(IconKind::Plus.icon().subpath_count(), 2);
assert_eq!(IconKind::Instances.icon().subpath_count(), 4);
assert_eq!(IconKind::Play.icon().subpath_count(), 1);
}
#[test]
fn filled_icons_have_no_stroke() {
for kind in IconKind::ALL {
let icon = kind.icon();
if icon.is_filled() {
assert_eq!(icon.stroke_width(20.0), 0.0, "{}", kind.name());
} else {
assert!(icon.stroke_width(20.0) > 1.0, "{}", kind.name());
}
}
}
#[test]
fn stroke_width_follows_svg_semantics() {
// SVG 语义:viewBox 单位 × (render/24) = 视觉像素。
assert!((stroke_width_for(20.0, VISUAL_STROKE_PX) - 1.8).abs() < 1.0e-6);
assert!((stroke_width_for(16.0, VISUAL_STROKE_PX) - 2.25).abs() < 1.0e-6);
assert!((stroke_width_for(24.0, VISUAL_STROKE_PX) - 1.5).abs() < 1.0e-6);
assert!((stroke_width_for(48.0, VISUAL_STROKE_PX) - 0.75).abs() < 1.0e-6);
// 退化输入不炸、不产生无穷大(Slint 口径的 1.5 绝对值这里不能照抄)。
assert_eq!(stroke_width_for(0.0, VISUAL_STROKE_PX), VISUAL_STROKE_PX);
assert_eq!(stroke_width_for(-4.0, VISUAL_STROKE_PX), VISUAL_STROKE_PX);
// 往返一致。
for size in IconSize::ALL {
let w = stroke_width_for(size.px(), VISUAL_STROKE_PX);
assert!((visual_stroke_px(size.px(), w) - VISUAL_STROKE_PX).abs() < 1.0e-6);
}
}
#[test]
fn icon_sizes_are_ordered() {
let px: Vec<f32> = IconSize::ALL.iter().map(|s| s.px()).collect();
for pair in px.windows(2) {
assert!(pair[0] < pair[1]);
}
}
#[test]
fn curve_flags_are_reported() {
assert!(IconKind::Settings.icon().has_curves());
assert!(!IconKind::Plus.icon().has_curves());
assert!(!IconKind::ChevronDown.icon().has_curves());
}
}
+134
View File
@@ -0,0 +1,134 @@
//! # unlucky-design · ULL 设计令牌唯一真源
//!
//! 视觉基线 = **Material 3 Expressive**(D26 锁定,风格与框架不再更换)。这个 crate 只装
//! **渲染无关的数据与纯函数**:色彩角色、排版 ramp、形状刻度、高程、动效参数、图标几何。
//! 它不依赖任何 UI / 渲染框架(iced / slint / winit / wgpu / gtk …),也不依赖任何第三方 crate,
//! 因此 iced 迁移、CLI、甚至离屏截图工具都能吃同一套值——这是「UI 整体性」的地基。
//!
//! ## 边界(谁负责什么)
//!
//! | 层 | 负责 | 不负责 |
//! |---|---|---|
//! | `unlucky-design`(本 crate) | 令牌的值与它们之间的纯函数关系(tone→色、type role→字号/行高/字重、shape→半径、spring→时长) | 布局、控件、绘制、字体加载、图片、动画驱动 |
//! | iced 适配层(后续 mission) | 把令牌翻成 iced 的类型(`Color` / `Length` / `Font` / 动画时钟) | 自己发明新令牌名或新取值 |
//! | 页面 mission | 消费令牌与 [`component_map`] 的档位约定 | 硬编码色值 / 字号 / 半径 / 时长 |
//!
//! ## 分模块
//!
//! - [`color`]:M3 语义色角色(49 个)× light/dark 双套 + tone 调色板(OKLCh 近似,口径见模块文档)
//! - [`typography`]:M3 type scale 15 档 + 三平台字体族回退栈
//! - [`shape`]:M3 Expressive 形状刻度 + shape morph 数据
//! - [`elevation`]:elevation level 与 surface tint / surface container 规则
//! - [`motion`]:时长、easing(可采样)、Expressive 弹簧参数
//! - [`spacing`]:4px 栅格与骨架尺寸(原 `metrics.slint` 的框架无关部分)
//! - [`icons`]:24 网格图标几何(SVG path 字符串)+ 描边语义
//! - [`component_map`]:组件 → 档位映射表(哪个组件用哪档形状/高程/动效/字体)
//!
//! ## 最小用法
//!
//! ```
//! use unlucky_design::color::{Role, Scheme, SchemeFlavor};
//! use unlucky_design::typography::{FontFamily, Platform, TypeRole};
//!
//! let light = Scheme::baseline(SchemeFlavor::Light);
//! let ratio = light.contrast(Role::OnSurface, Role::Surface);
//! assert!(ratio >= 4.5, "正文对表面必须满足 WCAG AA");
//!
//! let title = TypeRole::TitleLarge.style();
//! assert_eq!(title.size_px, 22.0);
//! let stack = Platform::current().font_stack(FontFamily::Display);
//! assert!(!stack.is_empty());
//! ```
//!
//! ## baseline MD3 vs M3 Expressive 差异清单
//!
//! 「M3 Expressive」不是「M3 换皮」——它真的加了一批令牌。本 crate 两代都提供,差异如下
//! (来源等级:**A** = androidx 源码 / 官方 token 文件;**B** = m3.material.io 文档表格;**C** = 社区转述)。
//!
//! | 领域 | baseline MD3 | M3 Expressive 新增/不同 | 本 crate 落点 | 等级 |
//! |---|---|---|---|---|
//! | 排版 | 15 档 type role,只有 Regular/Medium 两种字重档 | **每档多一条 `*Emphasized`**:字号/行高不变,字重升到 Medium(500) 或 Bold(700),字距按表微调(display-large −0.2→0、body-large 0.5→0.15、title-medium 0.2→0.15、body-medium 0.2→0.25) | [`typography::TypeRole::emphasized`](官方值);[`typography::TypeRole::emphasized_simplified`] 是我们的「只加一档字重」简化规则,**非官方** | A |
//! | 形状 | ExtraSmall 4 / Small 8 / Medium 12 / Large 16 / **ExtraLarge 28** / Full | 多出 **LargeIncreased 20 / ExtraLargeIncreased 32 / ExtraExtraLarge 48**,并引入 **shape morph**(按下/选中时档位之间弹性过渡) | [`shape::ShapeScale`](10 档)+ [`shape::ShapeMorph`] + [`component_map`] 的 `morph` 列 | A |
//! | 动效 | 16 档 duration + 7 条 cubic-bezier easing(时间驱动) | 把弹簧打包成 **standard / expressive 两套方案**,每套各有 spatial(位移,会回弹)与 effects(颜色/透明度,不回弹)× fast/default/slow。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) | [`motion::spring`] 的 **方案 × 类别 × 速度**二维表(12 格,`StandardMotionTokens`/`ExpressiveMotionTokens` 原值)+ 9 个具名常量;duration/easing 两代共用 | A |
//! | 组件尺寸 | 触控目标 48dp(无障碍下限本来就是 48) | Expressive 把按钮/列表的整体节奏放大(列表项 56/72/88),并明确 48dp 为**可点下限** | [`spacing::LayoutMetrics::DESKTOP`]:`touch_target = 48`(照规范)+ `control_height = 40`(可见高度,上下各 4px 内边距凑满 48)+ `row_height`/`nav_item_height` = 44、`titlebar_height` = 48(M63 §1.4+R13 的桌面档) | A/B |
//! | 字体 | 只有 400/500/700 的「概念」字重,没规定字体族要覆盖几档 | Expressive 的强调档**真的会要 700**(label-*、title-medium/small),中文字体覆盖差异巨大:雅黑只有 400/700(无 500)、苹方到 600 断(无 700)、Noto/思源可变字体全覆盖 | [`typography::FontFace`] 的 `WeightCoverage`(可变区间 / 静态表)+ [`typography::Platform::resolve_weight`] 纯函数给出「原生 / 最近档 / 合成加粗」三档结论 | B |
//! | 色彩 | 49 个角色(primary/secondary/tertiary/error 族、surface-container 系、`*-fixed*` 系、outline、inverse、scrim/shadow) | **没有新增颜色角色**;变化在用法:surface-container 系(Lowest…Highest)取代旧的 elevation tint 叠色,表达层级 | [`color::Role`] 全 49 个;[`elevation::Elevation::surface_role`] 给出层级映射,`deprecated_tint_alpha` 保留旧口径作对照 | A |
//! | 高程 | Level0..5 = 0/1/3/6/8/12dp | 数值不变;组件默认值不同(卡片 L0、菜单 L2、对话框 L3,见 [`component_map`]) | [`elevation::Elevation`] | A |
//!
//! ### 两条由此衍生的项目纪律
//!
//! 1. **shape morph 是克制的状态信号,不是每屏装饰**:Expressive 官方鼓励「形状即状态」,但
//! 桌面端信息密度高,圆角到处变会显得凌乱(社区已有人批评控制中心这么做)。本项目的约束是
//! **一屏最多 1 处形变、且只用于状态切换信号**;静态元素一律用 [`shape::ShapeScale`] 的固定档。
//! [`component_map`] 的 `morph` 列就是允许清单——不在表里的组件不许自己加形变。
//! 2. **中文字重必须真的有三档**:Expressive 的 700 落在中文字体上,雅黑缺 500、苹方缺 700,
//! 缺档就走「合成加粗」= 观感廉价。所以字体栈带 `WeightCoverage`,并在
//! [`typography::Platform::resolve_weight`] 里把降级路径显式化(合成加粗是最后一档)。
//!
//! 已知与文档的 0.05px 级差异:Compose 这一代 `TypeScaleTokens` 的 tracking 与 m3.material.io
//! 网页表格不同(display-large −0.2 vs −0.25、body-medium 0.2 vs 0.25、title-medium 0.2 vs 0.15),
//! 本项目**跟 Compose**(那是真正渲染出来的值,A 级)。
//!
//! ## 未支持的官方能力(gap 登记,不要以为已经有了)
//!
//! - **对比度等级**:Android 16 的「默认 / 中 / 高」三档对比度本 crate **不支持**。将来扩法是
//! **按等级偏移角色 tone 表**(例如高对比把 `on-*` 与承载色的 tone 差拉大 5~10),
//! 而不是新增一批角色——`Role` 的数量保持 49 不变,`Scheme` 多一个 `contrast_level` 维度。
//! (M63 的 G4 只说「高对比模式走 CPU 档」,那是渲染档位,不等价于支持对比度等级。)
//! - **动态壁纸取色**:Material You 的多色种子派生未实现(本启动器用不到)。
//! - **M3 的 elevation tint 叠色**:现行 M3 已改用 surface-container 表达层级,旧的
//! `deprecated_tint_alpha()` 只为读旧稿保留。
#![forbid(unsafe_code)]
pub mod color;
pub mod component_map;
pub mod elevation;
pub mod icons;
pub mod motion;
pub mod shape;
pub mod spacing;
pub mod typography;
pub use color::{Color, Role, Scheme, SchemeFlavor};
pub use motion::MotionScale;
pub use typography::TypeRole;
/// 设计令牌的整体入口:一份「当前该长什么样」的聚合快照。
///
/// iced 适配层只持有这个结构(深浅切换 = 重新 `DesignTokens::baseline(mode)`),
/// 不要在适配层里缓存散装色值——那样深浅热切会有一半画对一半画错。
#[derive(Debug, Clone, Copy)]
pub struct DesignTokens {
/// 当前深浅模式。
pub flavor: SchemeFlavor,
/// 当前模式的 M3 语义色角色全表。
pub colors: Scheme,
/// 动效时长的全局缩放(`1.0` = 标准;用户关掉动画时适配层传 `0.0`)。
pub motion_scale: MotionScale,
}
impl DesignTokens {
/// 按模式给一套出厂令牌(baseline seed `#6750A4`,等价 M3 baseline 调色板)。
pub fn baseline(flavor: SchemeFlavor) -> Self {
Self {
flavor,
colors: Scheme::baseline(flavor),
motion_scale: MotionScale::STANDARD,
}
}
/// 按用户强调色重建一套令牌(表面中性色仍走 baseline,强调色只驱动 primary/secondary/tertiary)。
pub fn with_accent(flavor: SchemeFlavor, accent: Color) -> Self {
Self {
flavor,
colors: Scheme::from_accent(flavor, accent),
motion_scale: MotionScale::STANDARD,
}
}
/// 便捷取色:`tokens.color(Role::Surface)`。
pub fn color(&self, role: Role) -> Color {
self.colors.get(role)
}
}
+308
View File
@@ -0,0 +1,308 @@
//! 动效令牌:时长、缓动(可采样)、M3 Expressive 弹簧参数。
//!
//! 三件东西分开表达,因为适配层要的接口不同:
//! - [`DurationToken`]:M3 的 short/medium/long/extra-long 十六档,给「固定时长 + easing」用;
//! - [`Easing`]:cubic-bezier 曲线,纯函数采样(适配层自己决定帧率);
//! - [`Spring`](见 [`spring`]):Expressive 的物理参数(stiffness/damping/mass),
//! 可用于实时求解或预估时长。
//!
//! **CPU / 减少动效路径**:把 [`MotionScale`] 设成 [`MotionScale::NONE`],
//! 所有时长归零、`Easing::sample` 仍返回 0/1 端值(适配层按 `t >= 1` 直接落终态)。
mod spring;
pub use spring::{
spring, MotionScale, MotionSchemeKind, Spring, SpringCategory, SpringSpeed,
SPRING_EFFECTS_DEFAULT, SPRING_EFFECTS_FAST, SPRING_EFFECTS_SLOW,
SPRING_EXPRESSIVE_SPATIAL_DEFAULT, SPRING_EXPRESSIVE_SPATIAL_FAST,
SPRING_EXPRESSIVE_SPATIAL_SLOW, SPRING_STANDARD_SPATIAL_DEFAULT, SPRING_STANDARD_SPATIAL_FAST,
SPRING_STANDARD_SPATIAL_SLOW,
};
/// 动效时长档位(M3 动效令牌,单位毫秒)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DurationToken {
/// 50ms:最小状态回弹。
Short1,
/// 100ms:小图标/勾选。
Short2,
/// 150ms:开关、小尺寸 fade。
Short3,
/// 200ms:按钮状态、hover。
Short4,
/// 250ms:小卡片展开。
Medium1,
/// 300ms:导航指示条、tab 切换。
Medium2,
/// 350ms:卡片展开、列表重排。
Medium3,
/// 400ms:面板开合。
Medium4,
/// 450ms:页面切换。
Long1,
/// 500ms:大面板进出。
Long2,
/// 550ms:全屏浮层。
Long3,
/// 600ms:对话框进出。
Long4,
/// 700ms:结构化过渡。
ExtraLong1,
/// 800ms:结构化过渡。
ExtraLong2,
/// 900ms:结构化过渡。
ExtraLong3,
/// 1000ms:整屏形变(shape morph 的慢档)。
ExtraLong4,
}
impl DurationToken {
/// 毫秒值。
pub const fn ms(self) -> u32 {
match self {
DurationToken::Short1 => 50,
DurationToken::Short2 => 100,
DurationToken::Short3 => 150,
DurationToken::Short4 => 200,
DurationToken::Medium1 => 250,
DurationToken::Medium2 => 300,
DurationToken::Medium3 => 350,
DurationToken::Medium4 => 400,
DurationToken::Long1 => 450,
DurationToken::Long2 => 500,
DurationToken::Long3 => 550,
DurationToken::Long4 => 600,
DurationToken::ExtraLong1 => 700,
DurationToken::ExtraLong2 => 800,
DurationToken::ExtraLong3 => 900,
DurationToken::ExtraLong4 => 1000,
}
}
/// 稳定字符串名(`short1` … `extra-long4`),写配置与日志用。
pub const fn name(self) -> &'static str {
match self {
DurationToken::Short1 => "short1",
DurationToken::Short2 => "short2",
DurationToken::Short3 => "short3",
DurationToken::Short4 => "short4",
DurationToken::Medium1 => "medium1",
DurationToken::Medium2 => "medium2",
DurationToken::Medium3 => "medium3",
DurationToken::Medium4 => "medium4",
DurationToken::Long1 => "long1",
DurationToken::Long2 => "long2",
DurationToken::Long3 => "long3",
DurationToken::Long4 => "long4",
DurationToken::ExtraLong1 => "extra-long1",
DurationToken::ExtraLong2 => "extra-long2",
DurationToken::ExtraLong3 => "extra-long3",
DurationToken::ExtraLong4 => "extra-long4",
}
}
}
/// 三次贝塞尔缓动曲线(CSS `cubic-bezier` 同样的参数含义)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CubicBezier {
/// 控制点 1 的 x(0..1)。
pub x1: f32,
/// 控制点 1 的 y。
pub y1: f32,
/// 控制点 2 的 x(0..1)。
pub x2: f32,
/// 控制点 2 的 y。
pub y2: f32,
}
impl CubicBezier {
/// 线性。
pub const LINEAR: Self = Self {
x1: 0.0,
y1: 0.0,
x2: 1.0,
y2: 1.0,
};
/// 给定时间参数 `t`(0..1),返回进度(0..1,标准曲线不越界)。
pub fn sample(self, t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
if t == 0.0 || t == 1.0 {
return t;
}
// 二分求 x(s) = t 的曲线参数 s,再取 y(s)。
let mut lo = 0.0_f32;
let mut hi = 1.0_f32;
let mut s = t;
for _ in 0..24 {
s = 0.5 * (lo + hi);
let x = bezier_axis(self.x1, self.x2, s);
if x < t {
lo = s;
} else {
hi = s;
}
}
bezier_axis(self.y1, self.y2, s)
}
}
/// 一维三次贝塞尔:端点固定为 0/1,控制点为 `c1`/`c2`。
fn bezier_axis(c1: f32, c2: f32, s: f32) -> f32 {
let u = 1.0 - s;
3.0 * u * u * s * c1 + 3.0 * u * s * s * c2 + s * s * s
}
/// M3 缓动档位(值取自 Material 动效令牌)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Easing {
/// 线性(进度指示器)。
Linear,
/// 标准:起止都平滑。
Standard,
/// 标准减速:进场(出现在屏幕上)。
StandardDecelerate,
/// 标准加速:退场。
StandardAccelerate,
/// 强调:注意力转移的主曲线。
Emphasized,
/// 强调减速:强调类进场。
EmphasizedDecelerate,
/// 强调加速:强调类退场。
EmphasizedAccelerate,
}
impl Easing {
/// 曲线参数。
pub const fn curve(self) -> CubicBezier {
match self {
Easing::Linear => CubicBezier::LINEAR,
Easing::Standard | Easing::Emphasized => CubicBezier {
x1: 0.2,
y1: 0.0,
x2: 0.0,
y2: 1.0,
},
Easing::StandardDecelerate => CubicBezier {
x1: 0.0,
y1: 0.0,
x2: 0.0,
y2: 1.0,
},
Easing::StandardAccelerate => CubicBezier {
x1: 0.3,
y1: 0.0,
x2: 1.0,
y2: 1.0,
},
Easing::EmphasizedDecelerate => CubicBezier {
x1: 0.05,
y1: 0.7,
x2: 0.1,
y2: 1.0,
},
Easing::EmphasizedAccelerate => CubicBezier {
x1: 0.3,
y1: 0.0,
x2: 0.8,
y2: 0.15,
},
}
}
/// 采样:`t` 0..1 → 进度 0..1。
pub fn sample(self, t: f32) -> f32 {
self.curve().sample(t)
}
/// 该曲线搭配的常用时长(我们的组件映射口径,非规范值)。
pub const fn default_duration(self) -> DurationToken {
match self {
Easing::Linear => DurationToken::Long2,
_ => DurationToken::Medium2,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const ALL_EASINGS: [Easing; 7] = [
Easing::Linear,
Easing::Standard,
Easing::StandardDecelerate,
Easing::StandardAccelerate,
Easing::Emphasized,
Easing::EmphasizedDecelerate,
Easing::EmphasizedAccelerate,
];
#[test]
fn durations_are_monotone() {
let ladder = [
DurationToken::Short1,
DurationToken::Short2,
DurationToken::Short3,
DurationToken::Short4,
DurationToken::Medium1,
DurationToken::Medium2,
DurationToken::Medium3,
DurationToken::Medium4,
DurationToken::Long1,
DurationToken::Long2,
DurationToken::Long3,
DurationToken::Long4,
DurationToken::ExtraLong1,
DurationToken::ExtraLong2,
DurationToken::ExtraLong3,
DurationToken::ExtraLong4,
];
assert_eq!(ladder.len(), 16);
for pair in ladder.windows(2) {
assert!(pair[0].ms() < pair[1].ms());
}
assert_eq!(DurationToken::Short1.ms(), 50);
assert_eq!(DurationToken::ExtraLong4.ms(), 1000);
// 名字唯一
let mut names = std::collections::BTreeSet::new();
for token in ladder {
assert!(names.insert(token.name()), "时长名重复:{}", token.name());
}
}
#[test]
fn easing_hits_endpoints_and_stays_in_range() {
for easing in ALL_EASINGS {
assert!(
(easing.sample(0.0) - 0.0).abs() < 1.0e-6,
"{easing:?} 起点不为 0"
);
assert!(
(easing.sample(1.0) - 1.0).abs() < 1.0e-6,
"{easing:?} 终点不为 1"
);
let mut prev = 0.0;
for step in 1..=20 {
let v = easing.sample(step as f32 / 20.0);
assert!((-0.001..=1.001).contains(&v), "{easing:?} 采样越界:{v}");
assert!(v + 1.0e-3 >= prev, "{easing:?} 采样非单调:{prev} → {v}");
prev = v;
}
}
}
#[test]
fn emphasized_decelerate_is_faster_early_than_accelerate() {
let early_decel = Easing::EmphasizedDecelerate.sample(0.25);
let early_accel = Easing::EmphasizedAccelerate.sample(0.25);
assert!(early_decel > early_accel);
}
#[test]
fn default_durations_are_registered_tokens() {
for easing in ALL_EASINGS {
assert!(easing.default_duration().ms() >= 250);
}
}
}
+380
View File
@@ -0,0 +1,380 @@
//! 弹簧参数(M3 Expressive 动效的物理表达)与动效时长缩放。
//!
//! **来源 A 级**:`androidx.compose.material3.tokens.ExpressiveMotionTokens`(v0_14_0)给出六组
//! `Spring{Damping, Stiffness}`——注意 Compose 的 `Damping` 是**阻尼比 ζ**(不是绝对阻尼系数),
//! 所以本模块的 [`Spring`] 也按 (stiffness, damping_ratio, mass) 存,两者可直接对照。
//! 阶跃响应用标准二阶系统闭式解(欠阻尼 / 临界 / 过阻尼三分支),是纯数学、无状态。
/// 弹簧参数(Compose 口径:刚度 + 阻尼比 + 质量)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Spring {
/// 刚度 k(越大越快)。
pub stiffness: f32,
/// 阻尼比 ζ(1.0 = 临界阻尼,< 1 会轻微弹过)。
pub damping_ratio: f32,
/// 质量 m。
pub mass: f32,
}
impl Spring {
/// 按 M3 令牌口径构造(刚度 + 阻尼比 + 质量)。
pub const fn from_ratio(stiffness: f32, damping_ratio: f32, mass: f32) -> Self {
Self {
stiffness,
damping_ratio,
mass,
}
}
/// 自然角频率 ωn(rad/s)。
pub fn natural_frequency(self) -> f32 {
(self.stiffness / self.mass).sqrt()
}
/// 绝对阻尼系数 c = 2·ζ·√(k·m)(需要喂给「阻尼系数」式积分器时用)。
pub fn damping_coefficient(self) -> f32 {
2.0 * self.damping_ratio * (self.stiffness * self.mass).sqrt()
}
/// 阶跃响应:`t_ms` 毫秒后的进度(0..1,欠阻尼时会轻微超过 1 = 回弹)。
pub fn sample(self, t_ms: f32) -> f32 {
if t_ms <= 0.0 {
return 0.0;
}
let t = t_ms / 1000.0;
let wn = self.natural_frequency();
let zeta = self.damping_ratio;
if wn <= 0.0 {
return 1.0;
}
if zeta < 1.0 {
let wd = wn * (1.0 - zeta * zeta).sqrt();
let decay = (-zeta * wn * t).exp();
1.0 - decay * ((wd * t).cos() + (zeta * wn / wd) * (wd * t).sin())
} else if (zeta - 1.0).abs() < 1.0e-4 {
let decay = (-wn * t).exp();
1.0 - decay * (1.0 + wn * t)
} else {
let root = (zeta * zeta - 1.0).sqrt();
let r1 = -wn * (zeta - root);
let r2 = -wn * (zeta + root);
let denom = r2 - r1;
if denom.abs() < f32::EPSILON {
return 1.0;
}
1.0 - (r2 * (r1 * t).exp() - r1 * (r2 * t).exp()) / denom
}
}
/// 收敛到 `threshold`(0.1% 是常用的「停下来」判定)所需毫秒数。
pub fn settle_ms(self, threshold: f32) -> f32 {
let wn = self.natural_frequency();
let zeta = self.damping_ratio;
if wn <= 0.0 {
return 0.0;
}
let thr = threshold.clamp(1.0e-6, 0.5);
let seconds = if zeta < 1.0 {
let wd = (1.0 - zeta * zeta).sqrt().max(1.0e-4);
-(thr * wd).ln() / (zeta * wn)
} else if (zeta - 1.0).abs() < 1.0e-4 {
-(thr).ln() / wn
} else {
let root = (zeta * zeta - 1.0).sqrt();
let slow = wn * (zeta - root);
-(thr).ln() / slow
};
(seconds * 1000.0).max(0.0)
}
}
/// 动效方案(Expressive 把弹簧打包成 standard / expressive **两套**,不是一组)。
///
/// - [`MotionSchemeKind::Standard`]:保守、几乎不弹(spatial ζ=0.9);
/// - [`MotionSchemeKind::Expressive`]:D26 的视觉基线,回弹明显(spatial ζ=0.6~0.8)。
///
/// `effects`(透明度/颜色类)在两套方案里数值相同——效应类不该有可见回弹。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MotionSchemeKind {
/// 标准方案(保守)。
Standard,
/// Expressive 方案(本项目默认)。
Expressive,
}
/// 弹簧类别:空间类管**位移/尺寸**,效应类管**颜色/透明度**。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SpringCategory {
/// 位移/尺寸(会产生可见回弹)。
Spatial,
/// 颜色/透明度(不允许过冲)。
Effects,
}
/// 弹簧速度档。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SpringSpeed {
/// 快速:hover/press、小元素。
Fast,
/// 默认:面板、卡片、按下形变。
Default,
/// 慢速:大位移(>120px 的面板/抽屉)。
Slow,
}
/// 二维表查询:`方案 × 类别 × 速度 → 弹簧`(A 级:`StandardMotionTokens` 与
/// `ExpressiveMotionTokens`,经 `MotionScheme.standard()/expressive()` 组装)。
pub const fn spring(
scheme: MotionSchemeKind,
category: SpringCategory,
speed: SpringSpeed,
) -> Spring {
use MotionSchemeKind::{Expressive, Standard};
use SpringCategory::{Effects, Spatial};
use SpringSpeed::{Default, Fast, Slow};
match (scheme, category, speed) {
(Standard, Spatial, Fast) => Spring::from_ratio(1400.0, 0.9, 1.0),
(Standard, Spatial, Default) => Spring::from_ratio(700.0, 0.9, 1.0),
(Standard, Spatial, Slow) => Spring::from_ratio(300.0, 0.9, 1.0),
(Expressive, Spatial, Fast) => Spring::from_ratio(800.0, 0.6, 1.0),
(Expressive, Spatial, Default) => Spring::from_ratio(380.0, 0.8, 1.0),
(Expressive, Spatial, Slow) => Spring::from_ratio(200.0, 0.8, 1.0),
// effects 两套同值:ζ=1.0(临界阻尼,不过冲)。
(_, Effects, Fast) => Spring::from_ratio(3800.0, 1.0, 1.0),
(_, Effects, Default) => Spring::from_ratio(1600.0, 1.0, 1.0),
(_, Effects, Slow) => Spring::from_ratio(800.0, 1.0, 1.0),
}
}
/// 标准方案 · 空间 · 快速。
pub const SPRING_STANDARD_SPATIAL_FAST: Spring = spring(
MotionSchemeKind::Standard,
SpringCategory::Spatial,
SpringSpeed::Fast,
);
/// 标准方案 · 空间 · 默认。
pub const SPRING_STANDARD_SPATIAL_DEFAULT: Spring = spring(
MotionSchemeKind::Standard,
SpringCategory::Spatial,
SpringSpeed::Default,
);
/// 标准方案 · 空间 · 慢速。
pub const SPRING_STANDARD_SPATIAL_SLOW: Spring = spring(
MotionSchemeKind::Standard,
SpringCategory::Spatial,
SpringSpeed::Slow,
);
/// Expressive 方案 · 空间 · 快速(D26 基线的默认选择)。
pub const SPRING_EXPRESSIVE_SPATIAL_FAST: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Spatial,
SpringSpeed::Fast,
);
/// Expressive 方案 · 空间 · 默认:面板、卡片、按下形变。
pub const SPRING_EXPRESSIVE_SPATIAL_DEFAULT: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Spatial,
SpringSpeed::Default,
);
/// Expressive 方案 · 空间 · 慢速:大位移(>120px)。
pub const SPRING_EXPRESSIVE_SPATIAL_SLOW: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Spatial,
SpringSpeed::Slow,
);
/// 效应类 · 快速(两套方案同值):hover/press 颜色。
pub const SPRING_EFFECTS_FAST: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Effects,
SpringSpeed::Fast,
);
/// 效应类 · 默认(两套方案同值)。
pub const SPRING_EFFECTS_DEFAULT: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Effects,
SpringSpeed::Default,
);
/// 效应类 · 慢速(两套方案同值)。
pub const SPRING_EFFECTS_SLOW: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Effects,
SpringSpeed::Slow,
);
/// 动效时长的全局缩放(用户「减少动效」/CPU 路径关动画时用)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MotionScale {
factor: f32,
}
impl MotionScale {
/// 标准(1.0)。
pub const STANDARD: MotionScale = MotionScale { factor: 1.0 };
/// 关闭动效(时长归零;适配层应直接落终态)。
pub const NONE: MotionScale = MotionScale { factor: 0.0 };
/// 半速(弱动效机器/低帧率兜底)。
pub const HALF: MotionScale = MotionScale { factor: 0.5 };
/// 自定义缩放(夹取到 0.0..=1.0;不放大,避免慢到不可用)。
pub fn new(factor: f32) -> Self {
Self {
factor: factor.clamp(0.0, 1.0),
}
}
/// 缩放系数。
pub fn factor(self) -> f32 {
self.factor
}
/// 是否完全关闭(适配层据此跳过动画)。
pub fn is_off(self) -> bool {
self.factor <= 0.0
}
/// 把某一档时长换算成实际毫秒。
pub fn ms(self, token: super::DurationToken) -> f32 {
token.ms() as f32 * self.factor
}
/// 把弹簧的收敛时间换算成实际毫秒。
pub fn spring_ms(self, spring: Spring) -> f32 {
spring.settle_ms(0.001) * self.factor
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::motion::DurationToken;
const PRESETS: [Spring; 9] = [
SPRING_STANDARD_SPATIAL_FAST,
SPRING_STANDARD_SPATIAL_DEFAULT,
SPRING_STANDARD_SPATIAL_SLOW,
SPRING_EXPRESSIVE_SPATIAL_FAST,
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
SPRING_EXPRESSIVE_SPATIAL_SLOW,
SPRING_EFFECTS_FAST,
SPRING_EFFECTS_DEFAULT,
SPRING_EFFECTS_SLOW,
];
#[test]
fn every_cell_of_the_two_dimensional_table_resolves() {
for scheme in [MotionSchemeKind::Standard, MotionSchemeKind::Expressive] {
for category in [SpringCategory::Spatial, SpringCategory::Effects] {
for speed in [SpringSpeed::Fast, SpringSpeed::Default, SpringSpeed::Slow] {
let spring = spring(scheme, category, speed);
assert!(spring.stiffness > 0.0);
assert!(spring.damping_ratio > 0.0);
}
}
}
// effects 两套方案同值(效应类不该有可见回弹差异)。
for speed in [SpringSpeed::Fast, SpringSpeed::Default, SpringSpeed::Slow] {
assert_eq!(
spring(MotionSchemeKind::Standard, SpringCategory::Effects, speed),
spring(MotionSchemeKind::Expressive, SpringCategory::Effects, speed)
);
}
// expressive 的空间类更弹(阻尼比更低)。
for speed in [SpringSpeed::Fast, SpringSpeed::Default, SpringSpeed::Slow] {
assert!(
spring(MotionSchemeKind::Expressive, SpringCategory::Spatial, speed).damping_ratio
< spring(MotionSchemeKind::Standard, SpringCategory::Spatial, speed)
.damping_ratio
);
}
}
#[test]
fn spring_presets_behave() {
for spring in PRESETS {
assert_eq!(spring.sample(0.0), 0.0);
let settle = spring.settle_ms(0.001);
assert!(
(60.0..1500.0).contains(&settle),
"收敛时间 {settle}ms 不合理(k={} ζ={})",
spring.stiffness,
spring.damping_ratio
);
let end = spring.sample(settle);
assert!((end - 1.0).abs() < 0.01, "收敛后应回到 1.0,实测 {end}");
}
}
#[test]
fn effects_presets_are_critically_damped_and_never_overshoot() {
for spring in [
SPRING_EFFECTS_FAST,
SPRING_EFFECTS_DEFAULT,
SPRING_EFFECTS_SLOW,
] {
assert!((spring.damping_ratio - 1.0).abs() < 1.0e-6);
for step in 1..=60 {
let v = spring.sample(step as f32 * 5.0);
assert!(v <= 1.0 + 1.0e-3, "效应类弹簧不应过冲:{v}");
}
}
}
#[test]
fn spatial_presets_overdamp_into_settle_without_wild_overshoot() {
for spring in [
SPRING_EXPRESSIVE_SPATIAL_FAST,
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
SPRING_EXPRESSIVE_SPATIAL_SLOW,
] {
assert!(
spring.damping_ratio < 1.0,
"空间类弹簧欠阻尼(允许轻微过冲)"
);
let mut peak: f32 = 0.0;
for step in 1..=200 {
peak = peak.max(spring.sample(step as f32 * 2.0));
}
assert!(peak < 1.15, "过冲 {peak} 太大");
}
}
#[test]
fn critically_damped_spring_does_not_overshoot() {
let wn = SPRING_EXPRESSIVE_SPATIAL_DEFAULT.natural_frequency();
let critical = Spring::from_ratio(wn * wn, 1.0, 1.0);
for step in 1..=40 {
let v = critical.sample(step as f32 * 5.0);
assert!(v <= 1.0 + 1.0e-3, "临界阻尼不应过冲:{v}");
}
}
#[test]
fn overdamped_spring_converges_slowly_without_overshoot() {
let wn = SPRING_EXPRESSIVE_SPATIAL_DEFAULT.natural_frequency();
let over = Spring::from_ratio(wn * wn, 2.0, 1.0);
let settle = over.settle_ms(0.001);
assert!((over.sample(settle) - 1.0).abs() < 0.01);
for step in 1..=40 {
assert!(over.sample(step as f32 * 10.0) <= 1.0 + 1.0e-3);
}
}
#[test]
fn damping_coefficient_matches_ratio() {
let spring = SPRING_EXPRESSIVE_SPATIAL_DEFAULT;
let c = spring.damping_coefficient();
let zeta = c / (2.0 * (spring.stiffness * spring.mass).sqrt());
assert!((zeta - spring.damping_ratio).abs() < 1.0e-5);
}
#[test]
fn motion_scale_scales_or_kills_duration() {
assert_eq!(MotionScale::STANDARD.ms(DurationToken::Medium2), 300.0);
assert_eq!(MotionScale::HALF.ms(DurationToken::Medium2), 150.0);
assert_eq!(MotionScale::NONE.ms(DurationToken::Medium2), 0.0);
assert!(MotionScale::NONE.is_off());
assert_eq!(MotionScale::new(5.0).factor(), 1.0);
assert_eq!(MotionScale::new(-1.0).factor(), 0.0);
assert!(MotionScale::STANDARD.spring_ms(SPRING_EXPRESSIVE_SPATIAL_DEFAULT) > 0.0);
}
}
+168
View File
@@ -0,0 +1,168 @@
//! M3 Expressive 形状刻度 + shape morph 数据。
//!
//! 形状是「唯一真源」的一部分:组件不许写裸半径(`border_radius(8)`),一律引用
//! [`ShapeScale`];需要动起来的(按下、选中、展开)用 [`ShapeMorph`] 的 from→to 数据。
use crate::motion::Spring;
/// M3 形状刻度(含 Expressive 新增档)。
///
/// 基础档(M3):none 0 / extra-small 4 / small 8 / medium 12 / large 16 / extra-large 28 / full。
/// Expressive 新增档:`large-increased` 20 / `extra-large-increased` 32 / `extra-extra-large` 48。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ShapeScale {
/// 直角(0dp):仅用于贴边容器/分隔。
None,
/// 4dp:小控件(chip、小徽标)。
ExtraSmall,
/// 8dp:控件(按钮、输入框、菜单项)。
Small,
/// 12dp:卡片、列表容器(Expressive 里卡片默认升到这一档)。
Medium,
/// 16dp:大卡片、面板。
Large,
/// 20dp(Expressive):按下/选中时的「胀开」档。
LargeIncreased,
/// 28dp:浮层、对话框。
ExtraLarge,
/// 32dp(Expressive):大浮层、FAB 展开态。
ExtraLargeIncreased,
/// 48dp(Expressive):超大容器、模态全屏卡。
ExtraExtraLarge,
/// 药丸/圆:按边长取半径一半(导航指示条、头像、switch 轨道)。
Full,
}
impl ShapeScale {
/// 半径(dp;[`ShapeScale::Full`] 返回 `f32::INFINITY`,由 [`ShapeScale::radius_px`] 折算)。
pub const fn dp(self) -> f32 {
match self {
ShapeScale::None => 0.0,
ShapeScale::ExtraSmall => 4.0,
ShapeScale::Small => 8.0,
ShapeScale::Medium => 12.0,
ShapeScale::Large => 16.0,
ShapeScale::LargeIncreased => 20.0,
ShapeScale::ExtraLarge => 28.0,
ShapeScale::ExtraLargeIncreased => 32.0,
ShapeScale::ExtraExtraLarge => 48.0,
ShapeScale::Full => f32::INFINITY,
}
}
/// 实际渲染半径:`Full` 取边长一半,其余不超过边长一半(避免方角变圆角溢出)。
pub fn radius_px(self, size_px: f32) -> f32 {
let half = size_px.max(0.0) / 2.0;
if self == ShapeScale::Full {
half
} else {
self.dp().min(half)
}
}
/// 是否是 Expressive 才有的档位(基础 M3 没有)。
pub const fn is_expressive(self) -> bool {
matches!(
self,
ShapeScale::LargeIncreased
| ShapeScale::ExtraLargeIncreased
| ShapeScale::ExtraExtraLarge
)
}
/// 在形状阶梯上的序号(0 = None,9 = Full),用于断言形变方向是「胀开」而不是「收紧」。
pub const fn rank(self) -> u8 {
match self {
ShapeScale::None => 0,
ShapeScale::ExtraSmall => 1,
ShapeScale::Small => 2,
ShapeScale::Medium => 3,
ShapeScale::Large => 4,
ShapeScale::LargeIncreased => 5,
ShapeScale::ExtraLarge => 6,
ShapeScale::ExtraLargeIncreased => 7,
ShapeScale::ExtraExtraLarge => 8,
ShapeScale::Full => 9,
}
}
}
/// 一次形状形变(Expressive 的「按下胀开」「展开成药丸」等)。
///
/// 数据形态是纯的:起止档位 + 弹簧参数。适配层负责跑时钟,`progress` 由弹簧/缓动给出。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ShapeMorph {
/// 静息档位。
pub from: ShapeScale,
/// 目标档位。
pub to: ShapeScale,
/// 驱动这次形变的弹簧。
pub spring: Spring,
}
impl ShapeMorph {
/// 恒定弹簧的形变。
pub const fn new(from: ShapeScale, to: ShapeScale, spring: Spring) -> Self {
Self { from, to, spring }
}
/// 在 `progress`(0..1,已含缓动)处的半径。`Full` 端点按 `size_px` 折算。
pub fn radius_px(&self, size_px: f32, progress: f32) -> f32 {
let t = progress.clamp(0.0, 1.0);
let a = self.from.radius_px(size_px);
let b = self.to.radius_px(size_px);
a + (b - a) * t
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::motion::SPRING_EXPRESSIVE_SPATIAL_DEFAULT;
#[test]
fn scale_ladder_is_monotone() {
let ladder = [
ShapeScale::None,
ShapeScale::ExtraSmall,
ShapeScale::Small,
ShapeScale::Medium,
ShapeScale::Large,
ShapeScale::LargeIncreased,
ShapeScale::ExtraLarge,
ShapeScale::ExtraLargeIncreased,
ShapeScale::ExtraExtraLarge,
];
for pair in ladder.windows(2) {
assert!(
pair[0].dp() < pair[1].dp(),
"{:?} 应小于 {:?}",
pair[0],
pair[1]
);
}
}
#[test]
fn full_is_half_of_edge_and_clamped() {
assert_eq!(ShapeScale::Full.radius_px(48.0), 24.0);
assert_eq!(ShapeScale::ExtraLarge.radius_px(20.0), 10.0);
assert_eq!(ShapeScale::ExtraLarge.radius_px(200.0), 28.0);
assert_eq!(ShapeScale::None.radius_px(200.0), 0.0);
}
#[test]
fn morph_hits_both_endpoints() {
let morph = ShapeMorph::new(
ShapeScale::Small,
ShapeScale::LargeIncreased,
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
);
assert_eq!(morph.radius_px(200.0, 0.0), 8.0);
assert_eq!(morph.radius_px(200.0, 1.0), 20.0);
assert_eq!(morph.radius_px(200.0, 0.5), 14.0);
// 越界 progress 夹取,不产生负半径/超大半径。
assert_eq!(morph.radius_px(200.0, -2.0), 8.0);
assert_eq!(morph.radius_px(200.0, 3.0), 20.0);
}
}
+198
View File
@@ -0,0 +1,198 @@
//! 形状 / 高程 / 动效之外的「尺子」:4px 栅格与骨架尺寸。
//!
//! 源流有两条:`DevDocs/ops/slint-reference/crates/app/ui/theme/metrics.slint`(M52 定稿的骨架节奏),
//! 以及 `ui-integration-contract.md` §3「间距只允许 4/8/12/16/24/32」。M3 同样用 4dp 栅格,
//! 所以这层**不用改口径**,只是把值搬进唯一真源。
/// 栅格基数(dp)。
pub const GRID: f32 = 4.0;
/// 间距档位(M3/M2 通用 4px 栅格)。禁止 6/10/14 这类非整倍数值。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Space {
/// 4dp:图标与文字之间。
X1,
/// 8dp:控件内边距、行内元素间距。
X2,
/// 12dp:控件间距。
X3,
/// 16dp:区块内行距、卡片内边距下限。
X4,
/// 24dp:区块间距、页面内边距。
X6,
/// 32dp:页级大留白。
X8,
}
impl Space {
/// 间距值(dp)。
pub const fn dp(self) -> f32 {
match self {
Space::X1 => 4.0,
Space::X2 => 8.0,
Space::X3 => 12.0,
Space::X4 => 16.0,
Space::X6 => 24.0,
Space::X8 => 32.0,
}
}
/// 全部档位,由小到大。
pub const ALL: [Space; 6] = [
Space::X1,
Space::X2,
Space::X3,
Space::X4,
Space::X6,
Space::X8,
];
}
/// 把任意数值对齐到 4px 栅格(最近档)。
pub fn snap_to_grid(value: f32) -> f32 {
(value / GRID).round() * GRID
}
/// 骨架尺寸:窗口结构(标题栏、导航、页头、行高)的单一来源。
///
/// 这些值不是 M3 规范值(M3 只规定组件内部节奏),而是本启动器的**布局契约**,
/// 由页面/组件两条线共同消费——任何页面自己写 `56px` 都是重复定义。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LayoutMetrics {
/// 标题栏高度。
pub titlebar_height: f32,
/// 标题栏按钮宽度(关闭/最大化/最小化)。
pub titlebar_button_width: f32,
/// 展开态导航宽度。
pub nav_expanded_width: f32,
/// 收起态导航宽度。
pub nav_collapsed_width: f32,
/// 导航项高度。
pub nav_item_height: f32,
/// 导航项间距。
pub nav_item_gap: f32,
/// 单行页头高度。
pub page_header_height: f32,
/// 两行页头高度(日志页)。
pub page_header_tall_height: f32,
/// 运行中状态条高度。
pub running_strip_height: f32,
/// 列表行高(桌面密集列表;见模块文档的「触控目标」说明)。
pub row_height: f32,
/// 标准控件可见高度(按钮/输入框)。
pub control_height: f32,
/// 最小**命中**区域(M3 Expressive 的无障碍下限)。
pub touch_target: f32,
/// 卡片内边距。
pub card_padding: f32,
/// AI 面板宽度。
pub ai_panel_width: f32,
}
impl LayoutMetrics {
/// 桌面基线(1180×760 窗口)。
///
/// **触控目标的换算依据**(reviewer P3-1:不许和 `metrics.slint` 沉默地不一致):
/// - 规范:M3 Expressive 要求可点目标 ≥**48dp**(无障碍下限,手机与桌面同标准)。
/// - 我们取 `touch_target = 48`(照规范),`control_height = 40`(**可见**高度):
/// 两者差 8,正好由控件上下各 4px 的内边距补足,于是「看起来 40 高的按钮」有 48 的命中区。
/// - `row_height = 44` / `nav_item_height = 44` 是 M63 §R13 + §1.4 表定的桌面密集档
/// (`metrics.slint` 的导航 56 是 Fluent 2 时代的旧节奏,D25 已终止):可见高度 44,
/// 点击命中也按 `touch_target`(48)判定,不靠行高本身。
/// - `titlebar_height = 48` 取自 M63 §1.4 的 S2 面板表(旧值 40 是 Fluent 口径)。
pub const DESKTOP: Self = Self {
titlebar_height: 48.0,
titlebar_button_width: 46.0,
nav_expanded_width: 88.0,
nav_collapsed_width: 48.0,
nav_item_height: 44.0,
nav_item_gap: 4.0,
page_header_height: 56.0,
page_header_tall_height: 84.0,
running_strip_height: 32.0,
row_height: 44.0,
control_height: 40.0,
touch_target: 48.0,
card_padding: 20.0,
ai_panel_width: 320.0,
};
}
impl LayoutMetrics {
/// 页头是否两行(日志页),决定主区与 AI 面板分隔线对齐的高度。
pub fn header_height(&self, two_line: bool) -> f32 {
if two_line {
self.page_header_tall_height
} else {
self.page_header_height
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_space_is_on_the_grid() {
for space in Space::ALL {
assert_eq!(space.dp() % GRID, 0.0, "{space:?} 不在 4px 栅格上");
}
}
#[test]
fn layout_values_are_on_the_grid() {
let m = LayoutMetrics::DESKTOP;
// 受 4px 栅格约束的字段(间距/高度/宽度类)。
// `titlebar_button_width` 刻意不在表内:46 对齐 Windows 11 原生 caption 按钮宽度,
// 属 OS 惯例值,单列一条断言钉住,不让它静默漂移。
for (name, value) in [
("titlebar_height", m.titlebar_height),
("nav_item_height", m.nav_item_height),
("nav_item_gap", m.nav_item_gap),
("page_header_height", m.page_header_height),
("page_header_tall_height", m.page_header_tall_height),
("running_strip_height", m.running_strip_height),
("row_height", m.row_height),
("control_height", m.control_height),
("touch_target", m.touch_target),
("card_padding", m.card_padding),
("ai_panel_width", m.ai_panel_width),
] {
assert_eq!(value % GRID, 0.0, "{name} = {value} 不在 4px 栅格上");
}
assert_eq!(
m.titlebar_button_width, 46.0,
"标题栏按钮宽度应对齐 Win11 原生 caption"
);
}
#[test]
fn touch_target_meets_the_expressive_48dp_minimum() {
let m = LayoutMetrics::DESKTOP;
// M3 Expressive 无障碍下限:可点目标 ≥48dp。
assert_eq!(m.touch_target, 48.0);
// 可见控件高 + 上下各 4px 内边距 = 命中区。
assert_eq!(m.control_height + 2.0 * Space::X1.dp(), m.touch_target);
// 密集行(M63 §1.4/R13 的桌面档)可见高度 44,命中仍按 touch_target。
assert_eq!(m.row_height, 44.0);
assert_eq!(m.nav_item_height, 44.0);
assert!(m.row_height <= m.touch_target);
// M63 §1.4 的 S2 面板表:标题栏 48。
assert_eq!(m.titlebar_height, 48.0);
}
#[test]
fn snap_rounds_to_nearest_grid_step() {
assert_eq!(snap_to_grid(5.0), 4.0);
assert_eq!(snap_to_grid(6.5), 8.0);
assert_eq!(snap_to_grid(0.0), 0.0);
}
#[test]
fn header_height_follows_line_count() {
let m = LayoutMetrics::DESKTOP;
assert_eq!(m.header_height(false), 56.0);
assert_eq!(m.header_height(true), 84.0);
}
}
@@ -0,0 +1,88 @@
//! 官方 M3 **Expressive** 强调排版表(15 档,A 级来源)。
//!
//! 来源:`androidx.compose.material3.tokens.TypeScaleTokens` v0_103 的
//! `<Role>Emphasized{Size,LineHeight,Tracking,Weight}` 四元组。要点:
//!
//! - **字号与行高与 baseline 完全相同**(Expressive 的强调不改变排版高度);
//! - 只动**字重**与**字距**:display/headline/title-large/body-* → Medium(500);
//! title-medium / title-small / label-* → Bold(700)(baseline 是 500);
//! - display-large 的字距从 −0.2 回 0;body-large 0.5 → 0.15;title-medium 0.2 → 0.15;
//! body-medium 0.2 → 0.25(唯一一个「强调后字距变大」的档)。
use super::{style, FontFamily, TypeRole, TypeStyle, Weight};
/// 取某档的官方强调样式。非 [`TypeRole`] 的每个变体都有值(15/15,无缺口)。
pub(super) const fn style_of(role: TypeRole) -> TypeStyle {
match role {
TypeRole::DisplayLarge => style(57.0, 64.0, Weight::Medium, 0.0, FontFamily::Display),
TypeRole::DisplayMedium => style(45.0, 52.0, Weight::Medium, 0.0, FontFamily::Display),
TypeRole::DisplaySmall => style(36.0, 44.0, Weight::Medium, 0.0, FontFamily::Display),
TypeRole::HeadlineLarge => style(32.0, 40.0, Weight::Medium, 0.0, FontFamily::Display),
TypeRole::HeadlineMedium => style(28.0, 36.0, Weight::Medium, 0.0, FontFamily::Display),
TypeRole::HeadlineSmall => style(24.0, 32.0, Weight::Medium, 0.0, FontFamily::Display),
// TitleLarge 官方用 Brand 族(与 Display 同栈)。
TypeRole::TitleLarge => style(22.0, 28.0, Weight::Medium, 0.0, FontFamily::Display),
TypeRole::TitleMedium => style(16.0, 24.0, Weight::Bold, 0.15, FontFamily::Text),
TypeRole::TitleSmall => style(14.0, 20.0, Weight::Bold, 0.1, FontFamily::Text),
TypeRole::BodyLarge => style(16.0, 24.0, Weight::Medium, 0.15, FontFamily::Text),
TypeRole::BodyMedium => style(14.0, 20.0, Weight::Medium, 0.25, FontFamily::Text),
TypeRole::BodySmall => style(12.0, 16.0, Weight::Medium, 0.4, FontFamily::Text),
TypeRole::LabelLarge => style(14.0, 20.0, Weight::Bold, 0.1, FontFamily::Text),
TypeRole::LabelMedium => style(12.0, 16.0, Weight::Bold, 0.5, FontFamily::Text),
TypeRole::LabelSmall => style(11.0, 16.0, Weight::Bold, 0.5, FontFamily::Text),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn emphasized_keeps_size_and_line_height() {
for role in TypeRole::ALL {
let base = role.style();
let emph = style_of(role);
assert_eq!(
base.size_px,
emph.size_px,
"{} 强调档不该改字号",
role.name()
);
assert_eq!(
base.line_height_px,
emph.line_height_px,
"{} 强调档不该改行高",
role.name()
);
}
}
#[test]
fn emphasized_weights_follow_the_official_split() {
// 官方两档:正文/展示类 → Medium(500);小标题与标签类 → Bold(700)。
for role in TypeRole::ALL {
let expect = match role {
TypeRole::TitleMedium
| TypeRole::TitleSmall
| TypeRole::LabelLarge
| TypeRole::LabelMedium
| TypeRole::LabelSmall => Weight::Bold,
_ => Weight::Medium,
};
assert_eq!(
style_of(role).weight,
expect,
"{} 强调档字重不对",
role.name()
);
}
}
#[test]
fn display_large_tracking_returns_to_zero() {
assert_eq!(TypeRole::DisplayLarge.style().letter_spacing_px, -0.2);
assert_eq!(TypeRole::DisplayLarge.emphasized().letter_spacing_px, 0.0);
assert_eq!(TypeRole::BodyLarge.style().letter_spacing_px, 0.5);
assert_eq!(TypeRole::BodyLarge.emphasized().letter_spacing_px, 0.15);
}
}
+304
View File
@@ -0,0 +1,304 @@
//! 三平台字体族回退栈(Win10+ / macOS 12+ / Linux)+ **字重覆盖**模型。
//!
//! 只写族名,不写文件路径、不内嵌字体(D14 + D28)。每条栈的**最后一项必须是通用族名**,
//! 否则干净安装的系统上会退化成豆腐块。
//!
//! ## 为什么要表达「这个族有哪几档字重」
//!
//! M3 Expressive 的强调档会要 **700**(title-medium/title-small/label-*)与 **500**
//! (display/headline/body-*)。而中文字体的字重覆盖差别很大:
//! **Windows 的 Microsoft YaHei UI 只有 400 / 700 两档**,没有 500;macOS 苹方到 600 就断了、
//! 没有 700;只有 Linux 常见的 Noto Sans CJK / 思源黑体(可变字体)才 100~900 全覆盖。
//!
//! 所以栈项不是「一个族名」,而是 `(族名, 字重覆盖)`([`FontFace`]),并且提供一个纯函数
//! [`Platform::resolve_weight`] 说明「这一档字重最终落到哪个族、是原生还是降级」:
//!
//! | 结果 | 含义 |
//! |---|---|
//! | [`WeightMatch::Native`] | 该族有这一档(可变字体在区间内也算原生) |
//! | [`WeightMatch::Nearest`] | 该族没有这一档,取最近的静态档(视觉上是「淡一点/粗一点」,不假) |
//! | [`WeightMatch::Synthetic`] | 最近的档差 ≥2 级(如只有 400 却要 700)→ 引擎**合成加粗**,观感廉价 |
//!
//! **合成加粗是降级,不是正常路径**(用户令:不许拿合成加粗当「还原到位」)。
//! iced/cosmic-text 侧机制:`Font { family, weight, .. }` 由 cosmic-text 按 (family, weight)
//! 选面,同族缺档时选最近字重;glyphon 另可用 swash 的合成(fake bold)兜底。
//! 中文缺档时的降级顺序:**① 换下一个有该档的族 → ② 用可变字体的 weight 轴 → ③ 最近静态档
//! → ④ 最后才是合成加粗**(并且不要用 700 排长中文正文)。
//!
//! ## 待确认(D28 联动,不在本 mission 拍板)
//!
//! 将来从 CDN 下载中文字体时,「一个 Regular 文件」不够——至少要 400/500/700 三档子集,
//! 或者一个可变字体(体积更大)。这个差异登记在这里,等 D28 的字体下载 mission 决定。
#[cfg(test)]
mod tests;
/// 平台(决定字体族回退栈)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Platform {
/// Windows 10/11。
Windows,
/// macOS 12+。
MacOs,
/// Linux(X11/Wayland,桌面发行版)。
Linux,
}
/// 字重覆盖(一个族实际提供了哪些字重)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WeightCoverage {
/// 可变字体:一个连续区间(weight 轴)。
Variable {
/// 最细一档。
min: u16,
/// 最粗一档。
max: u16,
},
/// 静态字体:离散字重表。
Static(&'static [u16]),
}
impl WeightCoverage {
/// 是否原生覆盖某档字重。
pub fn supports(&self, weight: u16) -> bool {
match self {
WeightCoverage::Variable { min, max } => (*min..=*max).contains(&weight),
WeightCoverage::Static(list) => list.contains(&weight),
}
}
/// 最近的可选字重(静态表取绝对值最近;可变字体夹取到区间内)。
pub fn nearest(&self, weight: u16) -> u16 {
match self {
WeightCoverage::Variable { min, max } => weight.clamp(*min, *max),
WeightCoverage::Static(list) => {
let mut best = list[0];
let mut best_delta = u16::MAX;
for candidate in *list {
let delta = candidate.abs_diff(weight);
if delta < best_delta {
best = *candidate;
best_delta = delta;
}
}
best
}
}
}
}
/// 一条字体栈项:族名 + 字重覆盖。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FontFace {
/// 族名(只写名字,不带路径/文件名)。
pub family: &'static str,
/// 该族提供的字重。
pub weights: WeightCoverage,
}
impl FontFace {
/// 构造。
pub const fn new(family: &'static str, weights: WeightCoverage) -> Self {
Self { family, weights }
}
/// 该族是否原生覆盖某档字重。
pub fn supports(&self, weight: crate::typography::Weight) -> bool {
self.weights.supports(weight.value())
}
}
/// 字重匹配的质量。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WeightMatch {
/// 原生有这一档。
Native,
/// 同族最近档(差 <2 级)。
Nearest,
/// 差 ≥2 级,引擎只能合成加粗/减细(降级)。
Synthetic,
}
/// 字重解析结果:最终用哪个族、哪个字重、是原生还是降级。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FontChoice {
/// 选中的族名。
pub family: &'static str,
/// 请求的字重。
pub requested: u16,
/// 实际可用的字重。
pub available: u16,
/// 匹配质量。
pub quality: WeightMatch,
}
impl FontChoice {
/// 是否走了合成加粗(降级路径)。
pub fn is_synthetic(&self) -> bool {
self.quality == WeightMatch::Synthetic
}
}
/// 字体族的用途分类(决定取哪条栈)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FontFamily {
/// 大字号展示(display/headline/title-large):光学尺寸更大。
Display,
/// 正文(title/body/label)。
Text,
/// 等宽(日志、代码、版本号)。
Mono,
}
// 字重覆盖来源等级:**B**(各平台字体规格/厂商文档;没有一份官方 token 文件能一次给全,
// 所以按「常见安装」标定,并用测试钉死——改值要连带改测试与协作文档)。
const W_VARIABLE_SEGOE: WeightCoverage = WeightCoverage::Variable { min: 300, max: 700 };
const W_VARIABLE_SF: WeightCoverage = WeightCoverage::Variable { min: 100, max: 900 };
const W_VARIABLE_NOTO: WeightCoverage = WeightCoverage::Variable { min: 100, max: 900 };
const W_VARIABLE_CASCADIA: WeightCoverage = WeightCoverage::Variable { min: 200, max: 700 };
const W_VARIABLE_JETBRAINS: WeightCoverage = WeightCoverage::Variable { min: 100, max: 800 };
const W_STATIC_SEGOE: WeightCoverage = WeightCoverage::Static(&[300, 400, 600, 700]);
const W_STATIC_YAHEI: WeightCoverage = WeightCoverage::Static(&[400, 700]);
const W_STATIC_HELVETICA: WeightCoverage = WeightCoverage::Static(&[300, 400, 500, 700]);
const W_STATIC_PINGFANG: WeightCoverage = WeightCoverage::Static(&[100, 200, 300, 400, 500, 600]);
const W_STATIC_MENLO: WeightCoverage = WeightCoverage::Static(&[400, 700]);
const W_STATIC_DEJAVU: WeightCoverage = WeightCoverage::Static(&[400, 700]);
// 各平台字体栈(写成 const 数组,好让 `font_stack` 的 `const fn` 直接返回 `&'static [_]`)。
const WIN_DISPLAY: [FontFace; 5] = [
FontFace::new("Segoe UI Variable Display", W_VARIABLE_SEGOE),
FontFace::new("Segoe UI", W_STATIC_SEGOE),
FontFace::new("Microsoft YaHei UI", W_STATIC_YAHEI),
FontFace::new("Microsoft YaHei", W_STATIC_YAHEI),
FontFace::new("sans-serif", W_VARIABLE_NOTO),
];
const WIN_TEXT: [FontFace; 5] = [
FontFace::new("Segoe UI Variable Text", W_VARIABLE_SEGOE),
FontFace::new("Segoe UI", W_STATIC_SEGOE),
FontFace::new("Microsoft YaHei UI", W_STATIC_YAHEI),
FontFace::new("Microsoft YaHei", W_STATIC_YAHEI),
FontFace::new("sans-serif", W_VARIABLE_NOTO),
];
const WIN_MONO: [FontFace; 4] = [
FontFace::new("Cascadia Mono", W_VARIABLE_CASCADIA),
FontFace::new("Consolas", W_STATIC_SEGOE),
FontFace::new("Courier New", W_STATIC_MENLO),
FontFace::new("monospace", W_STATIC_DEJAVU),
];
const MAC_DISPLAY: [FontFace; 4] = [
FontFace::new("SF Pro Display", W_VARIABLE_SF),
FontFace::new("Helvetica Neue", W_STATIC_HELVETICA),
FontFace::new("PingFang SC", W_STATIC_PINGFANG),
FontFace::new("sans-serif", W_VARIABLE_NOTO),
];
const MAC_TEXT: [FontFace; 4] = [
FontFace::new("SF Pro Text", W_VARIABLE_SF),
FontFace::new("Helvetica Neue", W_STATIC_HELVETICA),
FontFace::new("PingFang SC", W_STATIC_PINGFANG),
FontFace::new("sans-serif", W_VARIABLE_NOTO),
];
const MAC_MONO: [FontFace; 4] = [
FontFace::new("SF Mono", W_VARIABLE_SF),
FontFace::new("Menlo", W_STATIC_MENLO),
FontFace::new("Monaco", W_STATIC_MENLO),
FontFace::new("monospace", W_STATIC_DEJAVU),
];
const LINUX_SANS: [FontFace; 5] = [
FontFace::new("Roboto", W_VARIABLE_NOTO),
FontFace::new("Noto Sans", W_VARIABLE_NOTO),
FontFace::new("Noto Sans CJK SC", W_VARIABLE_NOTO),
FontFace::new("DejaVu Sans", W_STATIC_DEJAVU),
FontFace::new("sans-serif", W_VARIABLE_NOTO),
];
const LINUX_MONO: [FontFace; 4] = [
FontFace::new("JetBrains Mono", W_VARIABLE_JETBRAINS),
FontFace::new("Noto Sans Mono", W_VARIABLE_NOTO),
FontFace::new("DejaVu Sans Mono", W_STATIC_DEJAVU),
FontFace::new("monospace", W_STATIC_DEJAVU),
];
impl Platform {
/// 当前编译目标对应的平台(运行时用户覆盖请显式传 [`Platform`])。
pub const fn current() -> Self {
if cfg!(target_os = "windows") {
Platform::Windows
} else if cfg!(target_os = "macos") {
Platform::MacOs
} else {
Platform::Linux
}
}
/// 稳定字符串名。
pub const fn name(self) -> &'static str {
match self {
Platform::Windows => "windows",
Platform::MacOs => "macos",
Platform::Linux => "linux",
}
}
/// 字体族回退栈(由首选到兜底,最后一项是通用族名)。
pub const fn font_stack(self, family: FontFamily) -> &'static [FontFace] {
match (self, family) {
// Win10 起 Segoe UI 一定在;Win11 有光学尺寸分离的 Variable 版。
(Platform::Windows, FontFamily::Display) => &WIN_DISPLAY,
(Platform::Windows, FontFamily::Text) => &WIN_TEXT,
(Platform::Windows, FontFamily::Mono) => &WIN_MONO,
// macOS 12+:SF Pro 分 Display/Text 光学尺寸;中文走苹方(只到 600)。
(Platform::MacOs, FontFamily::Display) => &MAC_DISPLAY,
(Platform::MacOs, FontFamily::Text) => &MAC_TEXT,
(Platform::MacOs, FontFamily::Mono) => &MAC_MONO,
// Linux:不假设发行版预装什么,Roboto/Noto 是最常见的两套(Display 与 Text 同栈)。
(Platform::Linux, FontFamily::Display | FontFamily::Text) => &LINUX_SANS,
(Platform::Linux, FontFamily::Mono) => &LINUX_MONO,
}
}
/// 该平台的**中文**兜底族(CJK 字形实际落到的那个)。
///
/// 注意:栈里的 CJK 族只是「缺字形时的候选」,最终由文本引擎逐字形回退决定;
/// 这个函数把它单独暴露出来,好让调用方按模块文档的降级顺序处理字重。
pub const fn cjk_face(self) -> FontFace {
match self {
Platform::Windows => FontFace::new("Microsoft YaHei UI", W_STATIC_YAHEI),
Platform::MacOs => FontFace::new("PingFang SC", W_STATIC_PINGFANG),
Platform::Linux => FontFace::new("Noto Sans CJK SC", W_VARIABLE_NOTO),
}
}
/// 按栈顺序解析某档字重的落点(纯函数,不查系统)。
///
/// 规则:**优先原生**(栈里第一个真有的族)→ 都做不到时取首选族的最近档,
/// 差距 ≥200 判为 [`WeightMatch::Synthetic`]。
pub fn resolve_weight(
self,
family: FontFamily,
weight: crate::typography::Weight,
) -> FontChoice {
let stack = self.font_stack(family);
let requested = weight.value();
for face in stack {
if face.weights.supports(requested) {
return FontChoice {
family: face.family,
requested,
available: requested,
quality: WeightMatch::Native,
};
}
}
let first = stack[0];
let available = first.weights.nearest(requested);
FontChoice {
family: first.family,
requested,
available,
quality: if available.abs_diff(requested) >= 200 {
WeightMatch::Synthetic
} else {
WeightMatch::Nearest
},
}
}
}
+149
View File
@@ -0,0 +1,149 @@
//! 字体栈与字重解析的测试(独立文件是为了守住单文件 ≤400 行的硬约束)。
use super::*;
use crate::typography::Weight;
const PLATFORMS: [Platform; 3] = [Platform::Windows, Platform::MacOs, Platform::Linux];
const FAMILIES: [FontFamily; 3] = [FontFamily::Display, FontFamily::Text, FontFamily::Mono];
#[test]
fn font_stacks_are_well_formed() {
for platform in PLATFORMS {
for family in FAMILIES {
let stack = platform.font_stack(family);
assert!(
!stack.is_empty(),
"{} 的 {family:?} 栈为空",
platform.name()
);
let last = stack[stack.len() - 1].family;
let generic = match family {
FontFamily::Mono => "monospace",
_ => "sans-serif",
};
assert_eq!(
last,
generic,
"{} 的 {family:?} 栈必须以通用族名兜底",
platform.name()
);
for face in stack {
assert!(!face.family.is_empty());
assert!(
!face.family.contains(['/', '\\', '.', ':']),
"字体栈只允许族名,不得出现路径/文件名:{}",
face.family
);
assert!(
face.family.is_ascii(),
"族名应为 ASCII(非 ASCII 名交给系统匹配):{}",
face.family
);
// 每条栈项都必须声明字重覆盖(连 400 都没有的族写进栈没有意义)。
assert!(
face.supports(Weight::Regular),
"{} 连 400 都没有,写它没意义",
face.family
);
}
}
}
}
#[test]
fn display_and_text_stacks_differ_on_windows_and_macos() {
assert_ne!(
Platform::Windows.font_stack(FontFamily::Display)[0].family,
Platform::Windows.font_stack(FontFamily::Text)[0].family
);
assert_ne!(
Platform::MacOs.font_stack(FontFamily::Display)[0].family,
Platform::MacOs.font_stack(FontFamily::Text)[0].family
);
}
#[test]
fn chinese_fallback_is_present_but_not_first() {
// 中文族名必须在栈里,但不能排第一(纯英文环境不加载中文字体,D28)。
for platform in PLATFORMS {
let stack = platform.font_stack(FontFamily::Text);
let cjk = ["Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC"];
assert!(
stack.iter().any(|face| cjk.contains(&face.family)),
"{} 的正文栈缺 CJK 兜底",
platform.name()
);
assert!(
!cjk.contains(&stack[0].family),
"{} 的首选字体不该是 CJK 族",
platform.name()
);
}
}
#[test]
fn cjk_weight_coverage_is_registered() {
// 用户点名的坑:中文字重覆盖三平台差别很大。
let win = Platform::Windows.cjk_face();
assert!(win.supports(Weight::Regular) && win.supports(Weight::Bold));
assert!(
!win.supports(Weight::Medium),
"雅黑没有 500——这条断言就是「中文 500 会掉到降级路径」的登记"
);
let mac = Platform::MacOs.cjk_face();
assert!(mac.supports(Weight::Medium));
assert!(
!mac.supports(Weight::Bold),
"苹方到 600 为止,700 要合成/换族"
);
let linux = Platform::Linux.cjk_face();
for weight in [Weight::Regular, Weight::Medium, Weight::Bold] {
assert!(
linux.supports(weight),
"思源黑体/Noto CJK 应覆盖 400/500/700"
);
}
}
#[test]
fn resolve_prefers_native_and_flags_synthetic() {
// Windows 正文 700:Segoe UI Variable 覆盖 → 原生。
let bold = Platform::Windows.resolve_weight(FontFamily::Text, Weight::Bold);
assert_eq!(bold.quality, WeightMatch::Native);
assert_eq!(bold.available, 700);
assert!(!bold.is_synthetic());
// Linux 正文 500:Roboto 可变 → 原生。
assert_eq!(
Platform::Linux
.resolve_weight(FontFamily::Text, Weight::Medium)
.quality,
WeightMatch::Native
);
// 静态族缺档:只有 400 的族请求 700 → nearest 400,差 300 → 判为合成降级。
let only_regular = FontFace::new("Courier New", WeightCoverage::Static(&[400]));
assert_eq!(only_regular.weights.nearest(700), 400);
let synthetic = FontChoice {
family: only_regular.family,
requested: 700,
available: only_regular.weights.nearest(700),
quality: WeightMatch::Synthetic,
};
assert!(synthetic.is_synthetic());
}
#[test]
fn nearest_picks_closest_static_weight() {
let coverage = WeightCoverage::Static(&[300, 400, 600, 700]);
assert_eq!(coverage.nearest(500), 400);
assert_eq!(coverage.nearest(650), 600);
assert_eq!(coverage.nearest(900), 700);
let variable = WeightCoverage::Variable { min: 100, max: 900 };
assert_eq!(variable.nearest(500), 500);
assert_eq!(variable.nearest(50), 100);
}
#[test]
fn current_platform_returns_a_stack() {
let platform = Platform::current();
assert!(!platform.font_stack(FontFamily::Text).is_empty());
}
+346
View File
@@ -0,0 +1,346 @@
//! M3 type scale(15 档,baseline + Expressive 强调两套)+ 三平台字体族回退栈。
//!
//! **数值来源 A 级**:`androidx.compose.material3.tokens.TypeScaleTokens`(v0_103)。
//! baseline 四元组取 `<Role>{Size,LineHeight,Tracking,Weight}`,Expressive 强调档取
//! `<Role>Emphasized{...}`(见 [`emphasized`] 模块)。注意 Compose 这一代 token 的 tracking 与
//! m3.material.io 网页表格有 0.05px 级别的差异(display-large −0.2 vs −0.25、body-medium 0.2 vs
//! 0.25、title-medium 0.2 vs 0.15),本项目跟 **Compose(实际渲染值)**,差异登记在
//! crate 文档「baseline vs Expressive 差异清单」里。
//!
//! **只写字体族名,不内嵌任何字体文件**(Segoe UI / SF Pro / Roboto 都受各自许可约束,
//! 内嵌再分发是违规的;引用系统已安装的族名是合规且体积为零的做法——这是 D14 与 D28 的共同要求)。
//! 中文由系统字体匹配:栈里显式列出 CJK 族名,纯英文环境不会去加载任何中文字体(D28)。
mod emphasized;
mod fonts;
pub use fonts::{FontChoice, FontFace, FontFamily, Platform, WeightCoverage, WeightMatch};
/// 字重(M3 只用这四档;可变字体按连续值渲染时取 [`Weight::value`])。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Weight {
/// 400:正文。
Regular,
/// 500:label / title-medium / Expressive 强调的正文与展示类。
Medium,
/// 600:本项目的「区块标题」档(介于 Medium 与 Bold 之间)。
SemiBold,
/// 700:页面标题 / 空态主文案 / Expressive 强调的 label 与 title-small-medium。
Bold,
}
impl Weight {
/// 数值字重(CSS/OpenType 口径)。
pub const fn value(self) -> u16 {
match self {
Weight::Regular => 400,
Weight::Medium => 500,
Weight::SemiBold => 600,
Weight::Bold => 700,
}
}
/// 再重一档(最重封顶 Bold)。
pub const fn heavier(self) -> Self {
match self {
Weight::Regular => Weight::Medium,
Weight::Medium => Weight::SemiBold,
Weight::SemiBold | Weight::Bold => Weight::Bold,
}
}
}
/// 一条排版样式(已解析成渲染要用的四个数)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TypeStyle {
/// 字号(px;1.0 缩放时与 dp 同值)。
pub size_px: f32,
/// 行高(px)。
pub line_height_px: f32,
/// 字重。
pub weight: Weight,
/// 字间距(px;M3 的 type scale 用 px 表达,中文场景下正字距更易读)。
pub letter_spacing_px: f32,
/// 用哪条字体栈。
pub family: FontFamily,
}
impl TypeStyle {
/// 按用户字号缩放(`factor` 1.0 = 标准;行高与字距同比例,夹取到 0.5..2.0)。
pub fn scaled(self, factor: f32) -> Self {
let f = factor.clamp(0.5, 2.0);
Self {
size_px: self.size_px * f,
line_height_px: self.line_height_px * f,
letter_spacing_px: self.letter_spacing_px * f,
..self
}
}
/// 该样式的字体族回退栈(栈项带字重覆盖,见 [`FontFace`])。
pub fn font_stack(self, platform: Platform) -> &'static [FontFace] {
platform.font_stack(self.family)
}
/// 该样式在这台平台上「字重最终落到哪个族」——中文缺档时看它判断是否走降级。
pub fn resolve(self, platform: Platform) -> FontChoice {
platform.resolve_weight(self.family, self.weight)
}
}
/// M3 type scale 的 15 个角色。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TypeRole {
/// 57/64:落地页大标题(本启动器基本不用)。
DisplayLarge,
/// 45/52。
DisplayMedium,
/// 36/44。
DisplaySmall,
/// 32/40:空态主标题的大档。
HeadlineLarge,
/// 28/36。
HeadlineMedium,
/// 24/32。
HeadlineSmall,
/// 22/28:页面标题(导航栏下的主标题)。
TitleLarge,
/// 16/24:卡片标题。
TitleMedium,
/// 14/20:行标题。
TitleSmall,
/// 16/24:正文大档(说明文字)。
BodyLarge,
/// 14/20:默认正文。
BodyMedium,
/// 12/16:辅助说明。
BodySmall,
/// 14/20:按钮文字。
LabelLarge,
/// 12/16:标签、徽标。
LabelMedium,
/// 11/16:最小标签(谨慎使用,中文 11px 可读性差)。
LabelSmall,
}
impl TypeRole {
/// 全部 15 档,按 M3 的族序排列。
pub const ALL: [TypeRole; 15] = [
TypeRole::DisplayLarge,
TypeRole::DisplayMedium,
TypeRole::DisplaySmall,
TypeRole::HeadlineLarge,
TypeRole::HeadlineMedium,
TypeRole::HeadlineSmall,
TypeRole::TitleLarge,
TypeRole::TitleMedium,
TypeRole::TitleSmall,
TypeRole::BodyLarge,
TypeRole::BodyMedium,
TypeRole::BodySmall,
TypeRole::LabelLarge,
TypeRole::LabelMedium,
TypeRole::LabelSmall,
];
/// 稳定字符串名(`display-large` … `label-small`)。
pub const fn name(self) -> &'static str {
match self {
TypeRole::DisplayLarge => "display-large",
TypeRole::DisplayMedium => "display-medium",
TypeRole::DisplaySmall => "display-small",
TypeRole::HeadlineLarge => "headline-large",
TypeRole::HeadlineMedium => "headline-medium",
TypeRole::HeadlineSmall => "headline-small",
TypeRole::TitleLarge => "title-large",
TypeRole::TitleMedium => "title-medium",
TypeRole::TitleSmall => "title-small",
TypeRole::BodyLarge => "body-large",
TypeRole::BodyMedium => "body-medium",
TypeRole::BodySmall => "body-small",
TypeRole::LabelLarge => "label-large",
TypeRole::LabelMedium => "label-medium",
TypeRole::LabelSmall => "label-small",
}
}
/// **baseline MD3** 样式(官方 type scale 值:字号 / 行高 / 字重 / 字距 + 字体族分类)。
pub const fn style(self) -> TypeStyle {
match self {
TypeRole::DisplayLarge => style(57.0, 64.0, Weight::Regular, -0.2, FontFamily::Display),
TypeRole::DisplayMedium => style(45.0, 52.0, Weight::Regular, 0.0, FontFamily::Display),
TypeRole::DisplaySmall => style(36.0, 44.0, Weight::Regular, 0.0, FontFamily::Display),
TypeRole::HeadlineLarge => style(32.0, 40.0, Weight::Regular, 0.0, FontFamily::Display),
TypeRole::HeadlineMedium => {
style(28.0, 36.0, Weight::Regular, 0.0, FontFamily::Display)
}
TypeRole::HeadlineSmall => style(24.0, 32.0, Weight::Regular, 0.0, FontFamily::Display),
// 官方 TitleLarge 用 Brand 族(与 Display 同栈),其余 title 用 Plain(Text)。
TypeRole::TitleLarge => style(22.0, 28.0, Weight::Regular, 0.0, FontFamily::Display),
TypeRole::TitleMedium => style(16.0, 24.0, Weight::Medium, 0.2, FontFamily::Text),
TypeRole::TitleSmall => style(14.0, 20.0, Weight::Medium, 0.1, FontFamily::Text),
TypeRole::BodyLarge => style(16.0, 24.0, Weight::Regular, 0.5, FontFamily::Text),
TypeRole::BodyMedium => style(14.0, 20.0, Weight::Regular, 0.2, FontFamily::Text),
TypeRole::BodySmall => style(12.0, 16.0, Weight::Regular, 0.4, FontFamily::Text),
TypeRole::LabelLarge => style(14.0, 20.0, Weight::Medium, 0.1, FontFamily::Text),
TypeRole::LabelMedium => style(12.0, 16.0, Weight::Medium, 0.5, FontFamily::Text),
TypeRole::LabelSmall => style(11.0, 16.0, Weight::Medium, 0.5, FontFamily::Text),
}
}
/// **M3 Expressive** 官方强调档(15/15 全有值,A 级来源)。
///
/// 规则(官方):字号与行高不变;字重升到 500(展示/正文类)或 700(label 与小标题);
/// 字距按官方表微调(display-large −0.2→0、body-large 0.5→0.15、title-medium 0.2→0.15、
/// body-medium 0.2→0.25)。这是**官方值,不是我们的近似**。
pub const fn emphasized(self) -> TypeStyle {
emphasized::style_of(self)
}
/// **简化强调规则(非官方)**:只把字重升一级,字号/行高/字距全不动。
///
/// 用途:需要在「同一 role 内加强语气但不想引入任何字距变化」的场合(例如同屏多行标题
/// 需要严格对齐基线网格时)。**优先用 [`TypeRole::emphasized`]**——那才是 Expressive 的定义。
pub const fn emphasized_simplified(self) -> TypeStyle {
let base = self.style();
TypeStyle {
weight: base.weight.heavier(),
..base
}
}
}
/// `const fn` 用的构造器。
const fn style(
size: f32,
line_height: f32,
weight: Weight,
tracking_px: f32,
family: FontFamily,
) -> TypeStyle {
TypeStyle {
size_px: size,
line_height_px: line_height,
weight,
letter_spacing_px: tracking_px,
family,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_role_has_sane_metrics() {
for role in TypeRole::ALL {
let s = role.style();
assert!(s.size_px >= 11.0, "{} 字号过小", role.name());
assert!(
s.line_height_px >= s.size_px,
"{} 行高小于字号({} < {})",
role.name(),
s.line_height_px,
s.size_px
);
assert!(
(1.1..=1.8).contains(&(s.line_height_px / s.size_px)),
"{} 行高倍数 {} 超出 1.1..1.8(M3 的行高是 1.12~1.5 量级)",
role.name(),
s.line_height_px / s.size_px
);
assert!(s.letter_spacing_px.abs() < 1.0, "{} 字距过大", role.name());
}
}
#[test]
fn each_family_is_monotone_by_size() {
// M3 的 type scale 是「五族 × 三档」,跨族不保证单调(title-small 14 → body-large 16),
// 但族内必须由大到小。
for family in [
[
TypeRole::DisplayLarge,
TypeRole::DisplayMedium,
TypeRole::DisplaySmall,
],
[
TypeRole::HeadlineLarge,
TypeRole::HeadlineMedium,
TypeRole::HeadlineSmall,
],
[
TypeRole::TitleLarge,
TypeRole::TitleMedium,
TypeRole::TitleSmall,
],
[
TypeRole::BodyLarge,
TypeRole::BodyMedium,
TypeRole::BodySmall,
],
[
TypeRole::LabelLarge,
TypeRole::LabelMedium,
TypeRole::LabelSmall,
],
] {
for pair in family.windows(2) {
assert!(
pair[0].style().size_px > pair[1].style().size_px,
"{} 应大于 {}",
pair[0].name(),
pair[1].name()
);
}
}
}
#[test]
fn names_are_unique_and_kebab() {
let mut seen = std::collections::BTreeSet::new();
for role in TypeRole::ALL {
let name = role.name();
assert!(seen.insert(name), "排版角色名重复:{name}");
assert!(name.chars().all(|c| c.is_ascii_lowercase() || c == '-'));
}
}
#[test]
fn simplified_emphasis_only_bumps_weight() {
for role in TypeRole::ALL {
let base = role.style();
let emph = role.emphasized_simplified();
assert_eq!(base.size_px, emph.size_px);
assert_eq!(base.line_height_px, emph.line_height_px);
assert_eq!(base.letter_spacing_px, emph.letter_spacing_px);
assert!(emph.weight.value() > base.weight.value() || base.weight == Weight::Bold);
}
}
#[test]
fn official_emphasis_is_at_least_as_heavy_as_baseline() {
for role in TypeRole::ALL {
assert!(
role.emphasized().weight.value() >= role.style().weight.value(),
"{} 的官方强调档不该比 baseline 更轻",
role.name()
);
assert_ne!(
role.emphasized(),
role.style(),
"{} 强调档与 baseline 相同",
role.name()
);
}
}
#[test]
fn scaling_is_proportional_and_bounded() {
let base = TypeRole::BodyMedium.style();
let big = base.scaled(1.25);
assert!((big.size_px - 17.5).abs() < 1.0e-4);
assert!((big.line_height_px - 25.0).abs() < 1.0e-4);
assert_eq!(base.scaled(10.0).size_px, base.size_px * 2.0);
assert_eq!(base.scaled(0.1).size_px, base.size_px * 0.5);
}
}
+199
View File
@@ -0,0 +1,199 @@
//! WCAG AA 对比度门禁(M61 任务:「on-* 与其承载色至少满足 4.5:1,不达标的角色要显式标注例外」)。
//!
//! 三个性质让这条门禁可以做到「**换任何种子色都成立**」,而不是只对出厂紫成立:
//! 1. 颜色建在 CIELAB `L*`(tone)轴上,`TonalPalette::tone` 精确命中 tone(误差 ≤1);
//! 2. WCAG 相对亮度只取决于同一条 Y 轴(`L*` ↔ Y 是双射);
//! 3. 角色 → tone 的映射与色相/彩度无关。
//!
//! 所以对比度主要由「两个角色的 tone 差」决定,与 user 选的强调色无关——下面对多组种子
//! 逐一验证,就是在钉这个性质。
use unlucky_design::color::{Color, Role, Scheme, SchemeFlavor};
/// 正文文字:要求 ≥ 4.5:1(WCAG AA normal text)。
const AA_TEXT: f32 = 4.5;
/// 非文字元素(描边、图标块):要求 ≥ 3:1(WCAG AA non-text)。
const AA_NON_TEXT: f32 = 3.0;
/// 文字/图标对(前景, 承载色)。两个模式都要过。
const TEXT_PAIRS: [(Role, Role); 26] = [
(Role::OnPrimary, Role::Primary),
(Role::OnPrimaryContainer, Role::PrimaryContainer),
(Role::OnSecondary, Role::Secondary),
(Role::OnSecondaryContainer, Role::SecondaryContainer),
(Role::OnTertiary, Role::Tertiary),
(Role::OnTertiaryContainer, Role::TertiaryContainer),
(Role::OnError, Role::Error),
(Role::OnErrorContainer, Role::ErrorContainer),
(Role::OnSurface, Role::Surface),
(Role::OnSurfaceVariant, Role::SurfaceVariant),
(Role::OnBackground, Role::Background),
(Role::InverseOnSurface, Role::InverseSurface),
(Role::InversePrimary, Role::InverseSurface),
(Role::OnPrimaryFixed, Role::PrimaryFixed),
(Role::OnPrimaryFixedVariant, Role::PrimaryFixedDim),
(Role::OnSecondaryFixed, Role::SecondaryFixed),
(Role::OnSecondaryFixedVariant, Role::SecondaryFixedDim),
(Role::OnTertiaryFixed, Role::TertiaryFixed),
(Role::OnTertiaryFixedVariant, Role::TertiaryFixedDim),
// 卡片/浮层容器上的文字(M3 现行口径:层级靠 surface-container-* 表达)
(Role::OnSurface, Role::SurfaceContainerLowest),
(Role::OnSurface, Role::SurfaceContainerLow),
(Role::OnSurface, Role::SurfaceContainer),
(Role::OnSurface, Role::SurfaceContainerHigh),
(Role::OnSurface, Role::SurfaceContainerHighest),
(Role::OnSurfaceVariant, Role::SurfaceContainerHighest),
(Role::OnSurfaceVariant, Role::SurfaceContainerLow),
];
/// 显式登记的例外:`(前景, 承载色, 目标阈值, 原因)`。
///
/// 这些不是缺陷,是 M3 定义决定的(非文字元素 3:1、装饰件无要求)。
const EXCEPTIONS: [(Role, Role, f32, &str); 3] = [
(
Role::Outline,
Role::Surface,
AA_NON_TEXT,
"输入框/卡片描边是 M3 定义的非文字元素,规范目标 3:1(light 实测 4.29:1、dark 6.5:1 已高于目标)",
),
(
Role::OutlineVariant,
Role::Surface,
AA_NON_TEXT * 0.5,
"分隔线是纯装饰,M3 不要求对比度下限;1.6:1 是「几乎看不见但要能数出层级」的刻意取值",
),
(
Role::SurfaceVariant,
Role::Surface,
1.0,
"surface-variant 是容器色而不是前景色,不参与对比度门禁(M3 已不推荐用它做容器,保留仅为兼容旧值)",
),
];
fn seeds() -> Vec<Color> {
vec![
// 出厂基线(M3 baseline 紫)
Color::from_hex("#6750A4").expect("基线种子"),
// 冷蓝(原 Fluent 强调色,用户大概率会选这一族)
Color::from_hex("#0078D4").expect("蓝"),
// 青绿
Color::from_hex("#006A5C").expect("青"),
// 暖橙(高彩度,gamut 压力大)
Color::from_hex("#E8710A").expect("橙"),
// 亮黄(最容易被白字吃掉的极端)
Color::from_hex("#FFD400").expect("黄"),
// 近灰(低彩度种子,检验 neutral 不塌)
Color::from_hex("#6E6E6E").expect("灰"),
]
}
#[test]
fn text_pairs_meet_wcag_aa_for_every_seed_and_flavor() {
let mut failures = Vec::new();
for seed in seeds() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let scheme = Scheme::from_seed(flavor, seed);
for (fg, bg) in TEXT_PAIRS {
let ratio = scheme.contrast(fg, bg);
if ratio < AA_TEXT {
failures.push(format!(
"seed {} / {} / {} on {} = {ratio:.2}",
seed.to_hex(),
flavor.name(),
fg.name(),
bg.name()
));
}
}
}
}
assert!(
failures.is_empty(),
"以下角色对未达 WCAG AA(4.5:1):\n{}",
failures.join("\n")
);
}
#[test]
fn exception_list_is_honest_about_its_thresholds() {
// 例外必须**真的**是「非文字级」,不能悄悄把低于 3:1 的文字对塞进来。
for seed in seeds() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let scheme = Scheme::from_seed(flavor, seed);
for (fg, bg, threshold, reason) in EXCEPTIONS {
assert!(
!fg.is_foreground(),
"例外 {} 不该是文字前景色({reason})",
fg.name()
);
let ratio = scheme.contrast(fg, bg);
assert!(
ratio >= threshold,
"例外 {} on {} 实测 {ratio:.2},低于登记阈值 {threshold}(seed {} / {})",
fg.name(),
bg.name(),
seed.to_hex(),
flavor.name()
);
}
}
}
}
#[test]
fn baseline_light_and_dark_both_pass() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let scheme = Scheme::baseline(flavor);
for (fg, bg) in TEXT_PAIRS {
let ratio = scheme.contrast(fg, bg);
assert!(
ratio >= AA_TEXT,
"baseline {} 的 {} on {} 只有 {ratio:.2}",
flavor.name(),
fg.name(),
bg.name()
);
}
}
}
#[test]
fn accent_override_cannot_break_contrast() {
// 换了强调色之后,primary 族仍必须过 AA——这是「tone 轴固定」带来的结构性保证。
let base = Scheme::baseline(SchemeFlavor::Light);
for seed in seeds() {
let custom = Scheme::from_accent(SchemeFlavor::Light, seed);
for (fg, bg) in [
(Role::OnPrimary, Role::Primary),
(Role::OnPrimaryContainer, Role::PrimaryContainer),
(Role::OnSecondary, Role::Secondary),
(Role::OnTertiary, Role::Tertiary),
] {
let ratio = custom.contrast(fg, bg);
assert!(
ratio >= AA_TEXT,
"强调色 {} 下 {} on {} 只有 {ratio:.2}",
seed.to_hex(),
fg.name(),
bg.name()
);
}
// 表面色不受强调色影响(这条同时是 `from_accent` 的语义断言)。
assert_eq!(custom.get(Role::Surface), base.get(Role::Surface));
}
}
#[test]
fn outline_is_registered_as_non_text_exception() {
// 把「为什么 outline 不是 AA 例外失败项」钉死:它在 light 下 4.29:1(低于 4.5 但高于 3:1)。
let light = Scheme::baseline(SchemeFlavor::Light);
let ratio = light.contrast(Role::Outline, Role::Surface);
assert!(
ratio < AA_TEXT,
"light 的 outline 若已达 4.5 应把例外删掉:{ratio:.2}"
);
assert!(
ratio >= AA_NON_TEXT,
"light 的 outline 低于非文字 3:1:{ratio:.2}"
);
}
+224
View File
@@ -0,0 +1,224 @@
//! 令牌契约测试:从**消费方视角**(只用公开 API)钉住 M61 承诺的那些性质。
//!
//! 这份测试的作用是「冻结」:`IconKind`/`Role` 的次序与个数、type scale 的取值、形状/高程阶梯、
//! 描边的 SVG 语义换算。后续 iced 页面线靠它保证不会因为一次「顺手改动」而悄悄换观感。
use unlucky_design::color::{Role, Scheme, SchemeFlavor, TonalPalette};
use unlucky_design::component_map::Component;
use unlucky_design::elevation::{Elevation, MaterialLayer};
use unlucky_design::icons::{
stroke_width_for, visual_stroke_px, IconKind, IconPaint, IconSize, VIEWBOX,
};
use unlucky_design::motion::{
DurationToken, Easing, MotionScale, SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
};
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::{LayoutMetrics, Space};
use unlucky_design::typography::{FontFamily, Platform, TypeRole, Weight};
use unlucky_design::DesignTokens;
#[test]
fn color_role_table_is_frozen() {
assert_eq!(Role::ALL.len(), 49, "M3 基础角色集共 49 个");
assert_eq!(Role::ALL[0], Role::Primary);
assert_eq!(Role::ALL[16], Role::Surface);
assert_eq!(Role::ALL[48], Role::OnTertiaryFixedVariant);
// 每个角色都能从两套 scheme 里取到值,且前景角色都带 on- 语义。
let light = Scheme::baseline(SchemeFlavor::Light);
let dark = Scheme::baseline(SchemeFlavor::Dark);
for role in Role::ALL {
assert!(
light.get(role).is_opaque(),
"{} 应当是不透明色",
role.name()
);
assert!(dark.get(role).is_opaque());
if role.name().starts_with("on-") {
assert!(role.is_foreground(), "{} 应被判为前景色", role.name());
}
}
assert!(light.get(Role::Primary).is_opaque());
}
#[test]
fn tone_mapping_is_the_m3_table() {
// 抽查几行关键映射(light / dark),防止有人改 tone 表时静默换观感。
assert_eq!(Scheme::tone_of(Role::Primary, SchemeFlavor::Light), 40.0);
assert_eq!(Scheme::tone_of(Role::Surface, SchemeFlavor::Light), 98.0);
assert_eq!(Scheme::tone_of(Role::Surface, SchemeFlavor::Dark), 6.0);
assert_eq!(
Scheme::tone_of(Role::OnPrimaryContainer, SchemeFlavor::Dark),
90.0
);
assert_eq!(Scheme::tone_of(Role::Outline, SchemeFlavor::Light), 50.0);
}
#[test]
fn type_scale_values_are_the_m3_baseline() {
assert_eq!(TypeRole::ALL.len(), 15);
let title = TypeRole::TitleLarge.style();
assert_eq!((title.size_px, title.line_height_px), (22.0, 28.0));
let body = TypeRole::BodyMedium.style();
assert_eq!((body.size_px, body.line_height_px), (14.0, 20.0));
assert_eq!(body.weight, Weight::Regular);
let label = TypeRole::LabelLarge.style();
assert_eq!(label.weight, Weight::Medium);
assert_eq!(TypeRole::BodySmall.style().size_px, 12.0);
// 强调档只动字重。
assert_eq!(TypeRole::TitleLarge.emphasized().size_px, 22.0);
assert!(TypeRole::TitleMedium.emphasized().weight.value() > Weight::Medium.value());
}
#[test]
fn font_stacks_never_embed_font_files() {
for platform in [Platform::Windows, Platform::MacOs, Platform::Linux] {
for family in [FontFamily::Display, FontFamily::Text, FontFamily::Mono] {
let stack = platform.font_stack(family);
assert!(!stack.is_empty());
for face in stack {
let lower = face.family.to_ascii_lowercase();
for banned in [".ttf", ".otf", ".ttc", "woff", "file"] {
assert!(
!lower.contains(banned),
"{} 的字体栈出现文件引用 {}(只允许族名,禁止内嵌受限字体)",
platform.name(),
face.family
);
}
}
}
}
}
#[test]
fn cjk_weight_gap_is_registered() {
// 中文 700(Expressive 强调档要用的)在三平台都要能找到原生落点,否则就是合成加粗降级。
use unlucky_design::typography::{Weight, WeightMatch};
// Windows:Segoe UI Variable 覆盖 700(原生);CJK 落点是雅黑 700(原生,雅黑有 Bold)。
let win = Platform::Windows.resolve_weight(FontFamily::Text, Weight::Bold);
assert_eq!(win.quality, WeightMatch::Native);
assert!(Platform::Windows.cjk_face().supports(Weight::Bold));
// macOS:苹方只到 600 → CJK 的 700 只能合成/换族,这条 gap 已在文档登记。
assert!(!Platform::MacOs.cjk_face().supports(Weight::Bold));
// Linux:Noto Sans CJK 可变字体全覆盖。
for weight in [Weight::Regular, Weight::Medium, Weight::Bold] {
assert!(Platform::Linux.cjk_face().supports(weight));
}
}
#[test]
fn icon_kind_is_frozen_and_append_only() {
assert_eq!(IconKind::ALL.len(), 28, "M61 冻结 28 个图标");
// 迁移来的 24 个必须保持原次序(序号即持久化 id)。
assert_eq!(IconKind::ALL[0], IconKind::Instances);
assert_eq!(IconKind::ALL[4], IconKind::Ai);
assert_eq!(IconKind::ALL[20], IconKind::More);
assert_eq!(IconKind::ALL[23], IconKind::User);
// 新增的 4 个追加在末尾。
assert_eq!(IconKind::ALL[24], IconKind::Refresh);
assert_eq!(IconKind::ALL[27], IconKind::Trash);
for kind in IconKind::ALL {
assert!(kind.icon().path.starts_with('M'));
assert_eq!(kind.icon().kind, kind);
}
}
#[test]
fn icon_stroke_follows_svg_semantics_not_slint() {
// Slint 口径(绝对 1.5px)在 20px 渲染下会得到 1.5px 视觉线宽;SVG 口径要写 1.8。
let nav_width = IconKind::Settings.icon().stroke_width(IconSize::Nav.px());
assert!(
(nav_width - 1.8).abs() < 1.0e-6,
"20px 渲染应取 1.8 个 viewBox 单位"
);
let button_width = IconKind::Settings
.icon()
.stroke_width(IconSize::Button.px());
assert!((button_width - 2.25).abs() < 1.0e-6);
// 视觉宽度的往返一致性(这条是「别照抄 Slint 的 1.5」的守门测试)。
for size in IconSize::ALL {
let w = stroke_width_for(size.px(), 1.5);
assert!((visual_stroke_px(size.px(), w) - 1.5).abs() < 1.0e-6);
}
assert!((VIEWBOX - 24.0).abs() < f32::EPSILON);
// 实心图标不描边。
assert_eq!(IconKind::Play.icon().paint, IconPaint::Fill);
assert_eq!(IconKind::Play.icon().stroke_width(24.0), 0.0);
}
#[test]
fn shape_and_elevation_ladders() {
assert_eq!(ShapeScale::ExtraSmall.dp(), 4.0);
assert_eq!(ShapeScale::Medium.dp(), 12.0);
assert_eq!(ShapeScale::ExtraLarge.dp(), 28.0);
assert_eq!(ShapeScale::Full.radius_px(40.0), 20.0);
assert!(ShapeScale::ExtraLargeIncreased.is_expressive());
let dp: Vec<f32> = Elevation::ALL.iter().map(|e| e.dp()).collect();
assert_eq!(dp, vec![0.0, 1.0, 3.0, 6.0, 8.0, 12.0]);
assert_eq!(
MaterialLayer::Card.surface_role(),
Role::SurfaceContainerLow
);
}
#[test]
fn motion_tokens_are_usable_as_data() {
assert_eq!(DurationToken::Medium2.ms(), 300);
assert!((Easing::Standard.sample(0.0)).abs() < 1.0e-6);
assert!((Easing::Standard.sample(1.0) - 1.0).abs() < 1.0e-6);
let settle = SPRING_EXPRESSIVE_SPATIAL_DEFAULT.settle_ms(0.001);
assert!(settle > 0.0 && settle < 2000.0);
assert_eq!(MotionScale::NONE.ms(DurationToken::Long4), 0.0);
}
#[test]
fn spacing_and_layout_metrics_are_shared() {
assert_eq!(Space::X4.dp(), 16.0);
let metrics = LayoutMetrics::DESKTOP;
assert_eq!(metrics.header_height(false), 56.0);
assert_eq!(metrics.header_height(true), 84.0);
}
#[test]
fn component_map_covers_the_page_skeleton() {
assert_eq!(Component::ALL.len(), 20);
for component in Component::ALL {
let spec = component.spec();
assert_eq!(spec.component, component);
assert!(spec.text.style().size_px >= 11.0);
}
assert_eq!(Component::PrimaryButton.spec().shape, ShapeScale::Full);
// 浮层必须高于卡片层。
assert!(Component::Dialog.spec().elevation > Component::OutlinedCard.spec().elevation);
}
#[test]
fn design_tokens_entry_point() {
let light = DesignTokens::baseline(SchemeFlavor::Light);
let dark = DesignTokens::baseline(SchemeFlavor::Dark);
assert_eq!(light.flavor, SchemeFlavor::Light);
assert!(light.color(Role::Surface).relative_luminance() > 0.8);
assert!(dark.color(Role::Surface).relative_luminance() < 0.05);
assert!(!light.motion_scale.is_off());
let accent = DesignTokens::with_accent(
SchemeFlavor::Light,
unlucky_design::Color::from_hex("#006A5C").unwrap(),
);
assert_ne!(accent.color(Role::Primary), light.color(Role::Primary));
assert_eq!(accent.color(Role::Surface), light.color(Role::Surface));
}
#[test]
fn tonal_palette_hits_requested_tones() {
let palette = TonalPalette::new(150.0, 0.1);
for tone in [20.0_f32, 40.0, 60.0, 80.0] {
let color = palette.tone(tone);
assert!(
(color.lstar() - tone).abs() <= 1.0,
"tone {tone} 实际 {}",
color.lstar()
);
}
}
+252
View File
@@ -0,0 +1,252 @@
//! 官方数值钉死(二):**色彩 tone 表 49 条**、7 条 easing 曲线、16 档时长、28 个图标下标、
//! 形状阶梯,以及「同族容器不撞色」的回归(reviewer P3-2)。
//!
//! 期望值全部独立于实现重写;实现改一个数、表不改,这里就红。
use unlucky_design::color::{Role, Scheme, SchemeFlavor};
use unlucky_design::icons::IconKind;
use unlucky_design::motion::{DurationToken, Easing};
use unlucky_design::shape::ShapeScale;
fn expect_tone(role: Role, light: f32, dark: f32) {
assert_eq!(
Scheme::tone_of(role, SchemeFlavor::Light),
light,
"{} 的 light tone 被改动了",
role.name()
);
assert_eq!(
Scheme::tone_of(role, SchemeFlavor::Dark),
dark,
"{} 的 dark tone 被改动了",
role.name()
);
}
#[test]
fn all_forty_nine_tones_are_pinned() {
expect_tone(Role::Primary, 40.0, 80.0);
expect_tone(Role::OnPrimary, 100.0, 20.0);
expect_tone(Role::PrimaryContainer, 90.0, 30.0);
expect_tone(Role::OnPrimaryContainer, 10.0, 90.0);
expect_tone(Role::Secondary, 40.0, 80.0);
expect_tone(Role::OnSecondary, 100.0, 20.0);
expect_tone(Role::SecondaryContainer, 90.0, 30.0);
expect_tone(Role::OnSecondaryContainer, 10.0, 90.0);
expect_tone(Role::Tertiary, 40.0, 80.0);
expect_tone(Role::OnTertiary, 100.0, 20.0);
expect_tone(Role::TertiaryContainer, 90.0, 30.0);
expect_tone(Role::OnTertiaryContainer, 10.0, 90.0);
expect_tone(Role::Error, 40.0, 80.0);
expect_tone(Role::OnError, 100.0, 20.0);
expect_tone(Role::ErrorContainer, 90.0, 30.0);
expect_tone(Role::OnErrorContainer, 10.0, 90.0);
expect_tone(Role::Surface, 98.0, 6.0);
expect_tone(Role::OnSurface, 10.0, 90.0);
expect_tone(Role::SurfaceVariant, 90.0, 30.0);
expect_tone(Role::OnSurfaceVariant, 30.0, 80.0);
expect_tone(Role::Background, 99.0, 10.0);
expect_tone(Role::OnBackground, 10.0, 90.0);
expect_tone(Role::SurfaceDim, 87.0, 6.0);
expect_tone(Role::SurfaceBright, 98.0, 24.0);
expect_tone(Role::SurfaceContainerLowest, 100.0, 4.0);
expect_tone(Role::SurfaceContainerLow, 96.0, 10.0);
expect_tone(Role::SurfaceContainer, 94.0, 12.0);
expect_tone(Role::SurfaceContainerHigh, 92.0, 17.0);
expect_tone(Role::SurfaceContainerHighest, 90.0, 22.0);
expect_tone(Role::Outline, 50.0, 60.0);
expect_tone(Role::OutlineVariant, 80.0, 30.0);
expect_tone(Role::InverseSurface, 20.0, 90.0);
expect_tone(Role::InverseOnSurface, 95.0, 20.0);
expect_tone(Role::InversePrimary, 80.0, 40.0);
expect_tone(Role::Shadow, 0.0, 0.0);
expect_tone(Role::Scrim, 0.0, 0.0);
expect_tone(Role::SurfaceTint, 40.0, 80.0);
expect_tone(Role::PrimaryFixed, 90.0, 90.0);
expect_tone(Role::PrimaryFixedDim, 80.0, 80.0);
expect_tone(Role::OnPrimaryFixed, 10.0, 10.0);
expect_tone(Role::OnPrimaryFixedVariant, 30.0, 30.0);
expect_tone(Role::SecondaryFixed, 90.0, 90.0);
expect_tone(Role::SecondaryFixedDim, 80.0, 80.0);
expect_tone(Role::OnSecondaryFixed, 10.0, 10.0);
expect_tone(Role::OnSecondaryFixedVariant, 30.0, 30.0);
expect_tone(Role::TertiaryFixed, 90.0, 90.0);
expect_tone(Role::TertiaryFixedDim, 80.0, 80.0);
expect_tone(Role::OnTertiaryFixed, 10.0, 10.0);
expect_tone(Role::OnTertiaryFixedVariant, 30.0, 30.0);
}
#[test]
fn easing_curves_are_pinned() {
let cases: [(Easing, f32, f32, f32, f32); 7] = [
(Easing::Linear, 0.0, 0.0, 1.0, 1.0),
(Easing::Standard, 0.2, 0.0, 0.0, 1.0),
(Easing::StandardDecelerate, 0.0, 0.0, 0.0, 1.0),
(Easing::StandardAccelerate, 0.3, 0.0, 1.0, 1.0),
(Easing::Emphasized, 0.2, 0.0, 0.0, 1.0),
(Easing::EmphasizedDecelerate, 0.05, 0.7, 0.1, 1.0),
(Easing::EmphasizedAccelerate, 0.3, 0.0, 0.8, 0.15),
];
for (easing, x1, y1, x2, y2) in cases {
let curve = easing.curve();
assert_eq!(
(curve.x1, curve.y1, curve.x2, curve.y2),
(x1, y1, x2, y2),
"{easing:?} 的曲线参数被改动了"
);
}
}
#[test]
fn durations_are_pinned() {
let cases: [(DurationToken, u32); 16] = [
(DurationToken::Short1, 50),
(DurationToken::Short2, 100),
(DurationToken::Short3, 150),
(DurationToken::Short4, 200),
(DurationToken::Medium1, 250),
(DurationToken::Medium2, 300),
(DurationToken::Medium3, 350),
(DurationToken::Medium4, 400),
(DurationToken::Long1, 450),
(DurationToken::Long2, 500),
(DurationToken::Long3, 550),
(DurationToken::Long4, 600),
(DurationToken::ExtraLong1, 700),
(DurationToken::ExtraLong2, 800),
(DurationToken::ExtraLong3, 900),
(DurationToken::ExtraLong4, 1000),
];
for (token, ms) in cases {
assert_eq!(token.ms(), ms, "{} 的毫秒值被改动了", token.name());
}
}
#[test]
fn spring_presets_are_pinned() {
use unlucky_design::motion::{
spring, MotionSchemeKind, Spring, SpringCategory, SpringSpeed, SPRING_EFFECTS_DEFAULT,
SPRING_EFFECTS_FAST, SPRING_EFFECTS_SLOW, SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
SPRING_EXPRESSIVE_SPATIAL_FAST, SPRING_EXPRESSIVE_SPATIAL_SLOW,
SPRING_STANDARD_SPATIAL_DEFAULT, SPRING_STANDARD_SPATIAL_FAST,
SPRING_STANDARD_SPATIAL_SLOW,
};
// A 级:StandardMotionTokens / ExpressiveMotionTokens(damping = 阻尼比 ζ,stiffness = k)。
let cases: [(Spring, f32, f32); 9] = [
// standard 方案 spatial:ζ=0.9(保守)
(SPRING_STANDARD_SPATIAL_FAST, 1400.0, 0.9),
(SPRING_STANDARD_SPATIAL_DEFAULT, 700.0, 0.9),
(SPRING_STANDARD_SPATIAL_SLOW, 300.0, 0.9),
// expressive 方案 spatial:ζ=0.6/0.8(回弹明显)
(SPRING_EXPRESSIVE_SPATIAL_FAST, 800.0, 0.6),
(SPRING_EXPRESSIVE_SPATIAL_DEFAULT, 380.0, 0.8),
(SPRING_EXPRESSIVE_SPATIAL_SLOW, 200.0, 0.8),
// effects 两套同值:ζ=1.0(不过冲)
(SPRING_EFFECTS_FAST, 3800.0, 1.0),
(SPRING_EFFECTS_DEFAULT, 1600.0, 1.0),
(SPRING_EFFECTS_SLOW, 800.0, 1.0),
];
for (entry, stiffness, damping_ratio) in cases {
assert_eq!(entry.stiffness, stiffness, "弹簧刚度被改动");
assert_eq!(entry.damping_ratio, damping_ratio, "弹簧阻尼比被改动");
}
// 二维表查询与常量必须一致(改了表结构也别想绕过 pin)。
assert_eq!(
spring(
MotionSchemeKind::Expressive,
SpringCategory::Spatial,
SpringSpeed::Default
),
SPRING_EXPRESSIVE_SPATIAL_DEFAULT
);
assert_eq!(
spring(
MotionSchemeKind::Standard,
SpringCategory::Spatial,
SpringSpeed::Fast
),
SPRING_STANDARD_SPATIAL_FAST
);
}
#[test]
fn icon_indices_are_pinned() {
let names = [
"instances",
"download",
"logs",
"settings",
"ai",
"play",
"stop",
"search",
"plus",
"close",
"minimize",
"maximize",
"chevron-left",
"chevron-right",
"chevron-down",
"expand",
"send",
"check",
"warn",
"error",
"more",
"sidebar",
"restore",
"user",
"refresh",
"filter",
"folder",
"trash",
];
assert_eq!(IconKind::ALL.len(), names.len());
for (i, kind) in IconKind::ALL.iter().enumerate() {
assert_eq!(
kind.name(),
names[i],
"第 {i} 个图标被换位/改名了(序号即持久化 id)"
);
}
}
#[test]
fn shape_ladder_is_pinned() {
let cases: [(ShapeScale, f32); 9] = [
(ShapeScale::None, 0.0),
(ShapeScale::ExtraSmall, 4.0),
(ShapeScale::Small, 8.0),
(ShapeScale::Medium, 12.0),
(ShapeScale::Large, 16.0),
(ShapeScale::LargeIncreased, 20.0),
(ShapeScale::ExtraLarge, 28.0),
(ShapeScale::ExtraLargeIncreased, 32.0),
(ShapeScale::ExtraExtraLarge, 48.0),
];
for (scale, dp) in cases {
assert_eq!(scale.dp(), dp, "{scale:?} 的半径档位被改动了");
}
assert_eq!(ShapeScale::Full.radius_px(64.0), 32.0);
}
#[test]
fn same_family_containers_do_not_collide() {
// P3-2 回归:light 模式下 primary-container / secondary-container 曾经被 gamut 裁剪成同色。
let light = Scheme::baseline(SchemeFlavor::Light);
let primary = light.get(Role::PrimaryContainer);
let secondary = light.get(Role::SecondaryContainer);
assert_ne!(
primary, secondary,
"同族容器撞色(彩度压缩没生效):{primary:?}"
);
// secondary-container 必须比 primary-container 更灰(M3 的「次级」语义)。
let c_primary = unlucky_design::color::space::Oklch::from_color(primary).chroma;
let c_secondary = unlucky_design::color::space::Oklch::from_color(secondary).chroma;
assert!(
c_secondary < c_primary,
"secondary-container 彩度 {c_secondary} 应低于 primary-container {c_primary}"
);
// tertiary 与 primary 也该可区分(不同色相)。
assert_ne!(light.get(Role::TertiaryContainer), primary);
}
+106
View File
@@ -0,0 +1,106 @@
//! 官方数值钉死(一):**排版两张表**(baseline 15 档 + Expressive 强调 15 档)。
//!
//! 表的来源是 A 级 `androidx.compose.material3.tokens.TypeScaleTokens`(v0_103)。这里的期望值是
//! **独立于实现重写的一份**:任何人改动 `TypeRole::style()` / `emphasized()` 的数值,都必须同时改
//! 这张表才会同时绿——这就是「冻结」的含义(reviewer P2-1 的变异测试就是打这里)。
use unlucky_design::typography::{FontFamily, TypeRole, Weight};
/// baseline 单档对拍。
fn expect_baseline(
role: TypeRole,
size: f32,
line_height: f32,
weight: Weight,
tracking: f32,
family: FontFamily,
) {
let got = role.style();
assert_eq!(
(
got.size_px,
got.line_height_px,
got.weight,
got.letter_spacing_px,
got.family
),
(size, line_height, weight, tracking, family),
"{} 的 baseline 值被改动了(官方 TypeScaleTokens)",
role.name()
);
}
/// Expressive 强调档单档对拍。
fn expect_emphasized(role: TypeRole, size: f32, line_height: f32, weight: Weight, tracking: f32) {
let got = role.emphasized();
assert_eq!(
(
got.size_px,
got.line_height_px,
got.weight,
got.letter_spacing_px
),
(size, line_height, weight, tracking),
"{} 的 Expressive 强调档值被改动了(官方 TypeScaleTokens *Emphasized*)",
role.name()
);
}
#[test]
fn baseline_type_scale_matches_official_tokens() {
use FontFamily::{Display, Text};
use Weight::{Bold, Medium, Regular};
expect_baseline(TypeRole::DisplayLarge, 57.0, 64.0, Regular, -0.2, Display);
expect_baseline(TypeRole::DisplayMedium, 45.0, 52.0, Regular, 0.0, Display);
expect_baseline(TypeRole::DisplaySmall, 36.0, 44.0, Regular, 0.0, Display);
expect_baseline(TypeRole::HeadlineLarge, 32.0, 40.0, Regular, 0.0, Display);
expect_baseline(TypeRole::HeadlineMedium, 28.0, 36.0, Regular, 0.0, Display);
expect_baseline(TypeRole::HeadlineSmall, 24.0, 32.0, Regular, 0.0, Display);
expect_baseline(TypeRole::TitleLarge, 22.0, 28.0, Regular, 0.0, Display);
expect_baseline(TypeRole::TitleMedium, 16.0, 24.0, Medium, 0.2, Text);
expect_baseline(TypeRole::TitleSmall, 14.0, 20.0, Medium, 0.1, Text);
expect_baseline(TypeRole::BodyLarge, 16.0, 24.0, Regular, 0.5, Text);
expect_baseline(TypeRole::BodyMedium, 14.0, 20.0, Regular, 0.2, Text);
expect_baseline(TypeRole::BodySmall, 12.0, 16.0, Regular, 0.4, Text);
expect_baseline(TypeRole::LabelLarge, 14.0, 20.0, Medium, 0.1, Text);
expect_baseline(TypeRole::LabelMedium, 12.0, 16.0, Medium, 0.5, Text);
expect_baseline(TypeRole::LabelSmall, 11.0, 16.0, Medium, 0.5, Text);
let _ = Bold;
}
#[test]
fn expressive_emphasis_matches_official_tokens() {
use Weight::{Bold, Medium};
// 展示/正文类强调 → Medium(500);label 与小标题 → Bold(700)。
expect_emphasized(TypeRole::DisplayLarge, 57.0, 64.0, Medium, 0.0);
expect_emphasized(TypeRole::DisplayMedium, 45.0, 52.0, Medium, 0.0);
expect_emphasized(TypeRole::DisplaySmall, 36.0, 44.0, Medium, 0.0);
expect_emphasized(TypeRole::HeadlineLarge, 32.0, 40.0, Medium, 0.0);
expect_emphasized(TypeRole::HeadlineMedium, 28.0, 36.0, Medium, 0.0);
expect_emphasized(TypeRole::HeadlineSmall, 24.0, 32.0, Medium, 0.0);
expect_emphasized(TypeRole::TitleLarge, 22.0, 28.0, Medium, 0.0);
expect_emphasized(TypeRole::TitleMedium, 16.0, 24.0, Bold, 0.15);
expect_emphasized(TypeRole::TitleSmall, 14.0, 20.0, Bold, 0.1);
expect_emphasized(TypeRole::BodyLarge, 16.0, 24.0, Medium, 0.15);
expect_emphasized(TypeRole::BodyMedium, 14.0, 20.0, Medium, 0.25);
expect_emphasized(TypeRole::BodySmall, 12.0, 16.0, Medium, 0.4);
expect_emphasized(TypeRole::LabelLarge, 14.0, 20.0, Bold, 0.1);
expect_emphasized(TypeRole::LabelMedium, 12.0, 16.0, Bold, 0.5);
expect_emphasized(TypeRole::LabelSmall, 11.0, 16.0, Bold, 0.5);
}
#[test]
fn both_tables_cover_all_fifteen_roles() {
// 防止「表里少写一档」:15 个角色都必须被上面两张表摸到。
let mut names = std::collections::BTreeSet::new();
for role in TypeRole::ALL {
names.insert(role.name());
assert_ne!(
role.style(),
role.emphasized(),
"{} 强调档等同 baseline",
role.name()
);
}
assert_eq!(names.len(), 15);
}
+114 -7
View File
@@ -9,7 +9,9 @@
| 槽位 | 产物文件名 | 形态 | 归档内容 | | 槽位 | 产物文件名 | 形态 | 归档内容 |
|------|-----------|------|---------| |------|-----------|------|---------|
| `windows-x64` | `unlucky-launcher-<ver>-windows-x64.exe` | **裸 exe**(双击即用) | exe 单文件;三份合规文本作为并列 Release 资产 | | `windows-x64` | `unlucky-launcher-<ver>-windows-x64.exe` | **裸 exe**(双击即用) | exe 单文件;三份合规文本作为并列 Release 资产 |
| `windows-arm64` | `unlucky-launcher-<ver>-windows-arm64.exe` | **裸 exe**(同 x64 策略) | 同上;在 x64 宿主上交叉编译到 `aarch64-pc-windows-msvc` |
| `linux-x64` | `unlucky-launcher-<ver>-linux-x64.tar.gz` | 单二进制归档 | `unlucky-launcher` + `LICENSE` + `NOTICE` + `README.md` | | `linux-x64` | `unlucky-launcher-<ver>-linux-x64.tar.gz` | 单二进制归档 | `unlucky-launcher` + `LICENSE` + `NOTICE` + `README.md` |
| `linux-arm64` | `unlucky-launcher-<ver>-linux-arm64.tar.gz` | 单二进制归档(原生 ARM64 runner 构建) | 同上 |
| `macos-arm64` | `unlucky-launcher-<ver>-macos-arm64.tar.gz` | 单二进制归档 | 同上 | | `macos-arm64` | `unlucky-launcher-<ver>-macos-arm64.tar.gz` | 单二进制归档 | 同上 |
| `macos-x64` | `unlucky-launcher-<ver>-macos-x64.tar.gz` | 单二进制归档 | 同上 | | `macos-x64` | `unlucky-launcher-<ver>-macos-x64.tar.gz` | 单二进制归档 | 同上 |
| 全平台 | `SHA256SUMS.txt` | 校验清单 | 每行 `<sha256>␠␠<文件名>`(`sha256sum -c` 可直吃) | | 全平台 | `SHA256SUMS.txt` | 校验清单 | 每行 `<sha256>␠␠<文件名>`(`sha256sum -c` 可直吃) |
@@ -23,6 +25,9 @@
- `<ver>` 不带前导 `v`(tag 是 `v0.1.0`,文件名是 `0.1.0`)——由脚本统一裁剪。 - `<ver>` 不带前导 `v`(tag 是 `v0.1.0`,文件名是 `0.1.0`)——由脚本统一裁剪。
- UNIX 归档内 `mtime`/`uid`/`gid` 归零、权限固定(二进制 0755、文本 0644), - UNIX 归档内 `mtime`/`uid`/`gid` 归零、权限固定(二进制 0755、文本 0644),
同源构建的 sha256 不受打包时刻影响。 同源构建的 sha256 不受打包时刻影响。
- **架构不靠猜**:脚本打包前回读二进制的可执行文件头(PE 机器码 / ELF `e_machine` /
Mach-O `cputype`),与槽位声明的架构不符即失败——交叉槽位(x64 宿主产 arm64 exe)不会
出现"名字是 arm64、内容还是 x64"的假产物。
## 2. 版本来源(唯一真相源) ## 2. 版本来源(唯一真相源)
@@ -47,10 +52,26 @@ python packaging/make_artifacts.py --version v0.1.0 # 顺带校验版本一致
python packaging/make_artifacts.py --no-smoke # 跳过 ull --version 冒烟 python packaging/make_artifacts.py --no-smoke # 跳过 ull --version 冒烟
``` ```
脚本按宿主自动判定槽位(Windows x64 / Linux x64 / macOS arm64 / macOS x64), 脚本按宿主自动判定槽位(Windows x64 / Windows arm64 / Linux x64 / Linux arm64 / macOS arm64 /
其它组合直接报错退出——不在矩阵里的平台不出"凑合能跑"的产物。 macOS x64),其它组合直接报错退出——不在矩阵里的平台不出"凑合能跑"的产物。
### CI(三平台矩阵) 交叉构建(例如在 x64 宿主上产 Windows arm64 产物)靠两个参数把"产物根"和"槽位"显式喂进去,
脚本不去猜宿主与产物是不是同一架构:
```bash
rustup target add aarch64-pc-windows-msvc
cargo build --release --locked --target aarch64-pc-windows-msvc \
--bin unlucky-launcher --bin ull
python packaging/make_artifacts.py --slot windows-arm64 \
--build-dir target/aarch64-pc-windows-msvc/release --out dist
```
`--slot` 与宿主槽位不一致时,`ull --version` 冒烟会**自动跳过并打印原因**(arm64 产物在 x64
宿主机上根本执行不了;拿"跳过"当通过是假证据,所以跳过永远带原因)——那条路径的验证只能落在
CI 真机上:`windows-arm64` 由 cross 构建产物 + M50 的 dumpbin 导入表门禁覆盖,`linux-arm64`
由 `ubuntu-22.04-arm` 原生 runner 上真跑冒烟覆盖。
### CI(四平台六槽位矩阵)
`.github/workflows/release.yml`: `.github/workflows/release.yml`:
@@ -59,9 +80,15 @@ python packaging/make_artifacts.py --no-smoke # 跳过 ull --version 冒
要跑 tag 得 `gh workflow run release.yml --ref v0.1.0`。分支 ref 的 dispatch 与 push 一样被 要跑 tag 得 `gh workflow run release.yml --ref v0.1.0`。分支 ref 的 dispatch 与 push 一样被
`release` job 的 `if: github.ref_type == 'tag'` 挡在发布之外,所以「dispatch 只构建不发布」 `release` job 的 `if: github.ref_type == 'tag'` 挡在发布之外,所以「dispatch 只构建不发布」
与「dispatch 能关 pre-release」是两条互不冲突的规则。 与「dispatch 能关 pre-release」是两条互不冲突的规则。
- 矩阵:`windows-latest`(MSVC + `-C target-feature=+crt-static`)、`ubuntu-22.04`(glibc 2.35 地板)、 - 矩阵(六槽位;`matrix.build_dir` 参数化产物根,脚本不再假设产物一定在 `target/release`):
`macos-latest`(arm64)、`macos-15-intel`(Intel x64;`macos-13` 已下线,`macos-26-intel` 为更新替位)。 `windows-latest` x64(MSVC + `-C target-feature=+crt-static`)、`windows-latest` **交叉编译**到
- 每个槽位 `cargo build --release --locked` → `make_artifacts.py` → 归档内容自检 → 上传 artifact。 `aarch64-pc-windows-msvc`(产物在 `target/aarch64-pc-windows-msvc/release`)、`ubuntu-22.04`
(glibc 2.35 地板)、`ubuntu-22.04-arm`(原生 ARM64 runner,**与 x64 同 2.35 地板**)、`macos-latest`(arm64)、
`macos-15-intel`(Intel x64;`macos-13` 已下线,`macos-26-intel` 为更新替位)。
- 每个槽位 `cargo build --release --locked`(交叉槽位多两步:`rustup target add` + ARM64 库体检
——查 VC 的 `lib/arm64/libcmt.lib`(`+crt-static` 靠它)与 SDK 的 `um/arm64/kernel32.lib`,
缺了直接给出"给 VS 加 `VC.Tools.ARM64` 或改用原生 `windows-11-arm`"的可执行提示)→
`make_artifacts.py --slot … --build-dir …` → 归档内容自检 → 上传 artifact。
- `release` job:下载全部 artifact → 重算一份全量 `SHA256SUMS.txt` → 用 `packaging/release-notes.md` - `release` job:下载全部 artifact → 重算一份全量 `SHA256SUMS.txt` → 用 `packaging/release-notes.md`
模板渲染支持矩阵 → `gh release create`(**pre-release 默认开**:首建时 `push` 触发按 pre-release,`workflow_dispatch` 可关)。 模板渲染支持矩阵 → `gh release create`(**pre-release 默认开**:首建时 `push` 触发按 pre-release,`workflow_dispatch` 可关)。
- **同一 tag 重跑幂等**:`gh release view` 命中已存在的 Release 时改走 `gh release upload --clobber` - **同一 tag 重跑幂等**:`gh release view` 命中已存在的 Release 时改走 `gh release upload --clobber`
@@ -91,6 +118,22 @@ Windows 为什么用 MSVC 静态 CRT 而不是本机这套 GNU 工具链:两
本机(GNU 工具链)实测导入表证据见 §7。 本机(GNU 工具链)实测导入表证据见 §7。
### Windows ARM64(Win11 on ARM)
产物形态与门禁跟 x64 一模一样(裸 exe + 静态 CRT + dumpbin 导入表硬门禁),差别只在**怎么编**:
CI 在 `windows-latest`(x64 宿主)上交叉编译到 `aarch64-pc-windows-msvc`,产物落在
`target/aarch64-pc-windows-msvc/release`(由 `matrix.build_dir` 注入,脚本不硬编码路径)。
GitHub 文档(2026)已把 `windows-11-arm` / `windows-11-vs2026-arm` 列为公开仓库**免费**的标准
arm64 runner,原生路线可行;本流水线仍选交叉编译,因为①双 Windows 槽位共用同一套已验证镜像与
工具链、②M50 的 dumpbin 定位链取的是 `Hostx64/x64/dumpbin.exe`(读 ARM64 PE 的导入表没问题),
原生 ARM 宿主上那条链得再补 `Hostarm64` 分支——不为一个槽位放宽刚立的门禁。
运行时依赖与 x64 同表(UCRT 是 Win11 ARM 的系统组件,`+crt-static` 静态链 CRT,不需要可再发行包)。
游戏侧如实说:**启动器原生运行**;Temurin 没有 windows-aarch64 产物,Microsoft OpenJDK 有该
发行版(core 的 `microsoft` 源按 `<os>-<arch>` 拼名,命名已覆盖),但 Minecraft 本体没有
Windows ARM64 原生库(Mojang/LWJGL 只发 x64/x86),实际游玩仍走系统 x64 模拟层 + x64 JDK。
所以本槽位只承诺「启动器产物」支持,不替 Java/游戏管理做架构级承诺。
### Linux x64 ### Linux x64
| 依赖 | 说明 | | 依赖 | 说明 |
@@ -105,6 +148,15 @@ Windows 为什么用 MSVC 静态 CRT 而不是本机这套 GNU 工具链:两
Debian/Ubuntu 一行:`sudo apt install libxkbcommon0 fontconfig libgl1 libegl1 libxcb1 libx11-6 libwayland-client0`。 Debian/Ubuntu 一行:`sudo apt install libxkbcommon0 fontconfig libgl1 libegl1 libxcb1 libx11-6 libwayland-client0`。
运行期**不需要** `-dev` 包、不需要 Java(Java 是启动游戏时才由启动器自己管理的东西)。 运行期**不需要** `-dev` 包、不需要 Java(Java 是启动游戏时才由启动器自己管理的东西)。
### Linux arm64(aarch64)
依赖与 x64 完全同一套(`libxkbcommon` / `fontconfig` / `libGL` / `libxcb` / `libwayland-client`),
**glibc 地板也相同**:arm64 槽位跑在原生 `ubuntu-22.04-arm` runner 上,地板 = **2.35**,与 x64
用的 `ubuntu-22.04` 一致。选 22.04 而不是 24.04 就是为了这个地板——24.04 的 glibc 更高,会把
Debian 12(2.36)、Raspberry Pi OS Bookworm(2.36)这些实际在跑的 ARM 发行版挡在门外。
要抬地板(例如将来 ARM 生态整体上移)换 `release.yml` 里那个槽位的 `os` 即可,脚本与产物名都不用动;
`ubuntu-24.04-arm` / `ubuntu-26.04-arm` 都是同一家族的标准公开仓库标签(4C/16GB、免费不限量)。
### macOS 12+ ### macOS 12+
| 依赖 | 说明 | | 依赖 | 说明 |
@@ -216,6 +268,26 @@ CI 的 MSVC `+crt-static` 产物同理(静态 CRT);`release.yml` 里用 `d
总之不允许"悄悄要用户装可再发行包"。 总之不允许"悄悄要用户装可再发行包"。
注意:本机只装了 GNU 工具链,MSVC 那个槽位的产物本身**没有在本机验证过**,由 CI 首跑验证。 注意:本机只装了 GNU 工具链,MSVC 那个槽位的产物本身**没有在本机验证过**,由 CI 首跑验证。
### 7.5 arm64 槽位的本机验证(脚本层取证,不假装跑得动 arm64 二进制)
本机没有 ARM 机器,也不具备 MSVC ARM64 链接库组合,所以只做**能真实证明的东西**(`python` 直接加载
`packaging/make_artifacts.py` 调函数 + 走 CLI 全流程,证据见下):
| 取证项 | 结果 |
|--------|------|
| 宿主判定注入 | `win32/AMD64`→`windows-x64`、`win32/ARM64`→`windows-arm64`、`linux/x86_64`→`linux-x64`、`linux/aarch64`→`linux-arm64`、`linux/arm64`→`linux-arm64`、`darwin/arm64`→`macos-arm64`、`darwin/x86_64`→`macos-x64`、`freebsd/riscv64`→exit 1 |
| 文件头架构识别 | 真 `unlucky-launcher.exe`→`x64`;合成 PE `0xAA64`→`arm64` / `0x8664`→`x64`;ELF `0xB7`→`arm64` / `0x3E`→`x64`;Mach-O `0x0100000C`→`arm64` / `0x01000007`→`x64`;垃圾字节→`None`(拒绝出包) |
| `windows-arm64` stub 全流程 | 把真 exe 的 PE 机器码改成 ARM64 当 stub:`arch: … -> arm64 (ok for windows-arm64)`、`smoke: skipped (host is windows-x64, artifact is windows-arm64)`、产物 `unlucky-launcher-0.1.0-windows-arm64.exe` 14.01 MiB,`SHA256SUMS.txt` 行名一致 |
| 架构守卫真拦 | x64 二进制冒充 `--slot windows-arm64` → exit 1:`is a x64 binary but slot windows-arm64 says arm64` |
| `linux-arm64` stub 归档 | 成员 `unlucky-launcher-0.1.0-linux-arm64/{LICENSE,NOTICE,README.md,unlucky-launcher}`,权限 0644/0755、`uid=gid=0`、mtime 0、gzip 头 mtime 字段 `00 00 00 00`;连调两次 sha256 完全一致(`f949d0f5…`)→ 可复现规则与 x64 同源 |
| `windows-x64` 原生回归 | `arch: … -> x64`、`smoke: ull.exe --version -> 0.1.0 (ok)`、产物 14.01 MiB、`sha256sum -c SHA256SUMS.txt` OK(这一次用的是当前工作树的二进制,与 §7.3 那次不是同一次构建,sha256 自然不同) |
**没在本地验证、只能由 CI 补的**:`windows-arm64` 的交叉链接与真实 ARM64 导入表、`linux-arm64` 在
原生 arm64 runner 上的真跑。本机连 `raw.githubusercontent.com` 都解析不了,所以 runner 镜像里到底装没装
ARM64 的 MSVC 库**没法在这里查证**——`release.yml` 为此加了一道链接前的体检(查 `libcmt.lib` 与
`um/arm64/kernel32.lib`,缺了直接给"加 `VC.Tools.ARM64` 组件或改原生 runner"的提示),首跑即见分晓。
merge 后由 tower 重指 tag 重跑流水线,两槽位进 `SHA256SUMS.txt` 即算闭环。
## 8. 发行合规 ## 8. 发行合规
- `LICENSE`(Apache-2.0 全文)与 `NOTICE`(含 Slint Royalty-Free 2.0 义务说明)随包分发, - `LICENSE`(Apache-2.0 全文)与 `NOTICE`(含 Slint Royalty-Free 2.0 义务说明)随包分发,
@@ -232,15 +304,22 @@ CI 的 MSVC `+crt-static` 产物同理(静态 CRT);`release.yml` 里用 `d
| 平台 | 级别 | 运行时要求 | | 平台 | 级别 | 运行时要求 |
|------|------|-----------| |------|------|-----------|
| Windows 10/11 x64 | 一等 | 无(UCRT 系统自带) | | Windows 10/11 x64 | 一等 | 无(UCRT 系统自带) |
| Windows 11 ARM64 | 一等(启动器产物) | 无额外库;启动器原生运行,游戏本体走系统 x64 模拟层 + x64 JDK |
| macOS 12+(arm64 / x64) | 一等 | 无第三方库;未签名需去 quarantine | | macOS 12+(arm64 / x64) | 一等 | 无第三方库;未签名需去 quarantine |
| Linux x64 X11 | 一等 | glibc 2.35+、xkbcommon、fontconfig、libGL、xcb | | Linux x64 X11 | 一等 | glibc 2.35+、xkbcommon、fontconfig、libGL、xcb |
| Linux x64 Wayland | 支持 | 上述 + wayland-client | | Linux x64 Wayland | 支持 | 上述 + wayland-client |
| Linux arm64 X11 | 一等 | glibc 2.35+(`ubuntu-22.04-arm` 原生构建,**与 x64 同地板**)、xkbcommon、fontconfig、libGL、xcb |
| Linux arm64 Wayland | 支持 | 上述 + wayland-client |
| Windows 8.1/8/7 | 不支持 | 基线已上调(D21) | | Windows 8.1/8/7 | 不支持 | 基线已上调(D21) |
| Android Termux + X11 | 试验级(不承诺) | 仅 CLI(源码自建),GUI 未验证,不在本发布矩阵内 | | Android Termux + X11 | 试验级(不承诺) | 仅 CLI(源码自建),GUI 未验证,不在本发布矩阵内 |
`packaging/release-notes.md` 的 "Support matrix" 表与上表逐行对应(英文版,Termux 行同样标注 `packaging/release-notes.md` 的 "Support matrix" 表与上表逐行对应(英文版,Termux 行同样标注
experimental / not part of this release matrix)——改一边必须同步另一边,否则标题里的"一致"就是假话。 experimental / not part of this release matrix)——改一边必须同步另一边,否则标题里的"一致"就是假话。
两个 arm64 槽位都是**正式发布槽位**(产物进 Release、进 `SHA256SUMS.txt`、release job 的六槽位齐全性
检查会拦下缺失),不要跟 Termux 那行混为一谈:Termux 是"源码自建的实验级旁路",Linux arm64 是
"原生 runner 编、原生跑"的一等产物。
## 10. 已知边界 / 后续 ## 10. 已知边界 / 后续
1. **GUI 无 `--version`**:`unlucky-launcher.exe` 目前只解析 `--software-renderer` / `--screenshot*` / 1. **GUI 无 `--version`**:`unlucky-launcher.exe` 目前只解析 `--software-renderer` / `--screenshot*` /
@@ -256,12 +335,40 @@ experimental / not part of this release matrix)——改一边必须同步另
6. **本机 GNU 链接器的已知无害告警**:`cargo build --release`(GNU 工具链)会打一行 6. **本机 GNU 链接器的已知无害告警**:`cargo build --release`(GNU 工具链)会打一行
`warning: linker stderr: corrupt .drectve at end of def file`,来自 MinGW `ld` 对 `.drectve` 段的 `warning: linker stderr: corrupt .drectve at end of def file`,来自 MinGW `ld` 对 `.drectve` 段的
老式抱怨,不影响产物(体积/导入表/冒烟均已实测正常);CI 的 MSVC 槽位不会出现这条。 老式抱怨,不影响产物(体积/导入表/冒烟均已实测正常);CI 的 MSVC 槽位不会出现这条。
7. **两个 arm64 槽位本机跑不了**:本机是 x64 Windows,既没有 ARM 机器也没有 MSVC 的 ARM64 链接库
组合,`windows-arm64` / `linux-arm64` 的**真实产物只能由 CI 出**:前者靠 `dumpbin -dependents`
的静态 CRT 门禁 + 架构校验,后者在 `ubuntu-22.04-arm` 上真跑 `ull --version` 冒烟。
本机能做且已做的是脚本层取证(见 §7.5):宿主判定注入(`win32/ARM64`、`linux/aarch64` 等全槽位)、
可执行文件头架构识别(真 x64 exe + 合成 PE/ELF/Mach-O 头)、以及把真 exe 的 PE 机器码改成 ARM64
当 stub 走完命名/校验/归档全流程 —— 不假装能在 x64 上执行 arm64 二进制。
## 11. 文件清单 ## 11. CI 运行时冒烟(Linux 真渲染一帧)
**细节与依据在 [`packaging/ci-runtime-smoke.md`](ci-runtime-smoke.md)**(本节再增长也往那里写,两个文件都必须 ≤400 行)。
`ci.yml` 的 `runtime-smoke` job 在 `ubuntu-latest` 上补第一份**真实图形环境**运行证据:装 `xvfb` + 软渲染
依赖 + 字体,用 `xvfb-run` 起 GUI 进程,走既有进程内截图通道渲出一帧 PNG,再对**像素本身**断言
(退出码 0 / PNG 尺寸 / 非空白三项,artifact `runtime-smoke-screenshot`)。
要点三条:
- 只看退出码不够:PNG 是进程自己编码写出的,「合法 PNG + 退出码 0」不等于画面有内容,像素断言是纵深防御;
而「完全没有字体」与「非软渲染」这两种失效都是**硬失败**(panic / 报错,不产生 PNG;review 实测),运行步就红。
- 闸门下限 ≈「至少画出一条标题栏」(review 实测切片仍 pass):证明「场景被真实渲染过」,**不证明界面元素齐全,
更不证明视觉正确**——视觉靠人眼看 artifact。
- 只覆盖 Linux x64;macOS 是 dispatch-only 探针(无实测证据),Windows 已有本机人眼验收。
**本地复跑**:`bash packaging/linux-smoke-docker.sh . D:/tmp/ull-smoke`(输出目录别写 `/tmp`,Git Bash 下会
静默落进 Docker VM;脚本内部用 `cygpath -m` 归一并回显真实路径)。
## 12. 文件清单
| 文件 | 作用 | 行数上限 | | 文件 | 作用 | 行数上限 |
|------|------|---------| |------|------|---------|
| `packaging/README.md` | 本文:矩阵/依赖/版本/合规契约 | ≤400 | | `packaging/README.md` | 本文:矩阵/依赖/版本/合规契约 | ≤400 |
| `packaging/make_artifacts.py` | 打包与校验(本地 + CI 共用) | ≤400 | | `packaging/make_artifacts.py` | 打包与校验(本地 + CI 共用) | ≤400 |
| `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 | | `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 |
| `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 |
| `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 |
| `packaging/linux-smoke-docker.sh` | 本地复跑 `runtime-smoke`(Docker + xvfb) | ≤400 |
| `.github/workflows/release.yml` | tag 触发 → 矩阵构建 → Release | ≤400 | | `.github/workflows/release.yml` | tag 触发 → 矩阵构建 → Release | ≤400 |
+317
View File
@@ -0,0 +1,317 @@
#!/usr/bin/env python3
"""截图「不是一帧空白」判定:CI 运行时冒烟的门禁脚本(零第三方依赖)。
为什么需要它:`--screenshot` 的 PNG 是进程自己编码写出的,只要流程走到 `take_snapshot` 就会得到
「合法 PNG + 退出码 0」;像素断言是**纵深防御**,守的是「进程活着、写出合法 PNG,但场景几乎没画」这一档。
本机可复现的两种失效都是硬失败(运行步就红,不必等像素断言):①完全没有字体 → fontique panic
(退出码 101,不产生 PNG);②不是软渲染(默认 femtovg)→ `take_snapshot is not supported by this
FemtoVG backend`(退出码 1)。其余「渲染器出图但内容没上去」的情形本机暂无样本,属防御性设计。
三个判据(阈值依据见 `--selftest` 与 packaging/ci-runtime-smoke.md):
- `unique_colors`:唯一色数。全白 / 全黑图 = 1;真实 UI 数百到上千。
- `luma_stddev`:亮度标准差。纯色图 = 0;真实 UI 有面板/文字/渐变的明暗层次。
- `edge_ratio`:**强边缘像素占比**——水平相邻像素对里亮度差 ≥8 的比例。纯色图 = 0;真实 UI 的文字与
边框占千分之几到百分之几。用它而不是「平均亮度差」:空数据目录的主界面 57% 是同一片底色,
平均差会被大片平面稀释(实测只有 0.4),而强边缘占比只数真正的边界,跨主题/跨字体都稳定。
只用 Python 标准库:`zlib` 解压 PNG 的 deflate 流,自己反过滤(filter 0~4)。
不引 Pillow 是因为 CI 不该为了「看一张图」多装一套图像栈(也符合项目的依赖白名单纪律)。
用法::
python packaging/check_screenshot.py shot.png # 判定,退出码 0/1
python packaging/check_screenshot.py shot.png --json # 机器可读(CI 日志用)
python packaging/check_screenshot.py --selftest # 打印阈值依据的两组数值
"""
from __future__ import annotations
import argparse
import json
import math
import struct
import sys
import tempfile
import zlib
from collections import Counter
from pathlib import Path
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
# 判定阈值。两侧数值实测(M62 本机 Docker + xvfb + Windows 历史截图):
# - 真实帧:唯一色 342~2352、亮度σ 19.45~27.46、强边缘占比 0.0076~0.0433(最弱是 Linux 空数据主界面);
# - 人为纯色帧:1 / 0.0 / 0.0。
# 阈值取在两者之间、贴近真实下限的 1/5~1/8:换主题/换字体/换窗口尺寸都不会误报,空白帧一定红。
DEFAULT_MIN_UNIQUE = 64
DEFAULT_MIN_STDDEV = 3.0
DEFAULT_MIN_EDGE_RATIO = 0.001
# 「强边缘」的亮度差门槛:≥8/255 肉眼即视为一条边界;抗锯齿造成的 1~2 级抖动不算。
EDGE_LUMA_DELTA = 8
# PNG 颜色类型 → 每像素通道数(调色板 3 走索引,需要 PLTE,本脚本不解)
CHANNELS_BY_COLOR_TYPE = {0: 1, 2: 3, 4: 2, 6: 4}
class PngError(Exception):
"""PNG 结构不受支持或损坏。"""
def _iter_chunks(data: bytes):
"""按 PNG 规范切块:长度(4) + 类型(4) + 数据 + CRC(4)。"""
offset = len(PNG_SIGNATURE)
while offset + 12 <= len(data):
(length,) = struct.unpack(">I", data[offset : offset + 4])
yield data[offset + 4 : offset + 8], data[offset + 8 : offset + 8 + length]
offset += 12 + length
def _unfilter(kind: int, line: bytearray, prev: bytearray, bpp: int) -> None:
"""就地把一条扫描线反过滤(PNG 规范 §9.2 的五个过滤类型)。"""
if kind == 0:
return
if kind == 1: # Sub:加上左边
for index in range(bpp, len(line)):
line[index] = (line[index] + line[index - bpp]) & 0xFF
elif kind == 2: # Up:加上正上方
for index in range(len(line)):
line[index] = (line[index] + prev[index]) & 0xFF
elif kind == 3: # Average:加上 (左 + 上) // 2
for index in range(len(line)):
left = line[index - bpp] if index >= bpp else 0
line[index] = (line[index] + ((left + prev[index]) >> 1)) & 0xFF
elif kind == 4: # Paeth:加上 Paeth 预测子
for index in range(len(line)):
left = line[index - bpp] if index >= bpp else 0
up = prev[index]
upper_left = prev[index - bpp] if index >= bpp else 0
estimate = left + up - upper_left
dist_left = abs(estimate - left)
dist_up = abs(estimate - up)
dist_corner = abs(estimate - upper_left)
if dist_left <= dist_up and dist_left <= dist_corner:
predictor = left
elif dist_up <= dist_corner:
predictor = up
else:
predictor = upper_left
line[index] = (line[index] + predictor) & 0xFF
else:
raise PngError(f"未知的行过滤类型 {kind}")
def decode_png(path: Path) -> tuple[int, int, list[bytearray], int]:
"""读 8bit 非隔行 PNG,返回 (宽, 高, 每行原始字节, 通道数)。"""
data = path.read_bytes()
if not data.startswith(PNG_SIGNATURE):
raise PngError(f"{path} 不是 PNG(签名不符,可能是 0 字节文件或半截写入)")
width = height = depth = color_type = interlace = None
compressed = bytearray()
for kind, body in _iter_chunks(data):
if kind == b"IHDR":
width, height, depth, color_type, _, _, interlace = struct.unpack(">IIBBBBB", body)
elif kind == b"IDAT":
compressed.extend(body)
elif kind == b"IEND":
break
if width is None or height is None:
raise PngError("缺 IHDR 块")
if interlace:
raise PngError("不支持隔行(Adam7)PNG")
if depth != 8:
raise PngError(f"只支持 8bit PNG,实际 {depth}bit")
channels = CHANNELS_BY_COLOR_TYPE.get(color_type)
if channels is None:
raise PngError(f"不支持的颜色类型 {color_type}(调色板 PNG 请先转成 RGB/RGBA)")
raw = zlib.decompress(bytes(compressed))
stride = width * channels
if len(raw) < (stride + 1) * height:
raise PngError(f"像素数据不足:期望 {(stride + 1) * height} 字节,实际 {len(raw)}")
rows: list[bytearray] = []
previous = bytearray(stride)
offset = 0
for _ in range(height):
kind = raw[offset]
offset += 1
line = bytearray(raw[offset : offset + stride])
offset += stride
_unfilter(kind, line, previous, channels)
rows.append(line)
previous = line
return width, height, rows, channels
def measure(width: int, height: int, rows: list[bytearray], channels: int) -> dict:
"""统计「这帧图里有没有信息」的三项指标。"""
colors: Counter[int] = Counter()
luma_sum = 0
luma_square_sum = 0
strong_edges = 0
pair_count = 0
for row in rows:
previous_luma = None
for x in range(width):
base = x * channels
if channels >= 3:
red, green, blue = row[base], row[base + 1], row[base + 2]
else:
red = green = blue = row[base]
colors[(red << 16) | (green << 8) | blue] += 1
# 整数亮度(ITU-R BT.601 系数),避免浮点误差影响阈值比较
luma = (red * 299 + green * 587 + blue * 114) // 1000
luma_sum += luma
luma_square_sum += luma * luma
if previous_luma is not None:
pair_count += 1
if abs(luma - previous_luma) >= EDGE_LUMA_DELTA:
strong_edges += 1
previous_luma = luma
total = width * height
mean = luma_sum / total if total else 0.0
variance = max(luma_square_sum / total - mean * mean, 0.0) if total else 0.0
top_color, top_count = colors.most_common(1)[0] if colors else (0, 0)
return {
"width": width,
"height": height,
"pixels": total,
"unique_colors": len(colors),
"top_color": f"#{top_color:06x}",
"top_color_share": round(top_count / total, 4) if total else 0.0,
"luma_mean": round(mean, 2),
"luma_stddev": round(math.sqrt(variance), 2),
"edge_ratio": round(strong_edges / pair_count, 5) if pair_count else 0.0,
}
def verdict(
stats: dict, min_unique: int, min_stddev: float, min_edge_ratio: float
) -> list[str]:
"""把指标折成「失败原因清单」;空清单 = 通过。"""
problems = []
if stats["pixels"] <= 0:
problems.append("图像尺寸为 0(宽或高是 0)")
return problems
if stats["unique_colors"] < min_unique:
problems.append(
f"唯一色数 {stats['unique_colors']} < 阈值 {min_unique}(像纯色/空白帧)"
)
if stats["luma_stddev"] < min_stddev:
problems.append(
f"亮度标准差 {stats['luma_stddev']} < 阈值 {min_stddev}(整帧明暗无层次)"
)
if stats["edge_ratio"] < min_edge_ratio:
problems.append(
f"强边缘占比 {stats['edge_ratio']} < 阈值 {min_edge_ratio}(没有边界/文字的迹象)"
)
return problems
def encode_png(width: int, height: int, rgb_row) -> bytes:
"""最小 PNG 编码器(8bit RGB、filter 0、zlib 压缩)——只给 `--selftest` 造样图。"""
raw = bytearray()
for y in range(height):
raw.append(0)
raw.extend(rgb_row(y))
out = bytearray(PNG_SIGNATURE)
for kind, body in (
(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)),
(b"IDAT", zlib.compress(bytes(raw), 6)),
(b"IEND", b""),
):
out.extend(struct.pack(">I", len(body)))
out.extend(kind)
out.extend(body)
out.extend(struct.pack(">I", zlib.crc32(kind + body) & 0xFFFFFFFF))
return bytes(out)
def selftest() -> int:
"""造两张对照样图跑一遍,打印阈值依据的两组数值。
- 全白 / 全黑:模拟「窗口起来了但一个像素都没画」的空白帧;
- 一像素黑边框 + 中部一条横线:最弱的「有内容」下限(远弱于真实 UI)。
"""
width, height = 1180, 760
cases = {
"flat-white": lambda y: b"\xff" * (width * 3),
"flat-black": lambda y: b"\x00" * (width * 3),
"border-line": lambda y: b"".join(
b"\x00\x00\x00"
if (y in (0, height - 1) or x in (0, width - 1) or y == height // 2)
else b"\xff\xff\xff"
for x in range(width)
),
}
with tempfile.TemporaryDirectory() as tmp:
for name, describe in cases.items():
path = Path(tmp) / f"{name}.png"
path.write_bytes(encode_png(width, height, describe))
stats = measure(*decode_png(path))
failed = verdict(
stats, DEFAULT_MIN_UNIQUE, DEFAULT_MIN_STDDEV, DEFAULT_MIN_EDGE_RATIO
)
print(
json.dumps(
{
"case": name,
"unique_colors": stats["unique_colors"],
"luma_stddev": stats["luma_stddev"],
"edge_ratio": stats["edge_ratio"],
"verdict": "fail" if failed else "pass",
},
ensure_ascii=False,
)
)
return 0
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description="截图非空白判定(CI 运行时冒烟门禁)")
parser.add_argument("png", nargs="?", type=Path, help="待判定的 PNG 路径")
parser.add_argument("--json", action="store_true", help="以 JSON 输出统计结果")
parser.add_argument("--min-unique", type=int, default=DEFAULT_MIN_UNIQUE, help="唯一色数下限")
parser.add_argument("--min-stddev", type=float, default=DEFAULT_MIN_STDDEV, help="亮度标准差下限")
parser.add_argument(
"--min-edge-ratio", type=float, default=DEFAULT_MIN_EDGE_RATIO, help="强边缘占比下限"
)
parser.add_argument("--selftest", action="store_true", help="打印阈值依据的对照数值后退出")
args = parser.parse_args(argv)
if args.selftest:
return selftest()
if args.png is None:
parser.error("需要给出 PNG 路径(或 --selftest)")
try:
stats = measure(*decode_png(args.png))
except (PngError, OSError, zlib.error) as error:
print(f"[fail] 读不出截图 {args.png}: {error}", file=sys.stderr)
return 1
problems = verdict(stats, args.min_unique, args.min_stddev, args.min_edge_ratio)
if args.json:
print(json.dumps({**stats, "problems": problems}, ensure_ascii=False))
else:
print(
f"{args.png}: {stats['width']}x{stats['height']} "
f"唯一色 {stats['unique_colors']} 亮度σ {stats['luma_stddev']} "
f"强边缘 {stats['edge_ratio']} 主色 {stats['top_color']}"
f"({stats['top_color_share'] * 100:.1f}%)"
)
if problems:
for problem in problems:
print(f"[fail] {problem}", file=sys.stderr)
return 1
print("[ok] 截图非空白")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+78
View File
@@ -0,0 +1,78 @@
# CI 运行时冒烟(Linux 真渲染一帧)
`packaging/README.md` §11 是本节的索引;细节在这里。为什么拆:本节要写阈值依据、失效模式与复跑口径,
塞回 README 会撞 400 行硬门禁——**两文件都必须 ≤400 行**,任一边再增长就再拆一节。
「编得过」≠「跑得起来」。`ci.yml` 的 `runtime-smoke` job 在 `ubuntu-latest` 上补第一份**真实图形环境**
运行证据:装 `xvfb`(虚拟 X 服务器)+ 软渲染依赖 + 字体,用 `xvfb-run` 把 GUI 进程拉起来,走既有的
进程内截图通道(`--software-renderer --screenshot <png> --data-dir <tmp>`)渲出一帧 PNG,再对**像素本身**断言。
## 1. 三道断言
`packaging/check_screenshot.py` 退出码 0 才算过;artifact 名 `runtime-smoke-screenshot`。
1. 进程退出码 0(截图通道失败会以非 0 退出,不留半张图);
2. PNG 存在、可解析、宽高 > 0(0 字节 / 非 PNG / 路径不存在都是硬失败,脚本 exit 1);
3. **非空白**:唯一色数 / 亮度标准差 / 强边缘占比三项同时过阈值。
为什么不能只看退出码:`--screenshot` 的 PNG 是进程自己编码写出的,只要流程走到 `take_snapshot` 就会得到
「合法 PNG + 退出码 0」;像素断言是**纵深防御**,守的是「进程活着、写出合法 PNG,但场景几乎没画」这一档。
本机可复现的两种失效都是硬失败(运行步就红,不必等像素断言):①完全没有字体 → fontique panic
(退出码 101,不产生 PNG);②不是软渲染(默认 femtovg)→ `take_snapshot is not supported by this
FemtoVG backend`(退出码 1)。这两条由 M62 review 实测复现(本 mission 未自己跑这两种破坏性实验)。
其余「渲染器出图但内容没上去」的情形本机暂无样本,属防御性设计,不宣称已复现。
## 2. 阈值与依据
`python packaging/check_screenshot.py --selftest` 可复现后三行:
| 样本 | 唯一色数 | 亮度σ | 强边缘占比 | 判定 |
|------|---------|-------|-----------|------|
| 本机 Docker 真截图(xvfb + 软渲染,1180x760,空数据目录) | 342 | 19.45 | 0.00761 | pass |
| Windows 历史截图(M45/M52,同一界面深浅两套,1180x760) | 529~699 | 20.29~27.46 | 0.0186~0.0433 | pass |
| 人为纯白 / 纯黑(空白帧) | 1 | 0.0 | 0.0 | fail |
| 最弱内容(1 像素黑边框 + 一条中线) | 2 | 19.09 | 0.00169 | fail(卡唯一色数) |
| **阈值** | ≥64 | ≥3.0 | ≥0.001 | — |
阈值留 **5~8 倍**余量(342/64 = 5.3×、19.45/3.0 = 6.5×、0.00761/0.001 = 7.6×):换主题/换字体不会误报,
空白帧一定红。
第三项用**强边缘占比**(水平相邻像素亮度差 ≥8 的比例)而不是平均亮度差:空数据目录的主界面 57% 是同一片
底色,平均差被稀释到 0.4,强边缘只数真正的边界,跨主题稳定(同一界面深浅两套差 2 倍,仍都过线)。
**这道闸门的下限 ≈「至少画出一条标题栏」**(review 实测切片):只画 1180×40 标题栏的切片(174 / 35.10 /
0.02623)仍 pass,右下 400×300 的切片也 pass。所以它证明的是「场景被真实渲染过」,**不证明界面元素齐全、
更不证明视觉正确**;视觉正确只能靠人眼看 artifact。
## 3. 覆盖哪个平台
只有 Linux x64(X11 + Xvfb + 软渲染)。
- **macOS**:`runtime-smoke-macos` 只在 `workflow_dispatch` 触发且 `continue-on-error: true`。官方 macOS
runner 处于 LaunchDaemon 上下文、无登录 WindowServer 会话,winit 大概率建不了窗口;本机没有 macOS
且 worker 禁 push,这条路径**尚无实测证据**,故不拿它卡 PR 门禁。首跑定去留:绿 → 删 `if` 与
`continue-on-error` 升常驻;红 → 错误原文进 progress,按 D26 归入 iced 重写再规划。
- **Windows**:不新增 job——`--software-renderer --screenshot` 已在本机验收里多次人眼验证(M45/M52)。
## 4. 本地复跑
```bash
bash packaging/linux-smoke-docker.sh . D:/tmp/ull-smoke
```
用 Docker(`rust:1-bookworm` + TUNA apt/rustup 源)复刻同一套步骤,截图落在 `<输出目录>/runtime-smoke.png`。
**输出目录别写 `/tmp/...`**:Git Bash 里 `/tmp` 是 MSYS 虚拟路径,`docker -v` 会把它当 Linux VM 内的路径,
结果是「脚本说成功、宿主机什么都没有」。脚本对 `out_dir`/`repo_root` 统一做 `cygpath -m` 归一,并回显真实
路径;没有 `cygpath` 的平台(Linux/macOS)按原样用。
诚实边界:本机容器是 Debian 12(glibc 2.36),官方 runner 是 Ubuntu 24.04(glibc 2.39),图形栈来源不同,
容器绿只证明「这条通道能跑」,官方 runner 首跑仍要单独看。
## 5. 已知跳过项与迁移风险
**跳过项**(D26 收窄,本条只做「框架无关的最小运行时冒烟」):Wayland(`weston --headless`)、
`--screenshot-page` 子页覆盖、gsettings 主题探测回退都不在本 job 内,随 iced 重写另行规划。
**迁移风险**:本 job 绑死 Slint 的截图通道(`Window::take_snapshot`)。**UI 迁 iced 后必须重新验证**——
iced 没有等价快照 API,届时要提供「离屏渲染出一帧 PNG」的等价通道,否则本 job 会静默退化成「能起进程」
而不是「能出图」(它本来也只证明「能出图」,不证明视觉正确)。
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# 本地复跑 `.github/workflows/ci.yml` 的 `runtime-smoke` job:Linux + Xvfb + 软渲染截图。
#
# 为什么要有这条脚本:CI 的 runtime-smoke 在 GitHub 的 ubuntu-latest 上跑,
# 但「改动这个 job 的人」需要在没有 Linux 桌面的开发机上先自证一遍——否则改的就是没人验过的 YAML。
# 本脚本在 Docker 里复刻 job 的步骤(同样的 apt 依赖、同样的构建、同样的 xvfb-run 命令),
# 并把截图与判定结果留在宿主目录,供人眼与 `check_screenshot.py` 复核。
#
# 用法:
# bash packaging/linux-smoke-docker.sh [仓库根目录] [输出目录]
# ULL_SMOKE_SKIP_BUILD=1 bash packaging/linux-smoke-docker.sh # 复用缓存的二进制
#
# 说明(诚实边界):
# - 基础镜像 `rust:1-bookworm` 是 **Debian 12(glibc 2.36)**,官方 runner 是 **Ubuntu 24.04(glibc 2.39)**。
# 两者图形栈(X11/xvfb/Mesa 软渲染)来源不同,因此本脚本证明的是「Linux 上这条通道能真跑出图」,
# 不能代替官方 runner 的首跑验证。
# - 国内网络慢时可换 TUNA 镜像:脚本默认就用 TUNA 的 Debian 源(`ULL_APT_MIRROR` 可覆盖)。
set -euo pipefail
repo_root=$(cd "${1:-.}" && pwd)
out_dir=${2:-ull-smoke}
image=${ULL_SMOKE_IMAGE:-ull-linux-smoke:bookworm}
cargo_volume=${ULL_CARGO_VOLUME:-ull-cargo-reg}
target_volume=${ULL_CARGO_TARGET_VOLUME:-ull-linux-target}
apt_mirror=${ULL_APT_MIRROR:-https://mirrors.tuna.tsinghua.edu.cn}
rustup_mirror=${ULL_RUSTUP_MIRROR:-https://mirrors.tuna.tsinghua.edu.cn/rustup}
mkdir -p "$out_dir"
out_dir=$(cd "$out_dir" && pwd)
# Git Bash 里 /tmp、/d/x 都是 MSYS 虚拟路径:直接喂给 `docker -v` 会被当成 Linux VM 内的路径,
# 结果「脚本说成功、宿主机什么都没有」。有 cygpath 就统一转成 Windows 混合路径(D:/x)并回显真实位置。
if command -v cygpath >/dev/null 2>&1; then
repo_root=$(cygpath -m "$repo_root")
out_dir=$(cygpath -m "$out_dir")
fi
echo "== 仓库: $repo_root"
echo "== 输出: $out_dir(截图 $out_dir/runtime-smoke.png)"
# 1) 构建镜像:rust 工具链 + xvfb + 图形栈编译依赖 + 字体(软渲染器要从系统字体库取字形)
if ! docker image inspect "$image" >/dev/null 2>&1 || [ "${ULL_SMOKE_REBUILD_IMAGE:-0}" = "1" ]; then
context=$(mktemp -d)
trap 'rm -rf "$context"' EXIT
cat >"$context/Dockerfile" <<EOF
FROM rust:1-bookworm
# 工具链组件(rustfmt/clippy)在 rust-toolchain.toml 里是必备项,rustup 会去补齐;
# 默认 dist 服务器在国内会挂成「rustc 0.6% CPU 睡 12 分钟」的假死(M62 实测),所以走 TUNA。
ENV RUSTUP_DIST_SERVER=$rustup_mirror \\
RUSTUP_UPDATE_ROOT=$rustup_mirror/rustup
RUN sed -i 's|http://deb.debian.org|$apt_mirror|g' /etc/apt/sources.list.d/debian.sources \\
&& apt-get update \\
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \\
xvfb xauth python3 \\
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 \\
&& rm -rf /var/lib/apt/lists/*
RUN rustup component add rustfmt clippy && rustup toolchain list
WORKDIR /src
EOF
docker build -t "$image" "$context"
fi
# 2) 跑容器:构建 → xvfb 截图 → 像素判定
# 注意缓存卷要挂到 `registry` 子目录:直接挂 /usr/local/cargo 会把镜像里的 `bin/`(cargo 本体)盖掉,
# 变成 `cargo: command not found`(M62 实测踩到)。
MSYS_NO_PATHCONV=1 docker run --rm \
-v "$repo_root:/src" \
-v "$cargo_volume:/usr/local/cargo/registry" \
-v "$target_volume:/target" \
-v "$out_dir:/out" \
-w /src \
-e CARGO_TARGET_DIR=/target \
-e ULL_SMOKE_SKIP_BUILD="${ULL_SMOKE_SKIP_BUILD:-0}" \
-e CARGO_TERM_COLOR=never \
"$image" bash -euo pipefail -c '
rustc --version && cargo --version && cat /etc/debian_version
if [ "$ULL_SMOKE_SKIP_BUILD" != "1" ]; then
# 共享热缓存卷可能喂陈旧产物(踩坑记录 N7):先清掉受检 crate 再编
# 清不掉就算了(N7 兜底用);CI 里**没有**对应两行,别误抄进 ci.yml —— 那边每次都是干净 runner
cargo clean -p unlucky-app || true
cargo build --bin unlucky-launcher --locked -j 6
fi
mkdir -p /out/data
set +e
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
/target/debug/unlucky-launcher --software-renderer \
--screenshot /out/runtime-smoke.png --data-dir /out/data
code=$?
set -e
echo "exit=$code"
python3 packaging/check_screenshot.py /out/runtime-smoke.png
exit $code
'
echo "== 截图与数据目录已留在 $out_dir"
+106 -31
View File
@@ -3,11 +3,13 @@
Release matrix (see `packaging/README.md` for the full contract): Release matrix (see `packaging/README.md` for the full contract):
Windows x64 unlucky-launcher-<ver>-windows-x64.exe (bare exe, no zip) Windows x64 unlucky-launcher-<ver>-windows-x64.exe (bare exe, no zip)
Linux x64 unlucky-launcher-<ver>-linux-x64.tar.gz Windows arm64 unlucky-launcher-<ver>-windows-arm64.exe (bare exe, cross-built)
macOS arm64 unlucky-launcher-<ver>-macos-arm64.tar.gz Linux x64 unlucky-launcher-<ver>-linux-x64.tar.gz
macOS x64 unlucky-launcher-<ver>-macos-x64.tar.gz Linux arm64 unlucky-launcher-<ver>-linux-arm64.tar.gz
all SHA256SUMS.txt macOS arm64 unlucky-launcher-<ver>-macos-arm64.tar.gz
macOS x64 unlucky-launcher-<ver>-macos-x64.tar.gz
all SHA256SUMS.txt
`--version` is taken from the cargo workspace so a tag can never drift away from `--version` is taken from the cargo workspace so a tag can never drift away from
`Cargo.toml`; pass `--version` to cross-check a tag against it (mismatch is fatal). `Cargo.toml`; pass `--version` to cross-check a tag against it (mismatch is fatal).
@@ -15,9 +17,17 @@ The archives carry the launcher binary plus LICENSE / NOTICE / README.md, which
what the license obligations ask for; Windows ships the bare `.exe` (the single-file what the license obligations ask for; Windows ships the bare `.exe` (the single-file
selling point) and the same three documents are attached to the Release next to it. selling point) and the same three documents are attached to the Release next to it.
Slot and build directory are overridable because a cross build's binaries live in
`target/<triple>/release` and the build host (x64) is not the artifact (arm64): CI
passes both from its matrix, and the executable header is read back so a binary can
never be shipped under a slot name it was not built for.
Usage: Usage:
cargo build --release --locked cargo build --release --locked
python3 packaging/make_artifacts.py [--version v0.1.0] [--out dist] [--no-smoke] python3 packaging/make_artifacts.py [--version v0.1.0] [--out dist] [--no-smoke]
# cross build (arm64 exe from an x64 host):
python3 packaging/make_artifacts.py --slot windows-arm64 \\
--build-dir target/aarch64-pc-windows-msvc/release
""" """
from __future__ import annotations from __future__ import annotations
@@ -26,7 +36,6 @@ import argparse
import gzip import gzip
import hashlib import hashlib
import json import json
import os
import platform import platform
import shutil import shutil
import subprocess import subprocess
@@ -40,9 +49,32 @@ CLI_BIN = "ull"
# 随包分发的合规文本(仓库根目录);英文主版 README 是对外分发口径。 # 随包分发的合规文本(仓库根目录);英文主版 README 是对外分发口径。
COMPLIANCE_DOCS = ("LICENSE", "NOTICE", "README.md") COMPLIANCE_DOCS = ("LICENSE", "NOTICE", "README.md")
SUMS_NAME = "SHA256SUMS.txt" SUMS_NAME = "SHA256SUMS.txt"
DEFAULT_BUILD_DIR = "target/release"
# 发布矩阵:槽位 -> (OS 家族, 架构, 产物形态)。键名即产物文件名里的槽位段,
# 单一真相源是 packaging/README.md §1;未知槽位一律拒绝,不出"凑合能跑"的产物。
SLOTS = {
"windows-x64": ("windows", "x64", "exe"),
"windows-arm64": ("windows", "arm64", "exe"),
"linux-x64": ("linux", "x64", "tar"),
"linux-arm64": ("linux", "arm64", "tar"),
"macos-arm64": ("macos", "arm64", "tar"),
"macos-x64": ("macos", "x64", "tar"),
}
# `sys.platform` / `platform.machine()` 的别名归一(aarch64 与 arm64 是同一架构的两种叫法)。
OS_FAMILIES = (("win", "windows"), ("linux", "linux"), ("darwin", "macos"))
MACHINE_ALIASES = {"amd64": "x64", "x86_64": "x64", "aarch64": "arm64", "arm64": "arm64"}
# 可执行文件头里的机器码 -> 架构(PE / ELF / Mach-O)。
IMAGE_ARCH = {
("pe", 0x8664): "x64",
("pe", 0xAA64): "arm64",
("elf", 0x3E): "x64",
("elf", 0xB7): "arm64",
("macho", 0x01000007): "x64",
("macho", 0x0100000C): "arm64",
}
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
TARGET_DIR = REPO_ROOT / "target" / "release"
def fail(message: str) -> NoReturn: def fail(message: str) -> NoReturn:
@@ -72,28 +104,59 @@ def workspace_version() -> str:
return versions.pop() return versions.pop()
def platform_tag() -> str: def host_slot(os_name: str | None = None, machine: str | None = None) -> str:
"""Map the build host to the artifact matrix slot (hard-fail on unsupported ones).""" """Map the build host to a matrix slot.
os_name = sys.platform
machine = platform.machine().lower() Both arguments are injectable so the mapping can be exercised for a host we do not
if os_name.startswith("win") and machine in ("amd64", "x86_64"): own (no ARM machine here); they default to the real ones.
return "windows-x64" """
if os_name.startswith("linux") and machine in ("x86_64", "amd64"): os_name = (sys.platform if os_name is None else os_name).lower()
return "linux-x64" raw_machine = platform.machine() if machine is None else machine
if os_name == "darwin" and machine in ("arm64", "aarch64"): arch = MACHINE_ALIASES.get(raw_machine.lower())
return "macos-arm64" family = next((fam for prefix, fam in OS_FAMILIES if os_name.startswith(prefix)), None)
if os_name == "darwin" and machine in ("x86_64", "amd64"): for slot, (slot_os, slot_arch, _) in SLOTS.items():
return "macos-x64" if (slot_os, slot_arch) == (family, arch):
fail(f"unsupported build host {os_name}/{machine}; matrix is windows-x64 / linux-x64 / macos-arm64 / macos-x64") return slot
# 报错带**原始**取值而不是归一后的 None:`riscv64` 这类不认识的架构得在诊断里看得见。
fail(f"unsupported build host {os_name}/{raw_machine}; matrix is {' / '.join(SLOTS)}")
def binary(name: str) -> Path: def binary(name: str, build_dir: Path, family: str) -> Path:
path = TARGET_DIR / (f"{name}.exe" if sys.platform.startswith("win") else name) path = build_dir / (f"{name}.exe" if family == "windows" else name)
if not path.is_file(): if not path.is_file():
fail(f"missing {path}; run `cargo build --release --locked` first") fail(f"missing {path}; run `cargo build --release --locked` first")
return path return path
def image_arch(path: Path) -> str | None:
"""Architecture stamped into the executable header, or `None` if unreadable."""
with path.open("rb") as handle:
head = handle.read(0x40)
if head[:4] == b"\x7fELF":
order = "little" if head[5] == 1 else "big"
return IMAGE_ARCH.get(("elf", int.from_bytes(head[18:20], order)))
if head[:2] == b"MZ":
handle.seek(int.from_bytes(head[0x3C:0x40], "little"))
pe = handle.read(6)
if pe[:4] == b"PE\0\0":
return IMAGE_ARCH.get(("pe", int.from_bytes(pe[4:6], "little")))
return None
if head[:4] == b"\xcf\xfa\xed\xfe": # thin 64-bit little-endian Mach-O
return IMAGE_ARCH.get(("macho", int.from_bytes(head[4:8], "little")))
return None
def verify_arch(path: Path, slot: str) -> None:
"""Prove the binary really is built for the slot it is about to be named after."""
want = SLOTS[slot][1]
got = image_arch(path)
if got is None:
fail(f"cannot read the machine type of {path}; refusing to ship an unverified binary")
if got != want:
fail(f"{path} is a {got} binary but slot {slot} says {want}")
print(f"arch: {path.name} -> {got} (ok for {slot})")
def smoke_version(cli: Path, version: str) -> None: def smoke_version(cli: Path, version: str) -> None:
"""`ull --version` must report the workspace version (guards tag/version drift).""" """`ull --version` must report the workspace version (guards tag/version drift)."""
proc = subprocess.run( proc = subprocess.run(
@@ -108,7 +171,7 @@ def smoke_version(cli: Path, version: str) -> None:
fail(f"`{cli.name} --version` exited {proc.returncode}: {out.strip()}") fail(f"`{cli.name} --version` exited {proc.returncode}: {out.strip()}")
if version not in out: if version not in out:
fail(f"`{cli.name} --version` does not report {version}: {out.strip()}") fail(f"`{cli.name} --version` does not report {version}: {out.strip()}")
print(f"smoke: {cli.name} --version -> {version} (ok)") print(f"smoke: {cli.name} --version -> {version} (ok)")
def sha256(path: Path) -> str: def sha256(path: Path) -> str:
@@ -160,6 +223,8 @@ def main() -> int:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", help="expected version (a leading 'v' is stripped); cross-checks Cargo.toml") parser.add_argument("--version", help="expected version (a leading 'v' is stripped); cross-checks Cargo.toml")
parser.add_argument("--out", default="dist", help="output directory (default: dist/)") parser.add_argument("--out", default="dist", help="output directory (default: dist/)")
parser.add_argument("--slot", choices=list(SLOTS), help="matrix slot; defaults to the one derived from the host")
parser.add_argument("--build-dir", default=DEFAULT_BUILD_DIR, help=f"directory holding the built binaries (default: {DEFAULT_BUILD_DIR})")
parser.add_argument("--no-smoke", action="store_true", help="skip the `ull --version` smoke run") parser.add_argument("--no-smoke", action="store_true", help="skip the `ull --version` smoke run")
args = parser.parse_args() args = parser.parse_args()
@@ -168,22 +233,32 @@ def main() -> int:
expected = args.version[1:] if args.version.startswith("v") else args.version expected = args.version[1:] if args.version.startswith("v") else args.version
if expected != version: if expected != version:
fail(f"tag/version mismatch: requested {expected}, Cargo.toml says {version}") fail(f"tag/version mismatch: requested {expected}, Cargo.toml says {version}")
tag = platform_tag() native = host_slot()
launcher = binary(LAUNCHER_BIN) slot = args.slot or native
cli = binary(CLI_BIN) family, _, kind = SLOTS[slot]
if not args.no_smoke: build_dir = Path(args.build_dir)
smoke_version(cli, version) if not build_dir.is_absolute():
build_dir = REPO_ROOT / build_dir
launcher = binary(LAUNCHER_BIN, build_dir, family)
verify_arch(launcher, slot)
# 跨槽位产物(宿主 x64 / 目标 arm64)在本机根本执行不了,冒烟只能等 CI 真机;
# 拿"跳过了"当通过是假证据,所以把跳过原因原样打出来。
if args.no_smoke or slot != native:
reason = "--no-smoke" if args.no_smoke else f"host is {native}, artifact is {slot}"
print(f"smoke: skipped ({reason})")
else:
smoke_version(binary(CLI_BIN, build_dir, family), version)
out_dir = Path(args.out) out_dir = Path(args.out)
if not out_dir.is_absolute(): if not out_dir.is_absolute():
out_dir = REPO_ROOT / out_dir out_dir = REPO_ROOT / out_dir
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
if tag == "windows-x64": if kind == "exe":
asset = out_dir / f"unlucky-launcher-{version}-{tag}.exe" asset = out_dir / f"unlucky-launcher-{version}-{slot}.exe"
shutil.copy2(launcher, asset) shutil.copy2(launcher, asset)
else: else:
asset = stage_archive(out_dir, tag, version, launcher) asset = stage_archive(out_dir, slot, version, launcher)
sums = write_sums(out_dir, [asset]) sums = write_sums(out_dir, [asset])
size_mb = asset.stat().st_size / (1024 * 1024) size_mb = asset.stat().st_size / (1024 * 1024)
+13 -3
View File
@@ -9,7 +9,9 @@ No WebView, no bundled runtime, no JRE download needed to start the launcher its
| Platform | Asset | Notes | | Platform | Asset | Notes |
| --- | --- | --- | | --- | --- | --- |
| Windows 10/11 x64 | `unlucky-launcher-{{VERSION}}-windows-x64.exe` | single exe, double-click to run | | Windows 10/11 x64 | `unlucky-launcher-{{VERSION}}-windows-x64.exe` | single exe, double-click to run |
| Windows 11 ARM64 | `unlucky-launcher-{{VERSION}}-windows-arm64.exe` | single exe, runs natively on ARM64 Windows |
| Linux x64 (X11/Wayland) | `unlucky-launcher-{{VERSION}}-linux-x64.tar.gz` | single binary inside, no installer | | Linux x64 (X11/Wayland) | `unlucky-launcher-{{VERSION}}-linux-x64.tar.gz` | single binary inside, no installer |
| Linux arm64 (X11/Wayland) | `unlucky-launcher-{{VERSION}}-linux-arm64.tar.gz` | single binary inside; needs glibc 2.35+ |
| macOS 12+ Apple Silicon | `unlucky-launcher-{{VERSION}}-macos-arm64.tar.gz` | single binary inside, no installer | | macOS 12+ Apple Silicon | `unlucky-launcher-{{VERSION}}-macos-arm64.tar.gz` | single binary inside, no installer |
| macOS 12+ Intel | `unlucky-launcher-{{VERSION}}-macos-x64.tar.gz` | single binary inside, no installer | | macOS 12+ Intel | `unlucky-launcher-{{VERSION}}-macos-x64.tar.gz` | single binary inside, no installer |
@@ -22,22 +24,30 @@ with the download on every platform.
| Platform | Level | Runtime requirements | | Platform | Level | Runtime requirements |
| --- | --- | --- | | --- | --- | --- |
| Windows 10/11 x64 | first class | nothing beyond the OS (UCRT is part of Win10+) | | Windows 10/11 x64 | first class | nothing beyond the OS (UCRT is part of Win10+) |
| Windows 11 ARM64 | first class (launcher artifact) | nothing extra; launcher runs natively, the game itself runs through the OS x64 emulation layer with an x64 JDK |
| macOS 12+ (arm64 / x64) | first class | nothing; unsigned build, see Gatekeeper note | | macOS 12+ (arm64 / x64) | first class | nothing; unsigned build, see Gatekeeper note |
| Linux x64, X11 | first class | `libxkbcommon`, `fontconfig`, `libGL` (or software renderer), `libxcb` | | Linux x64, X11 | first class | `libxkbcommon`, `fontconfig`, `libGL` (or software renderer), `libxcb` |
| Linux x64, Wayland | supported | `wayland-client`, `libxkbcommon`, plus the X11 set for XWayland apps | | Linux x64, Wayland | supported | `wayland-client`, `libxkbcommon`, plus the X11 set for XWayland apps |
| Linux arm64, X11 | first class | glibc 2.35+ (built on `ubuntu-22.04-arm`, same floor as x64), `libxkbcommon`, `fontconfig`, `libGL`, `libxcb` |
| Linux arm64, Wayland | supported | the above plus `wayland-client` |
| Windows 8.1/8/7 | unsupported | baseline was raised to Win10 (decision D21) | | Windows 8.1/8/7 | unsupported | baseline was raised to Win10 (decision D21) |
| Android — Termux + X11 | experimental, not promised | CLI only (build from source), GUI unverified; not part of this release matrix | | Android — Termux + X11 | experimental, not promised | CLI only (build from source), GUI unverified; not part of this release matrix |
Both arm64 rows are **formally released slots** (the assets ship in this release and in
`SHA256SUMS.txt`), not the experimental Termux path: Linux arm64 is built and smoke-tested on a
native ARM64 runner, Windows ARM64 is cross-built with the same static-CRT gate as x64.
## Install ## Install
**Windows** — put the `.exe` anywhere and run it. Portable data lives next to the exe when a **Windows** — put the `.exe` anywhere and run it (pick `-windows-arm64.exe` on ARM64 Windows,
`-windows-x64.exe` everywhere else). Portable data lives next to the exe when a
`portable.marker` file is present, otherwise under `%APPDATA%`. `portable.marker` file is present, otherwise under `%APPDATA%`.
**Linux** **Linux**
```bash ```bash
tar -xzf unlucky-launcher-{{VERSION}}-linux-x64.tar.gz tar -xzf unlucky-launcher-{{VERSION}}-linux-x64.tar.gz # use linux-arm64 on aarch64
cd unlucky-launcher-{{VERSION}}-linux-x64 cd unlucky-launcher-{{VERSION}}-linux-x64 # ditto
chmod +x unlucky-launcher && ./unlucky-launcher chmod +x unlucky-launcher && ./unlucky-launcher
``` ```