Compare commits
11
Commits
v0.1.0
...
958ced2d7e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
958ced2d7e | ||
|
|
95e8a5ca42 | ||
|
|
0aac307d92 | ||
|
|
7e08e0ccc9 | ||
|
|
ea5f66cf92 | ||
|
|
a681ccb526 | ||
|
|
83e19e58f0 | ||
|
|
65ca0ed29a | ||
|
|
fef0922073 | ||
|
|
4c1a6c54e5 | ||
|
|
15caa1b61f |
No files matched your search
@@ -90,3 +90,83 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo build --workspace --release
|
||||
|
||||
# 运行时冒烟(M62):Linux 上**真渲染一帧**,补上「能编译 ≠ 能跑」的空白。
|
||||
# 上面四个 job 都只证明代码编得过、测得过,从不创建真实图形窗口;本 job 在 Xvfb 虚拟显示里
|
||||
# 启动 GUI 进程,让 Slint 软件渲染器把当前场景渲进内存像素缓冲并编码成 PNG,再对**像素本身**
|
||||
# 断言(不是只看退出码:窗口起来了但一帧空白同样是「退出码 0」)。
|
||||
# 诚实边界:证明「Linux 上真能跑出非空白的一帧」,**不**证明视觉正确——视觉靠人眼看 artifact。
|
||||
# 本 job 绑死 Slint Window::take_snapshot;迁 iced 后必须提供等价的离屏截图通道,否则本 job 静默退化成「能起进程」。
|
||||
runtime-smoke:
|
||||
name: runtime smoke (ubuntu x64, xvfb)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: 无头 X 与图形栈依赖(xvfb + 软渲染 + 字体)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
xvfb xauth \
|
||||
libxkbcommon-dev libxkbcommon-x11-0 libfontconfig1-dev \
|
||||
libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev \
|
||||
libx11-6 libxcursor1 libxrandr2 libxi6 \
|
||||
fonts-dejavu-core fonts-liberation2
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# 用 debug 而不是 release:本 job 验的是「运行时渲染通路能通」,不是优化后二进制的行为;
|
||||
# release 的 lto="fat" + codegen-units=1 会把这一步拖到十几分钟却不带来额外证据。
|
||||
- run: cargo build --bin unlucky-launcher --locked
|
||||
- name: 在 Xvfb 里跑一帧并截图(退出码非 0 即失败)
|
||||
run: |
|
||||
set -o pipefail # 当前没有管道(死行),保留是为将来加管道时先有失败传播
|
||||
mkdir -p "$RUNNER_TEMP/smoke/data"
|
||||
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
|
||||
target/debug/unlucky-launcher --software-renderer \
|
||||
--screenshot "$RUNNER_TEMP/smoke/runtime-smoke.png" \
|
||||
--data-dir "$RUNNER_TEMP/smoke/data"
|
||||
- name: 断言 PNG 存在且非空白(尺寸 / 唯一色数 / 亮度σ / 强边缘占比)
|
||||
run: python3 packaging/check_screenshot.py "$RUNNER_TEMP/smoke/runtime-smoke.png" --json
|
||||
- name: 上传截图与数据目录(失败也传,便于复盘)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: runtime-smoke-screenshot
|
||||
path: ${{ runner.temp }}/smoke/
|
||||
if-no-files-found: warn
|
||||
|
||||
# macOS 槽位探针(M62):**默认不在 PR 上跑**,只有手动 dispatch 才触发。
|
||||
# 之所以是探针而不是常驻槽位:官方 macOS runner 处于 LaunchDaemon 上下文,没有登录的
|
||||
# WindowServer 会话,Slint 的 winit backend 创建窗口大概率直接失败;而 worker 禁 push,
|
||||
# 本机也没有 macOS,这条路径**尚无任何实测证据**——不拿未验证的 job 卡住主干门禁。
|
||||
# 首跑结论决定去留:绿 → 去掉 if/continue-on-error 升为常驻槽位;红 → 按 D26 归入 iced
|
||||
# 重写后再规划,并把错误原文贴进 progress 文档(不静默删 job、不吞失败原因)。
|
||||
runtime-smoke-macos:
|
||||
name: runtime smoke (macos arm64, probe)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 30
|
||||
# 探针阶段保留证据但不算流水线结论:log/artifact 里失败原因照常可见,
|
||||
# 解除条件 = 该槽位在真实 runner 上首跑通过并把本行与上面那条 if 一起删掉。
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo build --bin unlucky-launcher --locked
|
||||
- name: 尝试截图(macOS 没有 X 服务器,直接跑;失败即为结论)
|
||||
run: |
|
||||
set -o pipefail # 当前没有管道(死行),保留是为将来加管道时先有失败传播
|
||||
mkdir -p "$RUNNER_TEMP/smoke/data"
|
||||
target/debug/unlucky-launcher --software-renderer \
|
||||
--screenshot "$RUNNER_TEMP/smoke/runtime-smoke.png" \
|
||||
--data-dir "$RUNNER_TEMP/smoke/data"
|
||||
- name: 断言 PNG 存在且非空白
|
||||
run: python3 packaging/check_screenshot.py "$RUNNER_TEMP/smoke/runtime-smoke.png" --json
|
||||
- name: 上传截图与数据目录(失败也传,便于复盘)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: runtime-smoke-screenshot-macos
|
||||
path: ${{ runner.temp }}/smoke/
|
||||
if-no-files-found: warn
|
||||
@@ -63,7 +63,9 @@ jobs:
|
||||
echo "::error file=$f::$f 有 $n 行,超过 $LIMIT 行上限,请按模块拆分"
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
done < <(git ls-files '*.rs' '*.md' '*.slint' '*.yml' '*.yaml' '*.toml' '*.json')
|
||||
# 覆盖范围含脚本(*.py/*.sh):packaging/ 下的构建辅助脚本同样受 400 行约束;
|
||||
# Cargo.lock 等机器生成文件刻意不在列(否则一生成就超限)
|
||||
done < <(git ls-files '*.rs' '*.md' '*.slint' '*.yml' '*.yaml' '*.toml' '*.json' '*.py' '*.sh')
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo "共 $failed 个文件超限"
|
||||
exit 1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Release
|
||||
|
||||
# 发布流水线:tag `v*` → 三平台(四槽位)矩阵构建 → 单文件产物 + SHA256SUMS
|
||||
# 发布流水线:tag `v*` → 四平台(六槽位)矩阵构建 → 单文件产物 + SHA256SUMS
|
||||
# → 上传 artifact + 创建 GitHub Release(pre-release 默认开)。同一 tag 重跑幂等:
|
||||
# Release 已存在时覆盖资产与说明,不再 `gh release create`(见 release job 末步);
|
||||
# 重跑**不碰** pre-release 标记,该标记只在 `inputs.prerelease` 显式给出时才被断言。
|
||||
@@ -42,26 +42,56 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Windows:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包,
|
||||
# Windows x64:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包,
|
||||
# 双击即用的单文件形态;UCRT 本身就是 Win10+ 的系统组件。
|
||||
# 产物根 target/release;自检与打包都从 matrix.build_dir 取路径(不硬编码)。
|
||||
- slot: windows-x64
|
||||
os: windows-latest
|
||||
build_dir: target/release
|
||||
rustflags: -C target-feature=+crt-static
|
||||
# Windows arm64:在 windows-latest(x64 宿主)上**交叉编译**到 aarch64-pc-windows-msvc。
|
||||
# 为什么不用 windows-11-arm 原生 runner(GitHub 文档 2026 已把 `windows-11-arm` 与
|
||||
# `windows-11-vs2026-arm` 列为公开仓库**免费**的标准 arm64 runner,原生路线可行):
|
||||
# ① 双 Windows 槽位共用同一套已验证镜像/工具链,tag 重跑行为与既有槽位一致;
|
||||
# ② M50 的 dumpbin 定位链取的是 `Hostx64/x64/dumpbin.exe`,dumpbin 读 ARM64 PE 的
|
||||
# 导入表没问题,而原生 ARM 宿主上那条链得再长一条 `Hostarm64/arm64` 分支——为一个
|
||||
# 槽位放宽刚立的门禁不值得;
|
||||
# ③ 交叉编译产物路径变成 target/<triple>/release,已用 matrix.build_dir 参数化。
|
||||
# 若将来改成原生:只需把 os 换成 windows-11-arm、删掉 target/rustflags 依赖并给 dumpbin
|
||||
# 定位链补 Hostarm64 分支(届时再评估,不在这里预先放宽)。
|
||||
- slot: windows-arm64
|
||||
os: windows-latest
|
||||
target: aarch64-pc-windows-msvc
|
||||
build_dir: target/aarch64-pc-windows-msvc/release
|
||||
rustflags: -C target-feature=+crt-static
|
||||
# Linux:固定 ubuntu-22.04(glibc 2.35 地板)而不是 latest,
|
||||
# 否则产物的 glibc 底线会跟着 runner 漂移(24.04 = 2.39)。
|
||||
# 否则产物的 glibc 底线会跟着 runner 漂移(24.04 的 glibc 明显更高)。
|
||||
- slot: linux-x64
|
||||
os: ubuntu-22.04
|
||||
build_dir: target/release
|
||||
# Linux arm64:公开仓库免费的原生 ARM64 runner(原生构建,不做交叉)。
|
||||
# 用 `ubuntu-22.04-arm` 而**不是** 24.04:与 x64 同 glibc 地板(2.35),
|
||||
# 覆盖 Debian 12 / Ubuntu 22.04 arm64 / Raspberry Pi OS Bookworm(2.36)这类实际在跑的
|
||||
# ARM 发行版;换 24.04 会把地板抬高、把上面前两个挡在门外。该家族标签有
|
||||
# `ubuntu-22.04-arm` / `ubuntu-24.04-arm` / `ubuntu-26.04-arm`,均 4C/16GB、
|
||||
# 公开仓库免费不限量(GitHub 文档 Standard runners for public repositories)。
|
||||
- slot: linux-arm64
|
||||
os: ubuntu-22.04-arm
|
||||
build_dir: target/release
|
||||
# macOS:latest = arm64;Intel x64 用 macos-15-intel(macos-13 已下线)。
|
||||
- slot: macos-arm64
|
||||
os: macos-latest
|
||||
build_dir: target/release
|
||||
- slot: macos-x64
|
||||
os: macos-15-intel
|
||||
build_dir: target/release
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Linux 图形栈构建依赖(winit / femtovg 编译期需要)
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
@@ -71,14 +101,56 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
# 只有交叉编译槽位需要额外 target(原生槽位留空 → 本步整体跳过)。
|
||||
# 不把 `${{ }}` 直接拼进 bash:与后面的打包步骤同一纪律,一律经 env 传入再展开。
|
||||
- name: 追加交叉编译目标 ${{ matrix.target }}
|
||||
if: matrix.target != ''
|
||||
shell: bash
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
run: rustup target add "$TARGET"
|
||||
|
||||
# 交叉链接的前提是镜像里真有 ARM64 的 MSVC 库(VC `/lib/arm64/libcmt.lib`,+crt-static
|
||||
# 就靠它)与 Windows SDK 的 ARM64 导入库(`um/arm64/kernel32.lib`)。缺了照样是失败,
|
||||
# 但先给一句能照做的话,而不是让链接器吐一屏乱码才发现方向:门禁不降级,只把失败说清楚。
|
||||
- name: Windows ARM64 交叉工具链体检
|
||||
if: matrix.target == 'aarch64-pc-windows-msvc'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
roots=("C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio")
|
||||
vc_lib=$(find "${roots[@]}" -maxdepth 11 -type f -path '*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib' 2>/dev/null | head -n1 || true)
|
||||
sdk_version=$(ls -d "C:/Program Files (x86)/Windows Kits/10/Lib"/* 2>/dev/null | tail -n1 || true)
|
||||
sdk_lib=""
|
||||
if [ -n "$sdk_version" ]; then
|
||||
sdk_lib=$(find "$sdk_version/um/arm64" -maxdepth 1 -name 'kernel32.lib' 2>/dev/null | head -n1 || true)
|
||||
fi
|
||||
echo "MSVC ARM64 静态 CRT: ${vc_lib:-<缺失>}"
|
||||
echo "SDK ARM64 导入库: ${sdk_lib:-<缺失>}"
|
||||
if [ -z "$vc_lib" ] || [ -z "$sdk_lib" ]; then
|
||||
echo "::error::交叉链接缺 ARM64 库:给 VS 加上 Microsoft.VisualStudio.Component.VC.Tools.ARM64(含 ARM64 Windows SDK),或把该槽位改用原生 windows-11-arm runner"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: cargo build --release --locked
|
||||
shell: bash
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
run: cargo build --release --locked --bin unlucky-launcher --bin ull
|
||||
TARGET: ${{ matrix.target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
target_args=()
|
||||
if [ -n "$TARGET" ]; then
|
||||
target_args+=(--target "$TARGET")
|
||||
fi
|
||||
cargo build --release --locked "${target_args[@]}" --bin unlucky-launcher --bin ull
|
||||
|
||||
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
env:
|
||||
# 跨槽位的关键点:产物在 target/<triple>/release 而不是 target/release。
|
||||
EXE: ${{ matrix.build_dir }}/unlucky-launcher.exe
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# +crt-static 是否真的生效,只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 说明还得装
|
||||
@@ -89,6 +161,7 @@ jobs:
|
||||
# ② find 兜底:Program Files 与 Program Files (x86) 两个 VS 根都扫(Build Tools 落在 x86 根);
|
||||
# ③ PATH:镜像若已把工具链预置进 PATH 则直接可用。
|
||||
# PATH 排最后是刻意的:PATH 最不可控,官方定位器优先。
|
||||
# Hostx64 的 dumpbin 读 ARM64 PE 的导入表没问题,所以 arm64 槽位共用同一条链。
|
||||
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
|
||||
DUMPBIN=""
|
||||
VIA=""
|
||||
@@ -133,7 +206,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"
|
||||
exit 1
|
||||
@@ -145,11 +219,15 @@ jobs:
|
||||
# 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
SLOT: ${{ matrix.slot }}
|
||||
BUILD_DIR: ${{ matrix.build_dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。
|
||||
PY=$(command -v python3 || command -v python)
|
||||
args=()
|
||||
# 槽位与产物根都从矩阵来:交叉槽位的二进制不在 target/release,脚本据此定位
|
||||
# 并回读可执行文件头校验架构(宿主 x64 ≠ 产物 arm64 时冒烟自动跳过,只等 CI 真机)。
|
||||
args=(--slot "$SLOT" --build-dir "$BUILD_DIR")
|
||||
if [ "$REF_TYPE" = "tag" ]; then
|
||||
args+=(--version "$TAG") # 与 Cargo.toml 版本交叉校验
|
||||
fi
|
||||
@@ -202,7 +280,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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
|
||||
echo "::error::矩阵缺产物:$slot"
|
||||
exit 1
|
||||
|
||||
Generated
+4
@@ -6070,6 +6070,10 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unlucky-design"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
|
||||
+7
-1
@@ -1,6 +1,12 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/core", "crates/agent", "crates/app", "crates/cli"]
|
||||
members = [
|
||||
"crates/core",
|
||||
"crates/agent",
|
||||
"crates/app",
|
||||
"crates/cli",
|
||||
"crates/design",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
"强调色覆盖不该动中性描边"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
];
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
];
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -9,7 +9,9 @@
|
||||
| 槽位 | 产物文件名 | 形态 | 归档内容 |
|
||||
|------|-----------|------|---------|
|
||||
| `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-arm64` | `unlucky-launcher-<ver>-linux-arm64.tar.gz` | 单二进制归档(原生 ARM64 runner 构建) | 同上 |
|
||||
| `macos-arm64` | `unlucky-launcher-<ver>-macos-arm64.tar.gz` | 单二进制归档 | 同上 |
|
||||
| `macos-x64` | `unlucky-launcher-<ver>-macos-x64.tar.gz` | 单二进制归档 | 同上 |
|
||||
| 全平台 | `SHA256SUMS.txt` | 校验清单 | 每行 `<sha256>␠␠<文件名>`(`sha256sum -c` 可直吃) |
|
||||
@@ -23,6 +25,9 @@
|
||||
- `<ver>` 不带前导 `v`(tag 是 `v0.1.0`,文件名是 `0.1.0`)——由脚本统一裁剪。
|
||||
- UNIX 归档内 `mtime`/`uid`/`gid` 归零、权限固定(二进制 0755、文本 0644),
|
||||
同源构建的 sha256 不受打包时刻影响。
|
||||
- **架构不靠猜**:脚本打包前回读二进制的可执行文件头(PE 机器码 / ELF `e_machine` /
|
||||
Mach-O `cputype`),与槽位声明的架构不符即失败——交叉槽位(x64 宿主产 arm64 exe)不会
|
||||
出现"名字是 arm64、内容还是 x64"的假产物。
|
||||
|
||||
## 2. 版本来源(唯一真相源)
|
||||
|
||||
@@ -47,10 +52,26 @@ python packaging/make_artifacts.py --version v0.1.0 # 顺带校验版本一致
|
||||
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`:
|
||||
|
||||
@@ -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 一样被
|
||||
`release` job 的 `if: github.ref_type == 'tag'` 挡在发布之外,所以「dispatch 只构建不发布」
|
||||
与「dispatch 能关 pre-release」是两条互不冲突的规则。
|
||||
- 矩阵:`windows-latest`(MSVC + `-C target-feature=+crt-static`)、`ubuntu-22.04`(glibc 2.35 地板)、
|
||||
`macos-latest`(arm64)、`macos-15-intel`(Intel x64;`macos-13` 已下线,`macos-26-intel` 为更新替位)。
|
||||
- 每个槽位 `cargo build --release --locked` → `make_artifacts.py` → 归档内容自检 → 上传 artifact。
|
||||
- 矩阵(六槽位;`matrix.build_dir` 参数化产物根,脚本不再假设产物一定在 `target/release`):
|
||||
`windows-latest` x64(MSVC + `-C target-feature=+crt-static`)、`windows-latest` **交叉编译**到
|
||||
`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`
|
||||
模板渲染支持矩阵 → `gh release create`(**pre-release 默认开**:首建时 `push` 触发按 pre-release,`workflow_dispatch` 可关)。
|
||||
- **同一 tag 重跑幂等**:`gh release view` 命中已存在的 Release 时改走 `gh release upload --clobber`
|
||||
@@ -91,6 +118,22 @@ Windows 为什么用 MSVC 静态 CRT 而不是本机这套 GNU 工具链:两
|
||||
|
||||
本机(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
|
||||
|
||||
| 依赖 | 说明 |
|
||||
@@ -105,6 +148,15 @@ Windows 为什么用 MSVC 静态 CRT 而不是本机这套 GNU 工具链:两
|
||||
Debian/Ubuntu 一行:`sudo apt install libxkbcommon0 fontconfig libgl1 libegl1 libxcb1 libx11-6 libwayland-client0`。
|
||||
运行期**不需要** `-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+
|
||||
|
||||
| 依赖 | 说明 |
|
||||
@@ -216,6 +268,26 @@ CI 的 MSVC `+crt-static` 产物同理(静态 CRT);`release.yml` 里用 `d
|
||||
总之不允许"悄悄要用户装可再发行包"。
|
||||
注意:本机只装了 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. 发行合规
|
||||
|
||||
- `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 11 ARM64 | 一等(启动器产物) | 无额外库;启动器原生运行,游戏本体走系统 x64 模拟层 + x64 JDK |
|
||||
| macOS 12+(arm64 / x64) | 一等 | 无第三方库;未签名需去 quarantine |
|
||||
| Linux x64 X11 | 一等 | glibc 2.35+、xkbcommon、fontconfig、libGL、xcb |
|
||||
| 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) |
|
||||
| Android Termux + X11 | 试验级(不承诺) | 仅 CLI(源码自建),GUI 未验证,不在本发布矩阵内 |
|
||||
|
||||
`packaging/release-notes.md` 的 "Support matrix" 表与上表逐行对应(英文版,Termux 行同样标注
|
||||
experimental / not part of this release matrix)——改一边必须同步另一边,否则标题里的"一致"就是假话。
|
||||
|
||||
两个 arm64 槽位都是**正式发布槽位**(产物进 Release、进 `SHA256SUMS.txt`、release job 的六槽位齐全性
|
||||
检查会拦下缺失),不要跟 Termux 那行混为一谈:Termux 是"源码自建的实验级旁路",Linux arm64 是
|
||||
"原生 runner 编、原生跑"的一等产物。
|
||||
|
||||
## 10. 已知边界 / 后续
|
||||
|
||||
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 工具链)会打一行
|
||||
`warning: linker stderr: corrupt .drectve at end of def file`,来自 MinGW `ld` 对 `.drectve` 段的
|
||||
老式抱怨,不影响产物(体积/导入表/冒烟均已实测正常);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/make_artifacts.py` | 打包与校验(本地 + CI 共用) | ≤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 |
|
||||
@@ -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:]))
|
||||
@@ -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 会静默退化成「能起进程」
|
||||
而不是「能出图」(它本来也只证明「能出图」,不证明视觉正确)。
|
||||
@@ -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
@@ -3,11 +3,13 @@
|
||||
|
||||
Release matrix (see `packaging/README.md` for the full contract):
|
||||
|
||||
Windows x64 unlucky-launcher-<ver>-windows-x64.exe (bare exe, no zip)
|
||||
Linux x64 unlucky-launcher-<ver>-linux-x64.tar.gz
|
||||
macOS arm64 unlucky-launcher-<ver>-macos-arm64.tar.gz
|
||||
macOS x64 unlucky-launcher-<ver>-macos-x64.tar.gz
|
||||
all SHA256SUMS.txt
|
||||
Windows x64 unlucky-launcher-<ver>-windows-x64.exe (bare exe, no zip)
|
||||
Windows arm64 unlucky-launcher-<ver>-windows-arm64.exe (bare exe, cross-built)
|
||||
Linux x64 unlucky-launcher-<ver>-linux-x64.tar.gz
|
||||
Linux arm64 unlucky-launcher-<ver>-linux-arm64.tar.gz
|
||||
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
|
||||
`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
|
||||
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:
|
||||
cargo build --release --locked
|
||||
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
|
||||
@@ -26,7 +36,6 @@ import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -40,9 +49,32 @@ CLI_BIN = "ull"
|
||||
# 随包分发的合规文本(仓库根目录);英文主版 README 是对外分发口径。
|
||||
COMPLIANCE_DOCS = ("LICENSE", "NOTICE", "README.md")
|
||||
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
|
||||
TARGET_DIR = REPO_ROOT / "target" / "release"
|
||||
|
||||
|
||||
def fail(message: str) -> NoReturn:
|
||||
@@ -72,28 +104,59 @@ def workspace_version() -> str:
|
||||
return versions.pop()
|
||||
|
||||
|
||||
def platform_tag() -> str:
|
||||
"""Map the build host to the artifact matrix slot (hard-fail on unsupported ones)."""
|
||||
os_name = sys.platform
|
||||
machine = platform.machine().lower()
|
||||
if os_name.startswith("win") and machine in ("amd64", "x86_64"):
|
||||
return "windows-x64"
|
||||
if os_name.startswith("linux") and machine in ("x86_64", "amd64"):
|
||||
return "linux-x64"
|
||||
if os_name == "darwin" and machine in ("arm64", "aarch64"):
|
||||
return "macos-arm64"
|
||||
if os_name == "darwin" and machine in ("x86_64", "amd64"):
|
||||
return "macos-x64"
|
||||
fail(f"unsupported build host {os_name}/{machine}; matrix is windows-x64 / linux-x64 / macos-arm64 / macos-x64")
|
||||
def host_slot(os_name: str | None = None, machine: str | None = None) -> str:
|
||||
"""Map the build host to a matrix slot.
|
||||
|
||||
Both arguments are injectable so the mapping can be exercised for a host we do not
|
||||
own (no ARM machine here); they default to the real ones.
|
||||
"""
|
||||
os_name = (sys.platform if os_name is None else os_name).lower()
|
||||
raw_machine = platform.machine() if machine is None else machine
|
||||
arch = MACHINE_ALIASES.get(raw_machine.lower())
|
||||
family = next((fam for prefix, fam in OS_FAMILIES if os_name.startswith(prefix)), None)
|
||||
for slot, (slot_os, slot_arch, _) in SLOTS.items():
|
||||
if (slot_os, slot_arch) == (family, arch):
|
||||
return slot
|
||||
# 报错带**原始**取值而不是归一后的 None:`riscv64` 这类不认识的架构得在诊断里看得见。
|
||||
fail(f"unsupported build host {os_name}/{raw_machine}; matrix is {' / '.join(SLOTS)}")
|
||||
|
||||
|
||||
def binary(name: str) -> Path:
|
||||
path = TARGET_DIR / (f"{name}.exe" if sys.platform.startswith("win") else name)
|
||||
def binary(name: str, build_dir: Path, family: str) -> Path:
|
||||
path = build_dir / (f"{name}.exe" if family == "windows" else name)
|
||||
if not path.is_file():
|
||||
fail(f"missing {path}; run `cargo build --release --locked` first")
|
||||
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:
|
||||
"""`ull --version` must report the workspace version (guards tag/version drift)."""
|
||||
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()}")
|
||||
if version not in out:
|
||||
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:
|
||||
@@ -160,6 +223,8 @@ def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
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("--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")
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -168,22 +233,32 @@ def main() -> int:
|
||||
expected = args.version[1:] if args.version.startswith("v") else args.version
|
||||
if expected != version:
|
||||
fail(f"tag/version mismatch: requested {expected}, Cargo.toml says {version}")
|
||||
tag = platform_tag()
|
||||
launcher = binary(LAUNCHER_BIN)
|
||||
cli = binary(CLI_BIN)
|
||||
if not args.no_smoke:
|
||||
smoke_version(cli, version)
|
||||
native = host_slot()
|
||||
slot = args.slot or native
|
||||
family, _, kind = SLOTS[slot]
|
||||
build_dir = Path(args.build_dir)
|
||||
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)
|
||||
if not out_dir.is_absolute():
|
||||
out_dir = REPO_ROOT / out_dir
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if tag == "windows-x64":
|
||||
asset = out_dir / f"unlucky-launcher-{version}-{tag}.exe"
|
||||
if kind == "exe":
|
||||
asset = out_dir / f"unlucky-launcher-{version}-{slot}.exe"
|
||||
shutil.copy2(launcher, asset)
|
||||
else:
|
||||
asset = stage_archive(out_dir, tag, version, launcher)
|
||||
asset = stage_archive(out_dir, slot, version, launcher)
|
||||
|
||||
sums = write_sums(out_dir, [asset])
|
||||
size_mb = asset.stat().st_size / (1024 * 1024)
|
||||
|
||||
@@ -9,7 +9,9 @@ No WebView, no bundled runtime, no JRE download needed to start the launcher its
|
||||
| Platform | Asset | Notes |
|
||||
| --- | --- | --- |
|
||||
| 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 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+ 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 |
|
||||
| --- | --- | --- |
|
||||
| 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 |
|
||||
| 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 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) |
|
||||
| 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
|
||||
|
||||
**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%`.
|
||||
|
||||
**Linux**
|
||||
|
||||
```bash
|
||||
tar -xzf unlucky-launcher-{{VERSION}}-linux-x64.tar.gz
|
||||
cd unlucky-launcher-{{VERSION}}-linux-x64
|
||||
tar -xzf unlucky-launcher-{{VERSION}}-linux-x64.tar.gz # use linux-arm64 on aarch64
|
||||
cd unlucky-launcher-{{VERSION}}-linux-x64 # ditto
|
||||
chmod +x unlucky-launcher && ./unlucky-launcher
|
||||
```
|
||||
|
||||
|
||||
Reference in new issue
Block a user