Compare commits
32
Commits
0aac307d92
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
352dd7937e | ||
|
|
69d0025d09 | ||
|
|
d3e9a62b21 | ||
|
|
614bd253c8 | ||
|
|
9e30d2cf15 | ||
|
|
6cfac9c823 | ||
|
|
4069204379 | ||
|
|
0cb1454067 | ||
|
|
aca280fd30 | ||
|
|
5a44870de5 | ||
|
|
a426cb5dcb | ||
|
|
9d9ab3e7e0 | ||
|
|
d0d24d42e7 | ||
|
|
a4bd4d2b38 | ||
|
|
2a0493c3eb | ||
|
|
cf52751124 | ||
|
|
40864d93d0 | ||
|
|
e37b452345 | ||
|
|
9519a89472 | ||
|
|
79404fa9d1 | ||
|
|
c20c742b60 | ||
|
|
a1adf024ba | ||
|
|
14da7b3dec | ||
|
|
df4ff373b7 | ||
|
|
3fcdc3d3e1 | ||
|
|
16bdad1b98 | ||
|
|
141e9b12c7 | ||
|
|
b89cc91b78 | ||
|
|
a4d86e9b19 | ||
|
|
29f65b8e6f | ||
|
|
958ced2d7e | ||
|
|
95e8a5ca42 |
No files matched your search
@@ -63,7 +63,9 @@ jobs:
|
|||||||
echo "::error file=$f::$f 有 $n 行,超过 $LIMIT 行上限,请按模块拆分"
|
echo "::error file=$f::$f 有 $n 行,超过 $LIMIT 行上限,请按模块拆分"
|
||||||
failed=$((failed + 1))
|
failed=$((failed + 1))
|
||||||
fi
|
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
|
if [ "$failed" -ne 0 ]; then
|
||||||
echo "共 $failed 个文件超限"
|
echo "共 $failed 个文件超限"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
Generated
+1272
-97
File diff suppressed because it is too large.
Load diff
@@ -6,6 +6,9 @@ members = [
|
|||||||
"crates/app",
|
"crates/app",
|
||||||
"crates/cli",
|
"crates/cli",
|
||||||
"crates/design",
|
"crates/design",
|
||||||
|
# P4(D26 iced 重写)第一步:与 Slint 版 `crates/app` 并存的新 UI crate。
|
||||||
|
# bin 名 `unlucky-launcher-iced`,翻牌时才改名(见 DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md)。
|
||||||
|
"crates/app-iced",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -18,13 +18,38 @@ Copyright 2026 AskaEth (YeiJ)
|
|||||||
|
|
||||||
Slint 的其余许可分支(GPL-3.0-only / Slint Software License 3.0)本项目不适用。
|
Slint 的其余许可分支(GPL-3.0-only / Slint Software License 3.0)本项目不适用。
|
||||||
|
|
||||||
2. Rust crates 生态
|
2. Google Material Color Utilities(https://github.com/material-foundation/material-color-utilities)
|
||||||
|
|
||||||
|
本产品的色彩令牌(crates/design 的 color 模块)是 Material 3 色彩算法(HCT 色彩空间 +
|
||||||
|
ColorSpec2021 调色板/角色映射)的 **Rust 重写移植**,依据 Apache License 2.0 使用,未把其任何语言的源码作为依赖
|
||||||
|
引入;除下面注明的那张生成常数表外,也没有复制其代码文件。
|
||||||
|
|
||||||
|
实际移植的部分(均为纯算法,逐函数对照后以 Rust 重写):
|
||||||
|
|
||||||
|
- `hct/Cam16.java`、`hct/ViewingConditions.java`、`hct/Hct.java` → `crates/design/src/color/cam16.rs`、
|
||||||
|
`viewing_conditions.rs`、`hct.rs`(CAM16 色相/彩度 + CIELAB `L*` 亮度)
|
||||||
|
- `hct/HctSolver.java` → `crates/design/src/color/hct_solver.rs` + `critical_planes.rs`
|
||||||
|
(色域映射求解器;其中 255 项 `CRITICAL_PLANES` 常数表是原样搬运的生成数据)
|
||||||
|
- `utils/ColorUtils.java`、`utils/MathUtils.java` → `crates/design/src/color/space.rs`
|
||||||
|
- `palettes/TonalPalette.java`、`dynamiccolor/ColorSpec2021.java`、`dynamiccolor/DynamicScheme.java`
|
||||||
|
→ `crates/design/src/color/tonal.rs`、`palettes.rs`、`scheme.rs`(Tonal Spot 变体的彩度常数与
|
||||||
|
49 个角色的 tone 映射)
|
||||||
|
|
||||||
|
对齐验证:`crates/design/tests/official_baseline.rs` 与 `tests/official_accent.rs` 的期望色值,
|
||||||
|
是把上述官方 Java 实现(Apache-2.0)在本机编译运行后输出的结果抄本——合计 290 项逐通道
|
||||||
|
比对(baseline 49 角色 × 2 模式 = 98 项;强调色 8 个种子 × 12 个强调族角色 × 2 模式 = 192 项),
|
||||||
|
误差上限 1(实测为 0)。两文件的表头各写着自己的分项口径。
|
||||||
|
|
||||||
|
Apache License 2.0 全文见 https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
上游版权声明:Copyright Google LLC。
|
||||||
|
|
||||||
|
3. Rust crates 生态
|
||||||
|
|
||||||
cargo-deny 白名单见 deny.toml:Apache-2.0 / MIT / BSD-2-Clause / BSD-3-Clause / Zlib / BSL-1.0 / ISC /
|
cargo-deny 白名单见 deny.toml:Apache-2.0 / MIT / BSD-2-Clause / BSD-3-Clause / Zlib / BSL-1.0 / ISC /
|
||||||
CC0-1.0 / MIT-0 / Unlicense / Unicode-3.0 / CDLA-Permissive-2.0 / Apache-2.0 WITH LLVM-exception。
|
CC0-1.0 / MIT-0 / Unlicense / Unicode-3.0 / CDLA-Permissive-2.0 / Apache-2.0 WITH LLVM-exception。
|
||||||
GPL 系(含 AGPL、LGPL 静态链接、SSPL)依赖不得进入主干。
|
GPL 系(含 AGPL、LGPL 静态链接、SSPL)依赖不得进入主干。
|
||||||
|
|
||||||
3. 参考实现致谢
|
4. 参考实现致谢
|
||||||
|
|
||||||
本项目设计参考自 HMCL(https://github.com/HMCL-dev/HMCL)与 PCL2
|
本项目设计参考自 HMCL(https://github.com/HMCL-dev/HMCL)与 PCL2
|
||||||
(https://github.com/Meloong-Git/PCL),两者均为 GPL-3.0,特此致谢。
|
(https://github.com/Meloong-Git/PCL),两者均为 GPL-3.0,特此致谢。
|
||||||
@@ -2,57 +2,91 @@
|
|||||||
|
|
||||||
**English** · [简体中文](README.zh-CN.md)
|
**English** · [简体中文](README.zh-CN.md)
|
||||||
|
|
||||||
> **Official project home**: https://github.com/AskaEth/Unlucky-Launcher
|
> **Official project home**: https://github.com/AskaEth/Unlucky-Launcher | downloads, issues and
|
||||||
|
> release notes all live there: <https://github.com/AskaEth/Unlucky-Launcher/releases>
|
||||||
|
|
||||||
Unlucky Launcher (ULL) is a free, open-source Minecraft: Java Edition launcher written in Rust
|
Unlucky Launcher (ULL) is a free, open-source Minecraft: Java Edition launcher written in Rust
|
||||||
(Apache-2.0, non-commercial), with a Slint + Metro UI. It embeds an AI agent client (chat /
|
(Apache-2.0). It ships as one native binary per platform — no installer, no bundled runtime, no
|
||||||
configuration / crash diagnosis) and integrates with the broader agent tooling ecosystem via MCP.
|
WebView. The launcher embeds an AI agent client (chat / configuration / crash diagnosis) and can be
|
||||||
Minimum baseline: Windows 10.
|
attached to other agent tooling as an MCP server.
|
||||||
|
|
||||||
- Runtime: a single native Rust binary — no JVM, no WebView, no external runtime
|
## Highlights
|
||||||
- UI: Slint (Royalty-Free 2.0; GL preferred, CPU software-rendering fallback so older machines
|
|
||||||
without a usable GPU still get the full UI)
|
|
||||||
- License: Apache-2.0 © AskaEth (YeiJ)
|
|
||||||
|
|
||||||
## Compliance statement
|
- **Single-file native binary**: the launcher itself needs no JVM, no WebView and no sidecar
|
||||||
|
runtime. The JDK a game needs is downloaded and managed per instance, not as a launcher
|
||||||
Authentication uses only official Microsoft/Mojang endpoints — Microsoft OAuth 2.0 device code
|
dependency.
|
||||||
flow, Xbox Live, XSTS, and api.minecraftservices.com — so players sign in with their own Microsoft
|
- **Two UI stacks during the rewrite**: the released UI is Slint (Royalty-Free 2.0); the
|
||||||
account and launch the game they already own. Account tokens are stored only on the user's local
|
next-generation UI is iced 0.14 + Material 3 Expressive and is still being written (see Status).
|
||||||
machine and are never sent to any server operated by the project. ULL does not distribute, mirror,
|
- **CPU software-rendering fallback**: `--software-renderer` keeps the interface rendering
|
||||||
proxy or modify any Minecraft game files. Online play always requires a Microsoft account with
|
normally on machines with no usable GPU or with broken drivers.
|
||||||
valid entitlements. Local offline profiles never contact Microsoft or Mojang services and cannot
|
- **AI agent + MCP**: chat, setting edits and crash diagnosis inside the launcher; `ull agent
|
||||||
join online-mode servers. Not affiliated with Mojang or Microsoft.
|
serve` exposes the same core as an MCP server (stdio or Streamable HTTP) to external agent tools.
|
||||||
|
- **Mod loaders**: Fabric, Quilt, NeoForge, Forge, with modpack and CurseForge / Modrinth clients in
|
||||||
|
the core.
|
||||||
|
- **Java management**: a Temurin → Oracle → Microsoft → Zulu fallback chain, region-aware.
|
||||||
|
- **Accounts**: sign in with a Microsoft account over OAuth 2.0 device code flow → Xbox Live →
|
||||||
|
XSTS → `api.minecraftservices.com`; account tokens are kept on this machine only.
|
||||||
|
- **Engine**: multi-source download (health scoring, mirrors, chunked transfers, hash while
|
||||||
|
downloading) and a YAML rule-based crash diagnosis engine.
|
||||||
|
- **License**: Apache-2.0 © AskaEth (YeiJ).
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
The core engine is complete (workspace: 983+ tests passing):
|
Two tracks. They are not the same product yet, so read the headings before downloading anything.
|
||||||
|
|
||||||
- **Microsoft sign-in**: OAuth 2.0 device code flow → Xbox Live → XSTS → Minecraft Services, with
|
### Track A — released: v0.1.0, Slint UI (this is what you can download today)
|
||||||
entitlement verification
|
|
||||||
- **Account credentials are stored only on the local machine** — never uploaded, and inaccessible
|
|
||||||
to the AI features
|
|
||||||
- Mod loaders: Forge / NeoForge / Fabric / Quilt, verified on real machines
|
|
||||||
- Java management: Temurin / Oracle / Microsoft / Zulu with a regional fallback chain
|
|
||||||
- Multi-source download engine; YAML rule-based crash diagnosis
|
|
||||||
- Usable as an MCP server for external agent tooling (`ull agent serve`)
|
|
||||||
- Local offline profiles: for local / isolated-network testing only; they perform no Microsoft
|
|
||||||
authentication and cannot join servers with online-mode enabled
|
|
||||||
|
|
||||||
**CLI (`ull`) is fully usable**: `install / launch / accounts / java / diagnose / agent`.
|
- **GitHub Releases `v0.1.0`** (2026-11-01, flagged pre-release): single-file assets for Windows
|
||||||
|
x64 (a bare `.exe`), Linux x64 and macOS (arm64 and x64) `.tar.gz`. Every release also carries
|
||||||
|
`SHA256SUMS.txt` and the `LICENSE` / `NOTICE` / `README.md` texts as separate assets; on Windows
|
||||||
|
those texts sit next to the `.exe` because a bare exe cannot hold them, while the unix archives
|
||||||
|
already contain them.
|
||||||
|
- **UI**: Slint + Metro. This is the shipped GUI — the instance, download, account, Java, log and
|
||||||
|
settings pages all run on the core engine (the in-app AI chat panel is a scripted placeholder).
|
||||||
|
- **CLI `ull`** is built from the same tree at the same version — but the `v0.1.0` assets ship
|
||||||
|
only the GUI binary. If you want the CLI, build it yourself:
|
||||||
|
`cargo build --release --bin ull`. Commands: `instances`, `install`, `launch`, `accounts`,
|
||||||
|
`sessions`, `logs`, `diag`, `config`, `java`, `agent`.
|
||||||
|
- Windows ARM64 and Linux ARM64 slots are wired into the release pipeline; the next tagged release
|
||||||
|
builds them. The `v0.1.0` assets predate those two slots.
|
||||||
|
|
||||||
**GUI in development**: `crates/app` is currently a Slint + Metro skeleton; the theme system and
|
### Track B — in progress on `dev`: next-generation UI (iced + Material 3 Expressive)
|
||||||
the full interface ship with subsequent versions — use the CLI until then.
|
|
||||||
|
**This track is not released yet — you can see it only by building from source.**
|
||||||
|
|
||||||
|
- **Locked direction**: the look is Material 3 Expressive and the framework is iced 0.14; the UI
|
||||||
|
layer is written from scratch. Neither the style nor the framework will change again.
|
||||||
|
- **Done**: the token crate `crates/design` (no UI framework, no third-party crates — real HCT
|
||||||
|
color, type scale, shape scale, spacing, elevation, motion, icon geometry, per-component
|
||||||
|
metrics) and the skeleton crate `crates/app-iced` (opens a window, self-drawn title bar and
|
||||||
|
window buttons, navigation rail, colors and type taken from tokens only, GPU→CPU backend probe
|
||||||
|
with automatic fallback, and the screenshot/diff channel the visual reviews use).
|
||||||
|
- **Not done**: the component library (buttons, dialogs, lists, menus, search, and the rest of the
|
||||||
|
M3 set), page migration, and the i18n pass. Every page in the iced binary is a placeholder.
|
||||||
|
- **Slint stays until the flip**: `crates/app` remains the reference implementation until the iced
|
||||||
|
build passes equivalence acceptance; only then is `unlucky-launcher-iced` renamed to
|
||||||
|
`unlucky-launcher` and the Slint crate retired.
|
||||||
|
|
||||||
|
Binaries in the workspace, so you can tell them apart:
|
||||||
|
|
||||||
|
| Binary | Crate | State |
|
||||||
|
|--------|-------|-------|
|
||||||
|
| `unlucky-launcher` | `crates/app` (Slint) | released in v0.1.0; still the usable GUI |
|
||||||
|
| `unlucky-launcher-iced` | `crates/app-iced` (iced 0.14) | under construction; skeleton only, not shipped |
|
||||||
|
| `ull` | `crates/cli` | built from the same tree at the same version; not in the v0.1.0 assets |
|
||||||
|
|
||||||
## Module map
|
## Module map
|
||||||
|
|
||||||
```
|
```
|
||||||
Unlucky-Launcher (cargo workspace)
|
Unlucky-Launcher (cargo workspace)
|
||||||
├── crates/core # domain library, zero UI dependencies, eight modules
|
├── crates/core # domain library: zero UI dependencies, eight modules
|
||||||
├── crates/agent # AI agent: ToolSpec registry + built-in client + MCP server (one shared core)
|
├── crates/agent # AI agent: ToolSpec registry + built-in client + MCP server (one core, two shapes)
|
||||||
├── crates/app # Slint UI: the only crate allowed to depend on a UI framework
|
├── crates/design # design tokens: real HCT color / type / shape / spacing / elevation / motion / icons
|
||||||
├── crates/cli # CLI (bin `ull`): headless, zero UI, depends on core + agent
|
│ # / component metrics — zero UI dependencies, no third-party crates
|
||||||
└── packaging # single-file artifact matrix and packaging assets (planned)
|
├── crates/app # Slint UI (bin `unlucky-launcher`): the released GUI, kept until equivalence acceptance
|
||||||
|
├── crates/app-iced # next-generation iced UI (bin `unlucky-launcher-iced`): under construction
|
||||||
|
├── crates/cli # CLI (bin `ull`): headless, zero UI
|
||||||
|
└── packaging # single-file artifact matrix and release scripts
|
||||||
```
|
```
|
||||||
|
|
||||||
The eight modules of `crates/core`:
|
The eight modules of `crates/core`:
|
||||||
@@ -63,7 +97,7 @@ The eight modules of `crates/core`:
|
|||||||
| `download` | multi-source health scoring, mirrors, chunked downloads, hash-while-downloading |
|
| `download` | multi-source health scoring, mirrors, chunked downloads, hash-while-downloading |
|
||||||
| `game` | version / instance repository, snapshots and inheritance resolution |
|
| `game` | version / instance repository, snapshots and inheritance resolution |
|
||||||
| `launch` | 10-stage launch pipeline, processes, JSONL event stream |
|
| `launch` | 10-stage launch pipeline, processes, JSONL event stream |
|
||||||
| `auth` | Microsoft sign-in / local offline profiles |
|
| `auth` | Microsoft sign-in (OAuth 2.0 device code → Xbox Live → XSTS → Minecraft Services) |
|
||||||
| `addon` | mod loader installation, modpacks, CurseForge / Modrinth |
|
| `addon` | mod loader installation, modpacks, CurseForge / Modrinth |
|
||||||
| `diagnose` | five signal collectors, YAML rule engine, hs_err parsing |
|
| `diagnose` | five signal collectors, YAML rule engine, hs_err parsing |
|
||||||
| `platform` | OS abstraction: paths, processes, file locks, capability probing, runtime OS detection |
|
| `platform` | OS abstraction: paths, processes, file locks, capability probing, runtime OS detection |
|
||||||
@@ -72,53 +106,85 @@ The eight modules of `crates/core`:
|
|||||||
|
|
||||||
Prerequisites:
|
Prerequisites:
|
||||||
|
|
||||||
- Rust stable (`rust-toolchain.toml` selects the toolchain automatically; components `rustfmt` /
|
- Rust stable (`rust-toolchain.toml` selects the toolchain automatically; the gates also need the
|
||||||
`clippy`)
|
`rustfmt` and `clippy` components)
|
||||||
- Windows: MSVC toolchain (used by CI) or MinGW-w64 (`x86_64-pc-windows-gnu`)
|
- Windows: MSVC toolchain (what CI uses) or MinGW-w64 (`x86_64-pc-windows-gnu`)
|
||||||
- Linux: `libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev`
|
- Linux: `libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev
|
||||||
(Slint winit backend + femtovg build dependencies)
|
libxcb1-dev` — needed by both UI stacks (Slint's winit + femtovg, iced's winit + wgpu/tiny-skia)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo fmt --all --check # formatting
|
cargo fmt --all --check # formatting
|
||||||
cargo clippy --workspace --all-targets -- -D warnings # lints (zero-warning gate)
|
cargo clippy --workspace --all-targets -- -D warnings # lints (zero-warning gate)
|
||||||
cargo test --workspace # tests
|
cargo test --workspace # tests
|
||||||
cargo build --release # artifact: target/release/unlucky-launcher[.exe]
|
cargo build --release # binaries land in target/release/
|
||||||
```
|
```
|
||||||
|
|
||||||
Software-rendering switch (machines without a usable GPU / with broken drivers):
|
What the build produces:
|
||||||
`unlucky-launcher --software-renderer` (equivalent to `SLINT_BACKEND=winit-software`).
|
|
||||||
|
- `unlucky-launcher` — the Slint UI (the released one)
|
||||||
|
- `unlucky-launcher-iced` — the iced UI skeleton. The name is temporary: it can only become
|
||||||
|
`unlucky-launcher` at the flip, because two crates in one workspace cannot emit the same binary
|
||||||
|
name into the same target directory.
|
||||||
|
- `ull` — the CLI
|
||||||
|
|
||||||
|
Rendering switches:
|
||||||
|
|
||||||
|
- `unlucky-launcher --software-renderer` (equivalent to `SLINT_BACKEND=winit-software`) forces the
|
||||||
|
CPU path on the Slint build
|
||||||
|
- `unlucky-launcher-iced --software-renderer` forces tiny-skia (`ICED_BACKEND=tiny-skia`);
|
||||||
|
`--screenshot <png>` writes one frame and exits, which is the acceptance channel
|
||||||
|
- Both stacks fall back to the CPU path when no usable adapter is there: the Slint build through its
|
||||||
|
own renderer fallback chain, the iced build through its explicit probe
|
||||||
|
|
||||||
Dependency license check (optional locally, required in CI):
|
Dependency license check (optional locally, required in CI):
|
||||||
`cargo deny check licenses bans sources`.
|
`cargo deny check licenses bans sources`.
|
||||||
|
|
||||||
## CI gates
|
## CI gates
|
||||||
|
|
||||||
|
Every push to `main` and every pull request runs fmt / clippy (warnings are errors) / the full test
|
||||||
|
suite, and the Linux headless render smoke is a hard gate on the same footing: a red result blocks
|
||||||
|
the merge. The macOS runtime probe is not there yet — it only runs on a manual `workflow_dispatch`
|
||||||
|
and its result is recorded, not enforced (`continue-on-error`), until the slot passes a first run on
|
||||||
|
a real runner.
|
||||||
|
|
||||||
| Workflow | Content |
|
| Workflow | Content |
|
||||||
|----------|---------|
|
|----------|---------|
|
||||||
| `.github/workflows/ci.yml` | fmt + clippy + test + build, Windows / Linux matrix |
|
| `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb: renders one real frame, then asserts the PNG is not blank); macOS runtime probe (manual dispatch only) |
|
||||||
| `.github/workflows/guards.yml` | zero-UI guard for core, 400-line-per-file guard, cargo-deny license guard |
|
| `.github/workflows/guards.yml` | zero-UI guard for `crates/core` (manifest plus `cargo tree`), 400-lines-per-file guard, cargo-deny license and source guard |
|
||||||
|
| `.github/workflows/release.yml` | tag `v*` → per-slot builds → single-file artifacts + `SHA256SUMS.txt` → GitHub Release |
|
||||||
|
|
||||||
## Platform baseline
|
## Platform baseline
|
||||||
|
|
||||||
Minimum baseline: **Windows 10**. Windows 8.1 / 8 / 7 are not supported:
|
Minimum baseline: **Windows 10**; Windows 8.1 / 8 / 7 are not supported.
|
||||||
|
|
||||||
- Windows 10/11 builds use the **standard toolchain** (MSVC / GNU) — no custom target specs, no
|
- Windows 10/11 builds use the **standard toolchain** (MSVC / GNU), built the same way as
|
||||||
`-Zbuild-std`, no import-table allowlists
|
upstream cargo
|
||||||
- Other platforms: macOS 12+ first class; Linux x64 with X11 preferred, Wayland as the long-term
|
- macOS 12+ (Apple Silicon and Intel) and Linux x64 / arm64 are supported build slots; on Linux,
|
||||||
direction; Android Termux experimental, not committed
|
X11 is the preferred path today and Wayland is the long-term direction. macOS builds are unsigned,
|
||||||
- UI fallback: Slint software rendering (fully usable on machines without a GPU or with broken
|
so the first launch needs the Gatekeeper quarantine attribute cleared
|
||||||
drivers)
|
(`xattr -d com.apple.quarantine`)
|
||||||
- Platform independence is expressed through **separate test matrices**, not separate artifact
|
- Android Termux is experimental and not promised: CLI only, GUI unverified
|
||||||
pipelines; capability differences use feature gates with runtime probing
|
- Each platform runs its own test matrix; capability differences use feature gates with runtime
|
||||||
|
probing.
|
||||||
|
|
||||||
## License and acknowledgements
|
## License and acknowledgements
|
||||||
|
|
||||||
- This project: Apache License 2.0, see [LICENSE](LICENSE); third-party notices in [NOTICE](NOTICE)
|
- **This project**: Apache License 2.0, see [LICENSE](LICENSE); third-party notices in
|
||||||
- The GUI uses [Slint](https://slint.dev) (Royalty-Free 2.0 track). Per the RF 2.0 obligations an
|
[NOTICE](NOTICE).
|
||||||
`AboutSlint` widget will be placed on the about page (P2); until then NOTICE and this section
|
- **Slint** (<https://slint.dev>), Royalty-Free 2.0: obligation for the Slint build that v0.1.0
|
||||||
serve the attribution duty.
|
ships — an `AboutSlint` widget on the about page (still outstanding) and, until it lands, the
|
||||||
|
NOTICE file, this section and a "Made with Slint" note on the release page. The obligation is
|
||||||
|
kept for as long as a Slint-linked binary ships.
|
||||||
|
- **iced** (<https://github.com/iced-rs/iced>), MIT: the framework of the next-generation UI,
|
||||||
|
together with `winit` and, through it, `wgpu` / `tiny-skia` (Apache-2.0 / MIT).
|
||||||
|
- **Google Material Color Utilities** (Apache-2.0): the color tokens in `crates/design` are a Rust
|
||||||
|
port of its HCT / ColorSpec2021 algorithms; details and the exact ported functions are listed in
|
||||||
|
[NOTICE](NOTICE).
|
||||||
|
- **Other major dependencies**: `rmcp` and `axum` (Apache-2.0 / MIT) for the MCP server, `tokio`
|
||||||
|
(MIT), `reqwest` with rustls (MIT / Apache-2.0), `serde` / `serde_json` (MIT / Apache-2.0),
|
||||||
|
`zip` / `flate2` / `tar` (MIT / Apache-2.0). The allow-list cargo-deny enforces is in `deny.toml`;
|
||||||
|
GPL-family dependencies may not enter the main line.
|
||||||
|
|
||||||
Thanks to [HMCL](https://github.com/HMCL-dev/HMCL) and
|
Thanks to [HMCL](https://github.com/HMCL-dev/HMCL) and [PCL2](https://github.com/Meloong-Git/PCL)
|
||||||
[PCL2](https://github.com/Meloong-Git/PCL) (both GPL-3.0) — these two launchers have accompanied
|
(both GPL-3.0). They have been with the vast majority of Minecraft players for years, and this
|
||||||
the vast majority of Minecraft players for many years. ULL learned a great deal about design and
|
project has benefited a great deal from their design choices and implementation details.
|
||||||
implementation from them, and this project is dedicated to those two pioneers.
|
|
||||||
+112
-45
@@ -2,41 +2,81 @@
|
|||||||
|
|
||||||
[English](README.md) · **简体中文**
|
[English](README.md) · **简体中文**
|
||||||
|
|
||||||
> **官方项目主页**:https://github.com/AskaEth/Unlucky-Launcher
|
> **官方项目主页**:https://github.com/AskaEth/Unlucky-Launcher | 下载、议题、发布说明都在这里:
|
||||||
|
> <https://github.com/AskaEth/Unlucky-Launcher/releases>
|
||||||
|
|
||||||
Rust 原生单文件 Minecraft 启动器:**Slint + Metro 视觉**,内置 **AI Agent 客户端**(对话 / 改配置 / 崩溃诊断),
|
Unlucky Launcher(ULL)是一个用 Rust 写的免费开源 Minecraft: Java 版启动器(Apache-2.0)。
|
||||||
融合主流 Agent 工具(**MCP**),**最低基线 Win10+**。
|
每个平台就一个原生二进制——没有安装包、没有捆绑运行时、没有 WebView。启动器内置 AI Agent
|
||||||
|
客户端(对话 / 改配置 / 崩溃诊断),也能作为 MCP server 接进别的 Agent 工具。
|
||||||
|
|
||||||
- 运行时:纯 Rust 原生二进制——无 JVM、无 WebView、无外部运行时
|
## 特性
|
||||||
- UI:Slint(Royalty-Free 2.0;GL 优先,CPU 软渲染兜底,无可用 GPU 的老机也有完整 UI)
|
|
||||||
- 许可:Apache-2.0 © AskaEth (YeiJ)
|
- **单文件原生二进制**:启动器本体不需要 JVM、不需要 WebView、不带旁挂运行时。游戏要的 JDK 由
|
||||||
|
启动器按实例下载管理,不是启动器的依赖。
|
||||||
|
- **重写期两套 UI 并存**:已发布版本是 Slint(Royalty-Free 2.0);下一代 UI 是 iced 0.14 +
|
||||||
|
Material 3 Expressive,目前仍在写(见「当前状态」)。
|
||||||
|
- **CPU 软渲染兜底**:`--software-renderer` 让没有可用 GPU 或驱动有问题的老机也能正常显示界面。
|
||||||
|
- **AI Agent + MCP**:启动器内可对话、改设置、读崩溃诊断;`ull agent serve` 把同一个内核以 MCP
|
||||||
|
server 形态(stdio 或 Streamable HTTP)开放给外部 Agent 工具。
|
||||||
|
- **加载器**:Fabric / Quilt / NeoForge / Forge;整合包与 CurseForge / Modrinth 客户端在 core 里。
|
||||||
|
- **Java 管理**:Temurin → Oracle → Microsoft → Zulu 回退链,带地区判定。
|
||||||
|
- **账户**:用 Microsoft 账户登录,认证走 OAuth 2.0 device code flow → Xbox Live → XSTS →
|
||||||
|
`api.minecraftservices.com`;账户令牌只保存在本机。
|
||||||
|
- **引擎**:多源下载(健康度评分、镜像、分块传输、边下边算)与 YAML 规则引擎的崩溃诊断。
|
||||||
|
- **许可**:Apache-2.0 © AskaEth (YeiJ)。
|
||||||
|
|
||||||
## 当前状态
|
## 当前状态
|
||||||
|
|
||||||
**核心引擎已完成**(workspace 983+ 测试全绿):
|
两条轨道,还不是同一个东西——看小标题再决定下载什么。
|
||||||
|
|
||||||
- **正版登录**:Microsoft OAuth 2.0 device code flow → Xbox Live → XSTS → Minecraft Services,校验游戏拥有状态
|
### 轨道 A —— 已发布:v0.1.0,Slint 版(现在能下载的就是它)
|
||||||
- **账户凭据仅保存在本机**,不上传任何服务器,AI 功能无法访问
|
|
||||||
- 加载器:Forge / NeoForge / Fabric / Quilt 四家真机验证
|
|
||||||
- Java 管理:Temurin / Oracle / Microsoft / Zulu 四发行版回退链
|
|
||||||
- 多源下载引擎、崩溃诊断规则引擎
|
|
||||||
- 可作为 MCP server 接入外部 Agent 工具(`ull agent serve`)
|
|
||||||
- 本地离线档案:仅用于本地/隔离网络环境的测试,不经过 Microsoft 账户认证,无法连接启用正版验证的服务器
|
|
||||||
|
|
||||||
**CLI(`ull`)完整可用**:`install / launch / accounts / java / diagnose / agent` 等全量命令。
|
- **GitHub Releases `v0.1.0`**(2026-11-01,标记为 pre-release):单文件产物覆盖 Windows x64(裸
|
||||||
|
`.exe`)、Linux x64、macOS(arm64 与 x64)`.tar.gz`。每个 Release 另挂 `SHA256SUMS.txt` 与
|
||||||
|
`LICENSE` / `NOTICE` / `README.md` 三份文本——Windows 是「一个 exe 装不下文本」所以并列挂出,
|
||||||
|
unix 归档里本来就装着。
|
||||||
|
- **UI**:Slint + Metro。这是已交付的图形界面——实例、下载、账户、Java、日志、设置
|
||||||
|
各页都由 core 引擎驱动(应用内 AI 对话面板仍是脚本化的占位)。
|
||||||
|
- **CLI `ull`** 与 v0.1.0 同源同版本,但发布资产里只有 GUI 单文件——要用它请自己构建:
|
||||||
|
`cargo build --release --bin ull`。命令:`instances`、`install`、`launch`、`accounts`、`sessions`、
|
||||||
|
`logs`、`diag`、`config`、`java`、`agent`。
|
||||||
|
- Windows ARM64 与 Linux ARM64 两个槽位已接进发布流水线,随下一个 tag 发布;`v0.1.0` 的资产早于
|
||||||
|
这两个槽位。
|
||||||
|
|
||||||
**图形界面(GUI)开发中**:`crates/app` 当前为 Slint + Metro 风格骨架,主题系统与完整界面
|
### 轨道 B —— dev 上的下一代 UI(iced + M3 Expressive,施工中)
|
||||||
随后续版本交付——在此之前请使用 CLI。
|
|
||||||
|
**这条轨道还没发布——要看只能自己从源码编。**
|
||||||
|
|
||||||
|
- **方向已锁死**:视觉 = Material 3 Expressive,框架 = iced 0.14,UI 层从头写;风格与框架都不会再换。
|
||||||
|
- **已完成**:令牌 crate `crates/design`(零 UI 框架依赖、零第三方 crate——真 HCT 色彩、排版档位、
|
||||||
|
形状刻度、间距、高程、动效、图标几何、组件度量)与骨架 crate `crates/app-iced`(能开窗、自绘标题栏
|
||||||
|
与窗口键、导航轨、颜色与字号只从令牌取、GPU→CPU 后端探测与自动降级、视觉验收用的截图与逐像素
|
||||||
|
对比通道)。
|
||||||
|
- **没完成**:组件库(按钮、对话框、列表、菜单、搜索栏,以及 M3 其余组件)、页面搬迁、i18n 文案。
|
||||||
|
iced 版里现在每一个页面都是占位。
|
||||||
|
- **Slint 版在翻牌前不退场**:`crates/app` 作为参考实现保留,直到 iced 版通过等价验收;那时才把
|
||||||
|
`unlucky-launcher-iced` 改名成 `unlucky-launcher`,并让 Slint 版退役。
|
||||||
|
|
||||||
|
工作区里的三个 bin,先认清楚:
|
||||||
|
|
||||||
|
| 二进制 | crate | 状态 |
|
||||||
|
|--------|-------|------|
|
||||||
|
| `unlucky-launcher` | `crates/app`(Slint) | v0.1.0 已发布,仍是当前能用的图形界面 |
|
||||||
|
| `unlucky-launcher-iced` | `crates/app-iced`(iced 0.14) | 施工中,仅骨架,不随发布包分发 |
|
||||||
|
| `ull` | `crates/cli` | 与发布版同源同版本构建;未随 v0.1.0 资产分发 |
|
||||||
|
|
||||||
## 模块地图
|
## 模块地图
|
||||||
|
|
||||||
```
|
```
|
||||||
Unlucky-Launcher (cargo workspace)
|
Unlucky-Launcher (cargo workspace)
|
||||||
├── crates/core # 领域库:零 UI 依赖,八模块
|
├── crates/core # 领域库:零 UI 依赖,八模块
|
||||||
├── crates/agent # AI Agent:ToolSpec 注册表 + 内置客户端 + MCP server(双形态共用一个内核)
|
├── crates/agent # AI Agent:ToolSpec 注册表 + 内置客户端 + MCP server(双形态共用一个内核)
|
||||||
├── crates/app # Slint UI:唯一允许依赖 UI 框架的 crate
|
├── crates/design # 设计令牌:真 HCT 色彩 / 排版 / 形状 / 间距 / 高程 / 动效 / 图标 / 组件度量
|
||||||
├── crates/cli # CLI(bin `ull`):headless,零 UI,只依赖 core + agent
|
│ # —— 零 UI 依赖、零第三方 crate
|
||||||
└── packaging # 单文件产物矩阵与打包资源(规划中)
|
├── crates/app # Slint UI(bin `unlucky-launcher`):已发布的图形界面,保留到等价验收通过
|
||||||
|
├── crates/app-iced # 下一代 iced UI(bin `unlucky-launcher-iced`):施工中
|
||||||
|
├── crates/cli # CLI(bin `ull`):headless,零 UI
|
||||||
|
└── packaging # 单文件产物矩阵与发布脚本
|
||||||
```
|
```
|
||||||
|
|
||||||
`crates/core` 的八个模块:
|
`crates/core` 的八个模块:
|
||||||
@@ -47,7 +87,7 @@ Unlucky-Launcher (cargo workspace)
|
|||||||
| `download` | 多源健康度、镜像、分块下载、边下边算 |
|
| `download` | 多源健康度、镜像、分块下载、边下边算 |
|
||||||
| `game` | 版本/实例仓库、快照与继承解析 |
|
| `game` | 版本/实例仓库、快照与继承解析 |
|
||||||
| `launch` | 10 阶段启动管线、进程、JSONL 事件流 |
|
| `launch` | 10 阶段启动管线、进程、JSONL 事件流 |
|
||||||
| `auth` | Microsoft 正版登录 / 本地离线档案 |
|
| `auth` | Microsoft 账户登录(OAuth 2.0 device code → Xbox Live → XSTS → Minecraft Services) |
|
||||||
| `addon` | 加载器安装、整合包、CurseForge / Modrinth |
|
| `addon` | 加载器安装、整合包、CurseForge / Modrinth |
|
||||||
| `diagnose` | 五类信号采集、YAML 规则引擎、hs_err 解析 |
|
| `diagnose` | 五类信号采集、YAML 规则引擎、hs_err 解析 |
|
||||||
| `platform` | OS 抽象:路径、进程、文件锁、能力探测、运行期 OS 检测 |
|
| `platform` | OS 抽象:路径、进程、文件锁、能力探测、运行期 OS 检测 |
|
||||||
@@ -56,44 +96,71 @@ Unlucky-Launcher (cargo workspace)
|
|||||||
|
|
||||||
前置:
|
前置:
|
||||||
|
|
||||||
- Rust stable(`rust-toolchain.toml` 会自动选择工具链;组件 `rustfmt` / `clippy`)
|
- Rust stable(`rust-toolchain.toml` 会自动选工具链;门禁还需要 `rustfmt` / `clippy` 组件)
|
||||||
- Windows:MSVC 工具链(CI 用)或 MinGW-w64(`x86_64-pc-windows-gnu`)
|
- Windows:MSVC 工具链(CI 用这套)或 MinGW-w64(`x86_64-pc-windows-gnu`)
|
||||||
- Linux:`libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev`
|
- Linux:`libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev
|
||||||
(Slint winit 后端 + femtovg 编译依赖)
|
libxcb1-dev`——两套 UI 都要用(Slint 的 winit + femtovg,iced 的 winit + wgpu/tiny-skia)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo fmt --all --check # 格式
|
cargo fmt --all --check # 格式
|
||||||
cargo clippy --workspace --all-targets -- -D warnings # 静态检查(零 warning 门禁)
|
cargo clippy --workspace --all-targets -- -D warnings # 静态检查(零 warning 门禁)
|
||||||
cargo test --workspace # 测试
|
cargo test --workspace # 测试
|
||||||
cargo build --release # 产物:target/release/unlucky-launcher[.exe]
|
cargo build --release # 三个 bin 都落在 target/release/
|
||||||
```
|
```
|
||||||
|
|
||||||
软渲染开关(无可用 GPU / 烂驱动的老机):`unlucky-launcher --software-renderer`
|
编出来的三个 bin:
|
||||||
(等价于 `SLINT_BACKEND=winit-software`)。
|
|
||||||
|
- `unlucky-launcher`——Slint 版(已发布的那个)
|
||||||
|
- `unlucky-launcher-iced`——iced 版骨架。名字是临时的:只有翻牌时才能改成 `unlucky-launcher`,
|
||||||
|
因为同一个工作区里两个 crate 不能往同一个 target 目录里产出同名 bin。
|
||||||
|
- `ull`——CLI
|
||||||
|
|
||||||
|
渲染开关:
|
||||||
|
|
||||||
|
- `unlucky-launcher --software-renderer`(等价于 `SLINT_BACKEND=winit-software`)强制 Slint 版走 CPU
|
||||||
|
- `unlucky-launcher-iced --software-renderer` 强制 tiny-skia(`ICED_BACKEND=tiny-skia`);
|
||||||
|
`--screenshot <png>` 出一帧图后退出,这是验收通道
|
||||||
|
- 两套栈在没有可用适配器时都会退到 CPU:Slint 版走它自己的渲染器回退链,iced 版走显式探测
|
||||||
|
|
||||||
依赖许可检查(本地可选,CI 必跑):`cargo deny check licenses bans sources`。
|
依赖许可检查(本地可选,CI 必跑):`cargo deny check licenses bans sources`。
|
||||||
|
|
||||||
## CI 门禁
|
## CI 门禁
|
||||||
|
|
||||||
|
每次推送到 `main` 与每个 PR 都跑 fmt / clippy(warnings 视为错误)/ 全量测试,Linux 无头渲染冒烟
|
||||||
|
与它们同级、是硬门禁:红了就不许合。macOS 运行期探针还不算——它只在手动 `workflow_dispatch` 时跑,
|
||||||
|
结果留证据但不作为流水线结论(`continue-on-error`),直到该槽位在真实 runner 上首跑通过。
|
||||||
|
|
||||||
| Workflow | 内容 |
|
| Workflow | 内容 |
|
||||||
|----------|------|
|
|----------|------|
|
||||||
| `.github/workflows/ci.yml` | fmt + clippy + test + build,Windows / Linux 矩阵 |
|
| `.github/workflows/ci.yml` | fmt + clippy + test + release build(Windows / Linux 矩阵);Linux runtime smoke(Xvfb 里真渲染一帧,再断言 PNG 非空白);macOS 运行期探针(仅手动 dispatch) |
|
||||||
| `.github/workflows/guards.yml` | core 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可守门 |
|
| `.github/workflows/guards.yml` | `crates/core` 零 UI 守门(依赖表 + `cargo tree`)、单文件 ≤400 行守门、cargo-deny 许可与来源守门 |
|
||||||
|
| `.github/workflows/release.yml` | tag `v*` → 分槽位构建 → 单文件产物 + `SHA256SUMS.txt` → GitHub Release |
|
||||||
|
|
||||||
## 平台基线
|
## 平台基线
|
||||||
|
|
||||||
最低基线 **Windows 10**。Windows 8.1 / 8 / 7 不在支持范围内:
|
最低基线 **Windows 10**;Windows 8.1 / 8 / 7 不在支持范围内。
|
||||||
|
|
||||||
- Windows 10/11 走**标准工具链**产物(MSVC / GNU),无自定义 target spec、无 `-Zbuild-std`、无导入表白名单
|
- Windows 10/11 的产物由**标准工具链**(MSVC / GNU)构建,构建方式与上游 cargo 一致
|
||||||
- 其他平台:macOS 12+ 一等;Linux x64 X11 优先、Wayland 为**大方向**;Android Termux 试验级不承诺
|
- macOS 12+(Apple Silicon 与 Intel)与 Linux x64 / arm64 都是支持槽位;Linux 上 X11 是当前首选路径,
|
||||||
- UI 兜底:Slint 软渲染(无 GPU / 烂驱动的老机可完整使用)
|
Wayland 是大方向。macOS 产物未签名,首次启动要自己清掉 Gatekeeper 隔离标记
|
||||||
- 平台独立性体现在**独立测试矩阵**,而非独立产物链;能力差异用 feature gate + 运行期探测表达
|
(`xattr -d com.apple.quarantine`)
|
||||||
|
- Android Termux 试验级、不承诺:只保证 CLI,GUI 未验证
|
||||||
|
- 每个平台跑各自的测试矩阵;能力差异用 feature gate + 运行期探测表达。
|
||||||
|
|
||||||
## 许可证与致谢
|
## 许可证与致谢
|
||||||
|
|
||||||
- 本项目:Apache License 2.0,见 [LICENSE](LICENSE);第三方声明见 [NOTICE](NOTICE)
|
- **本项目**:Apache License 2.0,见 [LICENSE](LICENSE);第三方声明见 [NOTICE](NOTICE)。
|
||||||
- GUI 使用 [Slint](https://slint.dev)(Royalty-Free 2.0 轨道)。按 RF 2.0 义务,关于页将放置
|
- **Slint**(<https://slint.dev>),Royalty-Free 2.0:v0.1.0 发布的正是 Slint 版,这项义务继续有效
|
||||||
`AboutSlint` 部件(P2),在此之前由 NOTICE 与本节承担告知义务
|
——关于页放 `AboutSlint` 部件(尚未落地),在此之前由 NOTICE、本节与发布页的「Made with Slint」
|
||||||
|
说明承担告知义务。只要还有链接 Slint 的产物在发,这条义务就保留。
|
||||||
|
- **iced**(<https://github.com/iced-rs/iced>),MIT:下一代 UI 的框架;与它同栈的 `winit`、以及经它
|
||||||
|
引入的 `wgpu` / `tiny-skia`(Apache-2.0 / MIT)。
|
||||||
|
- **Google Material Color Utilities**(Apache-2.0):`crates/design` 的色彩令牌是它 HCT /
|
||||||
|
ColorSpec2021 算法的 Rust 重写移植,移植清单与逐函数对照写在 [NOTICE](NOTICE)。
|
||||||
|
- **其余主要依赖**:MCP server 的 `rmcp` 与 `axum`(Apache-2.0 / MIT)、`tokio`(MIT)、`reqwest`
|
||||||
|
与 rustls(MIT / Apache-2.0)、`serde` / `serde_json`(MIT / Apache-2.0)、`zip` / `flate2` / `tar`
|
||||||
|
(MIT / Apache-2.0)。cargo-deny 强制执行的白名单在 `deny.toml`;GPL 系依赖不得进主干。
|
||||||
|
|
||||||
感谢 [HMCL](https://github.com/HMCL-dev/HMCL) 和 [PCL2](https://github.com/Meloong-Git/PCL)(均 GPL-3.0)这两个启动器陪伴了绝大多数 MC 玩家好多年。
|
感谢 [HMCL](https://github.com/HMCL-dev/HMCL) 和 [PCL2](https://github.com/Meloong-Git/PCL)
|
||||||
ULL 的设计与实现从它们身上学到了太多,谨以此项目向两位前辈致敬。
|
(均 GPL-3.0)。这两款启动器陪着绝大多数 MC 玩家走了很多年,
|
||||||
|
本项目在设计取舍与实现细节上都从中受益良多。
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
[package]
|
||||||
|
name = "unlucky-app-iced"
|
||||||
|
description = "Unlucky-Launcher 桌面壳(iced 0.14 重写版):M3 Expressive 骨架 / 令牌适配 / 截图通道"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
# 并存期(D26):Slint 版仍是 `crates/app` 的 `unlucky-launcher`,本 crate 出
|
||||||
|
# `unlucky-launcher-iced`。两个 package 在同一 target 目录产出同名 bin 会冲突,
|
||||||
|
# 所以**翻牌前不许改回 `unlucky-launcher`**(步骤见 SubAgentsTalk/app-iced-骨架与翻牌.md)。
|
||||||
|
[[bin]]
|
||||||
|
name = "unlucky-launcher-iced"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "unlucky_app_iced"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
|
||||||
|
unlucky-design = { path = "../design", version = "0.1" }
|
||||||
|
|
||||||
|
# UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。
|
||||||
|
# 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`),
|
||||||
|
# 这里给 `thread-pool`(iced 自己的默认执行器,不额外引 tokio 运行时)。
|
||||||
|
# `wgpu` 由本 crate 的 `gpu` feature 打开,于是「仅 CPU」体积基线用 `--no-default-features` 就能量到。
|
||||||
|
iced = { version = "=0.14.0", default-features = false, features = [
|
||||||
|
"tiny-skia",
|
||||||
|
"crisp",
|
||||||
|
"advanced",
|
||||||
|
"svg",
|
||||||
|
"thread-pool",
|
||||||
|
"x11",
|
||||||
|
"wayland",
|
||||||
|
"linux-theme-detection",
|
||||||
|
] }
|
||||||
|
|
||||||
|
# 截图落 PNG。只开 png 编码器,不要默认全套 codec(体积)。
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["gpu"]
|
||||||
|
# 硬件渲染后端(wgpu→Vulkan/DX12)。关闭后只剩 tiny-skia(CPU),用于体积基线与无 GPU 机器。
|
||||||
|
gpu = ["iced/wgpu"]
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
//! Elm 三件套:`State` / `Message` / `update` + `view` 接线。
|
||||||
|
//!
|
||||||
|
//! ## 启动序列(顺序有讲究)
|
||||||
|
//!
|
||||||
|
//! 1. **探测渲染后端** → 写 `ICED_BACKEND`(必须在 `run()` 之前,M60 Q1);
|
||||||
|
//! 2. **求值字体栈**(D28 前半)→ 一行启动日志;
|
||||||
|
//! 3. `iced::application(..).run()`:窗口 1180×760、无边框(自绘标题栏);
|
||||||
|
//! 4. 首个 `view()` 打「到首帧」耗时;截图模式下第 3 帧 `window::screenshot` 落盘后退出。
|
||||||
|
//!
|
||||||
|
//! ## 为什么截图要等 3 帧
|
||||||
|
//!
|
||||||
|
//! 无边框窗口启动时会先收到一次「显示器工作区尺寸」的 `Resized`,再回到设定尺寸(M60 坑 #5),
|
||||||
|
//! 第一帧可能是旧尺寸;再叠上字体系统首次上屏的排版抖动,留 2 帧余量最稳。
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use iced::{window, window::Direction, Element, Subscription, Task};
|
||||||
|
|
||||||
|
use unlucky_design::color::SchemeFlavor;
|
||||||
|
|
||||||
|
use crate::cli::Options;
|
||||||
|
use crate::fonts;
|
||||||
|
use crate::probe;
|
||||||
|
use crate::shell::{self, NavItem};
|
||||||
|
use crate::shot;
|
||||||
|
use crate::theme::{Families, Ui};
|
||||||
|
|
||||||
|
/// 验收窗口尺寸(1180×760,100% DPI —— D30 的硬要求)。
|
||||||
|
pub const WINDOW_SIZE: iced::Size = iced::Size {
|
||||||
|
width: 1180.0,
|
||||||
|
height: 760.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 产品名(自绘标题栏的标题;也是 `'static`,好让 view 借用)。
|
||||||
|
pub const APP_TITLE: &str = "Unlucky Launcher";
|
||||||
|
|
||||||
|
/// 窗口最小尺寸(低于这个值骨架会挤坏:88 导航 + 页面留白 + 卡片)。
|
||||||
|
const MIN_SIZE: iced::Size = iced::Size {
|
||||||
|
width: 880.0,
|
||||||
|
height: 560.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 截图落盘的帧号(见模块文档)。
|
||||||
|
const CAPTURE_AT_FRAME: u32 = 3;
|
||||||
|
|
||||||
|
/// 进程内「run() 开始」的时刻(首帧耗时基线)。
|
||||||
|
static STARTED: OnceLock<Instant> = OnceLock::new();
|
||||||
|
/// 首帧日志只打一次。
|
||||||
|
static FIRST_FRAME_LOGGED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// 应用状态(**只有 UI 状态**;业务状态等页面 mission 接 `host`/`state` 时再进)。
|
||||||
|
pub struct App {
|
||||||
|
options: Options,
|
||||||
|
families: Families,
|
||||||
|
ui: Ui,
|
||||||
|
nav: NavItem,
|
||||||
|
window: Option<window::Id>,
|
||||||
|
frames: u32,
|
||||||
|
/// 截图模式的「主题已定」闸门:`system::theme()` 是异步的,早拍会拍到错的深浅色。
|
||||||
|
theme_resolved: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 消息(单一大枚举;页面接进来时按页拆子枚举)。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Message {
|
||||||
|
/// 启动即发一次(`iced::application` 的 boot 闭包)。
|
||||||
|
Boot,
|
||||||
|
/// `window::latest()` 的结果。
|
||||||
|
WindowReady(Option<window::Id>),
|
||||||
|
/// 系统主题探测结果。
|
||||||
|
SystemTheme(iced::theme::Mode),
|
||||||
|
/// 一帧画完(只有截图模式才订阅)。
|
||||||
|
Frame,
|
||||||
|
/// 导航项切换。
|
||||||
|
Select(NavItem),
|
||||||
|
/// 按下标题栏 → 拖窗。
|
||||||
|
DragWindow,
|
||||||
|
/// 按下边缘热区 → 缩放窗口。
|
||||||
|
ResizeStart(Direction),
|
||||||
|
/// 最小化。
|
||||||
|
Minimize,
|
||||||
|
/// 最大化/还原。
|
||||||
|
ToggleMaximize,
|
||||||
|
/// 关闭。
|
||||||
|
Close,
|
||||||
|
/// 截图回来了。
|
||||||
|
Captured(window::Screenshot),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
fn new(options: Options, families: Families, flavor: SchemeFlavor) -> Self {
|
||||||
|
let options_theme_given = options.shot_flavor().is_some();
|
||||||
|
Self {
|
||||||
|
ui: Ui::new(flavor, families),
|
||||||
|
families,
|
||||||
|
options,
|
||||||
|
nav: NavItem::Instances,
|
||||||
|
window: None,
|
||||||
|
frames: 0,
|
||||||
|
// 显式给了主题就算已定;否则等 `SystemTheme` 到达
|
||||||
|
theme_resolved: options_theme_given,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_screenshot(&self) -> bool {
|
||||||
|
self.options.screenshot.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, message: Message) -> Task<Message> {
|
||||||
|
match message {
|
||||||
|
Message::Boot => {
|
||||||
|
let mut tasks = vec![window::latest().map(Message::WindowReady)];
|
||||||
|
|
||||||
|
// 主题:参数里没指定就探系统(深浅两套都要能出图)
|
||||||
|
if self.options.shot_flavor().is_none() {
|
||||||
|
tasks.push(iced::system::theme().map(Message::SystemTheme));
|
||||||
|
}
|
||||||
|
|
||||||
|
Task::batch(tasks)
|
||||||
|
}
|
||||||
|
Message::WindowReady(id) => {
|
||||||
|
self.window = id;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::SystemTheme(mode) => {
|
||||||
|
let flavor = match mode {
|
||||||
|
iced::theme::Mode::Dark => SchemeFlavor::Dark,
|
||||||
|
iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light,
|
||||||
|
};
|
||||||
|
eprintln!("[theme] 系统主题 = {mode:?} -> {}", slug(flavor));
|
||||||
|
self.set_flavor(flavor);
|
||||||
|
self.theme_resolved = true;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::Frame => self.on_frame(),
|
||||||
|
Message::Select(nav) => {
|
||||||
|
self.nav = nav;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::DragWindow => match self.window {
|
||||||
|
Some(id) => window::drag(id),
|
||||||
|
None => Task::none(),
|
||||||
|
},
|
||||||
|
Message::ResizeStart(direction) => match self.window {
|
||||||
|
Some(id) => window::drag_resize(id, direction),
|
||||||
|
None => Task::none(),
|
||||||
|
},
|
||||||
|
Message::Minimize => match self.window {
|
||||||
|
Some(id) => window::minimize(id, true),
|
||||||
|
None => Task::none(),
|
||||||
|
},
|
||||||
|
Message::ToggleMaximize => match self.window {
|
||||||
|
Some(id) => window::toggle_maximize(id),
|
||||||
|
None => Task::none(),
|
||||||
|
},
|
||||||
|
Message::Close => iced::exit(),
|
||||||
|
Message::Captured(screenshot) => self.on_captured(&screenshot),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切主题:只重建 [`Ui`](= 重建 Style),**不动任何 UI 状态**。
|
||||||
|
fn set_flavor(&mut self, flavor: SchemeFlavor) {
|
||||||
|
if self.ui.flavor() == flavor {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let next = Ui::new(flavor, self.families);
|
||||||
|
eprintln!(
|
||||||
|
"[theme] {} -> {} (Theme::name 变化触发缓存重算)",
|
||||||
|
self.ui.name(),
|
||||||
|
next.name()
|
||||||
|
);
|
||||||
|
self.ui = next;
|
||||||
|
}
|
||||||
|
fn on_frame(&mut self) -> Task<Message> {
|
||||||
|
self.frames += 1;
|
||||||
|
|
||||||
|
// 主题没定就不出图:等 `SystemTheme`(否则暗色系统上会拍到浅色图)
|
||||||
|
if !self.has_screenshot() || !self.theme_resolved || self.frames < CAPTURE_AT_FRAME {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.window {
|
||||||
|
Some(id) => window::screenshot(id).map(Message::Captured),
|
||||||
|
None => Task::none(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_captured(&mut self, screenshot: &window::Screenshot) -> Task<Message> {
|
||||||
|
let Some(path) = self.options.screenshot.clone() else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
|
||||||
|
match shot::save(screenshot, &path) {
|
||||||
|
Ok(summary) => {
|
||||||
|
eprintln!("{}", summary.describe());
|
||||||
|
eprintln!(
|
||||||
|
"[shot] theme={} renderer={}",
|
||||||
|
shot::flavor_slug(&self.ui),
|
||||||
|
renderer_note(self.options.backend)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => eprintln!("[shot] 失败:{error}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
iced::exit()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscription(&self) -> Subscription<Message> {
|
||||||
|
// 只有截图模式才订阅帧事件:否则「每帧一条消息 → 每帧重绘」会白烧 CPU。
|
||||||
|
if self.wants_frames() {
|
||||||
|
window::frames().map(|_| Message::Frame)
|
||||||
|
} else {
|
||||||
|
Subscription::none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否订阅帧事件(= 截图模式)。
|
||||||
|
fn wants_frames(&self) -> bool {
|
||||||
|
self.has_screenshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn theme(&self) -> iced::Theme {
|
||||||
|
self.ui.theme()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(&self) -> String {
|
||||||
|
APP_TITLE.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self) -> Element<'_, Message> {
|
||||||
|
if !FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) {
|
||||||
|
if let Some(started) = STARTED.get() {
|
||||||
|
eprintln!(
|
||||||
|
"[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)",
|
||||||
|
started.elapsed().as_secs_f64() * 1000.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shell::view(
|
||||||
|
&self.ui,
|
||||||
|
self.nav,
|
||||||
|
APP_TITLE,
|
||||||
|
self.nav.label(),
|
||||||
|
"Skeleton only: titlebar, navigation rail and one card. \
|
||||||
|
Business pages, the component library and the glass shader are follow-up missions.",
|
||||||
|
self.options.force_state,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn renderer_note(backend: crate::Backend) -> &'static str {
|
||||||
|
if cfg!(feature = "gpu") {
|
||||||
|
match backend {
|
||||||
|
crate::Backend::Cpu => "cpu(forced)",
|
||||||
|
crate::Backend::Gpu => "gpu(preferred)",
|
||||||
|
crate::Backend::Auto => "auto",
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"cpu(build without wgpu)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 主题名(日志用,和 `Ui::name()` 同一口径)。
|
||||||
|
fn slug(flavor: SchemeFlavor) -> &'static str {
|
||||||
|
if flavor.is_dark() {
|
||||||
|
"ull-m3-dark"
|
||||||
|
} else {
|
||||||
|
"ull-m3-light"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动 iced(阻塞到退出)。`--diff` 不走这里,见 `main.rs`。
|
||||||
|
pub(crate) fn run(options: Options) -> iced::Result {
|
||||||
|
let _ = STARTED.set(Instant::now());
|
||||||
|
|
||||||
|
let probe_started = Instant::now();
|
||||||
|
// 只有 `auto` 需要探测:`cpu` 与 `gpu` 的决策与「有没有硬件 GPU」无关,
|
||||||
|
// 而枚举 13 个适配器在本机要 0.65~1.2 s(实测),纯属白花。
|
||||||
|
let needs_probe = options.backend == crate::Backend::Auto;
|
||||||
|
let probe = if needs_probe {
|
||||||
|
probe::Probe::run()
|
||||||
|
} else {
|
||||||
|
probe::Probe::default()
|
||||||
|
};
|
||||||
|
let probe_ms = probe_started.elapsed().as_secs_f64() * 1000.0;
|
||||||
|
probe::apply(options.backend, &probe);
|
||||||
|
if cfg!(feature = "gpu") {
|
||||||
|
if needs_probe {
|
||||||
|
eprintln!("[probe] 探测耗时 {probe_ms:.0} ms(wgpu 实例 + 全后端适配器枚举)");
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"[probe] 探测耗时 {probe_ms:.0} ms(--renderer={} 与硬件无关,跳过枚举)",
|
||||||
|
options.backend.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let font_started = Instant::now();
|
||||||
|
let resolved = fonts::resolve();
|
||||||
|
eprintln!(
|
||||||
|
"[font] 求值耗时 {:.0} ms",
|
||||||
|
font_started.elapsed().as_secs_f64() * 1000.0
|
||||||
|
);
|
||||||
|
eprintln!("{}", resolved.describe());
|
||||||
|
if resolved.has_synthetic() {
|
||||||
|
eprintln!(
|
||||||
|
"[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换族)要处理"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let families = resolved.families();
|
||||||
|
|
||||||
|
// 截图模式:`--screenshot-theme` > `--theme` > 浅色(后续系统探测会覆盖)
|
||||||
|
let flavor = options.shot_flavor().unwrap_or(SchemeFlavor::Light);
|
||||||
|
let boot_options = options.clone();
|
||||||
|
|
||||||
|
let application = iced::application(
|
||||||
|
move || {
|
||||||
|
(
|
||||||
|
App::new(boot_options.clone(), families, flavor),
|
||||||
|
Task::done(Message::Boot),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
App::update,
|
||||||
|
App::view,
|
||||||
|
)
|
||||||
|
.title(App::title)
|
||||||
|
.theme(App::theme)
|
||||||
|
.subscription(App::subscription)
|
||||||
|
.default_font(iced::Font {
|
||||||
|
family: iced::font::Family::Name(families.text),
|
||||||
|
..iced::Font::DEFAULT
|
||||||
|
})
|
||||||
|
.window(window::Settings {
|
||||||
|
size: WINDOW_SIZE,
|
||||||
|
min_size: Some(MIN_SIZE),
|
||||||
|
decorations: false,
|
||||||
|
resizable: true,
|
||||||
|
..window::Settings::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
match options.scale_factor {
|
||||||
|
Some(scale) => application.scale_factor(move |_| scale).run(),
|
||||||
|
None => application.run(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const FAMILIES: Families = Families {
|
||||||
|
text: "Segoe UI",
|
||||||
|
display: "Segoe UI",
|
||||||
|
mono: "Consolas",
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switching_flavor_rebuilds_styles_but_keeps_ui_state() {
|
||||||
|
let mut app = App::new(Options::default(), FAMILIES, SchemeFlavor::Light);
|
||||||
|
app.nav = NavItem::Logs;
|
||||||
|
assert_eq!(app.ui.name(), "ull-m3-light");
|
||||||
|
|
||||||
|
app.set_flavor(SchemeFlavor::Dark);
|
||||||
|
|
||||||
|
assert_eq!(app.ui.name(), "ull-m3-dark", "切主题要换主题名");
|
||||||
|
assert_eq!(app.nav, NavItem::Logs, "切主题不许重置 UI 状态");
|
||||||
|
assert!(app.options.screenshot.is_none(), "切主题不许改启动参数");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_size_is_the_acceptance_size() {
|
||||||
|
assert_eq!((WINDOW_SIZE.width, WINDOW_SIZE.height), (1180.0, 760.0));
|
||||||
|
const {
|
||||||
|
assert!(MIN_SIZE.width < WINDOW_SIZE.width);
|
||||||
|
assert!(MIN_SIZE.height < WINDOW_SIZE.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slug_matches_the_theme_name() {
|
||||||
|
assert_eq!(slug(SchemeFlavor::Light), "ull-m3-light");
|
||||||
|
assert_eq!(slug(SchemeFlavor::Dark), "ull-m3-dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frames_are_only_requested_in_screenshot_mode() {
|
||||||
|
let mut app = App::new(Options::default(), FAMILIES, SchemeFlavor::Light);
|
||||||
|
assert!(!app.wants_frames(), "普通运行不订阅帧,避免每帧重绘");
|
||||||
|
|
||||||
|
app.options.screenshot = Some(std::path::PathBuf::from("x.png"));
|
||||||
|
assert!(
|
||||||
|
app.wants_frames(),
|
||||||
|
"截图模式必须订阅帧事件才能在第 3 帧出图"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
//! 命令行参数(只做骨架期需要的那些;业务开关等页面 mission 再加)。
|
||||||
|
//!
|
||||||
|
//! 取值语法与现役 Slint 版一致:`--key value` 与 `--key=value` 两种都认
|
||||||
|
//! (`crates/app/src/screenshot.rs` 的 `value_of` 是同一口径),这样将来 CI 换 bin 不用改脚本。
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use unlucky_design::color::SchemeFlavor;
|
||||||
|
|
||||||
|
/// 渲染后端请求(D27)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum Backend {
|
||||||
|
/// 探测后决定:有硬件 GPU 就让 iced 自己走 wgpu→tiny-skia 回退;没有就强制 CPU 起步。
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
/// 优先 GPU(写 `ICED_BACKEND=wgpu,tiny-skia`:拿不到适配器仍会降级,不硬失败)。
|
||||||
|
Gpu,
|
||||||
|
/// 强制 CPU(`--software-renderer` 的等价物,写 `ICED_BACKEND=tiny-skia`)。
|
||||||
|
Cpu,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Backend {
|
||||||
|
/// 稳定名字(日志用)。
|
||||||
|
pub const fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Backend::Auto => "auto",
|
||||||
|
Backend::Gpu => "gpu",
|
||||||
|
Backend::Cpu => "cpu",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析后的启动参数。
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Options {
|
||||||
|
/// 渲染后端请求。
|
||||||
|
pub backend: Backend,
|
||||||
|
/// 主题:`None` = 探测系统主题。
|
||||||
|
pub theme: Option<SchemeFlavor>,
|
||||||
|
/// 截图通道:给了路径就出图后退出。
|
||||||
|
pub screenshot: Option<PathBuf>,
|
||||||
|
/// 截图时用的主题(`--screenshot-theme`,优先级高于 `--theme`)。
|
||||||
|
pub screenshot_theme: Option<SchemeFlavor>,
|
||||||
|
/// 强制窗口缩放因子(截图验收要 100% DPI;`None` = 用系统 DPI)。
|
||||||
|
pub scale_factor: Option<f32>,
|
||||||
|
/// `--diff <left> <right>`:只做两图逐像素对比,不开窗。
|
||||||
|
pub diff: Option<(PathBuf, PathBuf)>,
|
||||||
|
/// `--diff-scan <n>`:对比时额外做 ±n px 的平移扫描(找最佳对齐,默认 0 = 不扫)。
|
||||||
|
pub diff_scan: u32,
|
||||||
|
/// `--force-state=hover|pressed`:把按钮强制成某个状态便于截图取证(D30 的状态图)。
|
||||||
|
pub force_state: Option<ForcedState>,
|
||||||
|
/// `--data-dir <dir>`:当前**接受并忽略**。
|
||||||
|
///
|
||||||
|
/// 现役 Slint 版的 CI 冒烟用 `--software-renderer --screenshot <png> --data-dir <dir>`,
|
||||||
|
/// 翻牌后同一套脚本要能直接跑 iced 版;真正的数据目录消费由后续页面 mission 接 `host`/`state` 时做。
|
||||||
|
pub data_dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 截图取证用的强制交互状态(只影响观感,不改变任何状态机)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ForcedState {
|
||||||
|
/// 所有可交互控件按 hover 画。
|
||||||
|
Hover,
|
||||||
|
/// 所有可交互控件按 pressed 画。
|
||||||
|
Pressed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ForcedState {
|
||||||
|
/// 稳定名字(日志用)。
|
||||||
|
pub const fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ForcedState::Hover => "hover",
|
||||||
|
ForcedState::Pressed => "pressed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Options {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
backend: Backend::Auto,
|
||||||
|
theme: None,
|
||||||
|
screenshot: None,
|
||||||
|
screenshot_theme: None,
|
||||||
|
scale_factor: None,
|
||||||
|
diff: None,
|
||||||
|
diff_scan: 0,
|
||||||
|
force_state: None,
|
||||||
|
data_dir: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Options {
|
||||||
|
/// 从进程参数解析。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// 参数值不合法(未知的 `--renderer` / `--theme` / 非法 `--scale-factor`)时返回人话。
|
||||||
|
pub fn from_env() -> Result<Self, String> {
|
||||||
|
Self::parse(std::env::args().skip(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从任意参数序列解析(`argv[1..]`,测试直接喂数组)。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// 同 [`Options::from_env`]。
|
||||||
|
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
|
||||||
|
let mut options = Self::default();
|
||||||
|
let mut args = args.into_iter();
|
||||||
|
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
let (key, inline) = match arg.split_once('=') {
|
||||||
|
Some((key, value)) => (key.to_owned(), Some(value.to_owned())),
|
||||||
|
None => (arg.clone(), None),
|
||||||
|
};
|
||||||
|
|
||||||
|
match key.as_str() {
|
||||||
|
"--software-renderer" => options.backend = Backend::Cpu,
|
||||||
|
"--renderer" => {
|
||||||
|
let value = take(&mut args, inline, "--renderer")?;
|
||||||
|
options.backend = match value.as_str() {
|
||||||
|
"auto" => Backend::Auto,
|
||||||
|
"gpu" => Backend::Gpu,
|
||||||
|
"cpu" => Backend::Cpu,
|
||||||
|
other => {
|
||||||
|
return Err(format!("--renderer 只认 auto|gpu|cpu,收到 {other:?}"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
"--theme" => {
|
||||||
|
options.theme = Some(flavor(&take(&mut args, inline, "--theme")?)?);
|
||||||
|
}
|
||||||
|
"--screenshot-theme" => {
|
||||||
|
options.screenshot_theme =
|
||||||
|
Some(flavor(&take(&mut args, inline, "--screenshot-theme")?)?);
|
||||||
|
}
|
||||||
|
"--screenshot" => {
|
||||||
|
options.screenshot =
|
||||||
|
Some(PathBuf::from(take(&mut args, inline, "--screenshot")?));
|
||||||
|
}
|
||||||
|
"--scale-factor" => {
|
||||||
|
let raw = take(&mut args, inline, "--scale-factor")?;
|
||||||
|
let value: f32 = raw
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| format!("--scale-factor 需要数字,收到 {raw:?}"))?;
|
||||||
|
if !(value.is_finite() && value > 0.0) {
|
||||||
|
return Err(format!("--scale-factor 必须 > 0,收到 {raw:?}"));
|
||||||
|
}
|
||||||
|
options.scale_factor = Some(value);
|
||||||
|
}
|
||||||
|
"--diff" => {
|
||||||
|
let left = take(&mut args, inline, "--diff")?;
|
||||||
|
let right = args
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "--diff 需要两张图的路径".to_owned())?;
|
||||||
|
options.diff = Some((PathBuf::from(left), PathBuf::from(right)));
|
||||||
|
}
|
||||||
|
"--diff-scan" => {
|
||||||
|
let raw = take(&mut args, inline, "--diff-scan")?;
|
||||||
|
options.diff_scan = raw
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| format!("--diff-scan 需要非负整数,收到 {raw:?}"))?;
|
||||||
|
}
|
||||||
|
"--force-state" => {
|
||||||
|
let raw = take(&mut args, inline, "--force-state")?;
|
||||||
|
options.force_state = Some(match raw.as_str() {
|
||||||
|
"hover" => ForcedState::Hover,
|
||||||
|
"pressed" => ForcedState::Pressed,
|
||||||
|
other => {
|
||||||
|
return Err(format!(
|
||||||
|
"--force-state 只认 hover|pressed,收到 {other:?}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 翻牌前的兼容参数:接受并忽略(见字段文档)
|
||||||
|
"--data-dir" => {
|
||||||
|
options.data_dir = Some(PathBuf::from(take(&mut args, inline, "--data-dir")?));
|
||||||
|
}
|
||||||
|
other => return Err(format!("未知参数 {other:?}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 截图时实际使用的主题(截图专用参数优先)。
|
||||||
|
pub fn shot_flavor(&self) -> Option<SchemeFlavor> {
|
||||||
|
self.screenshot_theme.or(self.theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take(
|
||||||
|
args: &mut impl Iterator<Item = String>,
|
||||||
|
inline: Option<String>,
|
||||||
|
flag: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
inline
|
||||||
|
.or_else(|| args.next())
|
||||||
|
.ok_or_else(|| format!("{flag} 需要一个值"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flavor(raw: &str) -> Result<SchemeFlavor, String> {
|
||||||
|
match raw {
|
||||||
|
"light" => Ok(SchemeFlavor::Light),
|
||||||
|
"dark" => Ok(SchemeFlavor::Dark),
|
||||||
|
other => Err(format!("--theme 只认 light|dark,收到 {other:?}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn parse(args: &[&str]) -> Result<Options, String> {
|
||||||
|
Options::parse(args.iter().map(|arg| (*arg).to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_are_auto_and_system_theme() {
|
||||||
|
let options = parse(&[]).expect("空参数要能解析");
|
||||||
|
assert_eq!(options.backend, Backend::Auto);
|
||||||
|
assert_eq!(options.theme, None);
|
||||||
|
assert_eq!(options.screenshot, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn software_renderer_is_an_alias_of_cpu() {
|
||||||
|
assert_eq!(
|
||||||
|
parse(&["--software-renderer"]).unwrap().backend,
|
||||||
|
Backend::Cpu
|
||||||
|
);
|
||||||
|
assert_eq!(parse(&["--renderer=cpu"]).unwrap().backend, Backend::Cpu);
|
||||||
|
assert_eq!(parse(&["--renderer", "gpu"]).unwrap().backend, Backend::Gpu);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn screenshot_accepts_both_argv_forms() {
|
||||||
|
let spaced = parse(&["--screenshot", "out.png"]).unwrap();
|
||||||
|
let inline = parse(&["--screenshot=out.png"]).unwrap();
|
||||||
|
assert_eq!(spaced.screenshot, Some(PathBuf::from("out.png")));
|
||||||
|
assert_eq!(spaced.screenshot, inline.screenshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn screenshot_theme_wins_over_theme() {
|
||||||
|
let options = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap();
|
||||||
|
assert_eq!(options.theme, Some(SchemeFlavor::Dark));
|
||||||
|
assert_eq!(options.shot_flavor(), Some(SchemeFlavor::Light));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bad_values_are_reported_not_panicked() {
|
||||||
|
assert!(parse(&["--renderer=metal"]).is_err());
|
||||||
|
assert!(parse(&["--theme=blue"]).is_err());
|
||||||
|
assert!(parse(&["--scale-factor=0"]).is_err());
|
||||||
|
assert!(parse(&["--scale-factor=abc"]).is_err());
|
||||||
|
assert!(parse(&["--screenshot"]).is_err());
|
||||||
|
assert!(parse(&["--diff", "only-one.png"]).is_err());
|
||||||
|
assert!(parse(&["--nope"]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn data_dir_is_accepted_and_ignored_for_the_flip() {
|
||||||
|
// 现役 CI 冒烟:--software-renderer --screenshot <png> --data-dir <dir>
|
||||||
|
let options = parse(&[
|
||||||
|
"--software-renderer",
|
||||||
|
"--screenshot",
|
||||||
|
"shot.png",
|
||||||
|
"--data-dir",
|
||||||
|
"/d/tmp/x",
|
||||||
|
])
|
||||||
|
.expect("翻牌前必须兼容 --data-dir");
|
||||||
|
assert_eq!(options.backend, Backend::Cpu);
|
||||||
|
assert_eq!(options.screenshot, Some(PathBuf::from("shot.png")));
|
||||||
|
assert_eq!(options.data_dir, Some(PathBuf::from("/d/tmp/x")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diff_scan_and_force_state_parse() {
|
||||||
|
assert_eq!(parse(&["--diff-scan=2"]).unwrap().diff_scan, 2);
|
||||||
|
assert_eq!(
|
||||||
|
parse(&["--force-state", "hover"]).unwrap().force_state,
|
||||||
|
Some(ForcedState::Hover)
|
||||||
|
);
|
||||||
|
assert!(parse(&["--force-state=focus"]).is_err());
|
||||||
|
assert!(parse(&["--diff-scan=-1"]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diff_takes_two_paths() {
|
||||||
|
let options = parse(&["--diff", "a.png", "b.png"]).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
options.diff,
|
||||||
|
Some((PathBuf::from("a.png"), PathBuf::from("b.png")))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
//! 字体基座(D28 前半):把令牌的字体栈求值成「本机真的有的族名」,并打一行启动日志。
|
||||||
|
//!
|
||||||
|
//! ## 做什么
|
||||||
|
//!
|
||||||
|
//! 1. 取 `Platform::current()` 的三条栈(Display / Text / Mono,见 `unlucky_design::typography`);
|
||||||
|
//! 2. 用 iced 的**全局字体系统**(`iced_graphics::text::font_system()`,即 cosmic-text 的
|
||||||
|
//! `FontSystem`,首次调用会扫系统字体)核对每个族名是否装机 → 取栈里第一个装机可用的族;
|
||||||
|
//! 3. 给出 400/500/700 三档字重的落点(原生 / 最近档 / 合成加粗):
|
||||||
|
//! **先查系统里这个族真实提供的字面(fontdb 的 `FaceInfo.weight`)**,查不到才回落到令牌的
|
||||||
|
//! 纯函数 `Platform::resolve_weight`。缺档按降级记录。
|
||||||
|
//!
|
||||||
|
//! ## 为什么要真查系统
|
||||||
|
//!
|
||||||
|
//! 令牌栈写的是「Win11 有 Segoe UI Variable Text」,但 Win10 / Server 只有 `Segoe UI`(静态
|
||||||
|
//! 300/400/600/700,**没有 500**),干净安装的 Linux 可能一个都没有。只按栈头写死族名会把
|
||||||
|
//! 「实际使用哪个族、哪一档字重」变成猜测——本 mission 的开发机就是 Windows Server 2025,
|
||||||
|
//! 实测就回退到了经典 `Segoe UI`(见交接文档的启动日志)。
|
||||||
|
|
||||||
|
//! ## 不做什么
|
||||||
|
//!
|
||||||
|
//! **中文字体 CDN 下载不在这里**(后续 mission)。本模块只做「系统字体优先」这一半,
|
||||||
|
//! 也没有能力判断 CJK 字形覆盖(那是 `ttf-parser` 查 cmap 的活,M60 Q3 的 spike 做过)。
|
||||||
|
|
||||||
|
use unlucky_design::typography::{FontFamily, Platform, Weight, WeightMatch};
|
||||||
|
|
||||||
|
use crate::theme::Families;
|
||||||
|
|
||||||
|
/// 一条栈的求值结果。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct FamilyReport {
|
||||||
|
/// 栈的用途(`text` / `display` / `mono`)。
|
||||||
|
pub stack: &'static str,
|
||||||
|
/// 实际选中的族名(栈里第一个装机可用的;都没装就是栈头)。
|
||||||
|
pub family: &'static str,
|
||||||
|
/// 选中的族是否真的在系统字体库里。
|
||||||
|
pub installed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一档字重的求值结果。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct WeightReport {
|
||||||
|
/// 请求的字重(400/500/700)。
|
||||||
|
pub requested: u16,
|
||||||
|
/// 实际可用的字重(降级后)。
|
||||||
|
pub available: u16,
|
||||||
|
/// 匹配质量(原生 / 最近档 / 合成加粗)。
|
||||||
|
pub quality: WeightMatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 字体求值的全部结论(启动日志与测试都用它)。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Resolved {
|
||||||
|
/// 平台(决定栈与字重覆盖表)。
|
||||||
|
pub platform: Platform,
|
||||||
|
/// 三条栈的落点。
|
||||||
|
pub families: [FamilyReport; 3],
|
||||||
|
/// 三档字重的落点(对**正文**栈求值)。
|
||||||
|
pub weights: [WeightReport; 3],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Resolved {
|
||||||
|
/// 把结论塞回主题适配层要的形状。
|
||||||
|
pub fn families(&self) -> Families {
|
||||||
|
Families {
|
||||||
|
text: self.families[0].family,
|
||||||
|
display: self.families[1].family,
|
||||||
|
mono: self.families[2].family,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否有任何一档走了「合成加粗」(= 观感降级,要记日志)。
|
||||||
|
pub fn has_synthetic(&self) -> bool {
|
||||||
|
self.weights
|
||||||
|
.iter()
|
||||||
|
.any(|weight| weight.quality == WeightMatch::Synthetic)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动日志(两行:一行族,一行字重)。
|
||||||
|
pub fn describe(&self) -> String {
|
||||||
|
let families = self
|
||||||
|
.families
|
||||||
|
.iter()
|
||||||
|
.map(|report| {
|
||||||
|
format!(
|
||||||
|
"{}={:?}{}",
|
||||||
|
report.stack,
|
||||||
|
report.family,
|
||||||
|
if report.installed {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
"(未装机→回落)"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
let weights = self
|
||||||
|
.weights
|
||||||
|
.iter()
|
||||||
|
.map(|report| {
|
||||||
|
let quality = match report.quality {
|
||||||
|
WeightMatch::Native => "native",
|
||||||
|
WeightMatch::Nearest => "nearest",
|
||||||
|
WeightMatch::Synthetic => "synthetic",
|
||||||
|
};
|
||||||
|
format!("w{}={}({quality})", report.requested, report.available)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"[font] platform={} {families} | weights {weights}",
|
||||||
|
self.platform.name()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 求值本机字体(会触发 iced 全局字体系统的首次初始化,约 0.1~0.8s)。
|
||||||
|
pub fn resolve() -> Resolved {
|
||||||
|
let platform = Platform::current();
|
||||||
|
let faces = system_faces();
|
||||||
|
|
||||||
|
let is_installed = |family: &str| -> bool {
|
||||||
|
faces
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|faces: &Vec<Face>| faces.iter().any(|face| face.family == family))
|
||||||
|
};
|
||||||
|
|
||||||
|
let families = [
|
||||||
|
FamilyReport {
|
||||||
|
stack: "text",
|
||||||
|
family: pick(platform, FontFamily::Text, &is_installed),
|
||||||
|
installed: is_installed(pick(platform, FontFamily::Text, &is_installed)),
|
||||||
|
},
|
||||||
|
FamilyReport {
|
||||||
|
stack: "display",
|
||||||
|
family: pick(platform, FontFamily::Display, &is_installed),
|
||||||
|
installed: is_installed(pick(platform, FontFamily::Display, &is_installed)),
|
||||||
|
},
|
||||||
|
FamilyReport {
|
||||||
|
stack: "mono",
|
||||||
|
family: pick(platform, FontFamily::Mono, &is_installed),
|
||||||
|
installed: is_installed(pick(platform, FontFamily::Mono, &is_installed)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 字重:**优先用系统里这个族真实提供的档位**(令牌表的 `WeightCoverage` 是「这个族通常有哪几档」,
|
||||||
|
// 装机版可能是静态子集:Win Server / 精简版只有经典 Segoe UI,没有 500)。查不到才回落到令牌纯函数。
|
||||||
|
let weights = [Weight::Regular, Weight::Medium, Weight::Bold]
|
||||||
|
.map(|weight| weight_report(platform, families[0].family, weight, faces.as_deref()));
|
||||||
|
|
||||||
|
Resolved {
|
||||||
|
platform,
|
||||||
|
families,
|
||||||
|
weights,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一条系统字面(族名 + 字重)。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct Face {
|
||||||
|
family: String,
|
||||||
|
weight: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 某档字重的落点:先问系统,再问令牌。
|
||||||
|
fn weight_report(
|
||||||
|
platform: Platform,
|
||||||
|
family: &str,
|
||||||
|
weight: Weight,
|
||||||
|
faces: Option<&[Face]>,
|
||||||
|
) -> WeightReport {
|
||||||
|
let requested = weight.value();
|
||||||
|
|
||||||
|
let available = faces.and_then(|faces| {
|
||||||
|
let mut weights = faces
|
||||||
|
.iter()
|
||||||
|
.filter(|face| face.family == family)
|
||||||
|
.map(|face| face.weight)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
weights.sort_unstable();
|
||||||
|
weights.dedup();
|
||||||
|
if weights.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(
|
||||||
|
weights
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.min_by_key(|candidate| candidate.abs_diff(requested))
|
||||||
|
.unwrap_or(requested),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
match available {
|
||||||
|
Some(available) => WeightReport {
|
||||||
|
requested,
|
||||||
|
available,
|
||||||
|
quality: quality_of(requested, available),
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
let choice = platform.resolve_weight(FontFamily::Text, weight);
|
||||||
|
WeightReport {
|
||||||
|
requested: choice.requested,
|
||||||
|
available: choice.available,
|
||||||
|
quality: choice.quality,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 与令牌层同一判据:原生 / 最近档(差 <200)/ 合成加粗(差 ≥200)。
|
||||||
|
fn quality_of(requested: u16, available: u16) -> WeightMatch {
|
||||||
|
match available.abs_diff(requested) {
|
||||||
|
0 => WeightMatch::Native,
|
||||||
|
delta if delta >= 200 => WeightMatch::Synthetic,
|
||||||
|
_ => WeightMatch::Nearest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在栈里选第一个**装机可用**的族(跳过 `sans-serif` 这类通用族名)。
|
||||||
|
///
|
||||||
|
/// 一个都没装上时返回栈头:让 cosmic-text 自己去回退,比我们猜一个更靠谱。
|
||||||
|
pub fn pick(
|
||||||
|
platform: Platform,
|
||||||
|
stack: FontFamily,
|
||||||
|
is_installed: &dyn Fn(&str) -> bool,
|
||||||
|
) -> &'static str {
|
||||||
|
let faces = platform.font_stack(stack);
|
||||||
|
|
||||||
|
faces
|
||||||
|
.iter()
|
||||||
|
.find(|face| !is_generic(face.family) && is_installed(face.family))
|
||||||
|
.map_or(faces[0].family, |face| face.family)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通用族名(CSS 关键字,不是真族名)。
|
||||||
|
fn is_generic(family: &str) -> bool {
|
||||||
|
matches!(family, "sans-serif" | "serif" | "monospace" | "cursive")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 系统字体库里的全部字面(`None` = 拿不到,视为「不知道」,全部当装机)。
|
||||||
|
fn system_faces() -> Option<Vec<Face>> {
|
||||||
|
let lock = iced::advanced::graphics::text::font_system();
|
||||||
|
let mut font_system = lock.write().ok()?;
|
||||||
|
|
||||||
|
let mut faces = Vec::new();
|
||||||
|
for face in font_system.raw().db().faces() {
|
||||||
|
for (family, _) in &face.families {
|
||||||
|
faces.push(Face {
|
||||||
|
family: family.clone(),
|
||||||
|
weight: face.weight.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(faces)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
//! `fonts` 的单元测试(拆出来只为「src 文件 ≤400 行」的门禁)。
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use unlucky_design::typography::WeightCoverage;
|
||||||
|
|
||||||
|
fn only(installed: &'static [&'static str]) -> impl Fn(&str) -> bool {
|
||||||
|
move |family| installed.contains(&family)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn windows_stack_falls_back_to_classic_segoe_when_variable_is_absent() {
|
||||||
|
// Win10 没有 Segoe UI Variable Text
|
||||||
|
let family = pick(
|
||||||
|
Platform::Windows,
|
||||||
|
FontFamily::Text,
|
||||||
|
&only(&["Segoe UI", "Microsoft YaHei UI"]),
|
||||||
|
);
|
||||||
|
assert_eq!(family, "Segoe UI");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn windows_stack_prefers_the_variable_face_on_win11() {
|
||||||
|
let family = pick(
|
||||||
|
Platform::Windows,
|
||||||
|
FontFamily::Text,
|
||||||
|
&only(&["Segoe UI Variable Text", "Segoe UI"]),
|
||||||
|
);
|
||||||
|
assert_eq!(family, "Segoe UI Variable Text");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generic_family_names_are_never_reported_as_the_resolved_family() {
|
||||||
|
// 一个都没装:返回栈头,且栈头不是通用族名
|
||||||
|
let family = pick(Platform::Linux, FontFamily::Text, &only(&[]));
|
||||||
|
assert_eq!(family, "Roboto");
|
||||||
|
assert!(!is_generic(family));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_cjk_fonts_do_not_change_the_latin_pick() {
|
||||||
|
// 装了苹方但没装 SF:macOS 栈应该选 Helvetica Neue 而不是中文族
|
||||||
|
let family = pick(
|
||||||
|
Platform::MacOs,
|
||||||
|
FontFamily::Text,
|
||||||
|
&only(&["PingFang SC", "Helvetica Neue"]),
|
||||||
|
);
|
||||||
|
assert_eq!(family, "Helvetica Neue");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn weight_reports_use_the_token_degradation_path() {
|
||||||
|
// 没有系统字面表时回落到令牌纯函数:Windows 栈里 Segoe UI Variable Text 是可变 300..700
|
||||||
|
let medium = weight_report(
|
||||||
|
Platform::Windows,
|
||||||
|
"Segoe UI Variable Text",
|
||||||
|
Weight::Medium,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(medium.requested, 500);
|
||||||
|
assert_eq!(medium.quality, WeightMatch::Native);
|
||||||
|
|
||||||
|
// 手工构造一个「只有 400/700」的族(= 经典 Segoe UI),500 应该落到最近档
|
||||||
|
let faces = [
|
||||||
|
Face {
|
||||||
|
family: "Segoe UI".to_owned(),
|
||||||
|
weight: 400,
|
||||||
|
},
|
||||||
|
Face {
|
||||||
|
family: "Segoe UI".to_owned(),
|
||||||
|
weight: 700,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let medium = weight_report(Platform::Windows, "Segoe UI", Weight::Medium, Some(&faces));
|
||||||
|
assert_eq!(medium.available, 400, "400 比 700 更近");
|
||||||
|
assert_eq!(medium.quality, WeightMatch::Nearest);
|
||||||
|
|
||||||
|
// 只有 400 却要 700 → 合成加粗
|
||||||
|
let only_regular = [Face {
|
||||||
|
family: "Narrow".to_owned(),
|
||||||
|
weight: 400,
|
||||||
|
}];
|
||||||
|
let bold = weight_report(
|
||||||
|
Platform::Windows,
|
||||||
|
"Narrow",
|
||||||
|
Weight::Bold,
|
||||||
|
Some(&only_regular),
|
||||||
|
);
|
||||||
|
assert_eq!(bold.quality, WeightMatch::Synthetic);
|
||||||
|
|
||||||
|
// 原生有 500 就用 500
|
||||||
|
let has_medium = [Face {
|
||||||
|
family: "Noto".to_owned(),
|
||||||
|
weight: 500,
|
||||||
|
}];
|
||||||
|
assert_eq!(
|
||||||
|
weight_report(Platform::Linux, "Noto", Weight::Medium, Some(&has_medium)).quality,
|
||||||
|
WeightMatch::Native
|
||||||
|
);
|
||||||
|
|
||||||
|
let narrow = WeightCoverage::Static(&[400]);
|
||||||
|
assert_eq!(narrow.nearest(700), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn describe_is_one_line_and_mentions_all_three_stacks() {
|
||||||
|
let resolved = Resolved {
|
||||||
|
platform: Platform::Windows,
|
||||||
|
families: [
|
||||||
|
FamilyReport {
|
||||||
|
stack: "text",
|
||||||
|
family: "Segoe UI",
|
||||||
|
installed: true,
|
||||||
|
},
|
||||||
|
FamilyReport {
|
||||||
|
stack: "display",
|
||||||
|
family: "Segoe UI Variable Display",
|
||||||
|
installed: false,
|
||||||
|
},
|
||||||
|
FamilyReport {
|
||||||
|
stack: "mono",
|
||||||
|
family: "Cascadia Mono",
|
||||||
|
installed: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
weights: [
|
||||||
|
WeightReport {
|
||||||
|
requested: 400,
|
||||||
|
available: 400,
|
||||||
|
quality: WeightMatch::Native,
|
||||||
|
},
|
||||||
|
WeightReport {
|
||||||
|
requested: 500,
|
||||||
|
available: 400,
|
||||||
|
quality: WeightMatch::Nearest,
|
||||||
|
},
|
||||||
|
WeightReport {
|
||||||
|
requested: 700,
|
||||||
|
available: 400,
|
||||||
|
quality: WeightMatch::Synthetic,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let line = resolved.describe();
|
||||||
|
assert_eq!(line.lines().count(), 1);
|
||||||
|
assert!(line.contains("text=\"Segoe UI\""), "{line}");
|
||||||
|
assert!(line.contains("未装机"), "{line}");
|
||||||
|
assert!(line.contains("w700=400(synthetic)"), "{line}");
|
||||||
|
assert!(resolved.has_synthetic());
|
||||||
|
assert_eq!(resolved.families().text, "Segoe UI");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn type_roles_pick_the_display_stack_for_big_text() {
|
||||||
|
use unlucky_design::typography::TypeRole;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
TypeRole::TitleLarge.style().family,
|
||||||
|
FontFamily::Display,
|
||||||
|
"22px 标题走 Display 栈"
|
||||||
|
);
|
||||||
|
assert_eq!(TypeRole::BodyMedium.style().family, FontFamily::Text);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
//! 令牌图标(`unlucky_design::icons` 的 24 网格 SVG path)→ iced `svg` 控件。
|
||||||
|
//!
|
||||||
|
//! ## 为什么走 SVG 而不是自绘 canvas
|
||||||
|
//!
|
||||||
|
//! 令牌里存的是 **SVG path 字符串**(含 `A` 弧线命令),自绘要自己写 path parser + 弧线细分,
|
||||||
|
//! 那是一份将来只有自己在维护的代码;iced 的 `svg` 控件已经带 usvg 解析与按 id 缓存的位图。
|
||||||
|
//! 代价是二进制多一份 resvg/usvg(体积基线里会单列出来)。
|
||||||
|
//!
|
||||||
|
//! ## 描边语义(`design-令牌约定.md` §4)
|
||||||
|
//!
|
||||||
|
//! 令牌的 `Icon::stroke_width(render_px)` 已经把「视觉 1.5px」折算成 **viewBox 单位**
|
||||||
|
//! (`1.5 × 24 / render_px`),本模块**不重算也不手抄常数**。
|
||||||
|
//! 本骨架所有图标都按 24px 渲染(12b 2.2 标题栏 / 5.5 导航),所以每个图标只有一份位图缓存。
|
||||||
|
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use iced::widget::svg;
|
||||||
|
use iced::Color;
|
||||||
|
|
||||||
|
use unlucky_design::icons::{IconKind, IconPaint, IconSize};
|
||||||
|
|
||||||
|
/// 骨架期图标的统一渲染尺寸(12b:标题栏与导航轨都是 24)。
|
||||||
|
pub const RENDER_PX: f32 = IconSize::Toolbar.px();
|
||||||
|
|
||||||
|
/// 生成一个图标的 SVG 源码(`viewBox` 24×24,颜色用 `currentColor` 交给 iced 的色滤镜)。
|
||||||
|
///
|
||||||
|
/// 之所以用 `currentColor` 而不是把令牌色写进 SVG:色滤镜(`svg::Style::color`)会按
|
||||||
|
/// **alpha 蒙版**重涂 RGB,这样一份 SVG 位图能被浅/深两套主题复用,缓存不会翻倍。
|
||||||
|
pub fn source(kind: IconKind) -> String {
|
||||||
|
let icon = kind.icon();
|
||||||
|
let paint = match icon.paint {
|
||||||
|
IconPaint::Stroke => format!(
|
||||||
|
"fill=\"none\" stroke=\"currentColor\" stroke-width=\"{}\" \
|
||||||
|
stroke-linecap=\"round\" stroke-linejoin=\"round\"",
|
||||||
|
icon.stroke_width(RENDER_PX)
|
||||||
|
),
|
||||||
|
IconPaint::Fill => "fill=\"currentColor\" stroke=\"none\"".to_owned(),
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{0}\" height=\"{0}\" \
|
||||||
|
viewBox=\"0 0 24 24\"><path d=\"{1}\" {paint}/></svg>",
|
||||||
|
RENDER_PX, icon.path
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 图标的 SVG 句柄(按 `IconKind` 缓存;句柄 id 是内容哈希,所以 iced 侧也能命中缓存)。
|
||||||
|
fn handle(kind: IconKind) -> svg::Handle {
|
||||||
|
static HANDLES: OnceLock<Vec<svg::Handle>> = OnceLock::new();
|
||||||
|
|
||||||
|
let handles = HANDLES.get_or_init(|| {
|
||||||
|
IconKind::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|kind| svg::Handle::from_memory(source(*kind).into_bytes()))
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
// 依赖「枚举下标 == IconKind::ALL 下标」(令牌层用 `GLYPHS[self as usize]` 同一约定)。
|
||||||
|
// 这条断言把约定钉在测试与 debug 构建里,`IconKind` 一旦乱序插入就会立刻炸。
|
||||||
|
debug_assert_eq!(
|
||||||
|
IconKind::ALL[kind as usize],
|
||||||
|
kind,
|
||||||
|
"IconKind::ALL 必须与枚举声明顺序一致"
|
||||||
|
);
|
||||||
|
|
||||||
|
handles[kind as usize].clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一个图标控件(尺寸走令牌,颜色走令牌角色)。
|
||||||
|
pub fn view<'a, Message: 'a>(kind: IconKind, color: Color) -> iced::Element<'a, Message> {
|
||||||
|
svg(handle(kind))
|
||||||
|
.width(RENDER_PX)
|
||||||
|
.height(RENDER_PX)
|
||||||
|
.style(move |_theme, _status| svg::Style { color: Some(color) })
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_icon_produces_wellformed_svg() {
|
||||||
|
for kind in IconKind::ALL {
|
||||||
|
let source = source(kind);
|
||||||
|
assert!(source.starts_with("<svg"), "{} 的 SVG 头不对", kind.name());
|
||||||
|
assert!(source.ends_with("</svg>"), "{} 的 SVG 尾不对", kind.name());
|
||||||
|
assert!(source.contains("viewBox=\"0 0 24 24\""), "{}", kind.name());
|
||||||
|
assert!(
|
||||||
|
source.contains(kind.icon().path),
|
||||||
|
"{} 的 path 没写进去",
|
||||||
|
kind.name()
|
||||||
|
);
|
||||||
|
assert!(!source.contains('#'), "{} 里出现了色值字面量", kind.name());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stroke_icons_carry_the_token_stroke_width() {
|
||||||
|
let stroke = source(IconKind::Settings);
|
||||||
|
let expected = IconKind::Settings.icon().stroke_width(RENDER_PX);
|
||||||
|
assert!(
|
||||||
|
stroke.contains(&format!("stroke-width=\"{expected}\"")),
|
||||||
|
"{stroke}"
|
||||||
|
);
|
||||||
|
assert!(stroke.contains("stroke-linecap=\"round\""));
|
||||||
|
// 24px 渲染时的视觉线宽必须是 1.5(M54 结论)
|
||||||
|
assert!((expected - 1.5).abs() < f32::EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filled_icons_do_not_get_a_stroke() {
|
||||||
|
// `more` 是三个填充圆点
|
||||||
|
let filled = source(IconKind::More);
|
||||||
|
assert!(filled.contains("fill=\"currentColor\""));
|
||||||
|
assert!(filled.contains("stroke=\"none\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handles_are_cached_per_kind() {
|
||||||
|
let first = handle(IconKind::Close);
|
||||||
|
let second = handle(IconKind::Close);
|
||||||
|
assert_eq!(first.id(), second.id());
|
||||||
|
assert_ne!(first.id(), handle(IconKind::Minimize).id());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
//! # unlucky-app-iced · iced 版桌面壳(P4 / D26)
|
||||||
|
//!
|
||||||
|
//! 本 crate 是「UI 从头写」的第一步:**只做骨架**(能开窗、颜色/字体对、层级在),
|
||||||
|
//! 业务页面、组件库、i18n 全文案、玻璃 shader 都是后续 mission。
|
||||||
|
//!
|
||||||
|
//! | 模块 | 职责 | 不负责 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | [`theme`] | 令牌(`unlucky-design`)→ iced 类型:`Color` / `Font` / `Radius` / `Theme` | 发明令牌值 |
|
||||||
|
//! | `cli` | `--renderer` / `--software-renderer` / `--theme` / `--screenshot*` 参数 | 业务开关 |
|
||||||
|
//! | `probe` | 渲染后端探测(D27)+ 写 `ICED_BACKEND`,失败自动降级 CPU | 绘制 |
|
||||||
|
//! | `fonts` | 字体栈求值(D28 前半):平台族名 → 系统字体库校验 → 启动日志 | 中文字体下载 |
|
||||||
|
//! | `app` / `shell` | Elm 三件套 + 自绘标题栏 / 导航轨 / 内容骨架 | 业务逻辑 |
|
||||||
|
//! | `shot` | `window::screenshot` → PNG(验收通道) | 图像比对 |
|
||||||
|
//! | `icons` | 令牌里的 SVG path → iced `svg` 控件 | 画新图标 |
|
||||||
|
//!
|
||||||
|
//! ## 消费纪律
|
||||||
|
//!
|
||||||
|
//! **组件层零硬编码色值/字号/半径/间距**:一律走 [`theme::Ui`]。守门测试在
|
||||||
|
//! `tests/no_hardcoded_tokens.rs`(扫 `src/**`,出现 `#RRGGBB` / `Color::from_rgb(` 即红)。
|
||||||
|
//!
|
||||||
|
//! ## 与 Slint 版的关系
|
||||||
|
//!
|
||||||
|
//! 并存期:`crates/app` 仍出 `unlucky-launcher`,本 crate 出 `unlucky-launcher-iced`。
|
||||||
|
//! 等价验收后翻牌(bin 改名 + 旧 crate 降级),步骤写在
|
||||||
|
//! `DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md`。
|
||||||
|
|
||||||
|
pub mod shot;
|
||||||
|
pub mod theme;
|
||||||
|
|
||||||
|
mod app;
|
||||||
|
mod cli;
|
||||||
|
mod fonts;
|
||||||
|
mod icons;
|
||||||
|
mod probe;
|
||||||
|
mod shell;
|
||||||
|
|
||||||
|
pub use cli::{Backend, Options};
|
||||||
|
pub use probe::Probe;
|
||||||
|
|
||||||
|
/// 启动 UI(阻塞到窗口关闭)。渲染后端由 `probe` 在 `run()` 之前写进 `ICED_BACKEND`。
|
||||||
|
pub fn run(options: Options) -> iced::Result {
|
||||||
|
app::run(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 与 [`run`] 同义,但把命令行做成最终结论:`Options::from_env()` + 启动日志。
|
||||||
|
///
|
||||||
|
/// 二进制入口用 [`Options::from_env`] + [`run`] 自己拼(要额外处理 `--diff`),这个函数留给
|
||||||
|
/// 集成测试与将来的 CLI 复用。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// 参数不合法时返回人话(不 panic、不 `exit`)。
|
||||||
|
pub fn run_from_env() -> Result<iced::Result, String> {
|
||||||
|
let options = Options::from_env()?;
|
||||||
|
Ok(run(options))
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
//! 入口。所有实际逻辑在 lib(`unlucky_app_iced`)里:
|
||||||
|
//!
|
||||||
|
//! - `--diff <a> <b>`:只读两张 PNG 做逐像素对比(D30 的 GPU/CPU 几何一致性检查),不开窗;
|
||||||
|
//! - 其余参数:开窗跑骨架,`--screenshot` 出图后退出。
|
||||||
|
//!
|
||||||
|
//! 退出码:`0` 成功 / `1` 截图有差异(diff 模式)/ `2` 参数或运行错误。
|
||||||
|
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
use unlucky_app_iced::shot;
|
||||||
|
use unlucky_app_iced::Options;
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
let options = match Options::from_env() {
|
||||||
|
Ok(options) => options,
|
||||||
|
Err(message) => {
|
||||||
|
eprintln!("[cli] {message}");
|
||||||
|
eprintln!(
|
||||||
|
"用法:unlucky-launcher-iced [--renderer=auto|gpu|cpu] [--software-renderer] \
|
||||||
|
[--theme=light|dark] [--screenshot <png>] [--screenshot-theme=light|dark] \
|
||||||
|
[--scale-factor <f32>] [--diff <a.png> <b.png>]"
|
||||||
|
);
|
||||||
|
return ExitCode::from(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((left, right)) = options.diff.clone() {
|
||||||
|
let scan = i32::try_from(options.diff_scan).unwrap_or(i32::MAX);
|
||||||
|
return match shot::diff_files_with_scan(&left, &right, scan) {
|
||||||
|
Ok(diff) => {
|
||||||
|
eprintln!("{}", diff.describe());
|
||||||
|
if diff.is_identical() {
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
} else {
|
||||||
|
ExitCode::from(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(message) => {
|
||||||
|
eprintln!("[diff] {message}");
|
||||||
|
ExitCode::from(2)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
match unlucky_app_iced::run(options) {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("[run] {error}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
//! 渲染后端探测 + 自动降级(D27)。
|
||||||
|
//!
|
||||||
|
//! ## 为什么需要探测
|
||||||
|
//!
|
||||||
|
//! iced 0.14 的 `Renderer` 本身就是「wgpu → tiny-skia」的运行时回退枚举(M60 Q1):
|
||||||
|
//! **不设** `ICED_BACKEND` 时,拿不到 GPU 适配器会自动降级到 CPU,零代码。
|
||||||
|
//! 需要自研的只有**判据**:
|
||||||
|
//!
|
||||||
|
//! - 本机会枚举出 `Microsoft Basic Render Driver [Cpu]`(= WARP 软光栅),
|
||||||
|
//! 所以「有没有适配器」不能当「有没有 GPU」(M60 实测),必须看 `device_type`。
|
||||||
|
//! - 没有硬件 GPU 时要**主动**把 CPU 放到候选首位(`tiny-skia,wgpu`),
|
||||||
|
//! 否则 wgpu 会先花时间尝试 WARP,观感与行为都难料。
|
||||||
|
//!
|
||||||
|
//! ## 语义订正(与 M60 备忘录的差异,以源码 + 实测为准)
|
||||||
|
//!
|
||||||
|
//! M60 记「`ICED_BACKEND=wgpu` 会**关闭**回退」。读 `iced_renderer-0.14.0/src/fallback.rs`
|
||||||
|
//! 得到的是:**候选列表**语义——列表里没有 `tiny-skia` 时才真的关闭回退。
|
||||||
|
//! 所以本 crate 的「优先 GPU」写 `wgpu,tiny-skia`(首选 GPU,拿不到仍降级,**绝不硬失败**),
|
||||||
|
//! 而不是 `wgpu`。
|
||||||
|
|
||||||
|
use crate::Backend;
|
||||||
|
|
||||||
|
/// 单个适配器的摘要(只留日志要用的字段)。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct AdapterSummary {
|
||||||
|
/// 适配器名(如 `AMD Radeon RX 6600 XT`)。
|
||||||
|
pub name: String,
|
||||||
|
/// `device_type` 的字符串形式(`DiscreteGpu` / `IntegratedGpu` / `Cpu` / …)。
|
||||||
|
pub device_type: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 探测结论。
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct Probe {
|
||||||
|
/// 枚举到的全部适配器(含 WARP 这类软光栅)。
|
||||||
|
pub adapters: Vec<AdapterSummary>,
|
||||||
|
/// 是否存在**真**硬件适配器(`device_type` 既不是 `Cpu` 也不是 `Other`)。
|
||||||
|
pub hardware: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Probe {
|
||||||
|
/// 探测本机渲染能力(编入 `wgpu` 时真的枚举适配器)。
|
||||||
|
#[cfg(feature = "gpu")]
|
||||||
|
pub fn run() -> Self {
|
||||||
|
run_wgpu()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 探测本机渲染能力(`--no-default-features` 的纯 CPU 构建:没有 wgpu,直接判定无硬件)。
|
||||||
|
#[cfg(not(feature = "gpu"))]
|
||||||
|
pub fn run() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一行日志:适配器清单 + 判定。
|
||||||
|
pub fn describe(&self) -> String {
|
||||||
|
if self.adapters.is_empty() {
|
||||||
|
return format!("adapters=0 hardware={}", self.hardware);
|
||||||
|
}
|
||||||
|
|
||||||
|
let list = self
|
||||||
|
.adapters
|
||||||
|
.iter()
|
||||||
|
.map(|adapter| format!("{} [{}]", adapter.name, adapter.device_type))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" | ");
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"adapters={} [{list}] hardware={}",
|
||||||
|
self.adapters.len(),
|
||||||
|
self.hardware
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按请求与探测结果决定 `ICED_BACKEND` 的值(`None` = 不干预,交给 iced 自己回退)。
|
||||||
|
///
|
||||||
|
/// 纯函数,好让「降级矩阵」被测试钉死。
|
||||||
|
pub fn backend_env(request: Backend, probe: &Probe, gpu_compiled: bool) -> Option<&'static str> {
|
||||||
|
match (request, gpu_compiled) {
|
||||||
|
// 命令行 --software-renderer / --renderer=cpu:只留 CPU 候选。
|
||||||
|
(Backend::Cpu, _) => Some("tiny-skia"),
|
||||||
|
// 用户点名要 GPU,但没编进 wgpu:只能 CPU,日志里说明。
|
||||||
|
(Backend::Gpu, false) => Some("tiny-skia"),
|
||||||
|
// 优先 GPU,但保留 tiny-skia 兜底(候选列表语义:不会硬失败)。
|
||||||
|
(Backend::Gpu, true) => Some("wgpu,tiny-skia"),
|
||||||
|
// 自动:有硬件就让 iced 走默认顺序(wgpu 优先,自动回退)。
|
||||||
|
(Backend::Auto, true) if probe.hardware => None,
|
||||||
|
// 自动 + 没编进 wgpu:只有 CPU 候选,别把 wgpu 写进列表(会与「未编入 wgpu」的日志自相矛盾)。
|
||||||
|
(Backend::Auto, false) => Some("tiny-skia"),
|
||||||
|
// 自动 + 有 wgpu 但没硬件(含只有 WARP 的机器):CPU 起步,wgpu 留作兜底。
|
||||||
|
(Backend::Auto, true) => Some("tiny-skia,wgpu"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把决策落到进程环境变量,并把探测结果 + 生效值打进日志。
|
||||||
|
///
|
||||||
|
/// 返回**实际生效**的后端描述(`"auto (iced: wgpu -> tiny-skia)"` 之类)。
|
||||||
|
pub fn apply(request: Backend, probe: &Probe) -> String {
|
||||||
|
let gpu_compiled = cfg!(feature = "gpu");
|
||||||
|
let env = backend_env(request, probe, gpu_compiled);
|
||||||
|
|
||||||
|
// ICED_BACKEND 是进程级环境变量,必须在 `iced::application(..).run()` 之前写。
|
||||||
|
// 仓库是 edition 2021 → `set_var` 仍是安全 API(升 2024 后要改成子进程 exec 自身)。
|
||||||
|
if let Some(value) = env {
|
||||||
|
std::env::set_var("ICED_BACKEND", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let effective = if gpu_compiled {
|
||||||
|
match (request, env) {
|
||||||
|
(Backend::Cpu, _) => "tiny-skia (CPU,强制)".to_owned(),
|
||||||
|
(_, None) => "auto (wgpu -> tiny-skia)".to_owned(),
|
||||||
|
(_, Some("wgpu,tiny-skia")) => "wgpu.then(tiny-skia) (优先 GPU)".to_owned(),
|
||||||
|
(_, Some(value)) => format!("{value} (CPU 起步)"),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"tiny-skia (本构建未编入 wgpu,只有 CPU)".to_owned()
|
||||||
|
};
|
||||||
|
|
||||||
|
let note = if !gpu_compiled && request != Backend::Cpu {
|
||||||
|
";本构建未编入 wgpu(--no-default-features),只能 CPU"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"[probe] {} :: request={} -> ICED_BACKEND={}{note}",
|
||||||
|
probe.describe(),
|
||||||
|
request.name(),
|
||||||
|
env.unwrap_or("<unset>")
|
||||||
|
);
|
||||||
|
eprintln!("[probe] renderer = {effective}");
|
||||||
|
|
||||||
|
effective
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "gpu")]
|
||||||
|
fn run_wgpu() -> Probe {
|
||||||
|
// `iced::wgpu` 就是 wgpu crate 本身(`pub use iced_renderer::wgpu::wgpu` 再导出),
|
||||||
|
// 所以不需要单独依赖 wgpu,版本也不会和 iced 打架。
|
||||||
|
use iced::wgpu::{Backends, DeviceType, Instance, InstanceDescriptor};
|
||||||
|
|
||||||
|
let instance = Instance::new(&InstanceDescriptor {
|
||||||
|
backends: Backends::all(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let adapters = instance
|
||||||
|
.enumerate_adapters(Backends::all())
|
||||||
|
.into_iter()
|
||||||
|
.map(|adapter| {
|
||||||
|
let info = adapter.get_info();
|
||||||
|
let device_type = match info.device_type {
|
||||||
|
DeviceType::DiscreteGpu => "DiscreteGpu",
|
||||||
|
DeviceType::IntegratedGpu => "IntegratedGpu",
|
||||||
|
DeviceType::VirtualGpu => "VirtualGpu",
|
||||||
|
DeviceType::Cpu => "Cpu",
|
||||||
|
DeviceType::Other => "Other",
|
||||||
|
};
|
||||||
|
AdapterSummary {
|
||||||
|
name: info.name,
|
||||||
|
device_type,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let hardware = adapters
|
||||||
|
.iter()
|
||||||
|
.any(|adapter| !matches!(adapter.device_type, "Cpu" | "Other"));
|
||||||
|
|
||||||
|
Probe { adapters, hardware }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn probe(types: &[&'static str]) -> Probe {
|
||||||
|
let adapters = types
|
||||||
|
.iter()
|
||||||
|
.map(|device_type| AdapterSummary {
|
||||||
|
name: "adapter".to_owned(),
|
||||||
|
device_type,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Probe {
|
||||||
|
adapters,
|
||||||
|
hardware: types
|
||||||
|
.iter()
|
||||||
|
.any(|device_type| !matches!(*device_type, "Cpu" | "Other")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warp_only_machine_counts_as_no_gpu() {
|
||||||
|
// 本机实测会枚举出 WARP:`Microsoft Basic Render Driver [Cpu]`
|
||||||
|
let warp = probe(&["Cpu"]);
|
||||||
|
assert!(!warp.hardware, "只有 WARP 不能当「有 GPU」");
|
||||||
|
assert_eq!(
|
||||||
|
backend_env(Backend::Auto, &warp, true),
|
||||||
|
Some("tiny-skia,wgpu")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auto_with_real_gpu_leaves_iced_alone() {
|
||||||
|
let gpu = probe(&["DiscreteGpu", "Cpu"]);
|
||||||
|
assert!(gpu.hardware);
|
||||||
|
assert_eq!(backend_env(Backend::Auto, &gpu, true), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_choices_always_keep_a_fallback() {
|
||||||
|
let gpu = probe(&["DiscreteGpu"]);
|
||||||
|
let none = probe(&[]);
|
||||||
|
// 优先 GPU 但候选列表里留 tiny-skia:拿不到适配器时降级而不是报错
|
||||||
|
assert_eq!(
|
||||||
|
backend_env(Backend::Gpu, &gpu, true),
|
||||||
|
Some("wgpu,tiny-skia")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
backend_env(Backend::Gpu, &none, true),
|
||||||
|
Some("wgpu,tiny-skia")
|
||||||
|
);
|
||||||
|
assert_eq!(backend_env(Backend::Cpu, &gpu, true), Some("tiny-skia"));
|
||||||
|
// 没编进 wgpu 的构建绝不写 wgpu(连候选列表也不许出现,reviewer P3-4)
|
||||||
|
assert_eq!(backend_env(Backend::Gpu, &gpu, false), Some("tiny-skia"));
|
||||||
|
assert_eq!(backend_env(Backend::Auto, &gpu, false), Some("tiny-skia"));
|
||||||
|
assert_eq!(backend_env(Backend::Auto, &none, false), Some("tiny-skia"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn describe_lists_every_adapter() {
|
||||||
|
let text = probe(&["DiscreteGpu", "Cpu"]).describe();
|
||||||
|
assert!(text.contains("DiscreteGpu"), "{text}");
|
||||||
|
assert!(text.contains("Cpu"), "{text}");
|
||||||
|
assert!(text.contains("hardware=true"), "{text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
//! 首屏骨架(**不做业务**):自绘标题栏 + 左侧导航轨 + 内容区(页头 + 一张卡片)。
|
||||||
|
//!
|
||||||
|
//! ## 与参考判据的对应(`research/12b-component-fidelity.md`)
|
||||||
|
//!
|
||||||
|
//! | 部件 | 参考 | 令牌落点 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | 标题栏高度 | 16.2 桌面标题栏 **40**(登记偏离) | `LM.titlebar_height = 48`(M63 §1.4/R13 桌面档,令牌优先) |
|
||||||
|
//! | 标题栏按钮 | 16.5 图标 **24** | `IconSize::Toolbar` + `LM.titlebar_button_width = 46` |
|
||||||
|
//! | 页头 | 16.1 官方 AppBar **64**;本项目 **56** | `LM.page_header_height = 56`(已有偏离登记) |
|
||||||
|
//! | 卡片 | 3.x Outlined:**Medium(12)**、L0、`OutlineVariant` 1px、`SurfaceContainerLow` | `Component::OutlinedCard.spec()` + `STROKE_PX` |
|
||||||
|
//! | 字级 | 16.4 标题 `TitleLarge`;卡片 `TitleMedium` / `BodySmall` | `Ui::style` / `Ui::emphasized` |
|
||||||
|
//!
|
||||||
|
//! 导航轨在 [`nav`] 子模块里(12b §5 的判据表也在那边)。
|
||||||
|
//!
|
||||||
|
//! 一屏只出现 4 档字号(22 / 16 / 14 / 12),且**没有实心 accent**(rubric K2:一屏最多一个实心
|
||||||
|
//! Primary,骨架期一个都不要)。阴影只给浮层,骨架里没有浮层,所以**全屏零阴影**(rubric K3)。
|
||||||
|
|
||||||
|
mod nav;
|
||||||
|
|
||||||
|
pub use nav::NavItem;
|
||||||
|
|
||||||
|
use iced::widget::text::LineHeight;
|
||||||
|
use iced::widget::{button, column, container, mouse_area, row, space, stack, text};
|
||||||
|
use iced::{mouse, Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow};
|
||||||
|
use iced::{window::Direction, Theme as IcedTheme};
|
||||||
|
|
||||||
|
use unlucky_design::color::{Role, StateLayer};
|
||||||
|
use unlucky_design::component_map::Component;
|
||||||
|
use unlucky_design::elevation::MaterialLayer;
|
||||||
|
use unlucky_design::icons::IconKind;
|
||||||
|
use unlucky_design::spacing::Space;
|
||||||
|
use unlucky_design::typography::TypeRole;
|
||||||
|
|
||||||
|
use crate::app::Message;
|
||||||
|
use crate::cli::ForcedState;
|
||||||
|
use crate::icons;
|
||||||
|
use crate::theme::Ui;
|
||||||
|
|
||||||
|
/// 整棵树。
|
||||||
|
pub fn view<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
selected: NavItem,
|
||||||
|
app_title: &'a str,
|
||||||
|
page_title: &'a str,
|
||||||
|
body: &'a str,
|
||||||
|
forced: Option<ForcedState>,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let work_area = column![
|
||||||
|
titlebar(ui, app_title, forced),
|
||||||
|
row![
|
||||||
|
nav::rail(ui, selected, forced),
|
||||||
|
content(ui, page_title, body)
|
||||||
|
]
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill),
|
||||||
|
]
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill);
|
||||||
|
|
||||||
|
// 无边框窗口没有系统缩放边框 → 自己给两条热区(东/南)。
|
||||||
|
// **必须叠层而不是兄弟列**:当兄弟列会吃掉 4px 布局宽(右留白变 28、caption 按钮离窗角 4px,
|
||||||
|
// 视觉复审 P3-1)。叠层容器自己铺满,再用 align 把 4px 热区贴到边上,布局宽保持 1180。
|
||||||
|
// 东侧热区从标题栏下方开始,避免吃掉三个 caption 按钮最右 4px 的点击区。
|
||||||
|
let thickness = ui.space(Space::X1);
|
||||||
|
|
||||||
|
let east = container(
|
||||||
|
column![
|
||||||
|
space::vertical().height(ui.metrics().titlebar_height),
|
||||||
|
resize_edge(
|
||||||
|
Direction::East,
|
||||||
|
(Length::Fixed(thickness), Length::Fill),
|
||||||
|
mouse::Interaction::ResizingHorizontally,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.height(Length::Fill),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.align_x(Alignment::End);
|
||||||
|
|
||||||
|
let south = container(resize_edge(
|
||||||
|
Direction::South,
|
||||||
|
(Length::Fill, Length::Fixed(thickness)),
|
||||||
|
mouse::Interaction::ResizingVertically,
|
||||||
|
))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.align_y(Alignment::End);
|
||||||
|
|
||||||
|
let shell = stack![work_area, east, south];
|
||||||
|
|
||||||
|
container(shell)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.style(|_theme| filled(ui.layer(MaterialLayer::Canvas)))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 窗口缩放热区(透明;玻璃/底色由根容器提供)。
|
||||||
|
///
|
||||||
|
/// 拖动时调 `window::drag_resize(id, direction)`——Windows/X11 走 winit 原生实现,
|
||||||
|
/// **macOS 返回 `NotSupported`**(M60 Q4),如实登记在交接文档里。
|
||||||
|
fn resize_edge<'a>(
|
||||||
|
direction: Direction,
|
||||||
|
size: (Length, Length),
|
||||||
|
interaction: mouse::Interaction,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let (width, height) = size;
|
||||||
|
|
||||||
|
mouse_area(container(space::horizontal()).width(width).height(height))
|
||||||
|
.interaction(interaction)
|
||||||
|
.on_press(Message::ResizeStart(direction))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 标题栏
|
||||||
|
|
||||||
|
fn titlebar<'a>(ui: &'a Ui, title: &'a str, forced: Option<ForcedState>) -> Element<'a, Message> {
|
||||||
|
let metrics = ui.metrics();
|
||||||
|
|
||||||
|
// 拖动热区:从窗口左缘到第一个按钮(按钮自己不能被拖区吞掉,所以只包左侧一段)
|
||||||
|
let drag_area = mouse_area(
|
||||||
|
container(label(
|
||||||
|
ui,
|
||||||
|
TypeRole::LabelLarge,
|
||||||
|
true,
|
||||||
|
title,
|
||||||
|
ui.color(Role::OnSurface),
|
||||||
|
))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.padding(Padding::from([0.0, ui.space(Space::X4)])),
|
||||||
|
)
|
||||||
|
.on_press(Message::DragWindow);
|
||||||
|
|
||||||
|
let bar = row![
|
||||||
|
drag_area,
|
||||||
|
caption(ui, IconKind::Minimize, Message::Minimize, forced),
|
||||||
|
caption(ui, IconKind::Maximize, Message::ToggleMaximize, forced),
|
||||||
|
caption(ui, IconKind::Close, Message::Close, forced),
|
||||||
|
]
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(metrics.titlebar_height)
|
||||||
|
.align_y(Alignment::Center);
|
||||||
|
|
||||||
|
container(bar)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(metrics.titlebar_height)
|
||||||
|
.style(|_theme| filled(ui.layer(MaterialLayer::Canvas)))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标题栏按钮(自绘最小化/最大化/关闭)。
|
||||||
|
///
|
||||||
|
/// 图标必须**再包一层固定 24×24 的容器**:iced 的 `button` 在 layout 里给内容 Fill 约束,
|
||||||
|
/// 直接把 `svg(...).width(24)` 塞进 button,usvg 会按 Contain 撑满内区(实测渲染盒 46 → 视觉线宽
|
||||||
|
/// 从 1.5 变 2.9,视觉复审 P2-2)。Container 尊重子元素的 Length,所以这层是「锁尺寸」用的。
|
||||||
|
fn caption<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
kind: IconKind,
|
||||||
|
message: Message,
|
||||||
|
forced: Option<ForcedState>,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let metrics = ui.metrics();
|
||||||
|
// 用 `center(Length::Fill)` 而不是只设 `width(24).height(24)`:后者会让容器缩到 24×24 并贴到
|
||||||
|
// button 内容的左上角(button 不居中「收缩后」的内容,r1 实测图标跑到 cell 左上 (left+11.5, 11.5));
|
||||||
|
// `center(Fill)` = 撑满按钮内容区 + 内容居中,字形仍是 24 盒、命中区仍是 46×48。
|
||||||
|
let icon = container(icons::view::<Message>(
|
||||||
|
kind,
|
||||||
|
ui.color(Role::OnSurfaceVariant),
|
||||||
|
))
|
||||||
|
.width(icons::RENDER_PX)
|
||||||
|
.height(icons::RENDER_PX)
|
||||||
|
.center(Length::Fill);
|
||||||
|
|
||||||
|
button(icon)
|
||||||
|
.width(metrics.titlebar_button_width)
|
||||||
|
.height(metrics.titlebar_height)
|
||||||
|
.padding(0)
|
||||||
|
.style(move |_theme: &IcedTheme, status| {
|
||||||
|
let background = match overridden(forced, status) {
|
||||||
|
button::Status::Hovered => Some(Background::Color(ui.state_over(
|
||||||
|
StateLayer::Hover,
|
||||||
|
Role::OnSurface,
|
||||||
|
Role::Surface,
|
||||||
|
))),
|
||||||
|
button::Status::Pressed => Some(Background::Color(ui.state_over(
|
||||||
|
StateLayer::Pressed,
|
||||||
|
Role::OnSurface,
|
||||||
|
Role::Surface,
|
||||||
|
))),
|
||||||
|
button::Status::Active | button::Status::Disabled => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
button::Style {
|
||||||
|
background,
|
||||||
|
text_color: ui.color(Role::OnSurfaceVariant),
|
||||||
|
border: Border::default(),
|
||||||
|
shadow: Shadow::default(),
|
||||||
|
snap: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_press(message)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 内容区
|
||||||
|
|
||||||
|
fn content<'a>(ui: &'a Ui, title: &'a str, body: &'a str) -> Element<'a, Message> {
|
||||||
|
let metrics = ui.metrics();
|
||||||
|
// 卡片档位由组件表决定(圆角/字级),组件层不自己挑
|
||||||
|
let card_spec = Component::OutlinedCard.spec();
|
||||||
|
let body_role = card_spec.support.unwrap_or(card_spec.text);
|
||||||
|
|
||||||
|
let header = container(label(
|
||||||
|
ui,
|
||||||
|
TypeRole::TitleLarge,
|
||||||
|
true,
|
||||||
|
title,
|
||||||
|
ui.color(Role::OnSurface),
|
||||||
|
))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(metrics.page_header_height)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.padding(Padding::from([0.0, ui.space(Space::X6)]));
|
||||||
|
|
||||||
|
let card = container(
|
||||||
|
column![
|
||||||
|
label(
|
||||||
|
ui,
|
||||||
|
card_spec.text,
|
||||||
|
true,
|
||||||
|
"Shell ready",
|
||||||
|
ui.color(Role::OnSurface),
|
||||||
|
),
|
||||||
|
label(ui, body_role, false, body, ui.color(Role::OnSurfaceVariant)),
|
||||||
|
]
|
||||||
|
.spacing(ui.space(Space::X2)),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.padding(metrics.card_padding)
|
||||||
|
.style(move |_theme| container::Style {
|
||||||
|
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
|
||||||
|
border: ui.outlined(card_spec.shape),
|
||||||
|
..container::Style::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let body_area = container(card)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.padding(ui.space(Space::X6))
|
||||||
|
.align_y(Alignment::Start);
|
||||||
|
|
||||||
|
column![header, body_area]
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 小工具
|
||||||
|
|
||||||
|
/// 一个文字元素:字号/行高/字重全部来自令牌排版档。
|
||||||
|
pub(super) fn label<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
role: TypeRole,
|
||||||
|
emphasized: bool,
|
||||||
|
content: &'a str,
|
||||||
|
color: Color,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let style = if emphasized {
|
||||||
|
ui.emphasized(role)
|
||||||
|
} else {
|
||||||
|
ui.style(role)
|
||||||
|
};
|
||||||
|
|
||||||
|
text(content)
|
||||||
|
.size(style.size_px)
|
||||||
|
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
|
||||||
|
.font(ui.font_of(style))
|
||||||
|
.color(color)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取证用的状态覆盖(`--force-state`):只改画出来的状态,不动任何状态机。
|
||||||
|
pub(super) fn overridden(forced: Option<ForcedState>, status: button::Status) -> button::Status {
|
||||||
|
match forced {
|
||||||
|
Some(ForcedState::Hover) => button::Status::Hovered,
|
||||||
|
Some(ForcedState::Pressed) => button::Status::Pressed,
|
||||||
|
None => status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn filled(color: Color) -> container::Style {
|
||||||
|
container::Style {
|
||||||
|
background: Some(Background::Color(color)),
|
||||||
|
..container::Style::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use unlucky_design::color::SchemeFlavor;
|
||||||
|
use unlucky_design::typography::Platform;
|
||||||
|
|
||||||
|
use crate::theme::Families;
|
||||||
|
|
||||||
|
fn ui(flavor: SchemeFlavor) -> Ui {
|
||||||
|
Ui::with_platform(
|
||||||
|
flavor,
|
||||||
|
Platform::Windows,
|
||||||
|
Families {
|
||||||
|
text: "Segoe UI",
|
||||||
|
display: "Segoe UI",
|
||||||
|
mono: "Consolas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_screen_only_uses_four_text_sizes_at_most() {
|
||||||
|
let ui = ui(SchemeFlavor::Dark);
|
||||||
|
let card = Component::OutlinedCard.spec();
|
||||||
|
let sizes = [
|
||||||
|
ui.emphasized(TypeRole::TitleLarge).size_px,
|
||||||
|
ui.emphasized(TypeRole::LabelLarge).size_px,
|
||||||
|
ui.emphasized(card.text).size_px,
|
||||||
|
ui.style(card.support.unwrap_or(card.text)).size_px,
|
||||||
|
ui.style(TypeRole::LabelMedium).size_px,
|
||||||
|
];
|
||||||
|
let mut unique = sizes.to_vec();
|
||||||
|
unique.sort_by(f32::total_cmp);
|
||||||
|
unique.dedup();
|
||||||
|
assert!(
|
||||||
|
unique.len() <= 4,
|
||||||
|
"rubric D4:一屏字号档 ≤4(实际 {} 档 {unique:?})",
|
||||||
|
unique.len()
|
||||||
|
);
|
||||||
|
assert!(unique[0] >= 12.0, "最小档不下 12px(caption ≥11)");
|
||||||
|
assert!(unique[unique.len() - 1] <= 28.0, "列表页标题不得 >28px");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flavor_slug_is_stable_for_filenames() {
|
||||||
|
assert_eq!(crate::shot::flavor_slug(&ui(SchemeFlavor::Light)), "light");
|
||||||
|
assert_eq!(crate::shot::flavor_slug(&ui(SchemeFlavor::Dark)), "dark");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
//! 左侧导航轨(M3 `NavigationRail`)。
|
||||||
|
//!
|
||||||
|
//! 判据来源 `research/12b-component-fidelity.md` §5:
|
||||||
|
//!
|
||||||
|
//! | 项 | 参考值 | 令牌落点 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | 轨宽 | 5.1 官方 96(narrow 80);本项目 **88** | [`nav_rail_width`](`LayoutMetrics` 骨架值,详见该函数文档的 M69 迁移说明) |
|
||||||
|
//! | 轨容器 | 5.3 形状 None / L0 / `Surface` | `MaterialLayer::Canvas` |
|
||||||
|
//! | 选中指示器 | 5.4 **56×32**、圆角 full、`SecondaryContainer` | [`indicator_size`](令牌推导) |
|
||||||
|
//! | 图标 | 5.5 24px | `IconSize::Toolbar` |
|
||||||
|
//! | 标签 | 5.5 `LabelMedium 12/16` | `TypeRole::LabelMedium` |
|
||||||
|
//! | 颜色 | 5.6 选中图标 `OnSecondaryContainer` / 选中标签 `Secondary` / 未选中 `OnSurfaceVariant` | `Ui::color` |
|
||||||
|
//! | 项间距 | 5.7 **4** | `LayoutMetrics::nav_item_gap` |
|
||||||
|
|
||||||
|
use iced::widget::{button, column, container};
|
||||||
|
use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow, Theme as IcedTheme};
|
||||||
|
|
||||||
|
use unlucky_design::color::{Role, StateLayer};
|
||||||
|
use unlucky_design::elevation::MaterialLayer;
|
||||||
|
use unlucky_design::icons::IconKind;
|
||||||
|
use unlucky_design::shape::ShapeScale;
|
||||||
|
use unlucky_design::spacing::{LayoutMetrics, Space};
|
||||||
|
use unlucky_design::typography::TypeRole;
|
||||||
|
|
||||||
|
use crate::app::Message;
|
||||||
|
use crate::cli::ForcedState;
|
||||||
|
use crate::icons;
|
||||||
|
use crate::shell::{filled, label, overridden};
|
||||||
|
use crate::theme::Ui;
|
||||||
|
|
||||||
|
/// 导航占位项(任务要求 4~6 个;真正的页面由后续 mission 接管)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum NavItem {
|
||||||
|
/// 实例列表。
|
||||||
|
Instances,
|
||||||
|
/// 下载/版本。
|
||||||
|
Download,
|
||||||
|
/// 日志。
|
||||||
|
Logs,
|
||||||
|
/// 设置。
|
||||||
|
Settings,
|
||||||
|
/// AI 助手。
|
||||||
|
Ai,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NavItem {
|
||||||
|
/// 全部占位项(顺序 = 导航栏自上而下)。
|
||||||
|
pub const ALL: [NavItem; 5] = [
|
||||||
|
NavItem::Instances,
|
||||||
|
NavItem::Download,
|
||||||
|
NavItem::Logs,
|
||||||
|
NavItem::Settings,
|
||||||
|
NavItem::Ai,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 显示名(i18n 是后续 mission;本 mission 用英文基线文案,避免干净 CI 上缺中文变豆腐块)。
|
||||||
|
pub const fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
NavItem::Instances => "Instances",
|
||||||
|
NavItem::Download => "Download",
|
||||||
|
NavItem::Logs => "Logs",
|
||||||
|
NavItem::Settings => "Settings",
|
||||||
|
NavItem::Ai => "Assistant",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 令牌图标。
|
||||||
|
pub const fn icon(self) -> IconKind {
|
||||||
|
match self {
|
||||||
|
NavItem::Instances => IconKind::Instances,
|
||||||
|
NavItem::Download => IconKind::Download,
|
||||||
|
NavItem::Logs => IconKind::Logs,
|
||||||
|
NavItem::Settings => IconKind::Settings,
|
||||||
|
NavItem::Ai => IconKind::Ai,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 整条导航轨。
|
||||||
|
pub(super) fn rail<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
selected: NavItem,
|
||||||
|
forced: Option<ForcedState>,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let metrics = ui.metrics();
|
||||||
|
let indicator = indicator_size(metrics);
|
||||||
|
|
||||||
|
let items = NavItem::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|item| rail_item(ui, *item, *item == selected, indicator, forced))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
// 轨顶留白走 container padding,而不是「先塞一个 spacer 元素」:后者会被 column 的
|
||||||
|
// spacing(4)再加一档,实测变 28(视觉复审 P3-5)。
|
||||||
|
container(column(items).spacing(metrics.nav_item_gap))
|
||||||
|
.width(nav_rail_width(metrics))
|
||||||
|
.height(Length::Fill)
|
||||||
|
.padding(Padding::from([ui.space(Space::X6), 0.0]))
|
||||||
|
.style(|_theme| filled(ui.layer(MaterialLayer::Canvas)))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rail_item<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
item: NavItem,
|
||||||
|
selected: bool,
|
||||||
|
indicator: (f32, f32),
|
||||||
|
forced: Option<ForcedState>,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let (indicator_width, indicator_height) = indicator;
|
||||||
|
|
||||||
|
let icon_color = if selected {
|
||||||
|
ui.color(Role::OnSecondaryContainer)
|
||||||
|
} else {
|
||||||
|
ui.color(Role::OnSurfaceVariant)
|
||||||
|
};
|
||||||
|
// 12b 5.6:选中标签用 `Secondary`,未选中 `OnSurfaceVariant`
|
||||||
|
let label_color = if selected {
|
||||||
|
ui.color(Role::Secondary)
|
||||||
|
} else {
|
||||||
|
ui.color(Role::OnSurfaceVariant)
|
||||||
|
};
|
||||||
|
|
||||||
|
// **不要用 `center_x/center_y(Fill)`**:那会把容器自己的 width/height 改成 Fill,
|
||||||
|
// 于是 56 被父级(button 宽 88)覆盖成 88、32 靠布局凑巧(视觉复审 P2-1)。
|
||||||
|
// 尺寸只认令牌推导值,居中交给父 column 的 `align_x`。
|
||||||
|
let pill = container(icons::view::<Message>(item.icon(), icon_color))
|
||||||
|
.width(indicator_width)
|
||||||
|
.height(indicator_height)
|
||||||
|
.align_x(Alignment::Center)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.style(move |_theme| container::Style {
|
||||||
|
background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))),
|
||||||
|
border: Border {
|
||||||
|
radius: ui.radius(ShapeScale::Full, indicator_height),
|
||||||
|
..Border::default()
|
||||||
|
},
|
||||||
|
..container::Style::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let item_height = nav_item_height(ui, indicator_height);
|
||||||
|
// 列必须显式占满轨宽,否则它会 shrink 到 56,`align_x(Center)` 就变成空操作,
|
||||||
|
// 药丸会贴左缘(x=0..55 而不是 16..71)——r1 实测踩到的第二个坑。
|
||||||
|
let stack = column![
|
||||||
|
pill,
|
||||||
|
label(ui, TypeRole::LabelMedium, false, item.label(), label_color),
|
||||||
|
]
|
||||||
|
.spacing(ui.space(Space::X1))
|
||||||
|
.width(nav_rail_width(ui.metrics()))
|
||||||
|
.align_x(Alignment::Center)
|
||||||
|
.height(item_height);
|
||||||
|
|
||||||
|
button(stack)
|
||||||
|
.width(nav_rail_width(ui.metrics()))
|
||||||
|
.height(item_height)
|
||||||
|
.padding(0)
|
||||||
|
.style(move |_theme: &IcedTheme, status| {
|
||||||
|
let background = match overridden(forced, status) {
|
||||||
|
button::Status::Hovered if !selected => Some(Background::Color(ui.state_over(
|
||||||
|
StateLayer::Hover,
|
||||||
|
Role::OnSurface,
|
||||||
|
Role::Surface,
|
||||||
|
))),
|
||||||
|
button::Status::Pressed if !selected => Some(Background::Color(ui.state_over(
|
||||||
|
StateLayer::Pressed,
|
||||||
|
Role::OnSurface,
|
||||||
|
Role::Surface,
|
||||||
|
))),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
button::Style {
|
||||||
|
background,
|
||||||
|
text_color: label_color,
|
||||||
|
border: Border::default(),
|
||||||
|
shadow: Shadow::default(),
|
||||||
|
snap: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_press(Message::Select(item))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 导航轨宽度(M69 起是令牌层的 `nav_rail_width`)。
|
||||||
|
///
|
||||||
|
/// M69(`141e9b1`)把 `LayoutMetrics` 的 nav 字段做了语义纠正:
|
||||||
|
///
|
||||||
|
/// | 旧 | 新 |
|
||||||
|
/// |---|---|
|
||||||
|
/// | `nav_expanded_width = 88` | **`nav_rail_width = 88`**(值不变,名字从「展开」改成「轨」) |
|
||||||
|
/// | `nav_collapsed_width = 48` | 删除(48 连 56 宽的选中指示器都放不下) |
|
||||||
|
/// | —(缺) | `nav_drawer_width = 240`(D33 的展开态抽屉,本 crate 未用) |
|
||||||
|
///
|
||||||
|
/// 三处调用点都走这里:将来令牌再改名,仍然只改这一行。
|
||||||
|
fn nav_rail_width(metrics: LayoutMetrics) -> f32 {
|
||||||
|
metrics.nav_rail_width
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选中指示器尺寸(12b 5.4 参考 56×32),全部由令牌推导、不写死:
|
||||||
|
///
|
||||||
|
/// - 宽 = [`nav_rail_width`] − 2 × `Space::X4` = 88 − 32 = **56**(轨的左右内边距 16)
|
||||||
|
/// - 高 = `control_height − Space::X2` = 40 − 8 = **32**(可见高度上下各让 4)
|
||||||
|
pub fn indicator_size(metrics: LayoutMetrics) -> (f32, f32) {
|
||||||
|
(
|
||||||
|
nav_rail_width(metrics) - Space::X4.dp() * 2.0,
|
||||||
|
metrics.control_height - Space::X2.dp(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 导航项高度 = 指示器 + 4 + 标签行高 = 32 + 4 + 16 = 52(`LM.nav_item_height` 是列表行节奏,
|
||||||
|
/// 药丸 + 标签的轨项放不进 44,偏差登记在交接文档里)。
|
||||||
|
pub fn nav_item_height(ui: &Ui, indicator_height: f32) -> f32 {
|
||||||
|
indicator_height + ui.space(Space::X1) + ui.style(TypeRole::LabelMedium).line_height_px
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use unlucky_design::color::SchemeFlavor;
|
||||||
|
use unlucky_design::typography::Platform;
|
||||||
|
|
||||||
|
use crate::theme::Families;
|
||||||
|
|
||||||
|
fn ui() -> Ui {
|
||||||
|
Ui::with_platform(
|
||||||
|
SchemeFlavor::Light,
|
||||||
|
Platform::Windows,
|
||||||
|
Families {
|
||||||
|
text: "Segoe UI",
|
||||||
|
display: "Segoe UI",
|
||||||
|
mono: "Consolas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn indicator_matches_the_reference_56x32() {
|
||||||
|
let (width, height) = indicator_size(LayoutMetrics::DESKTOP);
|
||||||
|
assert_eq!((width, height), (56.0, 32.0), "12b 5.4 的参考值");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nav_item_height_is_indicator_plus_gap_plus_label() {
|
||||||
|
let ui = ui();
|
||||||
|
let (_, indicator_height) = indicator_size(ui.metrics());
|
||||||
|
// 32 + 4 + 16
|
||||||
|
assert_eq!(nav_item_height(&ui, indicator_height), 52.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_nav_item_has_a_label_and_an_icon() {
|
||||||
|
assert!((4..=6).contains(&NavItem::ALL.len()));
|
||||||
|
for item in NavItem::ALL {
|
||||||
|
assert!(!item.label().is_empty(), "{item:?} 没有文案");
|
||||||
|
assert!(!item.icon().icon().path.is_empty(), "{item:?} 没有图标");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
//! 两图逐像素对比(GPU/CPU 几何一致性)与 ±N px 平移扫描。
|
||||||
|
//!
|
||||||
|
//! 拆成子模块只为「src 文件 ≤400 行」的门禁;对外通过 `shot::{compare, diff_files, Diff, Image}`
|
||||||
|
//! 使用,调用方不用感知这层。
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// 一张已解码的 RGBA 图。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Image {
|
||||||
|
/// RGBA8 字节(逐像素 4 字节)。
|
||||||
|
pub rgba: Vec<u8>,
|
||||||
|
/// 宽(px)。
|
||||||
|
pub width: u32,
|
||||||
|
/// 高(px)。
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Image {
|
||||||
|
/// 读 PNG(只支持 PNG:本 crate 的 `image` 只开了 png 编码/解码)。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// 读文件失败或解码失败时返回人话。
|
||||||
|
pub fn load(path: &Path) -> Result<Self, String> {
|
||||||
|
let reader = image::ImageReader::open(path)
|
||||||
|
.map_err(|error| format!("读图 {} 失败:{error}", path.display()))?
|
||||||
|
.with_guessed_format()
|
||||||
|
.map_err(|error| format!("判定 {} 的格式失败:{error}", path.display()))?;
|
||||||
|
|
||||||
|
let decoded = reader
|
||||||
|
.decode()
|
||||||
|
.map_err(|error| format!("解码 {} 失败:{error}", path.display()))?
|
||||||
|
.to_rgba8();
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
width: decoded.width(),
|
||||||
|
height: decoded.height(),
|
||||||
|
rgba: decoded.into_raw(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 两图对比结果(GPU/CPU 一致性靠它给数字,不靠肉眼)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct Diff {
|
||||||
|
/// 宽(px)。
|
||||||
|
pub width: u32,
|
||||||
|
/// 高(px)。
|
||||||
|
pub height: u32,
|
||||||
|
/// 有差异的像素数。
|
||||||
|
pub differing_pixels: usize,
|
||||||
|
/// 单通道最大差(0~255)。
|
||||||
|
pub max_channel_delta: u8,
|
||||||
|
/// 单通道平均差(0~255)。
|
||||||
|
pub mean_channel_delta: f64,
|
||||||
|
/// 平移扫描的最佳对齐(dx, dy);不扫描时为 (0, 0)。
|
||||||
|
pub best_shift: (i32, i32),
|
||||||
|
/// 最佳对齐下的差异像素数(不扫描时等于 `differing_pixels`)。
|
||||||
|
pub best_differing_pixels: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 逐像素对比两张尺寸相同的图。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// 尺寸不同直接报错——**几何不一致不许静默通过**。
|
||||||
|
pub fn compare(left: &Image, right: &Image) -> Result<Diff, String> {
|
||||||
|
if (left.width, left.height) != (right.width, right.height) {
|
||||||
|
return Err(format!(
|
||||||
|
"尺寸不同:{}x{} vs {}x{}",
|
||||||
|
left.width, left.height, right.width, right.height
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pixel_count = left.rgba.len() / 4;
|
||||||
|
let mut differing = 0_usize;
|
||||||
|
let mut max_delta = 0_u8;
|
||||||
|
let mut sum_delta = 0_u64;
|
||||||
|
|
||||||
|
for index in 0..pixel_count {
|
||||||
|
let offset = index * 4;
|
||||||
|
let mut pixel_differs = false;
|
||||||
|
|
||||||
|
for channel in 0..4 {
|
||||||
|
let delta = left.rgba[offset + channel].abs_diff(right.rgba[offset + channel]);
|
||||||
|
if delta > 0 {
|
||||||
|
pixel_differs = true;
|
||||||
|
max_delta = max_delta.max(delta);
|
||||||
|
sum_delta += u64::from(delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pixel_differs {
|
||||||
|
differing += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Diff {
|
||||||
|
width: left.width,
|
||||||
|
height: left.height,
|
||||||
|
differing_pixels: differing,
|
||||||
|
max_channel_delta: max_delta,
|
||||||
|
mean_channel_delta: if pixel_count == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
sum_delta as f64 / (pixel_count * 4) as f64
|
||||||
|
},
|
||||||
|
best_shift: (0, 0),
|
||||||
|
best_differing_pixels: differing,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ±`max_shift` px 的平移扫描:把 `right` 平移后再比,返回差异像素数最小的那一档。
|
||||||
|
///
|
||||||
|
/// 用途:D30 的「几何逐像素一致」除了看差异占比,还要证明**没有整体错位**——
|
||||||
|
/// 如果 (0,0) 就是最优,说明差异只来自抗锯齿而不是位移/缩放。CI 不必再依赖 numpy。
|
||||||
|
///
|
||||||
|
/// 平移后越界的像素按「无法比较」处理,不计入差异(否则边缘会造成假阳性)。
|
||||||
|
pub fn shift_scan(left: &Image, right: &Image, max_shift: i32) -> Result<Diff, String> {
|
||||||
|
let mut best = compare(left, right)?;
|
||||||
|
let max_shift = max_shift.unsigned_abs();
|
||||||
|
|
||||||
|
if (left.width, left.height) != (right.width, right.height) {
|
||||||
|
return Err("尺寸不同,无法做平移扫描".to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
for dy in -(max_shift as i32)..=(max_shift as i32) {
|
||||||
|
for dx in -(max_shift as i32)..=(max_shift as i32) {
|
||||||
|
if (dx, dy) == (0, 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = shifted_differing_pixels(left, right, dx, dy);
|
||||||
|
if count < best.best_differing_pixels {
|
||||||
|
best.best_differing_pixels = count;
|
||||||
|
best.best_shift = (dx, dy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(best)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把 `right` 平移 `(dx, dy)` 后与 `left` 比较,返回差异像素数(越界不算)。
|
||||||
|
fn shifted_differing_pixels(left: &Image, right: &Image, dx: i32, dy: i32) -> usize {
|
||||||
|
let width = left.width as i32;
|
||||||
|
let height = left.height as i32;
|
||||||
|
let mut differing = 0;
|
||||||
|
|
||||||
|
for y in 0..height {
|
||||||
|
let source_y = y - dy;
|
||||||
|
if !(0..height).contains(&source_y) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for x in 0..width {
|
||||||
|
let source_x = x - dx;
|
||||||
|
if !(0..width).contains(&source_x) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let left_offset = ((y * width + x) * 4) as usize;
|
||||||
|
let right_offset = ((source_y * width + source_x) * 4) as usize;
|
||||||
|
|
||||||
|
if left.rgba[left_offset..left_offset + 4] != right.rgba[right_offset..right_offset + 4]
|
||||||
|
{
|
||||||
|
differing += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
differing
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Diff {
|
||||||
|
/// 完全一致(几何 + 颜色都一致)。
|
||||||
|
pub fn is_identical(&self) -> bool {
|
||||||
|
self.differing_pixels == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一行日志。
|
||||||
|
pub fn describe(&self) -> String {
|
||||||
|
let total = (self.width as usize) * (self.height as usize);
|
||||||
|
let percent = if total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
self.differing_pixels as f64 * 100.0 / total as f64
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"[diff] {}x{} differing={}/{} ({percent:.2}%) max_delta={} mean_delta={:.4} best_shift=({},{}) best_differing={}",
|
||||||
|
self.width,
|
||||||
|
self.height,
|
||||||
|
self.differing_pixels,
|
||||||
|
total,
|
||||||
|
self.max_channel_delta,
|
||||||
|
self.mean_channel_delta,
|
||||||
|
self.best_shift.0,
|
||||||
|
self.best_shift.1,
|
||||||
|
self.best_differing_pixels,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--diff <left> <right>` 的实现:读两张 PNG、对比、打日志。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// 任一图读不出来,或尺寸不同。
|
||||||
|
pub fn diff_files(left: &Path, right: &Path) -> Result<Diff, String> {
|
||||||
|
diff_files_with_scan(left, right, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同 [`diff_files`],但额外做 ±`max_shift` px 的平移扫描(`--diff-scan`)。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// 任一图读不出来,或尺寸不同。
|
||||||
|
pub fn diff_files_with_scan(left: &Path, right: &Path, max_shift: i32) -> Result<Diff, String> {
|
||||||
|
let left = Image::load(left)?;
|
||||||
|
let right = Image::load(right)?;
|
||||||
|
|
||||||
|
if max_shift == 0 {
|
||||||
|
compare(&left, &right)
|
||||||
|
} else {
|
||||||
|
shift_scan(&left, &right, max_shift)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use iced::window;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::shot::{save, write_rgba};
|
||||||
|
|
||||||
|
fn temp_dir() -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join("ull-app-iced-shot-tests");
|
||||||
|
std::fs::create_dir_all(&dir).expect("建临时目录");
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identical_images_have_zero_diff() {
|
||||||
|
let image = Image {
|
||||||
|
rgba: vec![7_u8; 4 * 4],
|
||||||
|
width: 2,
|
||||||
|
height: 2,
|
||||||
|
};
|
||||||
|
let diff = compare(&image, &image).unwrap();
|
||||||
|
assert!(diff.is_identical());
|
||||||
|
assert_eq!(diff.max_channel_delta, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn geometry_mismatch_is_an_error_not_a_silent_pass() {
|
||||||
|
let square = Image {
|
||||||
|
rgba: vec![0; 16],
|
||||||
|
width: 2,
|
||||||
|
height: 2,
|
||||||
|
};
|
||||||
|
let strip = Image {
|
||||||
|
rgba: vec![0; 16],
|
||||||
|
width: 4,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
assert!(compare(&square, &strip).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn color_only_differences_are_counted_per_pixel() {
|
||||||
|
let left = Image {
|
||||||
|
rgba: vec![0, 0, 0, 255, 0, 0, 0, 255],
|
||||||
|
width: 2,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
let right = Image {
|
||||||
|
rgba: vec![10, 0, 0, 255, 0, 0, 0, 255],
|
||||||
|
width: 2,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
let diff = compare(&left, &right).unwrap();
|
||||||
|
assert_eq!(diff.differing_pixels, 1);
|
||||||
|
assert_eq!(diff.max_channel_delta, 10);
|
||||||
|
assert!(diff.describe().contains("differing=1/2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn png_roundtrip_through_disk() {
|
||||||
|
let path = temp_dir().join("roundtrip.png");
|
||||||
|
let bytes = vec![200_u8; 4 * 3 * 2];
|
||||||
|
write_rgba(&bytes, 3, 2, &path).unwrap();
|
||||||
|
|
||||||
|
let loaded = Image::load(&path).unwrap();
|
||||||
|
assert_eq!((loaded.width, loaded.height), (3, 2));
|
||||||
|
assert_eq!(loaded.rgba, bytes);
|
||||||
|
|
||||||
|
let diff = diff_files(&path, &path).unwrap();
|
||||||
|
assert!(diff.is_identical());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_scan_finds_the_best_alignment() {
|
||||||
|
// 5x5 图,R 通道存唯一值;构造 right[y][x] = left[y+1][x+1](即把左图向左上挪 1px),
|
||||||
|
// 于是「left 与 right 在 dy=1,dx=1 时逐像素相等」——扫描应该把 (1,1) 找出来。
|
||||||
|
let side = 5_i32;
|
||||||
|
let mut rgba = Vec::new();
|
||||||
|
for y in 0..side {
|
||||||
|
for x in 0..side {
|
||||||
|
rgba.extend([(y * side + x) as u8, 0, 0, 255]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let left = Image {
|
||||||
|
rgba,
|
||||||
|
width: side as u32,
|
||||||
|
height: side as u32,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut moved = vec![255_u8; (side * side * 4) as usize];
|
||||||
|
for y in 0..side - 1 {
|
||||||
|
for x in 0..side - 1 {
|
||||||
|
let source = ((y + 1) * side + (x + 1)) as usize * 4;
|
||||||
|
let target = (y * side + x) as usize * 4;
|
||||||
|
moved[target..target + 4].copy_from_slice(&left.rgba[source..source + 4]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let right = Image {
|
||||||
|
rgba: moved,
|
||||||
|
width: side as u32,
|
||||||
|
height: side as u32,
|
||||||
|
};
|
||||||
|
|
||||||
|
let diff = shift_scan(&left, &right, 2).unwrap();
|
||||||
|
assert_eq!(diff.best_shift, (1, 1), "应扫出 1px 的位移");
|
||||||
|
assert_eq!(diff.best_differing_pixels, 0, "对齐后应逐像素相等");
|
||||||
|
assert!(
|
||||||
|
diff.differing_pixels > 0,
|
||||||
|
"不对齐时应该有差异(否则这条测试没有信息量)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 同一张图:最佳对齐就是 (0,0)
|
||||||
|
let same = shift_scan(&left, &left, 2).unwrap();
|
||||||
|
assert_eq!(same.best_shift, (0, 0));
|
||||||
|
assert_eq!(same.best_differing_pixels, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_byte_count_is_reported() {
|
||||||
|
let screenshot = window::Screenshot::new(vec![0_u8; 3], iced::Size::new(1, 1), 1.0);
|
||||||
|
let error = save(&screenshot, &temp_dir().join("unused.png")).unwrap_err();
|
||||||
|
assert!(error.contains("字节数对不上"), "{error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
//! 截图通道(D30 验收用):`iced::window::screenshot` 的真窗口出图 → PNG,以及两图逐像素对比。
|
||||||
|
//!
|
||||||
|
//! ## 为什么用真窗口而不是 `iced_test::Emulator`
|
||||||
|
//!
|
||||||
|
//! 任务书首选路径①:真开窗截图覆盖了标题栏/DPI/窗口管理的真实行为,离屏通道不覆盖
|
||||||
|
//! (M60 §Q6 的边界说明)。本机与 Windows CI 都有交互会话,所以走路径①。
|
||||||
|
//!
|
||||||
|
//! ## 关键事实(M60 踩坑 Q3)
|
||||||
|
//!
|
||||||
|
//! `Screenshot` 的字节字段在 0.14 叫 **`rgba`**(0.13 是 `bytes`),`size` 是**物理像素**,
|
||||||
|
//! 另带 `scale_factor`(换算逻辑坐标用)。落盘用 `image::save_buffer` + `ExtendedColorType::Rgba8`。
|
||||||
|
//!
|
||||||
|
//! ## 为什么自己带一个 diff
|
||||||
|
//!
|
||||||
|
//! D30 的硬项是「GPU 档与 CPU 档**几何逐像素一致**,只有颜色/材质不同」。CI 里不该假设有
|
||||||
|
//! Python/PIL/ImageMagick,所以把「读两张 PNG → 数差异像素 → 给最大/平均通道差」做进本程序
|
||||||
|
//! (`--diff a.png b.png`),复用同一个二进制。
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use iced::window;
|
||||||
|
|
||||||
|
use crate::theme;
|
||||||
|
|
||||||
|
/// 把一帧截图写到 `path`(自动建父目录)。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// 目录建不出来、PNG 编码失败、字节数与尺寸对不上时返回人话(截图通道要能一眼看出哪一步断)。
|
||||||
|
pub fn save(screenshot: &window::Screenshot, path: &Path) -> Result<Summary, String> {
|
||||||
|
let width = screenshot.size.width;
|
||||||
|
let height = screenshot.size.height;
|
||||||
|
let bytes = screenshot.rgba.as_ref();
|
||||||
|
|
||||||
|
let expected = width as usize * height as usize * 4;
|
||||||
|
if bytes.len() != expected {
|
||||||
|
return Err(format!(
|
||||||
|
"截图字节数对不上:{width}x{height} 应该 {expected} B,实际 {} B",
|
||||||
|
bytes.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
write_rgba(bytes, width, height, path)?;
|
||||||
|
|
||||||
|
Ok(Summary {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
scale_factor: screenshot.scale_factor,
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_rgba(bytes: &[u8], width: u32, height: u32, path: &Path) -> Result<(), String> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
if !parent.as_os_str().is_empty() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|error| format!("建目录 {} 失败:{error}", parent.display()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
image::save_buffer(path, bytes, width, height, image::ExtendedColorType::Rgba8)
|
||||||
|
.map_err(|error| format!("写 PNG {} 失败:{error}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次截图的结果摘要(打日志与报告用)。
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Summary {
|
||||||
|
/// 物理宽(px)。
|
||||||
|
pub width: u32,
|
||||||
|
/// 物理高(px)。
|
||||||
|
pub height: u32,
|
||||||
|
/// 窗口缩放因子(100% DPI = 1.0)。
|
||||||
|
pub scale_factor: f32,
|
||||||
|
/// 落盘路径。
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Summary {
|
||||||
|
/// 一行日志。
|
||||||
|
pub fn describe(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"[shot] {}x{} scale={} -> {}",
|
||||||
|
self.width,
|
||||||
|
self.height,
|
||||||
|
self.scale_factor,
|
||||||
|
self.path.display()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod diff;
|
||||||
|
|
||||||
|
pub use diff::{compare, diff_files, diff_files_with_scan, shift_scan, Diff, Image};
|
||||||
|
|
||||||
|
/// 主题名 → 归档文件名片段(`visual-acceptance-protocol.md` §7 的命名)。
|
||||||
|
pub fn flavor_slug(ui: &theme::Ui) -> &'static str {
|
||||||
|
match ui.flavor() {
|
||||||
|
unlucky_design::color::SchemeFlavor::Light => "light",
|
||||||
|
unlucky_design::color::SchemeFlavor::Dark => "dark",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
//! 令牌 → iced 类型(`Color` / `Font` / `Border` / `Radius` / `Theme`)。
|
||||||
|
//!
|
||||||
|
//! ## 边界(M61 `design-令牌约定.md` §0 的落地)
|
||||||
|
//!
|
||||||
|
//! 本模块是**唯一**允许把 `unlucky-design` 的值翻成 iced 类型的地方。组件层(`shell`)
|
||||||
|
//! 只调 [`Ui`] 的方法,绝不出现色值 / 字号 / 半径 / 间距字面量——`tests/no_hardcoded_tokens.rs`
|
||||||
|
//! 会扫 `src/**` 做守门。
|
||||||
|
//!
|
||||||
|
//! ## 为什么不用 `iced::theme::Base` 自定义主题承载 49 个角色
|
||||||
|
//!
|
||||||
|
//! `iced` 的 `Palette` 只有 6 个颜色(`background/text/primary/success/warning/danger`),
|
||||||
|
//! M3 有 49 个角色,塞不进去(M60 Q5 + M61 §9-0 的同一结论)。所以:
|
||||||
|
//!
|
||||||
|
//! - **真源**是本模块持有的 [`Scheme`](49 角色);
|
||||||
|
//! - iced 的 `Theme` 只用来「换名字」——`Theme::name()` 变了 iced 才会重算受主题影响的缓存
|
||||||
|
//! (M61 §9 的最后一条)。`palette()` 只给 devtools 看,控件外观一律由各自的 `Style` 注入。
|
||||||
|
//!
|
||||||
|
//! `success` / `warning` 在 M3 里**没有对应角色**(`design-令牌约定.md` §2 明写),
|
||||||
|
//! 这里按「语义最近的现成角色」映射:`success → Secondary`(不是「成功」的承诺)、
|
||||||
|
//! `warning → Tertiary`(M3 把「提醒」放 Tertiary)。任何控件都不要去读这两个槽位。
|
||||||
|
|
||||||
|
use iced::border::Radius;
|
||||||
|
use iced::theme::Palette;
|
||||||
|
use iced::{Border, Color, Font, Theme};
|
||||||
|
|
||||||
|
use unlucky_design::color::{Role, Scheme, SchemeFlavor, StateLayer};
|
||||||
|
use unlucky_design::elevation::MaterialLayer;
|
||||||
|
use unlucky_design::shape::ShapeScale;
|
||||||
|
use unlucky_design::spacing::{LayoutMetrics, Space};
|
||||||
|
use unlucky_design::typography::{FontFamily, Platform, TypeRole, TypeStyle, Weight};
|
||||||
|
|
||||||
|
/// 全站描边宽度(逻辑 px @100% DPI)。rubric G7 要求全站 1px —— 令牌层的「一像素线单一真源」。
|
||||||
|
///
|
||||||
|
/// M69 起令牌层提供 `unlucky_design::stroke::HAIRLINE`(容器描边/分隔线/聚焦环共用),
|
||||||
|
/// 这里**再导出**而不是自己写 `1.0`:全仓只有令牌层一个定义点。
|
||||||
|
pub use unlucky_design::stroke::HAIRLINE as STROKE_PX;
|
||||||
|
|
||||||
|
/// 已经解析到系统的字体族名(`&'static str` 才能塞进 `iced::Font`)。
|
||||||
|
///
|
||||||
|
/// 由 `fonts` 模块按令牌栈 + 系统字体库求值,本模块只消费。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Families {
|
||||||
|
/// 正文族(`FontFamily::Text` 栈里第一个装机可用的)。
|
||||||
|
pub text: &'static str,
|
||||||
|
/// 展示族(`FontFamily::Display`)。
|
||||||
|
pub display: &'static str,
|
||||||
|
/// 等宽族(`FontFamily::Mono`,日志/版本号用)。
|
||||||
|
pub mono: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 令牌求值器:一个 `Ui` 对应一套主题(浅或深)。
|
||||||
|
///
|
||||||
|
/// 深浅热切 = 重新 `Ui::new(flavor, families)` 并整树重绘;控件里不要缓存颜色。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Ui {
|
||||||
|
scheme: Scheme,
|
||||||
|
platform: Platform,
|
||||||
|
metrics: LayoutMetrics,
|
||||||
|
families: Families,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ui {
|
||||||
|
/// 按平台(由 `cfg!` 决定)与令牌基线方案求值。
|
||||||
|
pub fn new(flavor: SchemeFlavor, families: Families) -> Self {
|
||||||
|
Self::with_platform(flavor, Platform::current(), families)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 显式指定平台(跨平台核对与测试用)。
|
||||||
|
pub fn with_platform(flavor: SchemeFlavor, platform: Platform, families: Families) -> Self {
|
||||||
|
Self {
|
||||||
|
scheme: Scheme::baseline(flavor),
|
||||||
|
platform,
|
||||||
|
metrics: LayoutMetrics::DESKTOP,
|
||||||
|
families,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前深浅模式。
|
||||||
|
pub fn flavor(&self) -> SchemeFlavor {
|
||||||
|
self.scheme.flavor()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前平台(决定字体族与字重降级)。
|
||||||
|
pub fn platform(&self) -> Platform {
|
||||||
|
self.platform
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 骨架尺寸(标题栏/导航/行高…)。
|
||||||
|
pub fn metrics(&self) -> LayoutMetrics {
|
||||||
|
self.metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已解析的字体族名。
|
||||||
|
pub fn families(&self) -> Families {
|
||||||
|
self.families
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 间距档位(4px 栅格)。
|
||||||
|
pub fn space(&self, space: Space) -> f32 {
|
||||||
|
space.dp()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// M3 角色 → iced 颜色。
|
||||||
|
pub fn color(&self, role: Role) -> Color {
|
||||||
|
to_iced(self.scheme.get(role))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 材质层(Canvas/Card/Overlay)→ 容器色。
|
||||||
|
pub fn layer(&self, layer: MaterialLayer) -> Color {
|
||||||
|
self.color(layer.surface_role())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 状态层叠在容器色上,返回**不透明**结果。
|
||||||
|
///
|
||||||
|
/// 为什么不用带 alpha 的叠加:D27 要求 CPU 档不出现半透明,而「不透明结果」让 GPU/CPU
|
||||||
|
/// 两档的颜色也逐像素一致(只有材质/玻璃才允许两档不同)。
|
||||||
|
pub fn state_over(&self, state: StateLayer, content: Role, container: Role) -> Color {
|
||||||
|
to_iced(state.over(self.scheme.get(content), self.scheme.get(container)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 圆角(令牌刻度);`side` 是控件在该轴上的边长(`ShapeScale::Full` 要按边长折算)。
|
||||||
|
pub fn radius(&self, shape: ShapeScale, side: f32) -> Radius {
|
||||||
|
Radius::new(shape.radius_px(side))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 固定档圆角(不需要边长)。`ShapeScale::Full` 必须走 [`Ui::radius`](它没有绝对值)。
|
||||||
|
pub fn radius_dp(&self, shape: ShapeScale) -> Radius {
|
||||||
|
debug_assert!(
|
||||||
|
!matches!(shape, ShapeScale::Full),
|
||||||
|
"Full 需要边长,请用 Ui::radius(shape, side)"
|
||||||
|
);
|
||||||
|
Radius::new(if matches!(shape, ShapeScale::Full) {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
shape.dp()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 描边:`OutlineVariant` + [`STROKE_PX`],圆角走刻度(固定档)。
|
||||||
|
pub fn outlined(&self, shape: ShapeScale) -> Border {
|
||||||
|
Border {
|
||||||
|
color: self.color(Role::OutlineVariant),
|
||||||
|
width: STROKE_PX,
|
||||||
|
radius: self.radius_dp(shape),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// baseline 排版档。
|
||||||
|
pub fn style(&self, role: TypeRole) -> TypeStyle {
|
||||||
|
role.style()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expressive 强调档(官方值:字重升到 500/700)。
|
||||||
|
pub fn emphasized(&self, role: TypeRole) -> TypeStyle {
|
||||||
|
role.emphasized()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 某个排版档对应的 iced 字体(族 + 字重)。
|
||||||
|
pub fn font_of(&self, style: TypeStyle) -> Font {
|
||||||
|
let family = match style.family {
|
||||||
|
FontFamily::Display => self.families.display,
|
||||||
|
FontFamily::Text => self.families.text,
|
||||||
|
FontFamily::Mono => self.families.mono,
|
||||||
|
};
|
||||||
|
|
||||||
|
Font {
|
||||||
|
family: iced::font::Family::Name(family),
|
||||||
|
weight: weight_of(style.weight),
|
||||||
|
..Font::DEFAULT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// baseline 档的字体。
|
||||||
|
pub fn font(&self, role: TypeRole) -> Font {
|
||||||
|
self.font_of(self.style(role))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 强调档的字体。
|
||||||
|
pub fn emphasized_font(&self, role: TypeRole) -> Font {
|
||||||
|
self.font_of(self.emphasized(role))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iced 的 6 色 `Palette`(只给 devtools / 兜底控件用;M3 的 49 角色在 [`Ui::color`])。
|
||||||
|
pub fn palette(&self) -> Palette {
|
||||||
|
Palette {
|
||||||
|
background: self.color(Role::Surface),
|
||||||
|
text: self.color(Role::OnSurface),
|
||||||
|
primary: self.color(Role::Primary),
|
||||||
|
success: self.color(Role::Secondary),
|
||||||
|
warning: self.color(Role::Tertiary),
|
||||||
|
danger: self.color(Role::Error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iced 主题对象:**名字带深浅**,切主题时 `Theme::name()` 变化会触发重算。
|
||||||
|
pub fn theme(&self) -> Theme {
|
||||||
|
Theme::custom(self.name(), self.palette())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Theme::name()` 的取值(稳定字符串,改名要先在协作文档登记)。
|
||||||
|
pub fn name(&self) -> &'static str {
|
||||||
|
match self.flavor() {
|
||||||
|
SchemeFlavor::Light => "ull-m3-light",
|
||||||
|
SchemeFlavor::Dark => "ull-m3-dark",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 令牌色(8 bit sRGB)→ iced 的 sRGB 分量。
|
||||||
|
pub fn to_iced(color: unlucky_design::color::Color) -> Color {
|
||||||
|
Color::from_rgba8(
|
||||||
|
color.r,
|
||||||
|
color.g,
|
||||||
|
color.b,
|
||||||
|
f32::from(color.a) / f32::from(u8::MAX),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 令牌字重 → iced 字重。
|
||||||
|
pub fn weight_of(weight: Weight) -> iced::font::Weight {
|
||||||
|
match weight {
|
||||||
|
Weight::Regular => iced::font::Weight::Normal,
|
||||||
|
Weight::Medium => iced::font::Weight::Medium,
|
||||||
|
Weight::SemiBold => iced::font::Weight::Semibold,
|
||||||
|
Weight::Bold => iced::font::Weight::Bold,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const FAMILIES: Families = Families {
|
||||||
|
text: "Segoe UI Variable Text",
|
||||||
|
display: "Segoe UI Variable Display",
|
||||||
|
mono: "Cascadia Mono",
|
||||||
|
};
|
||||||
|
|
||||||
|
fn ui(flavor: SchemeFlavor) -> Ui {
|
||||||
|
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn theme_name_tracks_the_flavor() {
|
||||||
|
assert_eq!(ui(SchemeFlavor::Light).name(), "ull-m3-light");
|
||||||
|
assert_eq!(ui(SchemeFlavor::Dark).name(), "ull-m3-dark");
|
||||||
|
assert_ne!(
|
||||||
|
ui(SchemeFlavor::Light).theme().to_string(),
|
||||||
|
ui(SchemeFlavor::Dark).theme().to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canvas_card_overlay_follow_surface_containers() {
|
||||||
|
let ui = ui(SchemeFlavor::Light);
|
||||||
|
assert_eq!(ui.layer(MaterialLayer::Canvas), ui.color(Role::Surface));
|
||||||
|
assert_eq!(
|
||||||
|
ui.layer(MaterialLayer::Card),
|
||||||
|
ui.color(Role::SurfaceContainerLow)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ui.layer(MaterialLayer::Overlay),
|
||||||
|
ui.color(Role::SurfaceContainerHigh)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn radii_only_come_from_the_scale() {
|
||||||
|
let ui = ui(SchemeFlavor::Light);
|
||||||
|
// 卡片 Medium(12):边长够大时就是 12
|
||||||
|
assert_eq!(ui.radius_dp(ShapeScale::Medium).top_left, 12.0);
|
||||||
|
assert_eq!(ui.radius_dp(ShapeScale::ExtraSmall).top_left, 4.0);
|
||||||
|
// 药丸 Full:按边长一半折算
|
||||||
|
assert_eq!(ui.radius(ShapeScale::Full, 32.0).top_left, 16.0);
|
||||||
|
// 不许超过边长一半
|
||||||
|
assert_eq!(ui.radius(ShapeScale::ExtraLarge, 20.0).top_left, 10.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn outlines_are_one_pixel_and_use_outline_variant() {
|
||||||
|
let ui = ui(SchemeFlavor::Light);
|
||||||
|
let border = ui.outlined(ShapeScale::Medium);
|
||||||
|
assert_eq!(border.width, STROKE_PX);
|
||||||
|
assert_eq!(border.width, 1.0, "rubric G7:全站 1px");
|
||||||
|
assert_eq!(border.color, ui.color(Role::OutlineVariant));
|
||||||
|
assert_eq!(border.radius.top_left, 12.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emphasized_weights_are_the_official_ones() {
|
||||||
|
let ui = ui(SchemeFlavor::Light);
|
||||||
|
// title-small 的强调档是 700(官方 Emphasized)
|
||||||
|
assert_eq!(
|
||||||
|
ui.emphasized_font(TypeRole::TitleSmall).weight,
|
||||||
|
iced::font::Weight::Bold
|
||||||
|
);
|
||||||
|
// baseline 正文是 400
|
||||||
|
assert_eq!(
|
||||||
|
ui.font(TypeRole::BodyMedium).weight,
|
||||||
|
iced::font::Weight::Normal
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn body_text_keeps_wcag_contrast_in_both_flavors() {
|
||||||
|
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||||
|
let scheme = Scheme::baseline(flavor);
|
||||||
|
assert!(
|
||||||
|
scheme.contrast(Role::OnSurface, Role::Surface) >= 4.5,
|
||||||
|
"{flavor:?} 的正文对比度不够"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
scheme.contrast(Role::OnSecondaryContainer, Role::SecondaryContainer) >= 4.5,
|
||||||
|
"{flavor:?} 的选中态药丸对比度不够"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_role_maps_to_an_iced_color() {
|
||||||
|
for role in Role::ALL {
|
||||||
|
let color = ui(SchemeFlavor::Dark).color(role);
|
||||||
|
assert!((0.0..=1.0).contains(&color.a), "{role:?} 的 alpha 越界");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_layers_are_opaque_so_cpu_and_gpu_match() {
|
||||||
|
let ui = ui(SchemeFlavor::Light);
|
||||||
|
let hovered = ui.state_over(StateLayer::Hover, Role::OnSurface, Role::Surface);
|
||||||
|
assert_eq!(hovered.a, 1.0, "状态层必须给出不透明结果");
|
||||||
|
assert_ne!(hovered, ui.color(Role::Surface));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
//! 门禁:**组件层零硬编码**(色值 / 尺寸)与「不依赖 slint」。
|
||||||
|
//!
|
||||||
|
//! 这三条测试会扫本 crate 的 `src/**` 与 `Cargo.toml`,故意写得「宁可错杀」:
|
||||||
|
//! 将来 reviewer 复跑 `cargo test -p unlucky-app-iced` 就能挡住「顺手写个 #RRGGBB」的提交。
|
||||||
|
//!
|
||||||
|
//! 判定口径(与 `component-fidelity-rubric.md` T1/T2、`visual-acceptance-protocol.md` §1 对齐):
|
||||||
|
//!
|
||||||
|
//! - **色值字面量**:`#` + 6 位十六进制(且**至少含一个数字**,这样文档里的 `#RRGGBB` 占位不会被误杀)
|
||||||
|
//! 或 `Color::from_rgb(0.1, …)` / `color!(0x…)` 这类带数值参数的构造。
|
||||||
|
//! - **令牌优先**:唯一允许的是 `theme.rs`(适配层),且它也只做 `token → iced` 的搬运。
|
||||||
|
//! - **尺寸字面量**:`shell.rs` 里 `.width(12.0)` / `.height(34)` / `.size(22)` 一律不许出现
|
||||||
|
//! (必须走 `LayoutMetrics` / `TypeStyle` / `Space`)。
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// 本 crate 根目录(编译期注入,换机器也成立)。
|
||||||
|
fn manifest_dir() -> PathBuf {
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 递归收集 `dir` 下的 `.rs` 文件。
|
||||||
|
fn rust_files(dir: &Path, collected: &mut Vec<PathBuf>) {
|
||||||
|
let entries =
|
||||||
|
std::fs::read_dir(dir).unwrap_or_else(|error| panic!("读不动 {}:{error}", dir.display()));
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let path = entry.expect("目录项读失败").path();
|
||||||
|
if path.is_dir() {
|
||||||
|
rust_files(&path, collected);
|
||||||
|
} else if path.extension().is_some_and(|ext| ext == "rs") {
|
||||||
|
collected.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sources() -> Vec<(PathBuf, String)> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
rust_files(&manifest_dir().join("src"), &mut files);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
files.len() >= 8,
|
||||||
|
"只扫到 {} 个源文件:扫描逻辑坏了,这条门禁等于没跑",
|
||||||
|
files.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
files
|
||||||
|
.into_iter()
|
||||||
|
.map(|path| {
|
||||||
|
let text = std::fs::read_to_string(&path)
|
||||||
|
.unwrap_or_else(|error| panic!("读不动 {}:{error}", path.display()));
|
||||||
|
(path, text)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `#RRGGBB` / `#AARRGGBB` 形式的色值字面量(至少含一个数字,避开 `#RRGGBB` 这种占位写法)。
|
||||||
|
fn hex_color_at(bytes: &[u8], start: usize) -> Option<&str> {
|
||||||
|
let digits = bytes.get(start + 1..start + 7)?;
|
||||||
|
let all_hex = digits
|
||||||
|
.iter()
|
||||||
|
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_whitespace());
|
||||||
|
let has_number = digits.iter().any(u8::is_ascii_digit);
|
||||||
|
|
||||||
|
if all_hex && has_number {
|
||||||
|
// 后面不能再跟着十六进制字符(否则是更长的 token,不是 6 位色值)
|
||||||
|
let next = bytes.get(start + 7).copied();
|
||||||
|
if next.is_none_or(|byte| !byte.is_ascii_hexdigit()) {
|
||||||
|
return Some("hex 色值字面量(#RRGGBB)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 带数值参数的取色构造(`Color::from_rgb(0.1, …)`、`Color::from_rgb8(103, 80, 164)`、`color!(0x12, …)`)。
|
||||||
|
///
|
||||||
|
/// **子串匹配要注意前后缀**:`from_rgb(` 匹配不到 `from_rgb8(`,所以 8 bit 那个必须单独列出来
|
||||||
|
/// (reviewer 造样就是这么绕过去的)。
|
||||||
|
const NUMERIC_COLOR_CALLS: [&str; 8] = [
|
||||||
|
"Color::from_rgb(",
|
||||||
|
"Color::from_rgb8(",
|
||||||
|
"Color::from_rgba(",
|
||||||
|
"Color::from_rgba8(",
|
||||||
|
"Color::from_hex(",
|
||||||
|
"Color::rgba(",
|
||||||
|
"Color::rgb8(",
|
||||||
|
"color!(",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 取色构造后面是不是跟着数值字面量(允许 `Color::from_rgba8(color.r, …)` 这类搬运)。
|
||||||
|
fn numeric_color_call(text: &str) -> Option<&'static str> {
|
||||||
|
for pattern in NUMERIC_COLOR_CALLS {
|
||||||
|
let mut from = 0;
|
||||||
|
while let Some(found) = text[from..].find(pattern) {
|
||||||
|
let after = from + found + pattern.len();
|
||||||
|
let next = text[after..].trim_start().chars().next();
|
||||||
|
|
||||||
|
// 参数以数字或 `0x`/`#` 开头才算字面量;`Color::from_rgba8(color.r, …)` 是搬运,放行。
|
||||||
|
if next.is_some_and(|ch| ch.is_ascii_digit() || ch == '#') {
|
||||||
|
return Some(pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
from = after;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 结构体字面量取色:`Color { r: 0.4, g: 0.3, b: 0.6, a: 1.0 }`。
|
||||||
|
///
|
||||||
|
/// 判据:`Color {` 之后 64 个字符内同时出现 `r:` 与 `g:` 且有数字(`Color { a: 0.0, .. }` 这种
|
||||||
|
/// 只改 alpha 的写法不会命中,因为它没有 `r:`)。
|
||||||
|
fn color_struct_literal(text: &str) -> bool {
|
||||||
|
let mut from = 0;
|
||||||
|
while let Some(found) = text[from..].find("Color {") {
|
||||||
|
let after = from + found + "Color {".len();
|
||||||
|
let window: String = text[after..].chars().take(64).collect();
|
||||||
|
let has_channels = window.contains("r:") && window.contains("g:");
|
||||||
|
let has_number = window.chars().any(|ch| ch.is_ascii_digit());
|
||||||
|
|
||||||
|
if has_channels && has_number {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
from = after;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_hardcoded_colors_in_sources() {
|
||||||
|
let mut offenders = Vec::new();
|
||||||
|
|
||||||
|
for (path, text) in sources() {
|
||||||
|
for (index, line) in text.lines().enumerate() {
|
||||||
|
let bytes = line.as_bytes();
|
||||||
|
let mut position = 0;
|
||||||
|
|
||||||
|
while let Some(found) = line[position..].find('#') {
|
||||||
|
let at = position + found;
|
||||||
|
if let Some(kind) = hex_color_at(bytes, at) {
|
||||||
|
offenders.push(format!(
|
||||||
|
"{}:{} {line:?} -> {kind}",
|
||||||
|
path.display(),
|
||||||
|
index + 1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
position = at + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(pattern) = numeric_color_call(line) {
|
||||||
|
offenders.push(format!(
|
||||||
|
"{}:{} {line:?} -> {pattern}",
|
||||||
|
path.display(),
|
||||||
|
index + 1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if color_struct_literal(line) {
|
||||||
|
offenders.push(format!(
|
||||||
|
"{}:{} {line:?} -> Color {{ r: … }} 结构体字面量",
|
||||||
|
path.display(),
|
||||||
|
index + 1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
offenders.is_empty(),
|
||||||
|
"组件层不许出现色值字面量(一律走 unlucky_design 的 Role):\n{}",
|
||||||
|
offenders.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断某个布局调用后面是不是直接跟了尺寸字面量;`0`(= 无内边距)放行。
|
||||||
|
///
|
||||||
|
/// iced 里尺寸经常被包一层:`.width(Length::Fixed(123.0))`、`.padding(Padding::from([12.0, 8.0]))`、
|
||||||
|
/// `.size(Size::new(10.0, 10.0))` —— 所以遇到这些包装要**往里剥一层**再判。
|
||||||
|
fn size_literal(argument: &str) -> Option<String> {
|
||||||
|
let mut rest = argument.trim_start();
|
||||||
|
|
||||||
|
for wrapper in ["Length::Fixed(", "Padding::from(", "Size::new("] {
|
||||||
|
if let Some(after) = rest.strip_prefix(wrapper) {
|
||||||
|
rest = after.trim_start().trim_start_matches(['[', '(']);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let number: String = rest
|
||||||
|
.trim_start()
|
||||||
|
.chars()
|
||||||
|
.take_while(|ch| ch.is_ascii_digit() || *ch == '.')
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if number.is_empty() || number.trim_end_matches('.').parse::<f64>() == Ok(0.0) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(number)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_takes_every_size_from_tokens() {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
rust_files(&manifest_dir().join("src").join("shell"), &mut files);
|
||||||
|
assert!(
|
||||||
|
files.len() >= 2,
|
||||||
|
"shell 模块至少有两个文件(mod.rs + nav.rs),实际扫到 {}",
|
||||||
|
files.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut offenders = Vec::new();
|
||||||
|
|
||||||
|
for path in files {
|
||||||
|
let text = std::fs::read_to_string(&path).expect("读 shell 源文件");
|
||||||
|
|
||||||
|
for (index, line) in text.lines().enumerate() {
|
||||||
|
// 文档注释是散文不是代码:里面写 `.width(24)` 是在解释坑,不该判违规
|
||||||
|
if line.trim_start().starts_with("//") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for pattern in [".width(", ".height(", ".size(", ".spacing(", ".padding("] {
|
||||||
|
let mut from = 0;
|
||||||
|
while let Some(found) = line[from..].find(pattern) {
|
||||||
|
let after = from + found + pattern.len();
|
||||||
|
if let Some(number) = size_literal(&line[after..]) {
|
||||||
|
offenders.push(format!(
|
||||||
|
"{}:{} {line:?} -> {pattern} 后面直接写了数字 {number}",
|
||||||
|
path.display(),
|
||||||
|
index + 1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
from = after;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
offenders.is_empty(),
|
||||||
|
"骨架尺寸必须来自 LayoutMetrics / TypeStyle / Space:\n{}",
|
||||||
|
offenders.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 依赖行里出现 `slint` 就算违规(注释行放过:说明并存期要提到 slint)。
|
||||||
|
fn manifest_has_slint_dependency(manifest: &str) -> Vec<String> {
|
||||||
|
manifest
|
||||||
|
.lines()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, line)| {
|
||||||
|
let trimmed = line.trim_start();
|
||||||
|
!trimmed.starts_with('#') && trimmed.contains("slint")
|
||||||
|
})
|
||||||
|
.map(|(index, line)| format!("Cargo.toml:{} {line}", index + 1))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn this_crate_never_depends_on_slint() {
|
||||||
|
let manifest =
|
||||||
|
std::fs::read_to_string(manifest_dir().join("Cargo.toml")).expect("读 Cargo.toml");
|
||||||
|
|
||||||
|
let offenders = manifest_has_slint_dependency(&manifest);
|
||||||
|
assert!(
|
||||||
|
offenders.is_empty(),
|
||||||
|
"P4 的 iced 版 crate 不许依赖 slint(D26):\n{}",
|
||||||
|
offenders.join("\n")
|
||||||
|
);
|
||||||
|
|
||||||
|
for (path, text) in sources() {
|
||||||
|
for (index, line) in text.lines().enumerate() {
|
||||||
|
// 只查真代码里的 use/路径,文档里提 slint 是允许的(要解释并存期)
|
||||||
|
let code = line.trim_start();
|
||||||
|
let is_import = code.starts_with("use ") || code.starts_with("extern crate");
|
||||||
|
if is_import && code.contains("slint") {
|
||||||
|
panic!("{}:{} 引入了 slint:{line}", path.display(), index + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_gate_itself_catches_a_planted_violation() {
|
||||||
|
// 自检:把违规样本喂给同一套判定,必须被抓住(否则门禁是空转)。
|
||||||
|
// **reviewer r1 造样验证过的 5 个漏网样本逐条钉在这里**,退化成「放行」立刻红。
|
||||||
|
|
||||||
|
// 1. `Color::from_rgb8(103, 80, 164)` —— 8bit 版曾因 `from_rgb(` 匹配不到而漏
|
||||||
|
assert!(numeric_color_call("Color::from_rgb8(103, 80, 164)").is_some());
|
||||||
|
// 2. `iced::Color::from_rgb8(...)` —— 带路径前缀的写法同样要拦
|
||||||
|
assert!(numeric_color_call("iced::Color::from_rgb8(103, 80, 164)").is_some());
|
||||||
|
// 3. `Color { r: 0.4, g: 0.3, b: 0.6, a: 1.0 }` —— 结构体字面量
|
||||||
|
assert!(color_struct_literal(
|
||||||
|
"Color { r: 0.4, g: 0.3, b: 0.6, a: 1.0 }"
|
||||||
|
));
|
||||||
|
// 4. `.width(Length::Fixed(123.0))` —— 尺寸被 iced 包装一层
|
||||||
|
assert_eq!(
|
||||||
|
size_literal("Length::Fixed(123.0))").as_deref(),
|
||||||
|
Some("123.0")
|
||||||
|
);
|
||||||
|
// 5. `.padding(Padding::from([12.0, 8.0]))` —— 数组字面量
|
||||||
|
assert_eq!(
|
||||||
|
size_literal("Padding::from([12.0, 8.0]))").as_deref(),
|
||||||
|
Some("12.0")
|
||||||
|
);
|
||||||
|
|
||||||
|
// 原有规则不能退化
|
||||||
|
assert!(hex_color_at(b" color: #6750A4,", 11).is_some());
|
||||||
|
assert!(hex_color_at(b"// placeholder #RRGGBB here", 15).is_none());
|
||||||
|
assert!(numeric_color_call("Color::from_rgb(0.1, 0.2, 0.3)").is_some());
|
||||||
|
assert!(numeric_color_call("Color::from_rgba8(color.r, color.g, color.b, color.a)").is_none());
|
||||||
|
assert!(numeric_color_call("color!(0x5865F2)").is_some());
|
||||||
|
assert!(size_literal("40.0)").is_some());
|
||||||
|
assert!(size_literal("0)").is_none());
|
||||||
|
assert!(size_literal("metrics.titlebar_height)").is_none());
|
||||||
|
assert!(size_literal("Length::Fixed(thickness))").is_none());
|
||||||
|
assert!(size_literal("Padding::from([0.0, ui.space(Space::X4)]))").is_none());
|
||||||
|
assert!(!color_struct_literal(
|
||||||
|
"Color::from_rgba8(color.r, color.g, color.b, 1.0)"
|
||||||
|
));
|
||||||
|
assert!(!color_struct_literal("let color = Color { a: 0.5 };"));
|
||||||
|
assert!(!manifest_has_slint_dependency("slint = \"1.18\"").is_empty());
|
||||||
|
assert!(manifest_has_slint_dependency("# 不得依赖 slint").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 渲染层回归守门(视觉复审 P2-1 的教训):`indicator_matches_the_reference_56x32` 只测纯函数,
|
||||||
|
/// 门禁全绿但图上的药丸实际是 88×32 —— 因为 `.center_x(Length::Fill)` 会把容器 width 覆盖成 Fill。
|
||||||
|
/// 这里直接扫 `nav.rs`:药丸容器**不许**出现 `center_x(` / `center_y(`。
|
||||||
|
#[test]
|
||||||
|
fn pill_never_overrides_its_size_with_fill() {
|
||||||
|
let nav = manifest_dir().join("src").join("shell").join("nav.rs");
|
||||||
|
let text = std::fs::read_to_string(&nav).expect("读 nav.rs");
|
||||||
|
|
||||||
|
for (index, line) in text.lines().enumerate() {
|
||||||
|
let code = line.trim_start();
|
||||||
|
if code.starts_with("//") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for pattern in ["center_x(", "center_y("] {
|
||||||
|
assert!(
|
||||||
|
!code.contains(pattern),
|
||||||
|
"nav.rs:{} 用了 {pattern}——`center_*(Length::Fill)` 会把容器尺寸改成 Fill, 药丸会被父级宽度覆盖(视觉复审 P2-1)。居中请用父级 `align_x`:{line}",
|
||||||
|
index + 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@
|
|||||||
//! - [`linux_caps`]:发行版(仅显示)+ 内核 release(喂 `os.version`)+ 会话/GL 能力探测;
|
//! - [`linux_caps`]:发行版(仅显示)+ 内核 release(喂 `os.version`)+ 会话/GL 能力探测;
|
||||||
//! - [`vk_probe`]:Vulkan 能力的安全层探测(loader 文件 / ICD 注册 / 环境变量,无 `unsafe`);
|
//! - [`vk_probe`]:Vulkan 能力的安全层探测(loader 文件 / ICD 注册 / 环境变量,无 `unsafe`);
|
||||||
//! - [`theme_probe`]:系统主题(浅色/深色)探测,供主题三态的 `auto` 初始化对齐与兜底跟随。
|
//! - [`theme_probe`]:系统主题(浅色/深色)探测,供主题三态的 `auto` 初始化对齐与兜底跟随。
|
||||||
|
//! - [`wallpaper`]:桌面壁纸**路径探测**(D38 配色三级优先里第②级的输入)。本模块只做路径
|
||||||
|
//! 探测——不解码图片、不取色、不生成调色板,莫奈取色与三级降级在 `crates/design` 的
|
||||||
|
//! 后续 mission。
|
||||||
//!
|
//!
|
||||||
//! 尚未落地(后续 mission):文件锁、进程工具、真实 GL/Vulkan 能力探测(钩子见
|
//! 尚未落地(后续 mission):文件锁、进程工具、真实 GL/Vulkan 能力探测(钩子见
|
||||||
//! [`linux_caps::probe_gl`] 与 [`vk_probe::probe_vk`],FFI 受限,实际探测归 app 层渲染后端选择)。
|
//! [`linux_caps::probe_gl`] 与 [`vk_probe::probe_vk`],FFI 受限,实际探测归 app 层渲染后端选择)。
|
||||||
@@ -39,4 +42,5 @@ pub mod receipt;
|
|||||||
pub mod redirect;
|
pub mod redirect;
|
||||||
pub mod theme_probe;
|
pub mod theme_probe;
|
||||||
pub mod vk_probe;
|
pub mod vk_probe;
|
||||||
|
pub mod wallpaper;
|
||||||
pub mod windows_version;
|
pub mod windows_version;
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
//! 桌面壁纸**路径探测**(D38 配色三级优先里的第②级输入)。
|
||||||
|
//!
|
||||||
|
//! 用户原话(D38,2026-11-01):「如果用户设置了启动器背景图,则使用莫奈取色从背景图取,
|
||||||
|
//! 没有背景图时用户也可以开启莫奈取色,此时从桌面壁纸取色。」本模块只承担中间那一级的
|
||||||
|
//! **输入**:按可信度给出「这台机器的桌面壁纸可能是哪个文件」的有序候选列表。
|
||||||
|
//!
|
||||||
|
//! # 边界(只做路径探测)
|
||||||
|
//!
|
||||||
|
//! - **不解码图片、不取色、不生成调色板**。莫奈取色本身、D38 三级降级、
|
||||||
|
//! 「取色失败回落到色卡当前选择 + 设置页可读提示」都在 `crates/design` 的后续 mission。
|
||||||
|
//! - 全部找不到时返回**空列表**([`WallpaperProbe::is_empty`])而不是错误:没有桌面壁纸是
|
||||||
|
//! 正常状态(服务器、无桌面环境、壁纸是纯色),调用方按 D38 直接落到第③级色卡。
|
||||||
|
//! - **零 `unsafe`、零 FFI**(core 是 `#![forbid(unsafe_code)]`):不用
|
||||||
|
//! `SystemParametersInfo(SPI_GETDESKWALLPAPER)`、不碰 win32 注册表 API,全部走文件读取与
|
||||||
|
//! `reg.exe` / `gsettings` / `defaults` 子进程——与 [`super::theme_probe`]、
|
||||||
|
//! [`super::windows_version`] 同一套路子。
|
||||||
|
//!
|
||||||
|
//! # 分层与可测性
|
||||||
|
//!
|
||||||
|
//! - 采集逻辑按平台分文件(`windows` / `linux` / `macos` 子模块),**无条件编译**、不按
|
||||||
|
//! `cfg` 裁剪:CI 是 Linux,Windows / macOS 的夹具用例同样要能在那里跑;
|
||||||
|
//! - 平台分流只发生在 [`detect_with`] 一处;
|
||||||
|
//! - 外部 IO 全部经 [`ProbeIo`](文件内容 / 文件头 / 目录列表 / 子进程)与
|
||||||
|
//! [`EnvSource`](环境变量表)注入,[`detect`] 只是把它们换成真实实现。
|
||||||
|
//!
|
||||||
|
//! # 什么算「候选壁纸」
|
||||||
|
//!
|
||||||
|
//! 只收「归一化后是绝对路径 + 文件头认得出是图片」的条目(魔数集合见 `parse` 子模块的
|
||||||
|
//! `looks_like_image`)。这条校验既挡掉过期配置里指向已删除文件的路径,也挡掉 macOS
|
||||||
|
//! 二进制 plist 字符串扫描出的碎片串。
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::platform::paths::{EnvSource, Platform, ProcessEnv};
|
||||||
|
|
||||||
|
use self::io::{HostIo, HostRoots, ProbeIo};
|
||||||
|
use self::parse::{looks_like_image, normalize_path};
|
||||||
|
|
||||||
|
mod io;
|
||||||
|
mod linux;
|
||||||
|
mod macos;
|
||||||
|
mod parse;
|
||||||
|
mod windows;
|
||||||
|
|
||||||
|
/// 读文件头做魔数嗅探时的字节数(够 `ftyp` 分支与文本 SVG 头)。
|
||||||
|
const IMAGE_HEAD_LEN: usize = 512;
|
||||||
|
|
||||||
|
/// 一个候选壁纸是**怎么被找到的**:平台 + 桌面环境标识。
|
||||||
|
///
|
||||||
|
/// 标识只表示「这条路径从哪个通道读出来」,不承诺该通道一定存在(读不到就走下一个)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SourceKind {
|
||||||
|
/// Windows:`%APPDATA%\Microsoft\Windows\Themes\TranscodedWallpaper`(系统转码后的当前壁纸)。
|
||||||
|
WindowsTranscoded,
|
||||||
|
/// Windows:`HKCU\Software\Microsoft\Windows\CurrentVersion\Themes` 的 `CurrentTheme`
|
||||||
|
/// 指向的 `.theme`(Win10+ 落在 `%LOCALAPPDATA%\Microsoft\Windows\Themes\Custom.theme`)。
|
||||||
|
///
|
||||||
|
/// 这是**当前生效**的那份主题,比 `%APPDATA%` 下的主题包可靠得多。
|
||||||
|
WindowsCurrentTheme,
|
||||||
|
/// Windows:`%APPDATA%\Microsoft\Windows\Themes\*.theme` 里的 `Wallpaper=` 行。
|
||||||
|
///
|
||||||
|
/// 用户另存的主题包(不一定被启用),只作最后一档兜底。
|
||||||
|
WindowsThemeFile,
|
||||||
|
/// Windows:注册表 `HKCU\Control Panel\Desktop` 的 `WallPaper` 值(`reg.exe` 子进程)。
|
||||||
|
WindowsRegistry,
|
||||||
|
/// Linux · GNOME:`gsettings get org.gnome.desktop.background picture-uri`(`-dark`)。
|
||||||
|
Gnome,
|
||||||
|
/// Linux · KDE Plasma:`plasma-org.kde.plasma.desktop-appletsrc` 的 `Image=` 行。
|
||||||
|
Kde,
|
||||||
|
/// Linux · swaybg / hyprpaper 配置里的 wallpaper 行。
|
||||||
|
WlrootsCompositor,
|
||||||
|
/// Linux · XFCE:`xfce4-desktop.xml` 的 `last-image` / `image-path` 属性。
|
||||||
|
Xfce,
|
||||||
|
/// macOS:`com.apple.wallpaper/Store/Index.plist` 里的 file URL / 绝对路径串。
|
||||||
|
MacOsStore,
|
||||||
|
/// macOS:`defaults read com.apple.desktop Background`(老式每屏壁纸设置)。
|
||||||
|
MacOsDesktopDefaults,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SourceKind {
|
||||||
|
/// 稳定标识(日志 / 设置页提示用)。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::WindowsTranscoded => "windows-transcoded",
|
||||||
|
Self::WindowsCurrentTheme => "windows-current-theme",
|
||||||
|
Self::WindowsThemeFile => "windows-theme-file",
|
||||||
|
Self::WindowsRegistry => "windows-registry",
|
||||||
|
Self::Gnome => "linux-gnome",
|
||||||
|
Self::Kde => "linux-kde",
|
||||||
|
Self::WlrootsCompositor => "linux-wlroots",
|
||||||
|
Self::Xfce => "linux-xfce",
|
||||||
|
Self::MacOsStore => "macos-store",
|
||||||
|
Self::MacOsDesktopDefaults => "macos-desktop-defaults",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 这条通道属于哪个平台族。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn platform(self) -> Platform {
|
||||||
|
match self {
|
||||||
|
Self::WindowsTranscoded
|
||||||
|
| Self::WindowsCurrentTheme
|
||||||
|
| Self::WindowsThemeFile
|
||||||
|
| Self::WindowsRegistry => Platform::Windows,
|
||||||
|
Self::MacOsStore | Self::MacOsDesktopDefaults => Platform::MacOs,
|
||||||
|
Self::Gnome | Self::Kde | Self::WlrootsCompositor | Self::Xfce => Platform::Linux,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一个候选壁纸来源。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct WallpaperSource {
|
||||||
|
/// 通道标识(平台 + 桌面环境)。
|
||||||
|
pub kind: SourceKind,
|
||||||
|
/// 归一化后的绝对路径。
|
||||||
|
pub path: PathBuf,
|
||||||
|
/// 原始证据:配置文件 / 注册表键 / 命令行,用于日志与设置页的「从哪读到的」。
|
||||||
|
pub evidence: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 探测结果:有序候选列表 + 各步失败原因。
|
||||||
|
///
|
||||||
|
/// 列表**不是** `Result`:找不到壁纸不是错误,见模块文档的边界一节。
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct WallpaperProbe {
|
||||||
|
sources: Vec<WallpaperSource>,
|
||||||
|
notes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WallpaperProbe {
|
||||||
|
/// 候选列表,按可信度从高到低(第一个就是首选)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn sources(&self) -> &[WallpaperSource] {
|
||||||
|
&self.sources
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 首选候选(等价于 `sources().first()`)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn best(&self) -> Option<&WallpaperSource> {
|
||||||
|
self.sources.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 候选迭代器(按可信度顺序)。
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = &WallpaperSource> {
|
||||||
|
self.sources.iter()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 候选个数。
|
||||||
|
#[must_use]
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.sources.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一个候选都没有(调用方应落到 D38 的第③级色卡)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.sources.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 各步的失败原因(`通道: 原因`),可直接展示在设置页的提示里。
|
||||||
|
#[must_use]
|
||||||
|
pub fn notes(&self) -> &[String] {
|
||||||
|
&self.notes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取走候选列表。
|
||||||
|
#[must_use]
|
||||||
|
pub fn into_sources(self) -> Vec<WallpaperSource> {
|
||||||
|
self.sources
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 真实探测宿主平台([`HostIo`] + 进程环境 + 编译目标平台)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn detect() -> WallpaperProbe {
|
||||||
|
detect_with(&HostIo, &ProcessEnv, Platform::current())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注入式探测:IO、环境变量表、平台族全部由调用方给。
|
||||||
|
///
|
||||||
|
/// 三平台采集逻辑都不带 `cfg`,所以任意宿主都能用固定夹具跑某一平台的用例。
|
||||||
|
#[must_use]
|
||||||
|
pub fn detect_with(io: &dyn ProbeIo, env: &dyn EnvSource, platform: Platform) -> WallpaperProbe {
|
||||||
|
let Some(roots) = HostRoots::from_env(env) else {
|
||||||
|
return WallpaperProbe {
|
||||||
|
sources: Vec::new(),
|
||||||
|
notes: vec!["host: HOME / USERPROFILE 都读不到,无法定位用户目录".to_owned()],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let mut collect = Collect::new(io, roots, platform);
|
||||||
|
match platform {
|
||||||
|
Platform::Windows => windows::collect(&mut collect),
|
||||||
|
Platform::MacOs => macos::collect(&mut collect),
|
||||||
|
// 其它 Unix 按 Linux 的桌面习惯兜底(与 `paths` 模块的处理一致)。
|
||||||
|
Platform::Linux | Platform::Other => linux::collect(&mut collect),
|
||||||
|
}
|
||||||
|
collect.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 采集过程的累加器:负责归一化、去重、图片校验与失败原因记录。
|
||||||
|
pub(crate) struct Collect<'io> {
|
||||||
|
io: &'io dyn ProbeIo,
|
||||||
|
roots: HostRoots,
|
||||||
|
platform: Platform,
|
||||||
|
sources: Vec<WallpaperSource>,
|
||||||
|
notes: Vec<String>,
|
||||||
|
seen: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'io> Collect<'io> {
|
||||||
|
/// 构造空累加器。
|
||||||
|
pub(crate) fn new(io: &'io dyn ProbeIo, roots: HostRoots, platform: Platform) -> Self {
|
||||||
|
Self {
|
||||||
|
io,
|
||||||
|
roots,
|
||||||
|
platform,
|
||||||
|
sources: Vec::new(),
|
||||||
|
notes: Vec::new(),
|
||||||
|
seen: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注入的 IO(文件 / 目录 / 子进程)。
|
||||||
|
pub(crate) fn io(&self) -> &'io dyn ProbeIo {
|
||||||
|
self.io
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 宿主根目录(由环境变量表推出)。
|
||||||
|
pub(crate) fn roots(&self) -> &HostRoots {
|
||||||
|
&self.roots
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记一条失败原因,探测继续往下走。
|
||||||
|
pub(crate) fn note(&mut self, step: &str, detail: impl std::fmt::Display) {
|
||||||
|
self.notes.push(format!("{step}: {detail}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收一个候选:归一化 → 去重 → 图片校验,任何一步不过都只记原因、不中断。
|
||||||
|
///
|
||||||
|
/// 返回是否真的收下(调用方据此决定要不要继续试别名)。
|
||||||
|
pub(crate) fn add(&mut self, kind: SourceKind, evidence: &str, raw: &str) -> bool {
|
||||||
|
match normalize_path(raw, &self.roots) {
|
||||||
|
Some(path) => self.add_path(kind, evidence, path),
|
||||||
|
None => {
|
||||||
|
self.note(evidence, format!("不是可用的绝对路径({raw})"));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收一个已经算好的路径(调用方自己拼的,不再归一化)。
|
||||||
|
pub(crate) fn add_path(&mut self, kind: SourceKind, evidence: &str, path: PathBuf) -> bool {
|
||||||
|
let key = dedupe_key(self.platform, &path);
|
||||||
|
if self.seen.contains(&key) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let head = self.io.read_head(&path, IMAGE_HEAD_LEN);
|
||||||
|
if !head.as_deref().is_some_and(looks_like_image) {
|
||||||
|
self.note(
|
||||||
|
evidence,
|
||||||
|
format!("读不到或认不出图片格式({})", path.display()),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.seen.push(key);
|
||||||
|
self.sources.push(WallpaperSource {
|
||||||
|
kind,
|
||||||
|
path,
|
||||||
|
evidence: evidence.to_owned(),
|
||||||
|
});
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收尾。
|
||||||
|
pub(crate) fn finish(self) -> WallpaperProbe {
|
||||||
|
WallpaperProbe {
|
||||||
|
sources: self.sources,
|
||||||
|
notes: self.notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 去重键:Windows 路径大小写不敏感(同一文件可能被写出两种大小写),其它平台按原样。
|
||||||
|
fn dedupe_key(platform: Platform, path: &std::path::Path) -> String {
|
||||||
|
let text = path.to_string_lossy().into_owned();
|
||||||
|
if platform == Platform::Windows {
|
||||||
|
text.to_ascii_lowercase()
|
||||||
|
} else {
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::io::{HostIo, HostRoots};
|
||||||
|
use super::*;
|
||||||
|
use crate::platform::paths::MapEnv;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_probe_is_empty_not_error() {
|
||||||
|
let probe = WallpaperProbe::default();
|
||||||
|
assert!(probe.is_empty());
|
||||||
|
assert_eq!(probe.len(), 0);
|
||||||
|
assert!(probe.best().is_none());
|
||||||
|
assert_eq!(probe.iter().count(), 0);
|
||||||
|
assert!(probe.notes().is_empty());
|
||||||
|
assert!(probe.into_sources().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_home_yields_note_and_no_candidates() {
|
||||||
|
let env = MapEnv::new();
|
||||||
|
for platform in [Platform::Windows, Platform::MacOs, Platform::Linux] {
|
||||||
|
let probe = detect_with(&HostIo, &env, platform);
|
||||||
|
assert!(probe.is_empty(), "{platform:?} 没有 HOME 时不该有候选");
|
||||||
|
assert_eq!(probe.notes().len(), 1);
|
||||||
|
assert!(probe.notes()[0].contains("HOME"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_kind_exposes_platform_and_stable_name() {
|
||||||
|
assert_eq!(SourceKind::Gnome.platform(), Platform::Linux);
|
||||||
|
assert_eq!(SourceKind::WindowsRegistry.platform(), Platform::Windows);
|
||||||
|
assert_eq!(SourceKind::MacOsStore.platform(), Platform::MacOs);
|
||||||
|
assert_eq!(SourceKind::WindowsTranscoded.as_str(), "windows-transcoded");
|
||||||
|
assert!(SourceKind::Kde.as_str().starts_with("linux-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_dedupes_and_keeps_priority_order() {
|
||||||
|
let env = MapEnv::new().with("HOME", "/home/u");
|
||||||
|
let roots = HostRoots::from_env(&env).expect("HOME 已给");
|
||||||
|
let io = io::fake::FakeIo::new()
|
||||||
|
.with_image("/home/u/a.png")
|
||||||
|
.with_image("/home/u/b.jpg");
|
||||||
|
let mut collect = Collect::new(&io, roots, Platform::Linux);
|
||||||
|
collect.add(SourceKind::Gnome, "gsettings", "/home/u/a.png");
|
||||||
|
collect.add(SourceKind::Kde, "kde", "file:///home/u/b.jpg");
|
||||||
|
// 同一条路径再来一次(换了个通道)不该重复计入。
|
||||||
|
collect.add(SourceKind::Xfce, "xfce", "/home/u/a.png");
|
||||||
|
collect.add(SourceKind::Xfce, "xfce", "/home/u/missing.png");
|
||||||
|
let probe = collect.finish();
|
||||||
|
assert_eq!(probe.len(), 2);
|
||||||
|
assert_eq!(probe.best().expect("有候选").kind, SourceKind::Gnome);
|
||||||
|
assert_eq!(probe.sources()[1].path, PathBuf::from("/home/u/b.jpg"));
|
||||||
|
assert!(
|
||||||
|
probe
|
||||||
|
.notes()
|
||||||
|
.iter()
|
||||||
|
.any(|note| note.contains("missing.png")),
|
||||||
|
"落选的候选要留原因:{:?}",
|
||||||
|
probe.notes()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
//! 壁纸探测要用的外部 IO:文件读取、文件头、目录列表、子进程,以及宿主根目录。
|
||||||
|
//!
|
||||||
|
//! 生产实现是 [`HostIo`],测试实现是 `fake::FakeIo`(固定夹具,不碰真机壁纸)。
|
||||||
|
//! 这一层的存在只为把「外部世界」变成注入参数,不含任何平台判断。
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::platform::paths::EnvSource;
|
||||||
|
|
||||||
|
/// 探测需要的外部世界:四个方法覆盖三平台的全部读法。
|
||||||
|
pub trait ProbeIo {
|
||||||
|
/// 读整个文件并按 UTF-8 lossy 转文本。
|
||||||
|
///
|
||||||
|
/// 二进制 plist 也走这里:路径串本身是可读 ASCII,坏字节会被替换成 U+FFFD 而不影响
|
||||||
|
/// 路径串的完整性(macOS 的 `Index.plist` 就是 bplist00)。
|
||||||
|
fn read_text(&self, path: &Path) -> Option<String>;
|
||||||
|
|
||||||
|
/// 读文件头最多 `limit` 字节(图片魔数嗅探用;不存在或读不了返回 `None`)。
|
||||||
|
fn read_head(&self, path: &Path, limit: usize) -> Option<Vec<u8>>;
|
||||||
|
|
||||||
|
/// 列出目录里的**文件**(非递归、不排序;目录不存在返回空列表)。排序由调用方做。
|
||||||
|
fn list_dir(&self, dir: &Path) -> Vec<PathBuf>;
|
||||||
|
|
||||||
|
/// 跑一个外部命令取 stdout(命令缺失 / 非零退出 / 空输出都返回 `None`)。
|
||||||
|
fn run(&self, program: &str, args: &[&str]) -> Option<String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 真实宿主 IO。
|
||||||
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
|
pub struct HostIo;
|
||||||
|
|
||||||
|
impl ProbeIo for HostIo {
|
||||||
|
fn read_text(&self, path: &Path) -> Option<String> {
|
||||||
|
let bytes = std::fs::read(path).ok()?;
|
||||||
|
Some(String::from_utf8_lossy(&bytes).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_head(&self, path: &Path, limit: usize) -> Option<Vec<u8>> {
|
||||||
|
use std::io::Read;
|
||||||
|
let file = std::fs::File::open(path).ok()?;
|
||||||
|
let mut buffer = vec![0_u8; limit];
|
||||||
|
let mut handle = file.take(limit as u64);
|
||||||
|
let read = handle.read(&mut buffer).ok()?;
|
||||||
|
buffer.truncate(read);
|
||||||
|
Some(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_dir(&self, dir: &Path) -> Vec<PathBuf> {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
entries
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.filter(|entry| {
|
||||||
|
entry
|
||||||
|
.file_type()
|
||||||
|
.map(|kind| kind.is_file())
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(&self, program: &str, args: &[&str]) -> Option<String> {
|
||||||
|
let output = std::process::Command::new(program)
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
// `Command::output` 对非零退出同样返回 `Ok`,所以退出码要单独判:三条命令
|
||||||
|
// (`reg.exe` / `gsettings` / `defaults`)出错时都走 stderr,只看 stdout 会把
|
||||||
|
// 「查不到」和「命令失败」混成一种,调用方的原因文案就名不副实了。
|
||||||
|
if !output.status.success() || output.stdout.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 探测要用的宿主根目录(全部由环境变量表推出,因此测试可以整体替换)。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct HostRoots {
|
||||||
|
/// 用户主目录(`HOME`,退 `USERPROFILE`)。
|
||||||
|
pub home: PathBuf,
|
||||||
|
/// Windows `%APPDATA%`(退 `~/AppData/Roaming`);其它平台用不到,但仍填上以免分支取空。
|
||||||
|
pub app_data: PathBuf,
|
||||||
|
/// Windows `%LOCALAPPDATA%`(退 `~/AppData/Local`):Win10+ 的「当前主题」
|
||||||
|
/// `Custom.theme` 落在这一侧。
|
||||||
|
pub local_app_data: PathBuf,
|
||||||
|
/// `$XDG_CONFIG_HOME`(仅接受绝对路径),退 `~/.config`。
|
||||||
|
pub config_home: PathBuf,
|
||||||
|
/// Windows `%SystemRoot%`(`.theme` 里 `Wallpaper=%SystemRoot%\...` 展开用),无则不展开。
|
||||||
|
pub system_root: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostRoots {
|
||||||
|
/// 从环境变量表推出根目录;连主目录都没有时返回 `None`(调用方记一条原因)。
|
||||||
|
///
|
||||||
|
/// 顺序固定(`HOME` 优先于 `USERPROFILE`),**不按编译目标分支**:否则在 Windows 上
|
||||||
|
/// 跑 Linux 夹具时会被 `USERPROFILE` 抢走主目录,夹具就不再确定。
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_env(env: &dyn EnvSource) -> Option<Self> {
|
||||||
|
let home = env
|
||||||
|
.var("HOME")
|
||||||
|
.or_else(|| env.var("USERPROFILE"))
|
||||||
|
.map(PathBuf::from)?;
|
||||||
|
let app_data = env
|
||||||
|
.var("APPDATA")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| home.join("AppData/Roaming"));
|
||||||
|
let local_app_data = env
|
||||||
|
.var("LOCALAPPDATA")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| home.join("AppData/Local"));
|
||||||
|
let config_home = env
|
||||||
|
.var("XDG_CONFIG_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.filter(|dir| dir.is_absolute())
|
||||||
|
.unwrap_or_else(|| home.join(".config"));
|
||||||
|
let system_root = env
|
||||||
|
.var("SYSTEMROOT")
|
||||||
|
.or_else(|| env.var("WINDIR"))
|
||||||
|
.map(PathBuf::from);
|
||||||
|
Some(Self {
|
||||||
|
home,
|
||||||
|
app_data,
|
||||||
|
local_app_data,
|
||||||
|
config_home,
|
||||||
|
system_root,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod fake {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use super::ProbeIo;
|
||||||
|
|
||||||
|
/// PNG 魔数(夹具用它冒充「像图片」的文件)。
|
||||||
|
pub(crate) const PNG_HEAD: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR";
|
||||||
|
|
||||||
|
/// 固定夹具 IO:文件内容、图片、目录列表、命令输出全部预先给定。
|
||||||
|
///
|
||||||
|
/// 未登记的路径一律「不存在 / 读不出 / 目录为空」,命令也未登记则报失败。
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct FakeIo {
|
||||||
|
texts: BTreeMap<PathBuf, String>,
|
||||||
|
images: Vec<PathBuf>,
|
||||||
|
dirs: BTreeMap<PathBuf, Vec<PathBuf>>,
|
||||||
|
commands: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeIo {
|
||||||
|
/// 空世界(一切探测都会失败)。
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登记一个文本文件(配置文件、`.theme`、plist、xml……)。
|
||||||
|
pub(crate) fn with_file(mut self, path: impl AsRef<Path>, text: &str) -> Self {
|
||||||
|
self.texts
|
||||||
|
.insert(path.as_ref().to_path_buf(), text.to_owned());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登记一个「图片」文件(`read_head` 返回 PNG 魔数)。
|
||||||
|
pub(crate) fn with_image(mut self, path: impl AsRef<Path>) -> Self {
|
||||||
|
self.images.push(path.as_ref().to_path_buf());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登记目录内容(按文件名给,路径拼在 `dir` 上)。
|
||||||
|
pub(crate) fn with_dir(mut self, dir: impl AsRef<Path>, names: &[&str]) -> Self {
|
||||||
|
let dir = dir.as_ref();
|
||||||
|
let entries = names.iter().map(|name| dir.join(name)).collect();
|
||||||
|
self.dirs.insert(dir.to_path_buf(), entries);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登记一条命令的 stdout;`key` 是「程序名 + 空格分隔的参数」整串。
|
||||||
|
pub(crate) fn with_command(mut self, key: &str, stdout: &str) -> Self {
|
||||||
|
self.commands.insert(key.to_owned(), stdout.to_owned());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_key(program: &str, args: &[&str]) -> String {
|
||||||
|
let mut key = program.to_owned();
|
||||||
|
for arg in args {
|
||||||
|
key.push(' ');
|
||||||
|
key.push_str(arg);
|
||||||
|
}
|
||||||
|
key
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProbeIo for FakeIo {
|
||||||
|
fn read_text(&self, path: &Path) -> Option<String> {
|
||||||
|
self.texts.get(path).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_head(&self, path: &Path, limit: usize) -> Option<Vec<u8>> {
|
||||||
|
if self.images.iter().any(|image| image.as_path() == path) {
|
||||||
|
return Some(PNG_HEAD[..limit.min(PNG_HEAD.len())].to_vec());
|
||||||
|
}
|
||||||
|
let text = self.texts.get(path)?;
|
||||||
|
let bytes = text.as_bytes();
|
||||||
|
Some(bytes[..limit.min(bytes.len())].to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_dir(&self, dir: &Path) -> Vec<PathBuf> {
|
||||||
|
self.dirs.get(dir).cloned().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(&self, program: &str, args: &[&str]) -> Option<String> {
|
||||||
|
let key = command_key(program, args);
|
||||||
|
let stdout = self.commands.get(&key)?;
|
||||||
|
if stdout.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(stdout.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::platform::paths::MapEnv;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roots_come_from_env_in_fixed_order() {
|
||||||
|
let env = MapEnv::new()
|
||||||
|
.with("HOME", "/home/u")
|
||||||
|
.with("USERPROFILE", "C:/Users/ignored")
|
||||||
|
.with("SYSTEMROOT", "C:/Windows");
|
||||||
|
let roots = HostRoots::from_env(&env).expect("HOME 已给");
|
||||||
|
assert_eq!(roots.home, PathBuf::from("/home/u"));
|
||||||
|
assert_eq!(roots.app_data, PathBuf::from("/home/u/AppData/Roaming"));
|
||||||
|
assert_eq!(roots.local_app_data, PathBuf::from("/home/u/AppData/Local"));
|
||||||
|
assert_eq!(roots.config_home, PathBuf::from("/home/u/.config"));
|
||||||
|
assert_eq!(roots.system_root, Some(PathBuf::from("C:/Windows")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roots_prefer_explicit_appdata_and_absolute_xdg() {
|
||||||
|
let env = MapEnv::new()
|
||||||
|
.with("HOME", "/home/u")
|
||||||
|
.with("APPDATA", "C:/Users/u/AppData/Roaming")
|
||||||
|
.with("LOCALAPPDATA", "C:/Users/u/AppData/Local")
|
||||||
|
// 相对路径按 XDG 规范忽略。
|
||||||
|
.with("XDG_CONFIG_HOME", "relative/dir");
|
||||||
|
let roots = HostRoots::from_env(&env).expect("HOME 已给");
|
||||||
|
assert_eq!(roots.app_data, PathBuf::from("C:/Users/u/AppData/Roaming"));
|
||||||
|
assert_eq!(
|
||||||
|
roots.local_app_data,
|
||||||
|
PathBuf::from("C:/Users/u/AppData/Local")
|
||||||
|
);
|
||||||
|
assert_eq!(roots.config_home, PathBuf::from("/home/u/.config"));
|
||||||
|
assert_eq!(roots.system_root, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_io_treats_non_zero_exit_as_failure() {
|
||||||
|
// 有 stdout 但退出码非零:只看 stdout 的旧实现会把这种输出当成功。
|
||||||
|
#[cfg(windows)]
|
||||||
|
let (program, args) = ("cmd", ["/c", "echo hi & exit 3"]);
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
let (program, args) = ("sh", ["-c", "echo hi; exit 3"]);
|
||||||
|
assert!(HostIo.run(program, &args).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fake_io_serves_only_registered_world() {
|
||||||
|
let io = fake::FakeIo::new()
|
||||||
|
.with_file("/home/u/note.txt", "hello")
|
||||||
|
.with_image("/home/u/a.png")
|
||||||
|
.with_dir("/home/u/dir", &["a.png", "b.theme"])
|
||||||
|
.with_command("gsettings get x y", "'file:///home/u/a.png'");
|
||||||
|
assert_eq!(
|
||||||
|
io.read_text(Path::new("/home/u/note.txt")).as_deref(),
|
||||||
|
Some("hello")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
io.read_head(Path::new("/home/u/note.txt"), 4).as_deref(),
|
||||||
|
Some(&b"hell"[..])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
io.read_head(Path::new("/home/u/a.png"), 8).as_deref(),
|
||||||
|
Some(&fake::PNG_HEAD[..8])
|
||||||
|
);
|
||||||
|
assert_eq!(io.list_dir(Path::new("/home/u/dir")).len(), 2);
|
||||||
|
assert!(io.run("gsettings", &["get", "x", "y"]).is_some());
|
||||||
|
assert!(io.run("reg.exe", &["query"]).is_none());
|
||||||
|
assert!(io.read_text(Path::new("/nope")).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
//! Linux 壁纸路径探测:GNOME → KDE Plasma → swaybg / hyprpaper → XFCE。
|
||||||
|
//!
|
||||||
|
//! 四步互相独立:`gsettings` 缺失、配置文件不存在、解析不到值都只记一条原因,探测继续往下
|
||||||
|
//! 走(Wayland 上一条配置都读不到是常态,此时应直接落到 D38 的第③级色卡)。
|
||||||
|
//!
|
||||||
|
//! 探测路径全部来自「桌面环境自己记在磁盘上的东西」,不需要 D-Bus 与 xdg-desktop-portal:
|
||||||
|
//!
|
||||||
|
//! 1. GNOME / Ubuntu 系:`gsettings get org.gnome.desktop.background picture-uri`,再看
|
||||||
|
//! `picture-uri-dark`(深色壁纸是独立键);
|
||||||
|
//! 2. KDE Plasma:`~/.config/plasma-org.kde.plasma.desktop-appletsrc` 的 `Image=` 行;
|
||||||
|
//! 3. swaybg / hyprpaper:`~/.config/hypr/hyprpaper.conf`(`wallpaper =` / `preload =`)与
|
||||||
|
//! `~/.config/sway/config`(`output <name> bg <path>`);
|
||||||
|
//! 4. XFCE:`~/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-desktop.xml` 的
|
||||||
|
//! `last-image` / `image-path` 属性。
|
||||||
|
//!
|
||||||
|
//! 本文件不带 `cfg`:平台分流只在 [`super::detect_with`] 一处,任何宿主都能跑夹具用例。
|
||||||
|
|
||||||
|
use super::{Collect, SourceKind};
|
||||||
|
|
||||||
|
/// GNOME 壁纸 schema。
|
||||||
|
pub(super) const GNOME_SCHEMA: &str = "org.gnome.desktop.background";
|
||||||
|
|
||||||
|
/// 探测的键,顺序即优先级(浅色在前,深色壁纸在后)。
|
||||||
|
pub(super) const GNOME_KEYS: &[&str] = &["picture-uri", "picture-uri-dark"];
|
||||||
|
|
||||||
|
const KDE_CONFIG: &str = "plasma-org.kde.plasma.desktop-appletsrc";
|
||||||
|
const HYPRPAPER_CONFIG: &str = "hypr/hyprpaper.conf";
|
||||||
|
const SWAY_CONFIG: &str = "sway/config";
|
||||||
|
const XFCE_DESKTOP_XML: &str = "xfce4/xfconf/xfce-perchannel-xml/xfce4-desktop.xml";
|
||||||
|
|
||||||
|
/// 四步依次探测。
|
||||||
|
pub(super) fn collect(collect: &mut Collect<'_>) {
|
||||||
|
gnome(collect);
|
||||||
|
kde(collect);
|
||||||
|
compositors(collect);
|
||||||
|
xfce(collect);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 第一步:GNOME 的 `picture-uri` / `picture-uri-dark`。
|
||||||
|
fn gnome(collect: &mut Collect<'_>) {
|
||||||
|
for key in GNOME_KEYS {
|
||||||
|
let evidence = format!("gsettings get {GNOME_SCHEMA} {key}");
|
||||||
|
match collect.io().run("gsettings", &["get", GNOME_SCHEMA, key]) {
|
||||||
|
Some(text) => match parse_gsettings_value(&text) {
|
||||||
|
Some(value) => {
|
||||||
|
collect.add(SourceKind::Gnome, &evidence, &value);
|
||||||
|
}
|
||||||
|
None => collect.note(
|
||||||
|
SourceKind::Gnome.as_str(),
|
||||||
|
format!("{evidence} = {}(没有设壁纸)", text.trim()),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
None => collect.note(
|
||||||
|
SourceKind::Gnome.as_str(),
|
||||||
|
format!("{evidence} 失败(命令缺失 / 非零退出 / 空输出)"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 第二步:KDE Plasma 的壁纸插件配置。
|
||||||
|
fn kde(collect: &mut Collect<'_>) {
|
||||||
|
let Some(text) = read_config(collect, SourceKind::Kde, KDE_CONFIG) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let values = parse_kde_images(&text);
|
||||||
|
if values.is_empty() {
|
||||||
|
collect.note(
|
||||||
|
SourceKind::Kde.as_str(),
|
||||||
|
format!("{KDE_CONFIG} 里没有 Image= 行"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let evidence = format!("{KDE_CONFIG} 的 Image=");
|
||||||
|
for value in values {
|
||||||
|
collect.add(SourceKind::Kde, &evidence, &value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 第三步:swaybg(sway 配置)/ hyprpaper。
|
||||||
|
fn compositors(collect: &mut Collect<'_>) {
|
||||||
|
for (relative, what) in [
|
||||||
|
(HYPRPAPER_CONFIG, "wallpaper/preload 行"),
|
||||||
|
(SWAY_CONFIG, "output ... bg 行"),
|
||||||
|
] {
|
||||||
|
let Some(text) = read_config(collect, SourceKind::WlrootsCompositor, relative) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let values = match relative {
|
||||||
|
HYPRPAPER_CONFIG => parse_hyprpaper_wallpapers(&text),
|
||||||
|
_ => parse_sway_backgrounds(&text),
|
||||||
|
};
|
||||||
|
if values.is_empty() {
|
||||||
|
collect.note(
|
||||||
|
SourceKind::WlrootsCompositor.as_str(),
|
||||||
|
format!("{relative} 里没有 {what}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let evidence = format!("{relative} 的 {what}");
|
||||||
|
for value in values {
|
||||||
|
collect.add(SourceKind::WlrootsCompositor, &evidence, &value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 第四步:XFCE 的 xfconf XML。
|
||||||
|
fn xfce(collect: &mut Collect<'_>) {
|
||||||
|
let Some(text) = read_config(collect, SourceKind::Xfce, XFCE_DESKTOP_XML) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let values = parse_xfce_last_image(&text);
|
||||||
|
if values.is_empty() {
|
||||||
|
collect.note(
|
||||||
|
SourceKind::Xfce.as_str(),
|
||||||
|
format!("{XFCE_DESKTOP_XML} 里没有 last-image/image-path 属性"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let evidence = format!("{XFCE_DESKTOP_XML} 的 last-image/image-path");
|
||||||
|
for value in values {
|
||||||
|
collect.add(SourceKind::Xfce, &evidence, &value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读 `$XDG_CONFIG_HOME` 下的一个配置文件;读不到只记原因并返回 `None`。
|
||||||
|
fn read_config(collect: &mut Collect<'_>, kind: SourceKind, relative: &str) -> Option<String> {
|
||||||
|
let path = collect.roots().config_home.join(relative);
|
||||||
|
match collect.io().read_text(&path) {
|
||||||
|
Some(text) => Some(text),
|
||||||
|
None => {
|
||||||
|
collect.note(kind.as_str(), format!("读不到 {}", path.display()));
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析 `gsettings get` 的输出:`'file:///home/u/a.png'`;`'none'` / 空串返回 `None`。
|
||||||
|
pub(super) fn parse_gsettings_value(text: &str) -> Option<String> {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
let value = trimmed
|
||||||
|
.strip_prefix('\'')
|
||||||
|
.and_then(|rest| rest.strip_suffix('\''))
|
||||||
|
.unwrap_or(trimmed)
|
||||||
|
.trim();
|
||||||
|
if value.is_empty() || value.eq_ignore_ascii_case("none") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(value.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取 Plasma 配置里的 `Image=` 值(壁纸插件是 `[org.kde.image][General]` 段)。
|
||||||
|
///
|
||||||
|
/// 只收绝对路径与 `file://` URI:Plasma 的其它插件也有 `Image=` 键,相对值当成非壁纸。
|
||||||
|
pub(super) fn parse_kde_images(text: &str) -> Vec<String> {
|
||||||
|
text.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let (key, value) = line.trim().split_once('=')?;
|
||||||
|
if !key.trim().eq_ignore_ascii_case("image") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let value = value.trim();
|
||||||
|
let usable = value.starts_with('/')
|
||||||
|
|| value.starts_with("file://")
|
||||||
|
|| value
|
||||||
|
.get(..2)
|
||||||
|
.is_some_and(|head| head.eq_ignore_ascii_case("~/"));
|
||||||
|
usable.then(|| value.to_owned())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取 hyprpaper 的 `wallpaper = <监视器>,<路径>` 与 `preload = <路径>` 行。
|
||||||
|
pub(super) fn parse_hyprpaper_wallpapers(text: &str) -> Vec<String> {
|
||||||
|
text.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.starts_with('#') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (key, value) = line.split_once('=')?;
|
||||||
|
let value = value.trim();
|
||||||
|
match key.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"preload" => non_empty(value),
|
||||||
|
// `wallpaper = eDP-1,/path/a.png`(监视器可留空);没写逗号时整段就是路径。
|
||||||
|
"wallpaper" => match value.split_once(',') {
|
||||||
|
Some((_, path)) => non_empty(path),
|
||||||
|
None => non_empty(value),
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取 sway 配置里 `output <名字|*> bg <路径> [填充方式]` 的路径。
|
||||||
|
///
|
||||||
|
/// sway 允许用引号包住含空格的对象名/路径,这里把引号拼回来;解析不出来的行直接跳过。
|
||||||
|
pub(super) fn parse_sway_backgrounds(text: &str) -> Vec<String> {
|
||||||
|
text.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.starts_with('#') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if tokens.len() < 4 || tokens[0] != "output" || tokens[2] != "bg" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let raw = tokens[3];
|
||||||
|
let mut value = raw.trim_start_matches('"').to_owned();
|
||||||
|
if raw.starts_with('"') && !raw.ends_with('"') {
|
||||||
|
for token in &tokens[4..] {
|
||||||
|
value.push(' ');
|
||||||
|
match token.strip_suffix('"') {
|
||||||
|
Some(tail) => {
|
||||||
|
value.push_str(tail);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
None => value.push_str(token),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
non_empty(value.trim_end_matches('"'))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取 XFCE `xfce4-desktop.xml` 里 `last-image` / `image-path` 属性的值。
|
||||||
|
pub(super) fn parse_xfce_last_image(text: &str) -> Vec<String> {
|
||||||
|
text.lines()
|
||||||
|
.filter(|line| line.contains("last-image") || line.contains("image-path"))
|
||||||
|
.filter_map(|line| non_empty(&attribute(line, "value")?))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取 XML 一行里 `name="..."` 属性的值。
|
||||||
|
fn attribute(line: &str, name: &str) -> Option<String> {
|
||||||
|
let needle = format!("{name}=\"");
|
||||||
|
let start = line.find(&needle)? + needle.len();
|
||||||
|
let rest = &line[start..];
|
||||||
|
let end = rest.find('"')?;
|
||||||
|
Some(rest[..end].to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_empty(value: &str) -> Option<String> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
(!trimmed.is_empty()).then(|| trimmed.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::super::io::fake::FakeIo;
|
||||||
|
use super::super::{detect_with, SourceKind};
|
||||||
|
use super::*;
|
||||||
|
use crate::platform::paths::{MapEnv, Platform};
|
||||||
|
|
||||||
|
const CONFIG: &str = "/home/u/.config";
|
||||||
|
|
||||||
|
fn env() -> MapEnv {
|
||||||
|
MapEnv::new().with("HOME", "/home/u")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gsettings(key: &str) -> String {
|
||||||
|
format!("gsettings get {GNOME_SCHEMA} {key}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config(name: &str) -> String {
|
||||||
|
format!("{CONFIG}/{name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gnome_picture_uri_hit_decodes_percent_escapes() {
|
||||||
|
let io = FakeIo::new()
|
||||||
|
.with_command(
|
||||||
|
&gsettings("picture-uri"),
|
||||||
|
"'file:///home/u/my%20wall.png'\n",
|
||||||
|
)
|
||||||
|
.with_image("/home/u/my wall.png");
|
||||||
|
let probe = detect_with(&io, &env(), Platform::Linux);
|
||||||
|
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
|
||||||
|
let best = probe.best().expect("有候选");
|
||||||
|
assert_eq!(best.kind, SourceKind::Gnome);
|
||||||
|
assert_eq!(best.path.display().to_string(), "/home/u/my wall.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn four_steps_yield_ordered_candidates() {
|
||||||
|
let io = ["a", "b", "k", "h", "h2", "s", "x"]
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| format!("/home/u/{name}.png"))
|
||||||
|
.fold(
|
||||||
|
FakeIo::new()
|
||||||
|
.with_command(&gsettings("picture-uri"), "'file:///home/u/a.png'\n")
|
||||||
|
.with_command(&gsettings("picture-uri-dark"), "'file:///home/u/b.png'\n")
|
||||||
|
.with_file(config(KDE_CONFIG), "Image=file:///home/u/k.png\n")
|
||||||
|
.with_file(
|
||||||
|
config(HYPRPAPER_CONFIG),
|
||||||
|
"preload = /home/u/h.png\nwallpaper = eDP-1, /home/u/h2.png\n",
|
||||||
|
)
|
||||||
|
.with_file(config(SWAY_CONFIG), "output * bg /home/u/s.png fill\n")
|
||||||
|
.with_file(
|
||||||
|
config(XFCE_DESKTOP_XML),
|
||||||
|
"<property name=\"last-image\" value=\"/home/u/x.png\"/>\n",
|
||||||
|
),
|
||||||
|
FakeIo::with_image,
|
||||||
|
);
|
||||||
|
let probe = detect_with(&io, &env(), Platform::Linux);
|
||||||
|
let seen: Vec<(SourceKind, String)> = probe
|
||||||
|
.iter()
|
||||||
|
.map(|source| (source.kind, source.path.display().to_string()))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
seen,
|
||||||
|
[
|
||||||
|
(SourceKind::Gnome, "/home/u/a.png"),
|
||||||
|
(SourceKind::Gnome, "/home/u/b.png"),
|
||||||
|
(SourceKind::Kde, "/home/u/k.png"),
|
||||||
|
(SourceKind::WlrootsCompositor, "/home/u/h.png"),
|
||||||
|
(SourceKind::WlrootsCompositor, "/home/u/h2.png"),
|
||||||
|
(SourceKind::WlrootsCompositor, "/home/u/s.png"),
|
||||||
|
(SourceKind::Xfce, "/home/u/x.png"),
|
||||||
|
]
|
||||||
|
.map(|(kind, path)| (kind, path.to_owned()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failing_step_does_not_stop_later_steps() {
|
||||||
|
// gsettings 说「没设壁纸」,KDE 指向一个不存在的文件,XFCE 才是真的。
|
||||||
|
let io = FakeIo::new()
|
||||||
|
.with_command(&gsettings("picture-uri"), "'none'\n")
|
||||||
|
.with_file(config(KDE_CONFIG), "Image=file:///home/u/gone.png\n")
|
||||||
|
.with_file(
|
||||||
|
config(XFCE_DESKTOP_XML),
|
||||||
|
"<property name=\"image-path\" value=\"/home/u/x.png\"/>\n",
|
||||||
|
)
|
||||||
|
.with_image("/home/u/x.png");
|
||||||
|
let probe = detect_with(&io, &env(), Platform::Linux);
|
||||||
|
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
|
||||||
|
assert_eq!(probe.best().expect("有候选").kind, SourceKind::Xfce);
|
||||||
|
assert!(
|
||||||
|
probe.notes().iter().any(|note| note.contains("gone.png")),
|
||||||
|
"落选原因要留:{:?}",
|
||||||
|
probe.notes()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn everything_missing_returns_empty_with_reasons() {
|
||||||
|
let probe = detect_with(&FakeIo::new(), &env(), Platform::Linux);
|
||||||
|
assert!(probe.is_empty(), "全部失败要返回空列表");
|
||||||
|
let notes = probe.notes().join("\n");
|
||||||
|
assert!(notes.contains("gsettings"), "{notes}");
|
||||||
|
assert!(notes.contains("xfce4-desktop.xml"), "{notes}");
|
||||||
|
assert!(notes.contains("hyprpaper.conf"), "{notes}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gsettings_output_parsing() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_gsettings_value("'file:///home/u/a.png'\n"),
|
||||||
|
Some("file:///home/u/a.png".to_owned())
|
||||||
|
);
|
||||||
|
assert_eq!(parse_gsettings_value("'none'\n"), None);
|
||||||
|
assert_eq!(parse_gsettings_value("''"), None);
|
||||||
|
assert_eq!(parse_gsettings_value(""), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_line_parsing() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_kde_images("Image=file:///home/u/a.png\nImage=relative/b.png\n"),
|
||||||
|
vec!["file:///home/u/a.png".to_owned()]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_hyprpaper_wallpapers(
|
||||||
|
"# 注释行\npreload = /home/u/a.png\nwallpaper = eDP-1, /home/u/b.png\nwallpaper = ,/home/u/c.png\nsplash = false\n"
|
||||||
|
),
|
||||||
|
vec![
|
||||||
|
"/home/u/a.png".to_owned(),
|
||||||
|
"/home/u/b.png".to_owned(),
|
||||||
|
"/home/u/c.png".to_owned(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_sway_backgrounds(
|
||||||
|
"output * bg /home/u/a.png fill\noutput eDP-1 bg \"/home/u/b c.png\" stretch\n"
|
||||||
|
),
|
||||||
|
vec!["/home/u/a.png".to_owned(), "/home/u/b c.png".to_owned()]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_xfce_last_image(
|
||||||
|
"<property name=\"last-image\" value=\"/home/u/a.png\"/>\n<property name=\"image-style\" value=\"3\"/>\n"
|
||||||
|
),
|
||||||
|
vec!["/home/u/a.png".to_owned()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
//! macOS 壁纸路径探测:`com.apple.wallpaper` 的 `Index.plist` → 老式 `com.apple.desktop` 域。
|
||||||
|
//!
|
||||||
|
//! 两条通道:
|
||||||
|
//!
|
||||||
|
//! 1. `~/Library/Application Support/com.apple.wallpaper/Store/Index.plist`——Big Sur 之后
|
||||||
|
//! 壁纸设置的真实落点,**二进制 plist(`bplist00`)**,里面记着
|
||||||
|
//! `file:///…/xxx.heic` 与裸绝对路径;
|
||||||
|
//! 2. `defaults read com.apple.desktop Background`——Catalina 及更早的每屏壁纸配置
|
||||||
|
//! (`ImageFilePath = "/Library/Desktop Pictures/…"`),新系统上该域通常已不存在。
|
||||||
|
//!
|
||||||
|
//! # 未在真机验证
|
||||||
|
//!
|
||||||
|
//! 本机(Windows)与 CI(Linux)都没有 macOS 通道:**下列代码没有在 macOS 真机上跑过**。
|
||||||
|
//! 因此这里刻意不引入 plist 解析依赖(`plist` crate 会为「读几个路径串」多背一个依赖),
|
||||||
|
//! 改成手写的字符串扫描:plist 里的路径串本身是可读 ASCII,按「可打印 ASCII 连续段」切出来
|
||||||
|
//! 再按「行首/分隔符后的 `/`」认路径开头即可,二进制 plist 的二进制部分天然成为分隔符。
|
||||||
|
//! 扫出的碎片串由 [`super::Collect`] 的图片头校验兜掉,不会喂给取色器。
|
||||||
|
//! 真机行为待后续 mission 在 macOS 上补充验证(`Index.plist` 的真实结构、`.heic` 壁纸路径)。
|
||||||
|
|
||||||
|
use super::{Collect, SourceKind};
|
||||||
|
|
||||||
|
/// Big Sur 之后的壁纸设置存储(相对用户主目录)。
|
||||||
|
pub(super) const STORE_INDEX: &str =
|
||||||
|
"Library/Application Support/com.apple.wallpaper/Store/Index.plist";
|
||||||
|
|
||||||
|
/// 老式壁纸配置域。
|
||||||
|
pub(super) const DESKTOP_DOMAIN: &str = "com.apple.desktop";
|
||||||
|
|
||||||
|
/// 一次扫描最多收多少个候选串(防止异常文件把内存打满;正常情况下只有个位数)。
|
||||||
|
const MAX_SCANNED: usize = 64;
|
||||||
|
|
||||||
|
/// 两步依次探测。
|
||||||
|
pub(super) fn collect(collect: &mut Collect<'_>) {
|
||||||
|
store_index(collect);
|
||||||
|
desktop_defaults(collect);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 第一步:壁纸 Store 的 `Index.plist`。
|
||||||
|
fn store_index(collect: &mut Collect<'_>) {
|
||||||
|
let path = collect.roots().home.join(STORE_INDEX);
|
||||||
|
let Some(text) = collect.io().read_text(&path) else {
|
||||||
|
collect.note(
|
||||||
|
SourceKind::MacOsStore.as_str(),
|
||||||
|
format!("读不到 {}(Catalina 及更早没有这个文件)", path.display()),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let values = scan_string_paths(&text);
|
||||||
|
if values.is_empty() {
|
||||||
|
collect.note(
|
||||||
|
SourceKind::MacOsStore.as_str(),
|
||||||
|
format!("{} 里没有路径串", path.display()),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let evidence = "Library/Application Support/com.apple.wallpaper/Store/Index.plist 的路径串";
|
||||||
|
for value in values {
|
||||||
|
collect.add(SourceKind::MacOsStore, evidence, &value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 第二步:`defaults read com.apple.desktop Background`(老系统)。
|
||||||
|
fn desktop_defaults(collect: &mut Collect<'_>) {
|
||||||
|
let evidence = format!("defaults read {DESKTOP_DOMAIN} Background");
|
||||||
|
match collect
|
||||||
|
.io()
|
||||||
|
.run("defaults", &["read", DESKTOP_DOMAIN, "Background"])
|
||||||
|
{
|
||||||
|
Some(text) => {
|
||||||
|
let values = scan_defaults_background(&text);
|
||||||
|
if values.is_empty() {
|
||||||
|
collect.note(
|
||||||
|
SourceKind::MacOsDesktopDefaults.as_str(),
|
||||||
|
format!("{evidence} 里没有 ImageFilePath"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for value in values {
|
||||||
|
collect.add(SourceKind::MacOsDesktopDefaults, &evidence, &value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => collect.note(
|
||||||
|
SourceKind::MacOsDesktopDefaults.as_str(),
|
||||||
|
format!("{evidence} 失败(命令缺失 / 非零退出 / 该域不存在)"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 plist 原文里扫出候选串:`file://` URL 与裸绝对路径。
|
||||||
|
///
|
||||||
|
/// 输入既可能是 XML plist(`<string>file:///…</string>`)也可能是二进制 plist 的 lossy 文本
|
||||||
|
/// (可读 ASCII 串之间夹着不可打印字节)。因此先按「可打印 ASCII 连续段」切,再在每段里认:
|
||||||
|
///
|
||||||
|
/// - 任意位置的 `file://` 开头;
|
||||||
|
/// - 「段首 / 分隔符之后 / `~` 之后」的 `/` 开头(路径中间的 `/` 不算新起点,避免同一路径
|
||||||
|
/// 被切成好几段)。
|
||||||
|
#[must_use]
|
||||||
|
pub(super) fn scan_string_paths(text: &str) -> Vec<String> {
|
||||||
|
let mut found = Vec::new();
|
||||||
|
for run in printable_runs(text) {
|
||||||
|
let mut cursor = 0;
|
||||||
|
while let Some(offset) = run[cursor..].find("file://") {
|
||||||
|
let start = cursor + offset;
|
||||||
|
push_candidate(&mut found, &run[start..]);
|
||||||
|
cursor = start + "file://".len();
|
||||||
|
}
|
||||||
|
for (position, _) in run.match_indices('/') {
|
||||||
|
let Some(start) = path_start(run, position) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
push_candidate(&mut found, &run[start..]);
|
||||||
|
}
|
||||||
|
if found.len() >= MAX_SCANNED {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found.truncate(MAX_SCANNED);
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析 `defaults read com.apple.desktop Background` 的文本:只认 `ImageFilePath` / `NewPath`。
|
||||||
|
#[must_use]
|
||||||
|
pub(super) fn scan_defaults_background(text: &str) -> Vec<String> {
|
||||||
|
text.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let (key, value) = line.split_once('=')?;
|
||||||
|
let key = key.trim().trim_matches('"');
|
||||||
|
if !key.eq_ignore_ascii_case("ImageFilePath") && !key.eq_ignore_ascii_case("NewPath") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let value = value.trim().trim_end_matches(';').trim().trim_matches('"');
|
||||||
|
(!value.is_empty()).then(|| value.to_owned())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 可打印 ASCII 连续段(二进制 plist 的二进制字节在这里变成分隔符)。
|
||||||
|
fn printable_runs(text: &str) -> Vec<&str> {
|
||||||
|
text.split(|ch: char| !(ch.is_ascii_graphic() || ch == ' '))
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|run| !run.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断 `run[position]` 的 `/` 是不是一个路径起点;是则返回该路径在 `run` 里的起点。
|
||||||
|
fn path_start(run: &str, position: usize) -> Option<usize> {
|
||||||
|
let next = run[position + 1..].chars().next()?;
|
||||||
|
if !(next.is_ascii_alphanumeric() || next == '_') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match run[..position].chars().next_back() {
|
||||||
|
// 段首:路径直接跟在二进制分隔符后面。
|
||||||
|
None => Some(position),
|
||||||
|
// `~/Library/...`:连 `~` 一起收,交给路径归一化展开。
|
||||||
|
Some('~') => Some(position - 1),
|
||||||
|
// 闭合标签 `</string>` 里的那个斜杠不是路径。
|
||||||
|
Some('<') => None,
|
||||||
|
// 路径中间的 `/`(前面还是路径字符)不当新起点。
|
||||||
|
Some(prev) if is_path_char(prev) => None,
|
||||||
|
Some(_) => Some(position),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_path_char(ch: char) -> bool {
|
||||||
|
ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | '%' | '+' | '@' | '~' | ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// plist/XML 序列化里的终止字符(`<string>` 的尖括号、引号、实体)切掉。
|
||||||
|
fn push_candidate(found: &mut Vec<String>, raw: &str) {
|
||||||
|
let end = raw.find(['<', '>', '"', '&', '\'']).unwrap_or(raw.len());
|
||||||
|
let candidate = raw[..end].trim_end();
|
||||||
|
if !candidate.is_empty() && !found.iter().any(|seen| seen.as_str() == candidate) {
|
||||||
|
found.push(candidate.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::super::io::fake::FakeIo;
|
||||||
|
use super::super::{detect_with, SourceKind};
|
||||||
|
use super::*;
|
||||||
|
use crate::platform::paths::{MapEnv, Platform};
|
||||||
|
|
||||||
|
const STORE_PATH: &str =
|
||||||
|
"/Users/me/Library/Application Support/com.apple.wallpaper/Store/Index.plist";
|
||||||
|
const DEFAULTS_KEY: &str = "defaults read com.apple.desktop Background";
|
||||||
|
|
||||||
|
fn env() -> MapEnv {
|
||||||
|
MapEnv::new().with("HOME", "/Users/me")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xml_plist(body: &str) -> String {
|
||||||
|
format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<plist version=\"1.0\">\n{body}\n</plist>\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_index_hit_decodes_file_url() {
|
||||||
|
let io = FakeIo::new()
|
||||||
|
.with_file(
|
||||||
|
STORE_PATH,
|
||||||
|
&xml_plist("<string>file:///Users/me/Pictures/my%20wall.heic</string>"),
|
||||||
|
)
|
||||||
|
.with_image("/Users/me/Pictures/my wall.heic");
|
||||||
|
let probe = detect_with(&io, &env(), Platform::MacOs);
|
||||||
|
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
|
||||||
|
let best = probe.best().expect("有候选");
|
||||||
|
assert_eq!(best.kind, SourceKind::MacOsStore);
|
||||||
|
assert_eq!(
|
||||||
|
best.path,
|
||||||
|
std::path::PathBuf::from("/Users/me/Pictures/my wall.heic")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_candidates_from_both_channels_keep_order() {
|
||||||
|
let io = FakeIo::new()
|
||||||
|
.with_file(
|
||||||
|
STORE_PATH,
|
||||||
|
&xml_plist(
|
||||||
|
"<array>\n\
|
||||||
|
<string>file:///System/Library/Desktop Pictures/a.heic</string>\n\
|
||||||
|
<string>/Users/me/Library/Application Support/com.apple.wallpaper/Store/b.heic</string>\n\
|
||||||
|
</array>",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_image("/System/Library/Desktop Pictures/a.heic")
|
||||||
|
.with_image(
|
||||||
|
"/Users/me/Library/Application Support/com.apple.wallpaper/Store/b.heic",
|
||||||
|
)
|
||||||
|
.with_image("/Library/Desktop Pictures/legacy.jpg")
|
||||||
|
.with_command(
|
||||||
|
DEFAULTS_KEY,
|
||||||
|
"{\n \"Display Identifier\" = \"Main\";\n \
|
||||||
|
ImageFilePath = \"/Library/Desktop Pictures/legacy.jpg\";\n}\n",
|
||||||
|
);
|
||||||
|
let probe = detect_with(&io, &env(), Platform::MacOs);
|
||||||
|
let kinds: Vec<SourceKind> = probe.iter().map(|source| source.kind).collect();
|
||||||
|
assert_eq!(
|
||||||
|
kinds,
|
||||||
|
vec![
|
||||||
|
SourceKind::MacOsStore,
|
||||||
|
SourceKind::MacOsStore,
|
||||||
|
SourceKind::MacOsDesktopDefaults,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
probe.best().expect("有候选").path,
|
||||||
|
std::path::PathBuf::from("/System/Library/Desktop Pictures/a.heic")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn everything_missing_returns_empty_with_reasons() {
|
||||||
|
let probe = detect_with(&FakeIo::new(), &env(), Platform::MacOs);
|
||||||
|
assert!(probe.is_empty(), "全部失败要返回空列表");
|
||||||
|
let notes = probe.notes().join("\n");
|
||||||
|
assert!(notes.contains("Index.plist"), "{notes}");
|
||||||
|
assert!(notes.contains("defaults"), "{notes}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn binary_plist_strings_are_scanned_between_binary_bytes() {
|
||||||
|
// bplist00 的形态:控制字符/长度字节把可读 ASCII 串隔开。
|
||||||
|
let raw = "\u{0}\u{1}\u{2}/Users/me/Pictures/a.heic\u{0}\u{3}file:///Users/me/b.jpg\u{0}";
|
||||||
|
let found = scan_string_paths(raw);
|
||||||
|
assert_eq!(
|
||||||
|
found,
|
||||||
|
vec![
|
||||||
|
"/Users/me/Pictures/a.heic".to_owned(),
|
||||||
|
"file:///Users/me/b.jpg".to_owned(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn xml_tags_are_not_part_of_the_candidate() {
|
||||||
|
let found = scan_string_paths(&xml_plist(
|
||||||
|
"<key>Content</key>\n<string>/Users/me/a.png</string>",
|
||||||
|
));
|
||||||
|
assert_eq!(found, vec!["/Users/me/a.png".to_owned()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_output_parsing() {
|
||||||
|
assert_eq!(
|
||||||
|
scan_defaults_background(
|
||||||
|
"{\n \"Display Identifier\" = \"Main\";\n \
|
||||||
|
ImageFilePath = \"/Library/Desktop Pictures/a.jpg\";\n \
|
||||||
|
NewPath = \"/Users/me/b.png\";\n}\n"
|
||||||
|
),
|
||||||
|
vec![
|
||||||
|
"/Library/Desktop Pictures/a.jpg".to_owned(),
|
||||||
|
"/Users/me/b.png".to_owned(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(scan_defaults_background("{\n BackgroundColor = \"0 0 0\";\n}\n").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tilde_paths_keep_their_prefix() {
|
||||||
|
let found = scan_string_paths("<string>~/Library/Desktop Pictures/a.heic</string>");
|
||||||
|
assert_eq!(found, vec!["~/Library/Desktop Pictures/a.heic".to_owned()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
//! 路径归一化与图片头嗅探:把各平台读出的「原始串」变成可用的绝对路径候选。
|
||||||
|
//!
|
||||||
|
//! 三平台读出来的东西形状都不一样(gsettings 的 `'file:///x'`、KDE 的 `file:///x`、
|
||||||
|
//! Windows `.theme` 的 `%SystemRoot%\x`、plist 里裸的 `/x`),全部在这里收口,
|
||||||
|
//! 上层子模块只管「从哪读」。
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use super::io::HostRoots;
|
||||||
|
|
||||||
|
/// 图片文件头识别(不用解码库,只看魔数与文本 SVG 头)。
|
||||||
|
///
|
||||||
|
/// 认得的:PNG / JPEG / GIF / BMP / WebP / HEIF-AVIF / TIFF,外加头部出现 `<svg` 的文本。
|
||||||
|
/// 认不出的一律不收——壁纸探测宁可少一个候选,也不要把随便一个文件喂给取色器。
|
||||||
|
#[must_use]
|
||||||
|
pub(super) fn looks_like_image(head: &[u8]) -> bool {
|
||||||
|
const PNG: &[u8] = b"\x89PNG\r\n\x1a\n";
|
||||||
|
if head.starts_with(PNG) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if head.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||||
|
return true; // JPEG
|
||||||
|
}
|
||||||
|
if head.starts_with(b"GIF87a") || head.starts_with(b"GIF89a") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if head.len() >= 14 && head.starts_with(b"BM") {
|
||||||
|
return true; // BMP
|
||||||
|
}
|
||||||
|
if head.len() >= 12 && &head[0..4] == b"RIFF" && &head[8..12] == b"WEBP" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if head.len() >= 12 && &head[4..8] == b"ftyp" {
|
||||||
|
// ISO-BMFF 家族:HEIC / HEIF 是 macOS 的默认壁纸容器(`.heic`),AVIF 是新格式。
|
||||||
|
let brand = &head[8..12];
|
||||||
|
return matches!(
|
||||||
|
brand,
|
||||||
|
b"avif" | b"avis" | b"heic" | b"heix" | b"mif1" | b"msf1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if head.starts_with(b"II*\0") || head.starts_with(b"MM\0*") {
|
||||||
|
return true; // TIFF
|
||||||
|
}
|
||||||
|
contains_ignore_ascii_case(head, b"<svg")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把一条原始串归一化成绝对路径;不像壁纸路径的返回 `None`(具体原因由调用方记)。
|
||||||
|
///
|
||||||
|
/// 处理顺序:去引号 → 认 `file://` URI(含百分号解码与 `/C:/` 盘符还原)→ 展开
|
||||||
|
/// `%USERPROFILE%` 之类的 Windows 变量 → 展开 `~/` → 要求绝对路径。
|
||||||
|
#[must_use]
|
||||||
|
pub(super) fn normalize_path(raw: &str, roots: &HostRoots) -> Option<PathBuf> {
|
||||||
|
let unquoted = unquote(raw).trim();
|
||||||
|
if unquoted.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// GNOME 的「无壁纸」与 macOS `defaults` 的「未设置」占位值。
|
||||||
|
if unquoted.eq_ignore_ascii_case("none") || unquoted.eq_ignore_ascii_case("default") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let text = match strip_prefix_ignore_ascii_case(unquoted, "file://") {
|
||||||
|
Some(rest) => strip_uri_drive_prefix(&percent_decode(rest)),
|
||||||
|
None => expand_vars(unquoted, roots),
|
||||||
|
};
|
||||||
|
let text = text.trim();
|
||||||
|
if let Some(rest) = text.strip_prefix("~/") {
|
||||||
|
return Some(roots.home.join(rest));
|
||||||
|
}
|
||||||
|
if !looks_absolute(text) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(PathBuf::from(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 去掉成对的单/双引号(`gsettings get` 的输出是 `'file:///x'`)。
|
||||||
|
fn unquote(raw: &str) -> &str {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
let bytes = trimmed.as_bytes();
|
||||||
|
if bytes.len() >= 2 {
|
||||||
|
let first = bytes[0];
|
||||||
|
if (first == b'\'' || first == b'"') && bytes[bytes.len() - 1] == first {
|
||||||
|
return &trimmed[1..trimmed.len() - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_prefix_ignore_ascii_case<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
|
||||||
|
let head = text.get(..prefix.len())?;
|
||||||
|
head.eq_ignore_ascii_case(prefix)
|
||||||
|
.then(|| &text[prefix.len()..])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `file://` URI 的路径部分:`/C:/Users/me/a.jpg` 在 Windows 上是 `C:/Users/me/a.jpg`。
|
||||||
|
///
|
||||||
|
/// 非 Windows 上这条分支不会命中(Unix 路径不会以 `/C:` 开头),统一处理不影响正确性。
|
||||||
|
fn strip_uri_drive_prefix(text: &str) -> String {
|
||||||
|
let bytes = text.as_bytes();
|
||||||
|
if bytes.len() >= 3 && bytes[0] == b'/' && bytes[1].is_ascii_alphabetic() && bytes[2] == b':' {
|
||||||
|
text[1..].to_owned()
|
||||||
|
} else {
|
||||||
|
text.to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 百分号解码(GNOME 会把空格编码成 `%20`;非法转义原样保留)。
|
||||||
|
fn percent_decode(text: &str) -> String {
|
||||||
|
let bytes = text.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
let mut index = 0;
|
||||||
|
while index < bytes.len() {
|
||||||
|
if bytes[index] == b'%'
|
||||||
|
&& index + 2 < bytes.len()
|
||||||
|
&& bytes[index + 1].is_ascii_hexdigit()
|
||||||
|
&& bytes[index + 2].is_ascii_hexdigit()
|
||||||
|
{
|
||||||
|
let hex = &text[index + 1..index + 3];
|
||||||
|
if let Ok(byte) = u8::from_str_radix(hex, 16) {
|
||||||
|
out.push(byte);
|
||||||
|
index += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(bytes[index]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 展开 Windows 路径里的 `%VAR%`(`.theme` 的 `Wallpaper=%SystemRoot%\web\...`)。
|
||||||
|
///
|
||||||
|
/// 认不出的变量原样保留,让后面的图片校验把它判掉——这里不猜路径。
|
||||||
|
fn expand_vars(text: &str, roots: &HostRoots) -> String {
|
||||||
|
let mut out = String::with_capacity(text.len());
|
||||||
|
let mut rest = text;
|
||||||
|
while let Some(start) = rest.find('%') {
|
||||||
|
let Some(offset) = rest[start + 1..].find('%') else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let name = &rest[start + 1..start + 1 + offset];
|
||||||
|
let value = match name.to_ascii_lowercase().as_str() {
|
||||||
|
"userprofile" | "home" => Some(roots.home.to_string_lossy()),
|
||||||
|
"appdata" => Some(roots.app_data.to_string_lossy()),
|
||||||
|
// Win10+ 的「当前主题」`Custom.theme` 在 `%LOCALAPPDATA%` 一侧。
|
||||||
|
"localappdata" => Some(roots.local_app_data.to_string_lossy()),
|
||||||
|
"systemroot" | "windir" => roots
|
||||||
|
.system_root
|
||||||
|
.as_deref()
|
||||||
|
.map(std::path::Path::to_string_lossy),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
match value {
|
||||||
|
Some(value) => {
|
||||||
|
out.push_str(&rest[..start]);
|
||||||
|
out.push_str(&value);
|
||||||
|
rest = &rest[start + offset + 2..];
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
out.push_str(&rest[..=start]);
|
||||||
|
rest = &rest[start + 1..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(rest);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 字符串层面判绝对路径:`/x`、`\\server\share`、`C:/x`、`C:\x`。
|
||||||
|
///
|
||||||
|
/// 不用 `Path::is_absolute` 是因为它按**编译目标**判,Windows 夹具在 Linux CI 上会被判成
|
||||||
|
/// 相对路径,夹具就不跨宿主了。
|
||||||
|
fn looks_absolute(text: &str) -> bool {
|
||||||
|
let bytes = text.as_bytes();
|
||||||
|
if text.starts_with('/') || text.starts_with('\\') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
bytes.len() >= 3
|
||||||
|
&& bytes[0].is_ascii_alphabetic()
|
||||||
|
&& bytes[1] == b':'
|
||||||
|
&& matches!(bytes[2], b'/' | b'\\')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains_ignore_ascii_case(haystack: &[u8], needle: &[u8]) -> bool {
|
||||||
|
if needle.is_empty() || haystack.len() < needle.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
haystack
|
||||||
|
.windows(needle.len())
|
||||||
|
.any(|window| window.eq_ignore_ascii_case(needle))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::platform::paths::MapEnv;
|
||||||
|
|
||||||
|
fn roots() -> HostRoots {
|
||||||
|
let env = MapEnv::new()
|
||||||
|
.with("HOME", "/home/u")
|
||||||
|
.with("APPDATA", "C:/Users/u/AppData/Roaming")
|
||||||
|
.with("SYSTEMROOT", "C:/Windows");
|
||||||
|
HostRoots::from_env(&env).expect("HOME 已给")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_sniff_accepts_common_wallpaper_containers() {
|
||||||
|
assert!(looks_like_image(b"\x89PNG\r\n\x1a\n\x00\x00"));
|
||||||
|
assert!(looks_like_image(b"\xFF\xD8\xFF\xE0\x00\x10JFIF"));
|
||||||
|
assert!(looks_like_image(b"GIF89a---"));
|
||||||
|
assert!(looks_like_image(
|
||||||
|
b"BM\x36\x00\x00\x00\x00\x00\x00\x00\x36\x00\x00\x00"
|
||||||
|
));
|
||||||
|
assert!(looks_like_image(b"RIFF\x24\x00\x00\x00WEBPVP8 "));
|
||||||
|
assert!(looks_like_image(
|
||||||
|
b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00heicmif1"
|
||||||
|
));
|
||||||
|
assert!(looks_like_image(b"II*\x00\x08\x00\x00\x00"));
|
||||||
|
assert!(looks_like_image(
|
||||||
|
b"<?xml version=\"1.0\"?><svg xmlns=\"...\""
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_sniff_rejects_non_images_and_short_heads() {
|
||||||
|
assert!(!looks_like_image(b""));
|
||||||
|
assert!(!looks_like_image(b"BM"));
|
||||||
|
assert!(!looks_like_image(
|
||||||
|
b"[Control Panel\\Desktop]\r\nWallpaper=x\r\n"
|
||||||
|
));
|
||||||
|
assert!(!looks_like_image(b"RIFF\x24\x00\x00\x00AVI LIST"));
|
||||||
|
assert!(!looks_like_image(b"\x00\x00\x00\x18ftypmp42"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_handles_file_uris_quotes_and_percent_encoding() {
|
||||||
|
let roots = roots();
|
||||||
|
assert_eq!(
|
||||||
|
normalize_path("'file:///home/u/Pictures/my%20wall.png'", &roots),
|
||||||
|
Some(PathBuf::from("/home/u/Pictures/my wall.png"))
|
||||||
|
);
|
||||||
|
// Windows 的 URI 会多一个前导斜杠,盘符要还原。
|
||||||
|
assert_eq!(
|
||||||
|
normalize_path("file:///C:/Users/u/a.jpg", &roots),
|
||||||
|
Some(PathBuf::from("C:/Users/u/a.jpg"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_path("\"C:\\Users\\u\\a.jpg\"", &roots),
|
||||||
|
Some(PathBuf::from("C:\\Users\\u\\a.jpg"))
|
||||||
|
);
|
||||||
|
// 非法百分号转义原样保留,不 panic(多字节字符也要安全)。
|
||||||
|
assert_eq!(
|
||||||
|
normalize_path("file:///home/u/100%zz%日.png", &roots),
|
||||||
|
Some(PathBuf::from("/home/u/100%zz%日.png"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_expands_windows_vars_and_home() {
|
||||||
|
let roots = roots();
|
||||||
|
assert_eq!(
|
||||||
|
normalize_path(r"%SystemRoot%\web\wallpaper\a.jpg", &roots),
|
||||||
|
Some(PathBuf::from("C:/Windows\\web\\wallpaper\\a.jpg"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_path(
|
||||||
|
"%APPDATA%\\Microsoft\\Windows\\Themes\\TranscodedWallpaper",
|
||||||
|
&roots
|
||||||
|
),
|
||||||
|
Some(PathBuf::from(
|
||||||
|
"C:/Users/u/AppData/Roaming\\Microsoft\\Windows\\Themes\\TranscodedWallpaper"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
// Win10+ 的当前主题在 %LOCALAPPDATA%,展开失败就会白丢最可靠的候选。
|
||||||
|
assert_eq!(
|
||||||
|
normalize_path(
|
||||||
|
"%LOCALAPPDATA%\\Microsoft\\Windows\\Themes\\Custom.theme",
|
||||||
|
&roots
|
||||||
|
),
|
||||||
|
Some(PathBuf::from(
|
||||||
|
"/home/u/AppData/Local\\Microsoft\\Windows\\Themes\\Custom.theme"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_path("~/Library/Desktop Pictures/a.heic", &roots),
|
||||||
|
Some(PathBuf::from("/home/u/Library/Desktop Pictures/a.heic"))
|
||||||
|
);
|
||||||
|
// 认不出的变量不猜:整串保留,随后被「必须绝对路径」这条校验判掉。
|
||||||
|
assert_eq!(normalize_path("%NOPE%\\a.jpg", &roots), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_rejects_placeholders_relative_and_empty() {
|
||||||
|
let roots = roots();
|
||||||
|
for raw in [
|
||||||
|
"",
|
||||||
|
" ",
|
||||||
|
"none",
|
||||||
|
"'none'",
|
||||||
|
"default",
|
||||||
|
"relative/a.png",
|
||||||
|
"./a.png",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_path(raw, &roots),
|
||||||
|
None,
|
||||||
|
"{raw:?} 不该被当成壁纸路径"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absolute_detection_is_host_independent() {
|
||||||
|
assert!(looks_absolute("/home/u/a.png"));
|
||||||
|
assert!(looks_absolute(r"\\server\share\a.png"));
|
||||||
|
assert!(looks_absolute("C:/Users/u/a.png"));
|
||||||
|
assert!(looks_absolute(r"C:\Users\u\a.png"));
|
||||||
|
assert!(!looks_absolute("a.png"));
|
||||||
|
assert!(!looks_absolute("C:"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
//! Windows 壁纸路径探测:四步,可信度从高到低。
|
||||||
|
//!
|
||||||
|
//! Win10+ 的真机事实(Windows 11 24H2 实测):`%APPDATA%\Microsoft\Windows\Themes\` 里
|
||||||
|
//! **通常只有** `TranscodedWallpaper`,一个 `.theme` 都没有;当前生效的主题记在
|
||||||
|
//! `HKCU\Software\Microsoft\Windows\CurrentVersion\Themes` 的 `CurrentTheme` 值里,指向
|
||||||
|
//! `%LOCALAPPDATA%\Microsoft\Windows\Themes\Custom.theme`。所以「扫 `%APPDATA%` 下的
|
||||||
|
//! `*.theme`」只能当最后一档兜底:那里的主题包是用户另存的,**可能从未启用**,拿它取色会
|
||||||
|
//! 取到与当前桌面无关的图(宁可少给一个候选,也不给错路径)。
|
||||||
|
//!
|
||||||
|
//! 1. `%APPDATA%\Microsoft\Windows\Themes\TranscodedWallpaper`——系统**当前**壁纸转码后的
|
||||||
|
//! 副本,没有扩展名(部分版本还有 `TranscodedWallpaper.jpg` 别名),所以「像不像图片」
|
||||||
|
//! 只看文件头魔数,不看后缀;
|
||||||
|
//! 2. `CurrentTheme` 指向的 `.theme`——当前生效主题里的 `Wallpaper=` 行(Win10+ 在
|
||||||
|
//! `%LOCALAPPDATA%` 一侧,支持 `%LOCALAPPDATA%` / `%SystemRoot%` 之类变量展开);
|
||||||
|
//! 3. `HKCU\Control Panel\Desktop` 的 `WallPaper` 值——注册表权威来源;
|
||||||
|
//! 4. `%APPDATA%\...\Themes\*.theme`——用户另存的主题包,`evidence` 里如实标「可能未启用」。
|
||||||
|
//!
|
||||||
|
//! 第 2、3 步走 `reg.exe` 子进程,不用 win32 API(core 是 `#![forbid(unsafe_code)]`,
|
||||||
|
//! 见 [`super`] 模块文档);命令缺失 / 非零退出 / 空输出都只记一条原因,探测继续。
|
||||||
|
//!
|
||||||
|
//! 本文件不带 `cfg`:Linux CI 上同样要能跑夹具用例,平台分流只在
|
||||||
|
//! [`super::detect_with`] 一处。
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use self::parse::{parse_reg_sz_value, parse_theme_wallpaper};
|
||||||
|
use super::io::HostRoots;
|
||||||
|
use super::parse::normalize_path;
|
||||||
|
use super::{Collect, SourceKind};
|
||||||
|
|
||||||
|
mod parse;
|
||||||
|
|
||||||
|
/// `reg query` 用的桌面键(`HKCU\Control Panel\Desktop`,壁纸值名 `WallPaper`)。
|
||||||
|
pub(super) const DESKTOP_KEY: &str = r"HKCU\Control Panel\Desktop";
|
||||||
|
|
||||||
|
/// `reg query` 用的主题键(Win10+ 当前主题记录在这里)。
|
||||||
|
pub(super) const THEMES_KEY: &str = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes";
|
||||||
|
|
||||||
|
/// 壁纸值名(注册表里是 `WallPaper`,`.theme` 里是 `Wallpaper=`,比对时都忽略大小写)。
|
||||||
|
pub(super) const WALLPAPER_VALUE: &str = "WallPaper";
|
||||||
|
|
||||||
|
/// 当前主题文件名(值是指向 `.theme` 的绝对路径)。
|
||||||
|
pub(super) const CURRENT_THEME_VALUE: &str = "CurrentTheme";
|
||||||
|
|
||||||
|
/// `%APPDATA%` 下的主题目录(分段拼,避免 Windows 上出现混用的分隔符)。
|
||||||
|
const THEMES_PARTS: &[&str] = &["Microsoft", "Windows", "Themes"];
|
||||||
|
|
||||||
|
/// 转码副本的两个已知文件名(无扩展名的是主力)。
|
||||||
|
const TRANSCODED_NAMES: &[&str] = &["TranscodedWallpaper", "TranscodedWallpaper.jpg"];
|
||||||
|
|
||||||
|
/// 主题文件后缀。
|
||||||
|
const THEME_EXTENSION: &str = "theme";
|
||||||
|
|
||||||
|
/// 四步依次探测。
|
||||||
|
pub(super) fn collect(collect: &mut Collect<'_>) {
|
||||||
|
transcoded(collect);
|
||||||
|
current_theme(collect);
|
||||||
|
registry(collect);
|
||||||
|
saved_theme_packages(collect);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn themes_dir(roots: &HostRoots) -> PathBuf {
|
||||||
|
let mut dir = roots.app_data.clone();
|
||||||
|
for part in THEMES_PARTS {
|
||||||
|
dir.push(part);
|
||||||
|
}
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 第一步:系统转码后的当前壁纸副本(第一个名字命中后就不再试别名)。
|
||||||
|
fn transcoded(collect: &mut Collect<'_>) {
|
||||||
|
let dir = themes_dir(collect.roots());
|
||||||
|
for name in TRANSCODED_NAMES {
|
||||||
|
let path = dir.join(name);
|
||||||
|
let evidence = format!("%APPDATA%\\Microsoft\\Windows\\Themes\\{name}");
|
||||||
|
if collect.add_path(SourceKind::WindowsTranscoded, &evidence, path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 第二步:当前生效主题(`CurrentTheme` 指向的 `.theme`)里的 `Wallpaper=`。
|
||||||
|
fn current_theme(collect: &mut Collect<'_>) {
|
||||||
|
let Some(raw) = query_reg(
|
||||||
|
collect,
|
||||||
|
SourceKind::WindowsCurrentTheme,
|
||||||
|
THEMES_KEY,
|
||||||
|
CURRENT_THEME_VALUE,
|
||||||
|
) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(theme) = normalize_path(&raw, collect.roots()) else {
|
||||||
|
collect.note(
|
||||||
|
SourceKind::WindowsCurrentTheme.as_str(),
|
||||||
|
format!("{CURRENT_THEME_VALUE} 不是可用的绝对路径({raw})"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let label = format!(
|
||||||
|
"reg query {THEMES_KEY} /v {CURRENT_THEME_VALUE} → {}",
|
||||||
|
theme.display()
|
||||||
|
);
|
||||||
|
collect_theme_file(collect, SourceKind::WindowsCurrentTheme, &theme, &label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 第三步:注册表里的权威壁纸值。
|
||||||
|
fn registry(collect: &mut Collect<'_>) {
|
||||||
|
let Some(value) = query_reg(
|
||||||
|
collect,
|
||||||
|
SourceKind::WindowsRegistry,
|
||||||
|
DESKTOP_KEY,
|
||||||
|
WALLPAPER_VALUE,
|
||||||
|
) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let label = format!("reg query {DESKTOP_KEY} /v {WALLPAPER_VALUE}");
|
||||||
|
collect.add(SourceKind::WindowsRegistry, &label, &value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 第四步(兜底):`%APPDATA%\...\Themes\*.theme`——用户另存的主题包,**可能未启用**。
|
||||||
|
fn saved_theme_packages(collect: &mut Collect<'_>) {
|
||||||
|
let dir = themes_dir(collect.roots());
|
||||||
|
let mut themes: Vec<PathBuf> = collect
|
||||||
|
.io()
|
||||||
|
.list_dir(&dir)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|path| {
|
||||||
|
path.extension()
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case(THEME_EXTENSION))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
themes.sort();
|
||||||
|
if themes.is_empty() {
|
||||||
|
collect.note(
|
||||||
|
SourceKind::WindowsThemeFile.as_str(),
|
||||||
|
format!("{} 下没有 .theme 文件", dir.display()),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for theme in themes {
|
||||||
|
let label = format!("{}(用户另存的主题包,可能未启用)", theme.display());
|
||||||
|
collect_theme_file(collect, SourceKind::WindowsThemeFile, &theme, &label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读一个 `.theme` 文件,把里面的 `Wallpaper=` 收成候选;读不到 / 没值只记原因。
|
||||||
|
fn collect_theme_file(collect: &mut Collect<'_>, kind: SourceKind, file: &Path, label: &str) {
|
||||||
|
let Some(text) = collect.io().read_text(file) else {
|
||||||
|
collect.note(kind.as_str(), format!("读不到 {}", file.display()));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let values = parse_theme_wallpaper(&text);
|
||||||
|
if values.is_empty() {
|
||||||
|
collect.note(
|
||||||
|
kind.as_str(),
|
||||||
|
format!("{} 里没有 Wallpaper= 行", file.display()),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let evidence = format!("{label} 的 Wallpaper=");
|
||||||
|
for value in values {
|
||||||
|
collect.add(kind, &evidence, &value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 跑一次 `reg query <键> /v <值名>` 并取出值;失败或缺值都只记原因、返回 `None`。
|
||||||
|
fn query_reg(
|
||||||
|
collect: &mut Collect<'_>,
|
||||||
|
kind: SourceKind,
|
||||||
|
key: &str,
|
||||||
|
value_name: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let query = format!("reg query {key} /v {value_name}");
|
||||||
|
match collect
|
||||||
|
.io()
|
||||||
|
.run("reg.exe", &["query", key, "/v", value_name])
|
||||||
|
{
|
||||||
|
Some(text) => match parse_reg_sz_value(&text, value_name) {
|
||||||
|
Some(value) => Some(value),
|
||||||
|
None => {
|
||||||
|
collect.note(
|
||||||
|
kind.as_str(),
|
||||||
|
format!("{query} 输出里没有 {value_name} 值(可能被策略禁用或壁纸为纯色)"),
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
collect.note(
|
||||||
|
kind.as_str(),
|
||||||
|
format!("{query} 失败(命令缺失 / 非零退出 / 空输出)"),
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::super::io::fake::FakeIo;
|
||||||
|
use super::super::{detect_with, SourceKind};
|
||||||
|
use super::*;
|
||||||
|
use crate::platform::paths::{MapEnv, Platform};
|
||||||
|
|
||||||
|
const APP_DATA: &str = "C:/Users/u/AppData/Roaming";
|
||||||
|
const LOCAL_APP_DATA: &str = "C:/Users/u/AppData/Local";
|
||||||
|
const DESKTOP_QUERY: &str = "reg.exe query HKCU\\Control Panel\\Desktop /v WallPaper";
|
||||||
|
const THEMES_QUERY: &str =
|
||||||
|
"reg.exe query HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes /v CurrentTheme";
|
||||||
|
const CUSTOM_THEME: &str = r"C:\Users\u\AppData\Local\Microsoft\Windows\Themes\Custom.theme";
|
||||||
|
|
||||||
|
fn env() -> MapEnv {
|
||||||
|
MapEnv::new()
|
||||||
|
.with("HOME", "C:/Users/u")
|
||||||
|
.with("APPDATA", APP_DATA)
|
||||||
|
.with("LOCALAPPDATA", LOCAL_APP_DATA)
|
||||||
|
.with("SYSTEMROOT", "C:/Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn roots() -> HostRoots {
|
||||||
|
HostRoots::from_env(&env()).expect("HOME 已给")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 夹具用**生产代码同一个拼法**造路径,所以 Windows / Linux 宿主上都能对上
|
||||||
|
/// (另有一条字面量断言盯着这个拼法本身,见 `transcoded_wallpaper_hit_comes_first`)。
|
||||||
|
fn themes() -> PathBuf {
|
||||||
|
themes_dir(&roots())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transcoded_wallpaper_hit_comes_first() {
|
||||||
|
let io = FakeIo::new().with_image(themes().join("TranscodedWallpaper"));
|
||||||
|
let probe = detect_with(&io, &env(), Platform::Windows);
|
||||||
|
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
|
||||||
|
let best = probe.best().expect("有候选");
|
||||||
|
assert_eq!(best.kind, SourceKind::WindowsTranscoded);
|
||||||
|
assert_eq!(best.path, themes().join("TranscodedWallpaper"));
|
||||||
|
// 字面量:夹具路径是拿生产代码的构造函数造出来的,这条断言防止两边一起拼错。
|
||||||
|
assert_eq!(
|
||||||
|
best.path,
|
||||||
|
PathBuf::from(
|
||||||
|
"C:/Users/u/AppData/Roaming/Microsoft/Windows/Themes/TranscodedWallpaper"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert!(best.evidence.contains("TranscodedWallpaper"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_theme_is_read_from_local_app_data() {
|
||||||
|
// Win10+ 的真机形态:%APPDATA% 下没有 .theme,当前主题在 %LOCALAPPDATA%。
|
||||||
|
let io = FakeIo::new()
|
||||||
|
.with_command(
|
||||||
|
THEMES_QUERY,
|
||||||
|
&format!(" CurrentTheme REG_SZ {CUSTOM_THEME}\r\n"),
|
||||||
|
)
|
||||||
|
.with_file(
|
||||||
|
CUSTOM_THEME,
|
||||||
|
"[Control Panel\\Desktop]\r\nWallpaper=%SystemRoot%\\web\\wallpaper\\Windows\\img0.jpg\r\n",
|
||||||
|
)
|
||||||
|
.with_image("C:/Windows\\web\\wallpaper\\Windows\\img0.jpg");
|
||||||
|
let probe = detect_with(&io, &env(), Platform::Windows);
|
||||||
|
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
|
||||||
|
let best = probe.best().expect("有候选");
|
||||||
|
assert_eq!(best.kind, SourceKind::WindowsCurrentTheme);
|
||||||
|
assert_eq!(
|
||||||
|
best.path,
|
||||||
|
PathBuf::from("C:/Windows\\web\\wallpaper\\Windows\\img0.jpg")
|
||||||
|
);
|
||||||
|
assert!(best.evidence.contains("CurrentTheme"), "{}", best.evidence);
|
||||||
|
assert!(best.evidence.contains("Windows\\Themes\\Custom.theme"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_theme_expands_local_app_data_env_var() {
|
||||||
|
let io = FakeIo::new()
|
||||||
|
.with_command(
|
||||||
|
THEMES_QUERY,
|
||||||
|
" CurrentTheme REG_SZ %LOCALAPPDATA%\\Microsoft\\Windows\\Themes\\Custom.theme\r\n",
|
||||||
|
)
|
||||||
|
.with_file(
|
||||||
|
CUSTOM_THEME,
|
||||||
|
"Wallpaper=C:\\Users\\u\\Pictures\\a.jpg\r\n",
|
||||||
|
)
|
||||||
|
.with_image(r"C:\Users\u\Pictures\a.jpg");
|
||||||
|
let probe = detect_with(&io, &env(), Platform::Windows);
|
||||||
|
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
|
||||||
|
assert_eq!(
|
||||||
|
probe.best().expect("有候选").kind,
|
||||||
|
SourceKind::WindowsCurrentTheme
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authority_order_puts_saved_packages_last() {
|
||||||
|
// 四步全在场,路径互不相同:顺序必须是 转码副本 > 当前主题 > 注册表 > 另存包。
|
||||||
|
let package = themes().join("a.theme");
|
||||||
|
let io = FakeIo::new()
|
||||||
|
.with_image(themes().join("TranscodedWallpaper"))
|
||||||
|
.with_command(
|
||||||
|
THEMES_QUERY,
|
||||||
|
&format!(" CurrentTheme REG_SZ {CUSTOM_THEME}\r\n"),
|
||||||
|
)
|
||||||
|
.with_file(
|
||||||
|
CUSTOM_THEME,
|
||||||
|
"Wallpaper=C:\\Users\\u\\Pictures\\one.jpg\r\n",
|
||||||
|
)
|
||||||
|
.with_command(
|
||||||
|
DESKTOP_QUERY,
|
||||||
|
" WallPaper REG_SZ C:\\Users\\u\\Pictures\\two.jpg\r\n",
|
||||||
|
)
|
||||||
|
.with_dir(themes(), &["a.theme", "b.theme", "notes.txt"])
|
||||||
|
.with_file(
|
||||||
|
package.clone(),
|
||||||
|
"Wallpaper=%SystemRoot%\\web\\wallpaper\\three.jpg\r\n",
|
||||||
|
)
|
||||||
|
.with_image(r"C:\Users\u\Pictures\one.jpg")
|
||||||
|
.with_image(r"C:\Users\u\Pictures\two.jpg")
|
||||||
|
.with_image("C:/Windows\\web\\wallpaper\\three.jpg");
|
||||||
|
let probe = detect_with(&io, &env(), Platform::Windows);
|
||||||
|
let seen: Vec<(SourceKind, String)> = probe
|
||||||
|
.iter()
|
||||||
|
.map(|source| (source.kind, source.path.display().to_string()))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(seen.len(), 4, "{:?}", probe.notes());
|
||||||
|
assert_eq!(seen[0].0, SourceKind::WindowsTranscoded);
|
||||||
|
assert_eq!(seen[1].0, SourceKind::WindowsCurrentTheme);
|
||||||
|
assert_eq!(seen[2].0, SourceKind::WindowsRegistry);
|
||||||
|
assert_eq!(seen[3].0, SourceKind::WindowsThemeFile);
|
||||||
|
// 兜底档在 evidence 里如实标明「可能未启用」,调用方据此可降权。
|
||||||
|
assert!(
|
||||||
|
probe.sources()[3].evidence.contains("可能未启用"),
|
||||||
|
"{}",
|
||||||
|
probe.sources()[3].evidence
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn everything_missing_returns_empty_with_reasons() {
|
||||||
|
let probe = detect_with(&FakeIo::new(), &env(), Platform::Windows);
|
||||||
|
assert!(probe.is_empty(), "全部失败要返回空列表而不是错误");
|
||||||
|
let notes = probe.notes().join("\n");
|
||||||
|
assert!(notes.contains("reg query"), "注册表失败要留原因:{notes}");
|
||||||
|
assert!(
|
||||||
|
notes.contains("CurrentTheme"),
|
||||||
|
"当前主题失败要留原因:{notes}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
notes.contains("TranscodedWallpaper"),
|
||||||
|
"转码副本缺失要留原因:{notes}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_path_from_two_steps_is_deduped() {
|
||||||
|
let transcoded = themes().join("TranscodedWallpaper");
|
||||||
|
let registry_value = transcoded.display().to_string().to_ascii_uppercase();
|
||||||
|
let io = FakeIo::new()
|
||||||
|
.with_image(&transcoded)
|
||||||
|
// 注册表指向同一个文件,大小写还不同(Windows 路径大小写不敏感)。
|
||||||
|
.with_command(
|
||||||
|
DESKTOP_QUERY,
|
||||||
|
&format!(" WallPaper REG_SZ {registry_value}\r\n"),
|
||||||
|
);
|
||||||
|
let probe = detect_with(&io, &env(), Platform::Windows);
|
||||||
|
assert_eq!(probe.len(), 1, "{:?}", probe.sources());
|
||||||
|
assert_eq!(
|
||||||
|
probe.best().expect("有候选").kind,
|
||||||
|
SourceKind::WindowsTranscoded
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
//! Windows 侧的纯解析函数:`.theme` 的 `Wallpaper=` 行与 `reg query` 输出的取值。
|
||||||
|
//!
|
||||||
|
//! 单独一个文件只为守住「单文件 ≤400 行」(`guards.yml` 的 file-size 闸):这里没有任何
|
||||||
|
//! IO,全部是纯函数,跨平台编译,夹具用例覆盖在文件尾部。
|
||||||
|
|
||||||
|
/// 取 `.theme` 文本里所有 `Wallpaper=` 行的值(键名大小写不敏感,值去首尾空白)。
|
||||||
|
///
|
||||||
|
/// `.theme` 是 INI:`[Control Panel\Desktop]` 段下的 `Wallpaper=...`。`Wallpaper` 这个键名
|
||||||
|
/// 本身不随系统语言变(Windows 只本地化少数键),所以直接按 ASCII 比对。
|
||||||
|
pub(super) fn parse_theme_wallpaper(text: &str) -> Vec<String> {
|
||||||
|
text.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let (key, value) = line.split_once('=')?;
|
||||||
|
if !key.trim().eq_ignore_ascii_case("wallpaper") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let value = value.trim();
|
||||||
|
(!value.is_empty()).then(|| value.to_owned())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取 `reg query <键> /v <值名>` 输出里某个值的原文。
|
||||||
|
///
|
||||||
|
/// 形如 ` WallPaper REG_SZ C:\Users\me\Pictures\a.jpg`;值里的空格要保留,所以
|
||||||
|
/// 「值名 + 类型」之后的部分整段取原文(不能 `split_whitespace` 再拼,会吃掉连续空格)。
|
||||||
|
/// 值名为 `WallPaper` 时是壁纸路径,`CurrentTheme` 时是 `.theme` 文件路径。
|
||||||
|
pub(super) fn parse_reg_sz_value(text: &str, value_name: &str) -> Option<String> {
|
||||||
|
text.lines().find_map(|line| {
|
||||||
|
let trimmed = line.trim_start();
|
||||||
|
let name_end = trimmed.find(char::is_whitespace)?;
|
||||||
|
if !trimmed[..name_end].eq_ignore_ascii_case(value_name) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let after_name = trimmed[name_end..].trim_start();
|
||||||
|
let kind_end = after_name.find(char::is_whitespace)?;
|
||||||
|
let value = after_name[kind_end..].trim();
|
||||||
|
(!value.is_empty()).then(|| value.to_owned())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::super::{CURRENT_THEME_VALUE, WALLPAPER_VALUE};
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const CUSTOM_THEME: &str = r"C:\Users\u\AppData\Local\Microsoft\Windows\Themes\Custom.theme";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn theme_text_yields_only_wallpaper_values() {
|
||||||
|
let text = "[Control Panel\\Desktop]\r\n\
|
||||||
|
Wallpaper=C:\\Users\\u\\a.jpg\r\n\
|
||||||
|
wallpaper = %SystemRoot%\\web\\wallpaper\\b.jpg\r\n\
|
||||||
|
[Slideshow]\r\n\
|
||||||
|
ImagesRootPath=C:\\Users\\u\\Pictures\r\n\
|
||||||
|
Wallpaper=\r\n";
|
||||||
|
assert_eq!(
|
||||||
|
parse_theme_wallpaper(text),
|
||||||
|
vec![
|
||||||
|
r"C:\Users\u\a.jpg".to_owned(),
|
||||||
|
r"%SystemRoot%\web\wallpaper\b.jpg".to_owned(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(parse_theme_wallpaper("").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reg_output_parses_value_and_keeps_spaces() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_reg_sz_value(
|
||||||
|
"HKEY_CURRENT_USER\\Control Panel\\Desktop\r\n \
|
||||||
|
WallPaper REG_SZ C:\\Users\\Jak e\\Desktop Pictures\\a.jpg\r\n",
|
||||||
|
WALLPAPER_VALUE
|
||||||
|
),
|
||||||
|
Some(r"C:\Users\Jak e\Desktop Pictures\a.jpg".to_owned())
|
||||||
|
);
|
||||||
|
// 纯色壁纸 = 空值。
|
||||||
|
assert_eq!(
|
||||||
|
parse_reg_sz_value(" WallPaper REG_SZ \r\n", WALLPAPER_VALUE),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(parse_reg_sz_value("", WALLPAPER_VALUE), None);
|
||||||
|
assert_eq!(
|
||||||
|
parse_reg_sz_value(" TileWallpaper REG_SZ 0\r\n", WALLPAPER_VALUE),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// 当前主题:值名换成 CurrentTheme,取到的是 `.theme` 的绝对路径。
|
||||||
|
assert_eq!(
|
||||||
|
parse_reg_sz_value(
|
||||||
|
&format!(" CurrentTheme REG_SZ {CUSTOM_THEME}\r\n"),
|
||||||
|
CURRENT_THEME_VALUE
|
||||||
|
),
|
||||||
|
Some(CUSTOM_THEME.to_owned())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_reg_sz_value(" CurrentTheme REG_SZ \r\n", CURRENT_THEME_VALUE),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
//! CAM16 正向换算:sRGB 色值 → CAM16 的 **hue**(色相角)与 **chroma**(彩度)。
|
||||||
|
//!
|
||||||
|
//! HCT = **H**ue / **C**hroma from CAM16 + **T**one from `L*`。这里只实现 M3 用到的方向
|
||||||
|
//! (色值 → hue/chroma);反向(hue/chroma/tone → 色值)在 [`super::hct_solver`] 里按官方
|
||||||
|
//! 求解器内联实现——两边如果各写一份,数值会对不上。
|
||||||
|
//!
|
||||||
|
//! 照搬 `java/hct/Cam16.java`(Apache-2.0,见 `NOTICE`),逐行对应,包括 `Math.signum` 的
|
||||||
|
//! 「零返回 0」这种 Java 语义差别(见 [`space::signum`])。
|
||||||
|
|
||||||
|
use super::space::{self, signum};
|
||||||
|
use super::viewing_conditions::{default_conditions, ViewingConditions};
|
||||||
|
use super::Color;
|
||||||
|
|
||||||
|
/// XYZ → CAM16 的「锥体响应」矩阵。
|
||||||
|
pub(crate) const XYZ_TO_CAM16_RGB: [[f64; 3]; 3] = [
|
||||||
|
[0.401_288, 0.650_173, -0.051_461],
|
||||||
|
[-0.250_268, 1.204_414, 0.045_854],
|
||||||
|
[-0.002_079, 0.048_952, 0.953_127],
|
||||||
|
];
|
||||||
|
|
||||||
|
/// CAM16 里 M3 真正用到的两个坐标(`J` 明度不参与 HCT,HCT 的 tone 走 `L*`)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub(crate) struct Cam16 {
|
||||||
|
/// 色相角(度,`0..360`)。
|
||||||
|
pub(crate) hue: f64,
|
||||||
|
/// 彩度(无上限;sRGB 立方体内最大值约 130)。
|
||||||
|
pub(crate) chroma: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cam16 {
|
||||||
|
/// 从色值取 CAM16 色相/彩度(默认 sRGB 观察条件)。
|
||||||
|
pub(crate) fn from_color(color: Color) -> Self {
|
||||||
|
Self::from_xyz(space::xyz_from_color(color), default_conditions())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 XYZ 取 CAM16 色相/彩度。
|
||||||
|
pub(crate) fn from_xyz(xyz: [f64; 3], vc: &ViewingConditions) -> Self {
|
||||||
|
let [r_t, g_t, b_t] = space::matrix_multiply(xyz, XYZ_TO_CAM16_RGB);
|
||||||
|
|
||||||
|
// 折扣光源(色适应)
|
||||||
|
let r_d = vc.rgb_d[0] * r_t;
|
||||||
|
let g_d = vc.rgb_d[1] * g_t;
|
||||||
|
let b_d = vc.rgb_d[2] * b_t;
|
||||||
|
|
||||||
|
// 非线性压缩(视觉响应)
|
||||||
|
let r_af = (vc.fl * r_d.abs() / 100.0).powf(0.42);
|
||||||
|
let g_af = (vc.fl * g_d.abs() / 100.0).powf(0.42);
|
||||||
|
let b_af = (vc.fl * b_d.abs() / 100.0).powf(0.42);
|
||||||
|
let r_a = signum(r_d) * 400.0 * r_af / (r_af + 27.13);
|
||||||
|
let g_a = signum(g_d) * 400.0 * g_af / (g_af + 27.13);
|
||||||
|
let b_a = signum(b_d) * 400.0 * b_af / (b_af + 27.13);
|
||||||
|
|
||||||
|
// 红绿 / 黄蓝对立通道
|
||||||
|
let a = (11.0 * r_a - 12.0 * g_a + b_a) / 11.0;
|
||||||
|
let b = (r_a + g_a - 2.0 * b_a) / 9.0;
|
||||||
|
|
||||||
|
// 辅助量
|
||||||
|
let u = (20.0 * r_a + 20.0 * g_a + 21.0 * b_a) / 20.0;
|
||||||
|
let p2 = (40.0 * r_a + 20.0 * g_a + b_a) / 20.0;
|
||||||
|
|
||||||
|
let hue = space::sanitize_degrees(b.atan2(a).to_degrees());
|
||||||
|
let ac = p2 * vc.nbb;
|
||||||
|
let j = 100.0 * (ac / vc.aw).powf(vc.c * vc.z);
|
||||||
|
|
||||||
|
// 彩度:eHue 的 20.14° 断点是官方原样(把橙红段挪到 0 附近做插值)
|
||||||
|
let hue_prime = if hue < 20.14 { hue + 360.0 } else { hue };
|
||||||
|
let e_hue = 0.25 * ((hue_prime.to_radians() + 2.0).cos() + 3.8);
|
||||||
|
let p1 = 50_000.0 / 13.0 * e_hue * vc.nc * vc.ncb;
|
||||||
|
let t = p1 * a.hypot(b) / (u + 0.305);
|
||||||
|
let alpha = (1.64 - 0.29_f64.powf(vc.n)).powf(0.73) * t.powf(0.9);
|
||||||
|
let chroma = alpha * (j / 100.0).sqrt();
|
||||||
|
|
||||||
|
Self { hue, chroma }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn primaries_have_the_official_hue_angles() {
|
||||||
|
// 官方 material-color-utilities 对纯红/绿/蓝解出的 CAM16 色相(Java 实测)。
|
||||||
|
let cases = [
|
||||||
|
(Color::rgb(255, 0, 0), 27.408_225, 113.357_887),
|
||||||
|
(Color::rgb(0, 255, 0), 142.139_894, 108.410_061),
|
||||||
|
(Color::rgb(0, 0, 255), 282.788_18, 87.230_694),
|
||||||
|
];
|
||||||
|
for (color, hue, chroma) in cases {
|
||||||
|
let cam = Cam16::from_color(color);
|
||||||
|
assert!(
|
||||||
|
(cam.hue - hue).abs() < 1.0e-4,
|
||||||
|
"{} 的 hue 是 {},官方 {hue}",
|
||||||
|
color.to_hex(),
|
||||||
|
cam.hue
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(cam.chroma - chroma).abs() < 1.0e-4,
|
||||||
|
"{} 的 chroma 是 {},官方 {chroma}",
|
||||||
|
color.to_hex(),
|
||||||
|
cam.chroma
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gray_has_near_zero_chroma() {
|
||||||
|
for value in [16_u8, 64, 128, 200] {
|
||||||
|
let cam = Cam16::from_color(Color::rgb(value, value, value));
|
||||||
|
assert!(cam.chroma < 2.5, "灰 {value} 的彩度 {}", cam.chroma);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn black_is_finite() {
|
||||||
|
let cam = Cam16::from_color(Color::BLACK);
|
||||||
|
assert!(cam.hue.is_finite() && cam.chroma.is_finite());
|
||||||
|
assert_eq!(cam.chroma, 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
//! 官方 `HctSolver.CRITICAL_PLANES` 表(255 项,**原样搬运**)。
|
||||||
|
//!
|
||||||
|
//! 这不是可以推导的常数集:它是 HCT 求解器在 sRGB 立方体上按 `Y` 找边界、再做色相二分时的
|
||||||
|
//! 「关键平面」索引表,由 Google 的脚本一次性生成后冻结。逐值复制是唯一忠实的做法——
|
||||||
|
//! 表错了,极端 tone(深色/浅色端)的颜色会偏一两个通道。
|
||||||
|
//!
|
||||||
|
//! 来源:`material-color-utilities/java/hct/HctSolver.java`(Apache-2.0),见仓库根 `NOTICE`。
|
||||||
|
|
||||||
|
/// sRGB 立方体上按 `Y` 递增排序的关键平面(线性 RGB 0..=100 标度)。
|
||||||
|
///
|
||||||
|
/// 手写密度由 `#[rustfmt::skip]` 保住:这张表是数据,不是代码,一列一个数会撑到 266 行。
|
||||||
|
#[rustfmt::skip]
|
||||||
|
pub(crate) const CRITICAL_PLANES: [f64; 255] = [
|
||||||
|
0.015176349177441876, 0.045529047532325624, 0.07588174588720938, 0.10623444424209313,
|
||||||
|
0.13658714259697685, 0.16693984095186062, 0.19729253930674434, 0.2276452376616281,
|
||||||
|
0.2579979360165119, 0.28835063437139563, 0.3188300904430532, 0.350925934958123,
|
||||||
|
0.3848314933096426, 0.42057480301049466, 0.458183274052838, 0.4976837250274023,
|
||||||
|
0.5391024159806381, 0.5824650784040898, 0.6277969426914107, 0.6751227633498623,
|
||||||
|
0.7244668422128921, 0.775853049866786, 0.829304845476233, 0.8848452951698498,
|
||||||
|
0.942497089126609, 1.0022825574869039, 1.0642236851973577, 1.1283421258858297,
|
||||||
|
1.1946592148522128, 1.2631959812511864, 1.3339731595349034, 1.407011200216447,
|
||||||
|
1.4823302800086415, 1.5599503113873272, 1.6398909516233677, 1.7221716113234105,
|
||||||
|
1.8068114625156377, 1.8938294463134073, 1.9832442801866852, 2.075074464868551,
|
||||||
|
2.1693382909216234, 2.2660538449872063, 2.36523901573795, 2.4669114995532007,
|
||||||
|
2.5710888059345764, 2.6777882626779785, 2.7870270208169257, 2.898822059350997,
|
||||||
|
3.0131901897720907, 3.1301480604002863, 3.2497121605402226, 3.3718988244681087,
|
||||||
|
3.4967242352587946, 3.624204428461639, 3.754355295633311, 3.887192587735158,
|
||||||
|
4.022731918402185, 4.160988767090289, 4.301978482107941, 4.445716283538092,
|
||||||
|
4.592217266055746, 4.741496401646282, 4.893568542229298, 5.048448422192488,
|
||||||
|
5.20615066083972, 5.3666897647573375, 5.5300801301023865, 5.696336044816294,
|
||||||
|
5.865471690767354, 6.037501145825082, 6.212438385869475, 6.390297286737924,
|
||||||
|
6.571091626112461, 6.7548350853498045, 6.941541251256611, 7.131223617812143,
|
||||||
|
7.323895587840543, 7.5195704746346665, 7.7182615035334345, 7.919981813454504,
|
||||||
|
8.124744458384042, 8.332562408825165, 8.543448553206703, 8.757415699253682,
|
||||||
|
8.974476575321063, 9.194643831691977, 9.417930041841839, 9.644347703669503,
|
||||||
|
9.873909240696694, 10.106627003236781, 10.342513269534024, 10.58158024687427,
|
||||||
|
10.8238400726681, 11.069304815507364, 11.317986476196008, 11.569896988756009,
|
||||||
|
11.825048221409341, 12.083451977536606, 12.345119996613247, 12.610063955123938,
|
||||||
|
12.878295467455942, 13.149826086772048, 13.42466730586372, 13.702830557985108,
|
||||||
|
13.984327217668513, 14.269168601521828, 14.55736596900856, 14.848930523210871,
|
||||||
|
15.143873411576273, 15.44220572664832, 15.743938506781891, 16.04908273684337,
|
||||||
|
16.35764934889634, 16.66964922287304, 16.985093187232053, 17.30399201960269,
|
||||||
|
17.62635644741625, 17.95219714852476, 18.281524751807332, 18.614349837764564,
|
||||||
|
18.95068293910138, 19.290534541298456, 19.633915083172692, 19.98083495742689,
|
||||||
|
20.331304511189067, 20.685334046541502, 21.042933821039977, 21.404114048223256,
|
||||||
|
21.76888489811322, 22.137256497705877, 22.50923893145328, 22.884842241736916,
|
||||||
|
23.264076429332462, 23.6469514538663, 24.033477234264016, 24.42366364919083,
|
||||||
|
24.817520537484558, 25.21505769858089, 25.61628489293138, 26.021211842414342,
|
||||||
|
26.429848230738664, 26.842203703840827, 27.258287870275353, 27.678110301598522,
|
||||||
|
28.10168053274597, 28.529008062403893, 28.96010235337422, 29.39497283293396,
|
||||||
|
29.83362889318845, 30.276079891419332, 30.722335150426627, 31.172403958865512,
|
||||||
|
31.62629557157785, 32.08401920991837, 32.54558406207592, 33.010999283389665,
|
||||||
|
33.4802739966603, 33.953417292456834, 34.430438229418264, 34.911345834551085,
|
||||||
|
35.39614910352207, 35.88485700094671, 36.37747846067349, 36.87402238606382,
|
||||||
|
37.37449765026789, 37.87891309649659, 38.38727753828926, 38.89959975977785,
|
||||||
|
39.41588851594697, 39.93615253289054, 40.460400508064545, 40.98864111053629,
|
||||||
|
41.520882981230194, 42.05713473317016, 42.597404951718396, 43.141702194811224,
|
||||||
|
43.6900349931913, 44.24241185063697, 44.798841244188324, 45.35933162437017,
|
||||||
|
45.92389141541209, 46.49252901546552, 47.065252796817916, 47.64207110610409,
|
||||||
|
48.22299226451468, 48.808024568002054, 49.3971762874833, 49.9904556690408,
|
||||||
|
50.587870934119984, 51.189430279724725, 51.79514187861014, 52.40501387947288,
|
||||||
|
53.0190544071392, 53.637271562750364, 54.259673423945976, 54.88626804504493,
|
||||||
|
55.517063457223934, 56.15206766869424, 56.79128866487574, 57.43473440856916,
|
||||||
|
58.08241284012621, 58.734331877617365, 59.39049941699807, 60.05092333227251,
|
||||||
|
60.715611475655585, 61.38457167773311, 62.057811747619894, 62.7353394731159,
|
||||||
|
63.417162620860914, 64.10328893648692, 64.79372614476921, 65.48848194977529,
|
||||||
|
66.18756403501224, 66.89098006357258, 67.59873767827808, 68.31084450182222,
|
||||||
|
69.02730813691093, 69.74813616640164, 70.47333615344107, 71.20291564160104,
|
||||||
|
71.93688215501312, 72.67524319850172, 73.41800625771542, 74.16517879925733,
|
||||||
|
74.9167682708136, 75.67278210128072, 76.43322770089146, 77.1981124613393, 77.96744375590167,
|
||||||
|
78.74122893956174, 79.51947534912904, 80.30219030335869, 81.08938110306934,
|
||||||
|
81.88105503125999, 82.67721935322541, 83.4778813166706, 84.28304815182372,
|
||||||
|
85.09272707154808, 85.90692527145302, 86.72564993000343, 87.54890820862819,
|
||||||
|
88.3767072518277, 89.2090541872801, 90.04595612594655, 90.88742016217518, 91.73345337380438,
|
||||||
|
92.58406282226491, 93.43925555268066, 94.29903859396902, 95.16341895893969,
|
||||||
|
96.03240364439274, 96.9059996312159, 97.78421388448044, 98.6670533535366, 99.55452497210776,
|
||||||
|
];
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
//! **HCT** 色彩空间:色相/彩度取自 CAM16,亮度取自 CIELAB 的 `L*`(即 M3 的 tone)。
|
||||||
|
//!
|
||||||
|
//! 为什么不是 OKLab/HSL:`L*` 与人眼感知线性的**对比度**直接挂钩(`Y` 是它的单调函数),
|
||||||
|
//! 所以「tone 差 40 ⇒ 对比度 ≥ 3:1」「tone 差 50 ⇒ ≥ 4.5:1」这类性质在 HCT 里是恒等式,
|
||||||
|
//! 换任何种子色都成立。CAM16 的色相/彩度则保证「同一条调色板只有一种色相」在感知上也是真的。
|
||||||
|
//!
|
||||||
|
//! 语义与官方 `java/hct/Hct.java` 一致:`Hct` 的真身是那个 **8 bit 色值**,`hue/chroma/tone`
|
||||||
|
//! 是它的测量值。所以 [`Hct::from_components`] 解出来的 `tone` 可能比请求值差一点点
|
||||||
|
//! (例如请求 tone 40、实测 40.08),官方也是这样——请求值只是求解目标。
|
||||||
|
//!
|
||||||
|
//! 来源:`material-color-utilities`(Apache-2.0),见仓库根 `NOTICE`。
|
||||||
|
|
||||||
|
use super::space;
|
||||||
|
use super::{cam16::Cam16, hct_solver, Color};
|
||||||
|
|
||||||
|
/// 一个色值的 HCT 坐标。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct Hct {
|
||||||
|
/// CAM16 色相角(度,`0..360`)。
|
||||||
|
pub hue: f64,
|
||||||
|
/// CAM16 彩度(无上限;sRGB 立方体内约 0..=130)。
|
||||||
|
pub chroma: f64,
|
||||||
|
/// CIELAB `L*`(0 = 黑 / 100 = 白)——M3 里叫 **tone**。
|
||||||
|
pub tone: f64,
|
||||||
|
/// 这个坐标对应的 8 bit 色值(HCT 的真身)。
|
||||||
|
color: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hct {
|
||||||
|
/// 量一个色值的 HCT 坐标。
|
||||||
|
pub fn from_color(color: Color) -> Self {
|
||||||
|
let cam = Cam16::from_color(color);
|
||||||
|
Self {
|
||||||
|
hue: cam.hue,
|
||||||
|
chroma: cam.chroma,
|
||||||
|
tone: space::lstar_from_color(color),
|
||||||
|
color,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解 HCT 方程:给定色相/彩度/tone,求最接近的 sRGB 色值。
|
||||||
|
///
|
||||||
|
/// 彩度装不下时**只压彩度**(色相与 tone 保住),这就是 M3 的 gamut 映射;
|
||||||
|
/// 返回值的 `tone` 是实测值,可能与 `tone` 参数相差千分之几。
|
||||||
|
pub fn from_components(hue: f64, chroma: f64, tone: f64) -> Self {
|
||||||
|
Self::from_color(hct_solver::solve_to_color(hue, chroma, tone))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 该坐标对应的色值。
|
||||||
|
pub fn to_color(self) -> Color {
|
||||||
|
self.color
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 色相是否落在「黄」段(官方 `TonalPalette` 的 tone 99 特例用它)。
|
||||||
|
pub fn is_yellow(hue: f64) -> bool {
|
||||||
|
(105.0..125.0).contains(&hue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// 官方 Java 实测的种子色 HCT 坐标(`hct.Count` 打印,APCA/CAM16 D65 默认条件)。
|
||||||
|
const SEED_HCT: [(&str, f64, f64, f64); 6] = [
|
||||||
|
("#6750A4", 298.980_997, 47.856_526, 40.083_244),
|
||||||
|
("#FF0000", 27.408_225, 113.357_887, 53.232_882),
|
||||||
|
("#00FF00", 142.139_894, 108.410_061, 87.737_033),
|
||||||
|
("#0000FF", 282.788_18, 87.230_694, 32.302_587),
|
||||||
|
("#6E7C8A", 245.036_867, 14.675_86, 51.342_101),
|
||||||
|
("#D000FF", 324.929_26, 99.544_477, 52.542_037),
|
||||||
|
];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seed_coordinates_match_official() {
|
||||||
|
for (hex, hue, chroma, tone) in SEED_HCT {
|
||||||
|
let hct = Hct::from_color(Color::from_hex(hex).expect("合法 hex"));
|
||||||
|
assert!((hct.hue - hue).abs() < 1.0e-4, "{hex} hue {}", hct.hue);
|
||||||
|
assert!(
|
||||||
|
(hct.chroma - chroma).abs() < 1.0e-4,
|
||||||
|
"{hex} chroma {}",
|
||||||
|
hct.chroma
|
||||||
|
);
|
||||||
|
assert!((hct.tone - tone).abs() < 1.0e-4, "{hex} tone {}", hct.tone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn solver_reproduces_official_hexes() {
|
||||||
|
// 官方 Java 实测(`hct.Probe`):同一组 (h, c, t) 必须解出同一个 hex。
|
||||||
|
let cases = [
|
||||||
|
(95.0, 36.0, 40.0, "#705D0D"),
|
||||||
|
(298.98, 36.0, 90.0, "#E9DDFF"),
|
||||||
|
(25.0, 84.0, 80.0, "#FFB4AB"),
|
||||||
|
(245.0, 16.0, 80.0, "#B8C8D9"),
|
||||||
|
(245.0, 42.0, 30.0, "#004B72"),
|
||||||
|
(10.0, 120.0, 5.0, "#2C000A"),
|
||||||
|
(245.0, 4.0, 30.0, "#45474A"),
|
||||||
|
];
|
||||||
|
for (hue, chroma, tone, hex) in cases {
|
||||||
|
assert_eq!(
|
||||||
|
hct_solver::solve_to_color(hue, chroma, tone).to_hex(),
|
||||||
|
hex,
|
||||||
|
"HCT({hue}, {chroma}, {tone}) 与官方不一致"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn solver_keeps_hue_and_tone_when_chroma_is_available() {
|
||||||
|
for (hue, chroma, tone) in [
|
||||||
|
(95.0, 36.0, 40.0),
|
||||||
|
(298.98, 36.0, 90.0),
|
||||||
|
(245.0, 42.0, 30.0),
|
||||||
|
(25.0, 84.0, 80.0),
|
||||||
|
(245.0, 16.0, 80.0),
|
||||||
|
] {
|
||||||
|
let solved = Hct::from_components(hue, chroma, tone);
|
||||||
|
assert_eq!(
|
||||||
|
solved.to_color(),
|
||||||
|
hct_solver::solve_to_color(hue, chroma, tone)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(solved.tone - tone).abs() < 1.0,
|
||||||
|
"tone {} vs {tone}",
|
||||||
|
solved.tone
|
||||||
|
);
|
||||||
|
let delta = (solved.hue - hue)
|
||||||
|
.abs()
|
||||||
|
.min(360.0 - (solved.hue - hue).abs());
|
||||||
|
assert!(delta < 1.0, "色相 {} vs {hue}", solved.hue);
|
||||||
|
assert!(
|
||||||
|
solved.chroma <= chroma + 1.0,
|
||||||
|
"彩度反被拉高:{}",
|
||||||
|
solved.chroma
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn low_chroma_hue_is_ill_conditioned_even_officially() {
|
||||||
|
// 彩度 8 在 tone 30 上,8 bit 量化会让 CAM16 色相跳 6°——官方同样如此
|
||||||
|
// (Java 实测 HCT(245,8,30) → #42474E / hue 251.5965)。这条钉的是「别去修它」。
|
||||||
|
let solved = Hct::from_components(245.0, 8.0, 30.0);
|
||||||
|
assert_eq!(solved.to_color().to_hex(), "#42474E");
|
||||||
|
assert!(
|
||||||
|
(solved.hue - 251.596_498).abs() < 1.0e-4,
|
||||||
|
"hue {}",
|
||||||
|
solved.hue
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn yellow_band_matches_official_definition() {
|
||||||
|
assert!(Hct::is_yellow(105.0));
|
||||||
|
assert!(Hct::is_yellow(124.9));
|
||||||
|
assert!(!Hct::is_yellow(125.0));
|
||||||
|
assert!(!Hct::is_yellow(104.9));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
//! HCT 求解器:给定 `(hue, chroma, tone)` 求 sRGB 色值——**这就是 M3 的 gamut 映射**。
|
||||||
|
//!
|
||||||
|
//! M3 不做「越界就硬裁」:它先假设色域足够,用 Newton 迭代解出线性 RGB;只有当解落进 sRGB
|
||||||
|
//! 立方体之外时,才退到「在色调平面(`Y` 恒定)上沿色相二分」,把彩度压到该色相能取到的最大值,
|
||||||
|
//! **色相与 tone 都保持不变**。所以深色/浅色端的颜色是「变灰」而不是「变色」。
|
||||||
|
//!
|
||||||
|
//! 照搬 `java/hct/HctSolver.java`(Apache-2.0,见 `NOTICE`):两段(Newton + 二分)、
|
||||||
|
//! 迭代次数、`100.01` 这类边界容差都一致。关键平面表见 [`super::critical_planes`]。
|
||||||
|
|
||||||
|
use super::space::{self, signum};
|
||||||
|
use super::viewing_conditions::default_conditions;
|
||||||
|
use super::Color;
|
||||||
|
|
||||||
|
/// 线性 RGB → 「折扣光源」标度的矩阵(求解器内联版,与 [`super::cam16`] 等价但省一次除法)。
|
||||||
|
const SCALED_DISCOUNT_FROM_LINRGB: [[f64; 3]; 3] = [
|
||||||
|
[
|
||||||
|
0.001_200_833_568_784_504,
|
||||||
|
0.002_389_694_492_170_889,
|
||||||
|
0.000_279_574_288_586_112_4,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.000_589_108_665_137_599_9,
|
||||||
|
0.002_978_550_257_343_875_8,
|
||||||
|
0.000_327_066_610_400_839_8,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.000_101_466_924_916_405_72,
|
||||||
|
0.000_536_421_435_918_669_4,
|
||||||
|
0.003_297_940_177_071_207_6,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 「折扣光源」标度 → 线性 RGB([`SCALED_DISCOUNT_FROM_LINRGB`] 的逆)。
|
||||||
|
const LINRGB_FROM_SCALED_DISCOUNT: [[f64; 3]; 3] = [
|
||||||
|
[
|
||||||
|
1_373.219_870_959_423_1,
|
||||||
|
-1_100.425_119_075_482_1,
|
||||||
|
-7.278_681_089_101_213,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-271.815_969_077_903,
|
||||||
|
559.658_046_594_073_3,
|
||||||
|
-32.460_474_827_911_94,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1.962_289_959_966_566_6,
|
||||||
|
-57.173_814_538_844_006,
|
||||||
|
308.723_319_781_238_5,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 线性 RGB 的 `Y` 权重(Rec.709 亮度系数)。
|
||||||
|
const Y_FROM_LINRGB: [f64; 3] = [0.2126, 0.7152, 0.0722];
|
||||||
|
|
||||||
|
/// `(hue, chroma, tone)` → 色值。彩度取不到时压到该色相/色调下的最大值(色相与 tone 不变)。
|
||||||
|
pub(crate) fn solve_to_color(hue_degrees: f64, chroma: f64, lstar: f64) -> Color {
|
||||||
|
// 端点与极低彩度直接走灰:官方用同一断点,保证 tone 0/100 是纯黑/纯白。
|
||||||
|
if chroma < 0.0001 || lstar < 0.0001 || lstar > 99.9999 {
|
||||||
|
return space::color_from_lstar(lstar);
|
||||||
|
}
|
||||||
|
let hue_radians = space::sanitize_degrees(hue_degrees).to_radians();
|
||||||
|
let y = space::y_from_lstar(lstar);
|
||||||
|
if let Some(exact) = find_result_by_j(hue_radians, chroma, y) {
|
||||||
|
return exact;
|
||||||
|
}
|
||||||
|
space::color_from_linrgb(bisect_to_limit(y, hue_radians))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 牛顿迭代直接解(色域内能解出来时,这条路径是精确解)。
|
||||||
|
fn find_result_by_j(hue_radians: f64, chroma: f64, y: f64) -> Option<Color> {
|
||||||
|
let vc = default_conditions();
|
||||||
|
let mut j = y.sqrt() * 11.0;
|
||||||
|
let t_inner_coeff = 1.0 / (1.64 - 0.29_f64.powf(vc.n)).powf(0.73);
|
||||||
|
let e_hue = 0.25 * ((hue_radians + 2.0).cos() + 3.8);
|
||||||
|
let p1 = e_hue * (50_000.0 / 13.0) * vc.nc * vc.ncb;
|
||||||
|
let h_sin = hue_radians.sin();
|
||||||
|
let h_cos = hue_radians.cos();
|
||||||
|
for round in 0..5 {
|
||||||
|
let j_normalized = j / 100.0;
|
||||||
|
let alpha = if chroma == 0.0 || j == 0.0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
chroma / j_normalized.sqrt()
|
||||||
|
};
|
||||||
|
let t = (alpha * t_inner_coeff).powf(1.0 / 0.9);
|
||||||
|
let ac = vc.aw * j_normalized.powf(1.0 / vc.c / vc.z);
|
||||||
|
let p2 = ac / vc.nbb;
|
||||||
|
let gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11.0 * t * h_cos + 108.0 * t * h_sin);
|
||||||
|
let a = gamma * h_cos;
|
||||||
|
let b = gamma * h_sin;
|
||||||
|
let r_a = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0;
|
||||||
|
let g_a = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0;
|
||||||
|
let b_a = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0;
|
||||||
|
let linrgb = space::matrix_multiply(
|
||||||
|
[
|
||||||
|
inverse_chromatic_adaptation(r_a),
|
||||||
|
inverse_chromatic_adaptation(g_a),
|
||||||
|
inverse_chromatic_adaptation(b_a),
|
||||||
|
],
|
||||||
|
LINRGB_FROM_SCALED_DISCOUNT,
|
||||||
|
);
|
||||||
|
if linrgb[0] < 0.0 || linrgb[1] < 0.0 || linrgb[2] < 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let f_nj = Y_FROM_LINRGB[0] * linrgb[0]
|
||||||
|
+ Y_FROM_LINRGB[1] * linrgb[1]
|
||||||
|
+ Y_FROM_LINRGB[2] * linrgb[2];
|
||||||
|
if f_nj <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if round == 4 || (f_nj - y).abs() < 0.002 {
|
||||||
|
if linrgb[0] > 100.01 || linrgb[1] > 100.01 || linrgb[2] > 100.01 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
return Some(space::color_from_linrgb(linrgb));
|
||||||
|
}
|
||||||
|
// Newton:用 2 * fn(j) / j 近似 fn'(j)
|
||||||
|
j -= (f_nj - y) * j / (2.0 * f_nj);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 色域外时的兜底:在 `Y` 恒定的平面上按色相二分,取边界上的颜色。
|
||||||
|
fn bisect_to_limit(y: f64, target_hue: f64) -> [f64; 3] {
|
||||||
|
let (mut left, mut right) = bisect_to_segment(y, target_hue);
|
||||||
|
let mut left_hue = hue_of(left);
|
||||||
|
for axis in 0..3 {
|
||||||
|
if left[axis] == right[axis] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (mut l_plane, mut r_plane) = if left[axis] < right[axis] {
|
||||||
|
(
|
||||||
|
critical_plane_below(space::true_delinearized(left[axis])),
|
||||||
|
critical_plane_above(space::true_delinearized(right[axis])),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
critical_plane_above(space::true_delinearized(left[axis])),
|
||||||
|
critical_plane_below(space::true_delinearized(right[axis])),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
for _ in 0..8 {
|
||||||
|
if (r_plane - l_plane).abs() <= 1 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let m_plane = ((f64::from(l_plane) + f64::from(r_plane)) / 2.0).floor() as i32;
|
||||||
|
let coordinate = super::critical_planes::CRITICAL_PLANES[m_plane as usize];
|
||||||
|
let mid = set_coordinate(left, coordinate, right, axis);
|
||||||
|
let mid_hue = hue_of(mid);
|
||||||
|
if are_in_cyclic_order(left_hue, target_hue, mid_hue) {
|
||||||
|
right = mid;
|
||||||
|
r_plane = m_plane;
|
||||||
|
} else {
|
||||||
|
left = mid;
|
||||||
|
left_hue = mid_hue;
|
||||||
|
l_plane = m_plane;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
midpoint(left, right)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 找到包含目标色相的边界线段(sRGB 立方体与 `Y` 平面的交是多边形)。
|
||||||
|
fn bisect_to_segment(y: f64, target_hue: f64) -> ([f64; 3], [f64; 3]) {
|
||||||
|
let mut left = [-1.0_f64; 3];
|
||||||
|
let mut right = left;
|
||||||
|
let mut left_hue = 0.0;
|
||||||
|
let mut initialized = false;
|
||||||
|
let mut uncut = true;
|
||||||
|
for n in 0..12 {
|
||||||
|
let Some(mid) = nth_vertex(y, n) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mid_hue = hue_of(mid);
|
||||||
|
if !initialized {
|
||||||
|
initialized = true;
|
||||||
|
left = mid;
|
||||||
|
right = mid;
|
||||||
|
left_hue = mid_hue;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if uncut || are_in_cyclic_order(left_hue, mid_hue, hue_of(right)) {
|
||||||
|
uncut = false;
|
||||||
|
if are_in_cyclic_order(left_hue, target_hue, mid_hue) {
|
||||||
|
right = mid;
|
||||||
|
} else {
|
||||||
|
left = mid;
|
||||||
|
left_hue = mid_hue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(left, right)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Y` 平面与立方体的第 `n` 个顶点(不在立方体内返回 `None`)。
|
||||||
|
fn nth_vertex(y: f64, n: usize) -> Option<[f64; 3]> {
|
||||||
|
let [k_r, k_g, k_b] = Y_FROM_LINRGB;
|
||||||
|
let coord_a = if n % 4 <= 1 { 0.0 } else { 100.0 };
|
||||||
|
let coord_b = if n.is_multiple_of(2) { 0.0 } else { 100.0 };
|
||||||
|
let vertex = if n < 4 {
|
||||||
|
let (g, b) = (coord_a, coord_b);
|
||||||
|
[((y - g * k_g - b * k_b) / k_r), g, b]
|
||||||
|
} else if n < 8 {
|
||||||
|
let (b, r) = (coord_a, coord_b);
|
||||||
|
[r, ((y - r * k_r - b * k_b) / k_g), b]
|
||||||
|
} else {
|
||||||
|
let (r, g) = (coord_a, coord_b);
|
||||||
|
[r, g, ((y - r * k_r - g * k_g) / k_b)]
|
||||||
|
};
|
||||||
|
if vertex.iter().all(|c| (0.0..=100.0).contains(c)) {
|
||||||
|
Some(vertex)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线性 RGB 的色相(弧度,未归一)。
|
||||||
|
fn hue_of(linrgb: [f64; 3]) -> f64 {
|
||||||
|
let scaled = space::matrix_multiply(linrgb, SCALED_DISCOUNT_FROM_LINRGB);
|
||||||
|
let r_a = chromatic_adaptation(scaled[0]);
|
||||||
|
let g_a = chromatic_adaptation(scaled[1]);
|
||||||
|
let b_a = chromatic_adaptation(scaled[2]);
|
||||||
|
let a = (11.0 * r_a - 12.0 * g_a + b_a) / 11.0;
|
||||||
|
let b = (r_a + g_a - 2.0 * b_a) / 9.0;
|
||||||
|
b.atan2(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 角度上 `b` 是否落在 `a → c` 的前进区间内。
|
||||||
|
fn are_in_cyclic_order(a: f64, b: f64, c: f64) -> bool {
|
||||||
|
space::sanitize_radians(b - a) < space::sanitize_radians(c - a)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把线段 `source → target` 在 `axis` 上截到 `coordinate`。
|
||||||
|
fn set_coordinate(source: [f64; 3], coordinate: f64, target: [f64; 3], axis: usize) -> [f64; 3] {
|
||||||
|
let t = (coordinate - source[axis]) / (target[axis] - source[axis]);
|
||||||
|
[
|
||||||
|
source[0] + (target[0] - source[0]) * t,
|
||||||
|
source[1] + (target[1] - source[1]) * t,
|
||||||
|
source[2] + (target[2] - source[2]) * t,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线段中点。
|
||||||
|
fn midpoint(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||||
|
[
|
||||||
|
(a[0] + b[0]) / 2.0,
|
||||||
|
(a[1] + b[1]) / 2.0,
|
||||||
|
(a[2] + b[2]) / 2.0,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 非线性压缩的正向(与 CAM16 里那段同公式)。
|
||||||
|
fn chromatic_adaptation(component: f64) -> f64 {
|
||||||
|
let af = component.abs().powf(0.42);
|
||||||
|
signum(component) * 400.0 * af / (af + 27.13)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 非线性压缩的逆。
|
||||||
|
fn inverse_chromatic_adaptation(adapted: f64) -> f64 {
|
||||||
|
let adapted_abs = adapted.abs();
|
||||||
|
let base = (27.13 * adapted_abs / (400.0 - adapted_abs)).max(0.0);
|
||||||
|
signum(adapted) * base.powf(1.0 / 0.42)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关键平面索引:向下取(官方 `(int) Math.floor(x - 0.5)`)。
|
||||||
|
fn critical_plane_below(x: f64) -> i32 {
|
||||||
|
(x - 0.5).floor() as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关键平面索引:向上取(官方 `(int) Math.ceil(x - 0.5)`)。
|
||||||
|
fn critical_plane_above(x: f64) -> i32 {
|
||||||
|
(x - 0.5).ceil() as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn in_gamut_requests_are_met_exactly() {
|
||||||
|
// 官方 Java 实测:HCT(95, 36, 40) → #705D0D。
|
||||||
|
assert_eq!(solve_to_color(95.0, 36.0, 40.0).to_hex(), "#705D0D");
|
||||||
|
assert_eq!(solve_to_color(25.0, 84.0, 40.0).to_hex(), "#BA1A1A");
|
||||||
|
assert_eq!(solve_to_color(95.0, 36.0, 90.0).to_hex(), "#FCE186");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn out_of_gamut_requests_keep_hue_and_tone() {
|
||||||
|
// 请求 chroma 200(sRGB 装不下):官方只在彩度上退让,色相与 tone 必须保住。
|
||||||
|
let color = solve_to_color(200.0, 200.0, 70.0);
|
||||||
|
assert_eq!(color.to_hex(), "#00BEC3");
|
||||||
|
let hct = super::super::Hct::from_color(color);
|
||||||
|
assert!((hct.tone - 70.0).abs() < 1.0, "tone 掉了:{}", hct.tone);
|
||||||
|
assert!(
|
||||||
|
(hct.hue - 200.0).abs() < 1.0,
|
||||||
|
"色相偏了:{}(应 ≈200)",
|
||||||
|
hct.hue
|
||||||
|
);
|
||||||
|
assert!(hct.chroma < 200.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn neutral_requests_return_exact_grays() {
|
||||||
|
for lstar in [0.0_f64, 4.0, 10.0, 50.0, 87.0, 98.0, 100.0] {
|
||||||
|
let color = solve_to_color(120.0, 0.0, lstar);
|
||||||
|
assert!(color.r == color.g && color.g == color.b, "{:?}", color);
|
||||||
|
}
|
||||||
|
assert_eq!(solve_to_color(0.0, 0.0, 0.0), Color::BLACK);
|
||||||
|
assert_eq!(solve_to_color(0.0, 0.0, 100.0), Color::WHITE);
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
-30
@@ -16,43 +16,109 @@
|
|||||||
//! assert_eq!(custom.get(Role::Surface), surface);
|
//! assert_eq!(custom.get(Role::Surface), surface);
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! ## 口径声明:这是 M3 **近似**,不是标准 HCT 动态取色
|
//! ## 口径声明:真 HCT,不是近似
|
||||||
//!
|
//!
|
||||||
//! 标准 M3 的调色板建在 **HCT**(CAM16 色相/彩度 + CIELAB `L*`)上。本 crate 不实现 CAM16,
|
//! 标准 M3 的调色板建在 **HCT**(CAM16 色相/彩度 + CIELAB 的 `L*`)上。本模块**就是**那套算法:
|
||||||
//! 用的是 **OKLCh**(色相/彩度)+ **CIELAB `L*`**(tone 轴)。具体差异:
|
//! CAM16 正换算(`cam16` 模块)取色相/彩度,`L*` 取 tone,色域外走官方的 `HctSolver`
|
||||||
|
//! (`hct_solver` 模块:Newton 迭代 + 色调平面上的色相二分,**只压彩度、不硬裁**)。
|
||||||
//!
|
//!
|
||||||
//! | 轴 | 标准 M3 | 本实现 | 影响 |
|
//! | 轴 | 标准 M3 | 本实现 | 一致性 |
|
||||||
//! |---|---|---|---|
|
//! |---|---|---|---|
|
||||||
//! | tone | CIELAB `L*` | CIELAB `L*`(二分反解 OKLab 明度,误差 ≤1 tone) | **无偏差**:tone 定义严格对齐 |
|
//! | tone | CIELAB `L*` | CIELAB `L*`(同一个 `Y` 轴) | 逐值一致 |
|
||||||
//! | 色相 | CAM16 hue | OKLCh hue(种子色相原样保留) | 绝对色相角不同,但「同族色相恒定」这个性质成立;派生色(tertiary = +60°)偏移量与 M3 不完全相同 |
|
//! | 色相 / 彩度 | CAM16 | CAM16(D65 默认观察条件) | 逐值一致(误差 < 1e-4) |
|
||||||
//! | 彩度 | CAM16 chroma(如 primary 36 / secondary 16 / neutral 6) | OKLCh chroma(标定值见 [`palettes`]) | 数值量纲不同,语义等价;饱和度观感与官方色板会有可见差别(不是抄错,是量纲换算不通用) |
|
//! | gamut 映射 | `HctSolver` | 同一套两段求解器,关键平面表原样搬运 | 逐通道一致 |
|
||||||
//! | gamut | CAM16 gamut mapping(按色相压缩彩度) | 按 tone 二分压 OKLCh 彩度进 sRGB | 极端 tone(5/95+)的饱和度可能比官方色板略低 |
|
//! | 调色板彩度 | Tonal Spot 变体:36 / 16 / 24 / 6 / 8,error 25°/84 | 同(primary/secondary/tertiary/neutral-variant/error 逐值一致) | 逐值一致 |
|
||||||
//! | 动态色 | Material You 从壁纸取 5 组种子 | 只用**一个**种子 + 固定派生规则 | 没有「多色壁纸取色」能力;本启动器用不到(壁纸取色是安卓端特性) |
|
//! | 中性彩度 | 6(几乎无色) | 官方口径 6;**产品默认用 12**(有意偏离,见下节「出厂默认色」) | 仅此处偏离 |
|
||||||
//! | 对比度模式 | Dynamic Contrast 三档(standard/medium/high) | 只有 standard | 不提供高对比方案;WCAG AA 在 standard 下已满足(见 [`super::color::contrast`] 与测试) |
|
//! | 角色 tone 表 | ColorSpec2021 | 同(49 角色 × light/dark) | 逐值一致 |
|
||||||
//!
|
//!
|
||||||
//! 结论:**不许对外宣称「标准 M3 动态取色」**。要对比官方色板时按上表解释差异,
|
//! 对齐证据在 `tests/official_baseline.rs` 与 `tests/official_accent.rs`:期望值全部由
|
||||||
//! 不要试图逐值对齐 material-color-utilities 的输出(那需要 CAM16 + HCT,是另一个量级的实现)。
|
//! `material-color-utilities`(官方 Java 实现,Apache-2.0)跑出来,**每通道误差 ≤1** 才通过。
|
||||||
|
//! 所以本 crate 可以对外说「M3 动态取色(Tonal Spot 变体 / standard 对比度)」。
|
||||||
|
//!
|
||||||
|
//! ## 未实现的部分(gap 登记,别以为已经有)
|
||||||
|
//!
|
||||||
|
//! | 能力 | 官方叫什么 | 状态 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | 对比度三档(default/medium/high) | `ContrastCurve` + `contrastLevel` | **未实现**:只做 `contrastLevel = 0`。在 0 档下官方那层「tone 差对 + 对比度修正」机制的输入全是常数,等价于本模块的静态 tone 表(已由对齐测试反证) |
|
||||||
|
//! | 其他配色变体 | `Vibrant` / `Expressive` / `Fidelity` / `Content` / `Monochrome` / `Neutral` / `Rainbow` / `FruitSalad` / `CMF` | **未实现**:只有 `TonalSpot`(M3 默认、也是 Material Theme Builder 默认) |
|
||||||
|
//! | 壁纸多色取色 | `QuantizerCelebi` / `Score` / 多 source 色 | **未实现**:桌面启动器用不到(安卓端特性),也避免引入图像依赖 |
|
||||||
|
//! | 换观察条件 | `Hct.inViewingConditions` | **未实现**:只有 sRGB 屏的默认条件(D65 / 200 lux / 背景 `L* 50`) |
|
||||||
|
//!
|
||||||
|
//! ## 「自定义强调色」与官方 `from_seed` 的关系
|
||||||
|
//!
|
||||||
|
//! 官方 `SchemeTonalSpot(accent)` 会把**中性表面也染上**种子的色相;本项目的产品语义是
|
||||||
|
//! 「换强调色不该让整个窗口底色变脏」,所以 [`Scheme::from_accent`] 只换 primary/secondary/
|
||||||
|
//! tertiary 三族,中性色与 error 保持基线。强调族的色值与官方 `from_seed(accent)` 逐个对得上
|
||||||
|
//! (`tests/official_accent.rs`,8 个种子 × 12 个强调族角色 × 2 模式 = 192 项)。
|
||||||
|
//!
|
||||||
|
//! ## 出厂默认色(M70:浅冷天蓝)——含**两处**有意偏离(同一处偏离的两半)
|
||||||
|
//!
|
||||||
|
//! 产品默认不走官方紫,而是「浅冷天蓝」。默认值收在两个常量里,**换默认只改这两行**
|
||||||
|
//! (用户 2026-11-01 拍板采用候选 **④ 天蓝**;③ 浅冷天蓝 `#87CEEB` 留作备选,值仍 pin 在
|
||||||
|
//! `tests/skyseed_candidates.rs`):
|
||||||
|
//!
|
||||||
|
//! | 项 | 当前值 | 官方值 | 说明 |
|
||||||
|
//! |---|---|---|---|
|
||||||
|
//! | [`palettes::DEFAULT_SEED`] | `#3C8DBC`(CAM16 hue ≈ 240°) | `#6750A4`([`palettes::BASELINE_SEED`]) | 种子只贡献**色相**(CAM16 240°);种子彩度会被 Tonal Spot 丢掉 |
|
||||||
|
//! | [`palettes::DEFAULT_NEUTRAL_CHROMA`](neutral) | `12` | `6` | 让 surface / surface-container-* 带淡蓝调 |
|
||||||
|
//! | 联动的 neutral-variant | `14` | `8`(**绝对值**;官方不是「neutral + 2」) | `outline` / `outline-variant` / `surface-variant` 一起偏蓝(light `outline` = `#6A7985` 就是 14 彩度的口径) |
|
||||||
|
//!
|
||||||
|
//! **定位(D38)**:这套默认只是「**莫奈关闭时的出厂预选色卡**」(首次启动、用户还没选过色卡时
|
||||||
|
//! 用的那一套),**不是唯一的配色来源**。用户的配色来源有三级优先:① 启动器背景图取色(Monet)
|
||||||
|
//! → ② 桌面壁纸取色 → ③ 莫奈关闭(默认)时从内置多套色卡里选。取色与「内置色卡库」都不在本 crate,
|
||||||
|
//! 也还没实现;本模块只负责「给定一个种子 + 中性彩度,派生出 49 个角色」。
|
||||||
|
//!
|
||||||
|
//! > ⚠️ **有意偏离登记(两处,同一处偏离的两半)**:
|
||||||
|
//! >
|
||||||
|
//! > 1. **neutral 彩度 `6 → 12`**。理由=产品要「浅冷」的冷调表面:官方 6 的表面几乎无色,单靠换
|
||||||
|
//! > 种子做不到「浅」——primary 的 tone 被官方钉在 40,种子再浅主色也还是中蓝,所以「浅」只能
|
||||||
|
//! > 落在中性色上。
|
||||||
|
//! > 2. **neutral-variant 彩度 `8 → 14`**。这是第 1 条的连带结果:官方
|
||||||
|
//! > `getNeutralVariantPalette` 写死的是**绝对值 8**(不是「neutral + 2」这种相对式),
|
||||||
|
//! > 本实现为了让层次不倒挂,让它跟着 neutral 联动(`nv = neutral + 2`)。若把 nv 钉回 8,
|
||||||
|
//! > 表面(12)会比中性变体(8)更「有色」,破坏官方的「neutral < neutral-variant」层次。
|
||||||
|
//! > 影响面不只表面:`outline` / `outline-variant` / `surface-variant` 三个角色一起偏蓝。
|
||||||
|
//! >
|
||||||
|
//! > 除这两处外全部走官方常数(primary 36 / secondary 16 / tertiary 24 且色相 +60° /
|
||||||
|
//! > error 25°·84),tone 表与 gamut 映射一行未动;[`Scheme::official_baseline`] 仍是官方的
|
||||||
|
//! > 6(neutral)/ 8(neutral-variant)口径。
|
||||||
|
//!
|
||||||
|
//! 官方口径的锚点仍然完整保留:[`Scheme::official_baseline`](种子 `#6750A4` + 中性 6)
|
||||||
|
//! 是 `tests/official_baseline.rs` 的对账入口,98 项仍是每通道 ≤1;
|
||||||
|
//! `tests/skyseed_candidates.rs` 把四档候选(保守蓝 / 冷调蓝 / 浅冷蓝 / 天蓝)的色值逐个 pin 住,
|
||||||
|
//! 换默认时它会指着要改的行。
|
||||||
|
//!
|
||||||
|
//! ✅ 默认色已由用户 2026-11-01 拍板:**④ 天蓝 `#3C8DBC`**。备选 ③ 浅冷蓝 `#87CEEB`
|
||||||
|
//! (主色偏青 `#0C6780`)仍在候选表里,换回去只需改 [`palettes::DEFAULT_SEED`] 一行。
|
||||||
//!
|
//!
|
||||||
//! ## 对比度纪律(WCAG AA)
|
//! ## 对比度纪律(WCAG AA)
|
||||||
//!
|
//!
|
||||||
//! - 所有 `on-*` 与其承载色(`*-container`、`primary`、`surface`)都满足 **≥ 4.5:1**;
|
//! - 所有 `on-*` 与其承载色(`*-container`、`primary`、`surface`)都满足 **≥ 4.5:1**;
|
||||||
//! 这一条由 `tests/contrast_aa.rs` 全表验证,且因为 tone 轴固定,**换任何种子色都成立**
|
//! 这一条由 `tests/contrast_aa.rs` 全表验证,且因为 tone 轴固定,**换任何种子色都成立**
|
||||||
//! (WCAG 对比度只取决于相对亮度,而 tone 直接钉住 L*)。
|
//! (WCAG 对比度只取决于相对亮度,而 tone 直接钉住 `L*`;中性彩度同理,
|
||||||
|
//! `sky_seed_candidates_meet_wcag_aa` 逐档验证了 M70 的四档候选:4 档 × 2 模式 × 26 对 = 208 项)。
|
||||||
//! - 例外(显式登记,非缺陷):
|
//! - 例外(显式登记,非缺陷):
|
||||||
//! - [`Role::Outline`] 对 [`Role::Surface`]:M3 定义的**非文字**元素(输入框描边),
|
//! - [`Role::Outline`] 对 [`Role::Surface`]:M3 定义的**非文字**元素(输入框描边),
|
||||||
//! 目标 3:1,实测 light 4.29:1 / dark 6.5:1;
|
//! 目标 3:1,实测 light 4.26:1 / dark 5.89:1;
|
||||||
//! - [`Role::OutlineVariant`] 对 [`Role::Surface`]:纯装饰分隔线,M3 不要求对比度下限;
|
//! - [`Role::OutlineVariant`] 对 [`Role::Surface`]:纯装饰分隔线,M3 不要求对比度下限;
|
||||||
//! - [`Role::SurfaceVariant`]:它是**容器**色(不是前景),不参与对比度门禁。
|
//! - [`Role::SurfaceVariant`]:它是**容器**色(不是前景),不参与对比度门禁。
|
||||||
//! - 禁用态按 M3 的 alpha 叠加([`state::DISABLED_CONTENT_ALPHA`]),刻意低于 AA。
|
//! - 禁用态按 M3 的 alpha 叠加([`state::DISABLED_CONTENT_ALPHA`]),刻意低于 AA。
|
||||||
|
|
||||||
|
pub mod hct;
|
||||||
pub mod palettes;
|
pub mod palettes;
|
||||||
pub mod roles;
|
pub mod roles;
|
||||||
pub mod scheme;
|
pub mod scheme;
|
||||||
pub mod space;
|
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod tonal;
|
pub mod tonal;
|
||||||
|
|
||||||
pub use palettes::{PaletteSlot, Palettes, BASELINE_SEED};
|
mod cam16;
|
||||||
|
mod critical_planes;
|
||||||
|
mod hct_solver;
|
||||||
|
mod space;
|
||||||
|
mod viewing_conditions;
|
||||||
|
|
||||||
|
pub use hct::Hct;
|
||||||
|
pub use palettes::{PaletteSlot, Palettes, BASELINE_SEED, DEFAULT_NEUTRAL_CHROMA, DEFAULT_SEED};
|
||||||
pub use roles::Role;
|
pub use roles::Role;
|
||||||
pub use scheme::{Scheme, SchemeFlavor, SCRIM_ALPHA, SHADOW_ALPHA};
|
pub use scheme::{Scheme, SchemeFlavor, SCRIM_ALPHA, SHADOW_ALPHA};
|
||||||
pub use state::StateLayer;
|
pub use state::StateLayer;
|
||||||
@@ -143,17 +209,19 @@ impl Color {
|
|||||||
self.a == 255
|
self.a == 255
|
||||||
}
|
}
|
||||||
|
|
||||||
/// WCAG 相对亮度(0..1,按 sRGB→XYZ 的 Y 行计算)。
|
/// WCAG 相对亮度(0..1,即 XYZ 的 `Y` 除以 100)。
|
||||||
|
///
|
||||||
|
/// 与 WCAG 2.x 的定义同一条 `Y` 轴(Rec.709 系数 0.2126/0.7152/0.0722),
|
||||||
|
/// 所以 [`Color::contrast_ratio`] 就是标准对比度公式。
|
||||||
pub fn relative_luminance(self) -> f32 {
|
pub fn relative_luminance(self) -> f32 {
|
||||||
let lin = |c: u8| space::srgb_to_linear(f32::from(c) / 255.0);
|
(space::xyz_from_color(self)[1] / 100.0) as f32
|
||||||
space::luminance([lin(self.r), lin(self.g), lin(self.b)])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CIELAB `L*`(0..100)——这就是 M3 的 **tone**。
|
/// CIELAB `L*`(0..100)——这就是 M3 的 **tone**。
|
||||||
///
|
///
|
||||||
/// 调色板按这条轴生成,验收也按这条轴断言;想知道某个色值「是几号 tone」就用它。
|
/// 调色板按这条轴生成,验收也按这条轴断言;想知道某个色值「是几号 tone」就用它。
|
||||||
pub fn lstar(self) -> f32 {
|
pub fn lstar(self) -> f32 {
|
||||||
space::lstar_from_luminance(self.relative_luminance())
|
space::lstar_from_color(self) as f32
|
||||||
}
|
}
|
||||||
|
|
||||||
/// WCAG 对比度(1..21)。`self` 与 `other` 都不透明时才严格;带 alpha 时先算「不计 alpha」的值。
|
/// WCAG 对比度(1..21)。`self` 与 `other` 都不透明时才严格;带 alpha 时先算「不计 alpha」的值。
|
||||||
@@ -200,16 +268,6 @@ impl Color {
|
|||||||
a: mix(self.a, other.a),
|
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)]
|
#[cfg(test)]
|
||||||
@@ -250,4 +308,24 @@ mod tests {
|
|||||||
assert_eq!(a.lerp(b, 1.0), b);
|
assert_eq!(a.lerp(b, 1.0), b);
|
||||||
assert_eq!(a.lerp(b, 0.5).r, 128);
|
assert_eq!(a.lerp(b, 0.5).r, 128);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lstar_anchors_at_the_ends() {
|
||||||
|
assert!(Color::BLACK.lstar().abs() < 1.0e-3);
|
||||||
|
assert!((Color::WHITE.lstar() - 100.0).abs() < 1.0e-3);
|
||||||
|
// 中灰(sRGB 128)的 L* 是 53.6 左右,不是 50——这正是「L* 不是 sRGB 编码值」的证据。
|
||||||
|
let mid = Color::rgb(128, 128, 128).lstar();
|
||||||
|
assert!((mid - 53.585).abs() < 0.01, "中灰 L* = {mid}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tone_delta_implies_contrast_floor() {
|
||||||
|
// M3 的招牌性质:tone 差 50 ⇒ 对比度 ≥ 4.5:1(与色相/彩度无关)。
|
||||||
|
for (low, high) in [(10.0_f64, 60.0), (30.0, 80.0), (40.0, 90.0), (20.0, 70.0)] {
|
||||||
|
let a = space::color_from_lstar(low);
|
||||||
|
let b = space::color_from_lstar(high);
|
||||||
|
let ratio = a.contrast_ratio(b);
|
||||||
|
assert!(ratio >= 4.5, "tone {low}→{high} 只有 {ratio:.2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,46 +1,101 @@
|
|||||||
//! 六条 tone 调色板的集合(M3 scheme 的原料)。
|
//! 六条 tone 调色板的集合(M3 scheme 的原料)。
|
||||||
//!
|
//!
|
||||||
//! 角色 → 调色板槽位 → tone 的三段映射在 [`super::scheme`];这里只负责「种子 → 六条调色板」。
|
//! 角色 → 调色板槽位 → tone 的三段映射在 [`super::scheme`];这里只负责「种子 → 六条调色板」,
|
||||||
|
//! 以及**官方 Tonal Spot 变体**的彩度常数。
|
||||||
|
//!
|
||||||
|
//! 彩度值来自 `material-color-utilities` 的 `ColorSpec2021.get*Palette(Variant.TONAL_SPOT)`
|
||||||
|
//! (Apache-2.0,见 `NOTICE`):primary 恒定 **36**、secondary **16**、tertiary **24**
|
||||||
|
//! (色相 +60°)、neutral **6**、neutral-variant **8**、error 固定色相 **25** 彩度 **84**。
|
||||||
|
//! 注意 primary 的 36 与种子自身的彩度无关——这是官方口径:Tonal Spot 是「镇静」变体,
|
||||||
|
//! 再鲜艳的种子也会被收到 36,再灰的种子也会被提到 36。
|
||||||
|
//!
|
||||||
|
//! ## 两个入口:官方口径 vs 产品默认(M70)
|
||||||
|
//!
|
||||||
|
//! | 入口 | 种子 | 中性彩度 | 用途 |
|
||||||
|
//! |---|---|---|---|
|
||||||
|
//! | [`Palettes::official_baseline`] / [`super::Scheme::official_baseline`] | [`BASELINE_SEED`](`#6750A4`) | 6(官方) | 与官方 Java 实现逐角色对齐的**锚点**,`tests/official_baseline.rs` 用它 |
|
||||||
|
//! | [`Palettes::baseline`] / [`super::Scheme::baseline`] | [`DEFAULT_SEED`] | [`DEFAULT_NEUTRAL_CHROMA`] | **产品出厂默认**(消费方拿到的就是它) |
|
||||||
|
//!
|
||||||
|
//! 换默认色只改 [`DEFAULT_SEED`] 与 [`DEFAULT_NEUTRAL_CHROMA`] 两行常量(中性彩度不动就只改一行),
|
||||||
|
//! 然后重跑 `tests/skyseed_candidates.rs` 的 pin(它会打印新值并指出哪几行要同步)。
|
||||||
|
//! 官方锚点不受影响——**算法没动,动的只是入参**。
|
||||||
|
//!
|
||||||
|
//! ⚠️ 中性彩度 6 → 12 是**有意偏离**官方 Tonal Spot(理由:产品要「浅冷」的冷调表面),
|
||||||
|
//! 偏离登记见 [`super`] 模块文档的「出厂默认色」小节。
|
||||||
|
|
||||||
use super::tonal::TonalPalette;
|
use super::tonal::TonalPalette;
|
||||||
use super::Color;
|
use super::Color;
|
||||||
|
|
||||||
/// M3 baseline 种子色(与 material-color-utilities 的默认基线同值)。
|
/// M3 baseline 种子色(与 material-color-utilities 的默认基线同值)。
|
||||||
|
///
|
||||||
|
/// **不是**产品默认色——产品默认见 [`DEFAULT_SEED`]。
|
||||||
pub const BASELINE_SEED: Color = Color::rgb(0x67, 0x50, 0xA4);
|
pub const BASELINE_SEED: Color = Color::rgb(0x67, 0x50, 0xA4);
|
||||||
|
|
||||||
|
/// 产品出厂默认种子色 —— M70 候选④「天蓝」,**用户 2026-11-01 拍板采用**。
|
||||||
|
///
|
||||||
|
/// `#3C8DBC`(CAM16 hue ≈ 239.5°,主色 `#236488`)。注意种子的彩度(42.6)会被 Tonal Spot
|
||||||
|
/// 丢掉——主色板的彩度恒为 36,种子只贡献**色相**。
|
||||||
|
///
|
||||||
|
/// ## 定位(D38):这是「莫奈关闭时的出厂预选色卡」,不是唯一的配色来源
|
||||||
|
///
|
||||||
|
/// 用户定了三级优先的配色来源:① 启动器背景图取色(Monet)→ ② 桌面壁纸取色 → ③ 莫奈关闭
|
||||||
|
/// (默认)时用户从内置多套色卡里选。本常量 + [`DEFAULT_NEUTRAL_CHROMA`] 只定义**第三级里
|
||||||
|
/// 出厂预选的那一套**(首次启动、用户没选过色卡时的那一套)。取色(Monet / 壁纸 /
|
||||||
|
/// `QuantizerCelebi`)与「内置多色卡库」都不在本 crate,也还没实现。
|
||||||
|
pub const DEFAULT_SEED: Color = Color::rgb(0x3C, 0x8D, 0xBC);
|
||||||
|
|
||||||
|
/// 产品出厂默认的中性调色板彩度(官方 Tonal Spot 是 6,这里 12 = **有意偏离**)。
|
||||||
|
///
|
||||||
|
/// 官方 6 让表面几乎无色;12 让 surface / surface-container-* 带一层淡蓝调,
|
||||||
|
/// 这是「浅冷天蓝」唯一能落在表面上的手段(primary 的 tone 被官方钉在 40,
|
||||||
|
/// 种子再浅也变不成浅蓝)。
|
||||||
|
///
|
||||||
|
/// **数值安全区间 `6..=13`,产品取 12**:neutral-variant 跟 neutral 联动(`nv = neutral + 2`),
|
||||||
|
/// 13 时 nv = 15 仍低于 secondary 的 16;**14 才会撞上**(nv = 16 = secondary),届时要一并改
|
||||||
|
/// `NEUTRAL_VARIANT_OFFSET`(私有常量)的规则。nv 的那一半偏离见 `color` 模块文档的偏离登记。
|
||||||
|
///
|
||||||
|
/// 同样只服务于「出厂预选色卡」(D38 第三级),不约束将来的取色结果。将来若做「内置多套色卡」,
|
||||||
|
/// 每套色卡各带自己的中性彩度更合适——本常量到时候会退化成「默认那套的取值」。
|
||||||
|
pub const DEFAULT_NEUTRAL_CHROMA: f64 = 12.0;
|
||||||
|
|
||||||
/// 调色板槽位(M3 的六条 tonal palette)。
|
/// 调色板槽位(M3 的六条 tonal palette)。
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum PaletteSlot {
|
pub enum PaletteSlot {
|
||||||
/// 主调色板(种子色相)。
|
/// 主调色板(种子色相,彩度 36)。
|
||||||
Primary,
|
Primary,
|
||||||
/// 次调色板(同色相、低彩度)。
|
/// 次调色板(同色相,彩度 16)。
|
||||||
Secondary,
|
Secondary,
|
||||||
/// 第三调色板(种子色相 +60°,中彩度)。
|
/// 第三调色板(种子色相 +60°,彩度 24)。
|
||||||
Tertiary,
|
Tertiary,
|
||||||
/// 中性调色板(近零彩度的表面色)。
|
/// 中性调色板(同色相;官方彩度 6,产品默认 12——见 [`DEFAULT_NEUTRAL_CHROMA`])。
|
||||||
Neutral,
|
Neutral,
|
||||||
/// 中性变体调色板(比中性略彩色,做分隔与次级容器)。
|
/// 中性变体调色板(同色相,彩度 = neutral + 2;官方 8)。
|
||||||
NeutralVariant,
|
NeutralVariant,
|
||||||
/// 错误调色板(固定红相)。
|
/// 错误调色板(固定红相 25°,彩度 84)。
|
||||||
Error,
|
Error,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下面几个常数是**我们的标定值**(OKLCh 彩度单位),不是 M3 的 CAM16 chroma:
|
/// Tonal Spot 的 primary 彩度(官方常数,与种子彩度无关)。
|
||||||
// CAM16 的 chroma 36/16/24/6/8 量纲与本实现不同,直接照抄会得到过饱和的色板。
|
const PRIMARY_CHROMA: f64 = 36.0;
|
||||||
/// 次色的彩度上限。
|
/// secondary 彩度。
|
||||||
const SECONDARY_CHROMA: f32 = 0.055;
|
const SECONDARY_CHROMA: f64 = 16.0;
|
||||||
/// 第三色的彩度上限。
|
/// tertiary 彩度。
|
||||||
const TERTIARY_CHROMA: f32 = 0.085;
|
const TERTIARY_CHROMA: f64 = 24.0;
|
||||||
/// 中性的彩度上限(几乎是灰)。
|
/// 官方 Tonal Spot 的 neutral 彩度(几乎是灰)。产品默认用 [`DEFAULT_NEUTRAL_CHROMA`]。
|
||||||
const NEUTRAL_CHROMA: f32 = 0.008;
|
const TONAL_SPOT_NEUTRAL_CHROMA: f64 = 6.0;
|
||||||
/// 中性变体的彩度上限。
|
/// neutral-variant 相对 neutral 的彩度偏移。
|
||||||
const NEUTRAL_VARIANT_CHROMA: f32 = 0.016;
|
///
|
||||||
/// 错误色的色相(OKLCh 红相)。
|
/// ⚠️ 官方 `getNeutralVariantPalette` 写死的是**绝对值 8**,**不是**「neutral + 2」这种相对式;
|
||||||
const ERROR_HUE_DEG: f32 = 25.0;
|
/// 本实现选择让 neutral-variant 随 neutral 联动,于是产品默认(neutral 12)取到 **14**。
|
||||||
/// 错误色的彩度上限。
|
/// 这是**有意偏离**的另一半(见 `color` 模块文档的偏离登记),不是官方口径——
|
||||||
const ERROR_CHROMA: f32 = 0.18;
|
/// 官方的 6 / 8 由 [`Palettes::official_baseline`] 原样保留。
|
||||||
/// 第三色调色板的色相偏移。
|
const NEUTRAL_VARIANT_OFFSET: f64 = 2.0;
|
||||||
const TERTIARY_HUE_SHIFT_DEG: f32 = 60.0;
|
/// error 的色相(HCT 红相)。
|
||||||
|
const ERROR_HUE_DEG: f64 = 25.0;
|
||||||
|
/// error 的彩度。
|
||||||
|
const ERROR_CHROMA: f64 = 84.0;
|
||||||
|
/// tertiary 相对种子的色相偏移。
|
||||||
|
const TERTIARY_HUE_SHIFT_DEG: f64 = 60.0;
|
||||||
|
|
||||||
/// 一套完整的六条调色板。
|
/// 一套完整的六条调色板。
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
@@ -57,50 +112,71 @@ pub struct Palettes {
|
|||||||
pub neutral_variant: TonalPalette,
|
pub neutral_variant: TonalPalette,
|
||||||
/// 错误调色板。
|
/// 错误调色板。
|
||||||
pub error: TonalPalette,
|
pub error: TonalPalette,
|
||||||
/// 彩度压缩的**共享基准**(取建方案时的 primary 彩度,强调色覆盖时**不变**)。
|
|
||||||
///
|
|
||||||
/// 为什么单独存一份:`from_accent` 换掉 primary 后,中性表面不该跟着变色调
|
|
||||||
/// (否则「换强调色不动底色」这条语义就破了),而彩度压缩又必须用同一个基准才能
|
|
||||||
/// 保证 primary/secondary 之间的相对灰度——两者靠这个字段同时成立。
|
|
||||||
reference_chroma: f32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Palettes {
|
impl Palettes {
|
||||||
/// 出厂基线(种子 `#6750A4`,等价 M3 baseline)。
|
/// **产品出厂默认**:种子 [`DEFAULT_SEED`] + 中性彩度 [`DEFAULT_NEUTRAL_CHROMA`]。
|
||||||
|
///
|
||||||
|
/// 消费方(`DesignTokens::baseline`、iced 适配层)拿到的就是这套,所以产品默认色
|
||||||
|
/// 只需要改那两个常量。
|
||||||
pub fn baseline() -> Self {
|
pub fn baseline() -> Self {
|
||||||
|
Self::from_seed_with_neutral_chroma(DEFAULT_SEED, DEFAULT_NEUTRAL_CHROMA)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **官方基线的锚点**:种子 [`BASELINE_SEED`] + 官方中性彩度 6(等价官方
|
||||||
|
/// `SchemeTonalSpot(0xFF6750A4)`)。只给对齐测试与文档表格用。
|
||||||
|
pub fn official_baseline() -> Self {
|
||||||
Self::from_seed(BASELINE_SEED)
|
Self::from_seed(BASELINE_SEED)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从一个种子色派生全套调色板。
|
/// 从一个种子色派生全套调色板(官方 Tonal Spot 变体,中性彩度取官方 6)。
|
||||||
pub fn from_seed(seed: Color) -> Self {
|
pub fn from_seed(seed: Color) -> Self {
|
||||||
let primary = TonalPalette::from_color(seed);
|
Self::from_seed_with_neutral_chroma(seed, TONAL_SPOT_NEUTRAL_CHROMA)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从一个种子色派生,并显式指定中性调色板彩度(`6.0` 即官方 Tonal Spot)。
|
||||||
|
///
|
||||||
|
/// ⚠️ **调用方负责传 `6.0..=13.0`(产品取 12)**。本函数**不做范围校验**,越界的表现是
|
||||||
|
/// 静默退化而不是 panic:
|
||||||
|
/// - `neutral_chroma < 0` 或 `NaN` → 彩度被夹到 0(`TonalPalette::new` 里 `.max(0.0)`),
|
||||||
|
/// 得到一套纯灰的表面(surface ≈ `#F9F9F9`),**不报错**;
|
||||||
|
/// - `neutral_chroma >= 14` → neutral-variant(= neutral + 2)够到甚至超过 secondary 的 16,
|
||||||
|
/// 「中性 < 中性变体 < 次色」的层次被破坏(描边会明显发蓝)。
|
||||||
|
///
|
||||||
|
/// 除 neutral / neutral-variant 外**全部走官方常数**:primary 恒定 36、secondary 16、
|
||||||
|
/// tertiary 24(色相 +60°)、error 固定 25°/84。所以这条入口不绕算法,只是把「官方写死在
|
||||||
|
/// `getNeutralPalette` / `getNeutralVariantPalette` 里的那两个数」变成可调入参
|
||||||
|
/// (neutral-variant 这里取相对式 `neutral + 2`,属**有意偏离**,见 `NEUTRAL_VARIANT_OFFSET`)。
|
||||||
|
pub fn from_seed_with_neutral_chroma(seed: Color, neutral_chroma: f64) -> Self {
|
||||||
|
let hue = super::Hct::from_color(seed).hue;
|
||||||
|
Self::tonal_spot(hue, neutral_chroma)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 只换强调色:primary/secondary/tertiary 跟着新强调色的色相走,
|
||||||
|
/// **表面中性色与错误色保持不变**。
|
||||||
|
///
|
||||||
|
/// 这是本项目的产品语义(换强调色不该让整个窗口底色变脏,见 `DesignTokens::with_accent`):
|
||||||
|
/// 强调族的色值与官方 `from_seed(accent)` 逐个对得上,中性表面则仍走基线——
|
||||||
|
/// 官方 `from_seed` 会把中性色也染上种子的色相,那是「整窗染色」,不是我们要的。
|
||||||
|
pub fn with_accent(self, accent: Color) -> Self {
|
||||||
|
let hue = super::Hct::from_color(accent).hue;
|
||||||
Self {
|
Self {
|
||||||
primary,
|
primary: TonalPalette::new(hue, PRIMARY_CHROMA),
|
||||||
secondary: primary.limited_chroma(SECONDARY_CHROMA),
|
secondary: TonalPalette::new(hue, SECONDARY_CHROMA),
|
||||||
tertiary: primary
|
tertiary: TonalPalette::new(hue + TERTIARY_HUE_SHIFT_DEG, TERTIARY_CHROMA),
|
||||||
.rotated(TERTIARY_HUE_SHIFT_DEG)
|
..self
|
||||||
.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 跟着新强调色走,**表面中性色保持不变**。
|
/// 官方 Tonal Spot 的六条调色板(色相来自种子,彩度取官方常数,neutral 可调)。
|
||||||
///
|
fn tonal_spot(hue: f64, neutral_chroma: f64) -> Self {
|
||||||
/// 这是「用户自定义强调色」的正确语义——换强调色不该让整个界面底色变脏;
|
|
||||||
/// 也因为 tone 轴固定,任何色相都能自动满足 WCAG AA(不需要再算一遍对比度)。
|
|
||||||
/// `reference_chroma` 也保持原值,所以中性色与 baseline 逐像素一致(有测试钉住)。
|
|
||||||
pub fn with_accent(self, accent: Color) -> Self {
|
|
||||||
let accent_primary = TonalPalette::from_color(accent);
|
|
||||||
Self {
|
Self {
|
||||||
primary: accent_primary,
|
primary: TonalPalette::new(hue, PRIMARY_CHROMA),
|
||||||
secondary: accent_primary.limited_chroma(SECONDARY_CHROMA),
|
secondary: TonalPalette::new(hue, SECONDARY_CHROMA),
|
||||||
tertiary: accent_primary
|
tertiary: TonalPalette::new(hue + TERTIARY_HUE_SHIFT_DEG, TERTIARY_CHROMA),
|
||||||
.rotated(TERTIARY_HUE_SHIFT_DEG)
|
neutral: TonalPalette::new(hue, neutral_chroma),
|
||||||
.limited_chroma(TERTIARY_CHROMA),
|
neutral_variant: TonalPalette::new(hue, neutral_chroma + NEUTRAL_VARIANT_OFFSET),
|
||||||
..self
|
error: TonalPalette::new(ERROR_HUE_DEG, ERROR_CHROMA),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,16 +192,9 @@ impl Palettes {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 方案级取色:以 [`Palettes::reference_chroma`] 为**共享基准**压所有调色板(见
|
/// 方案级取色:槽位 + tone → 色值。
|
||||||
/// [`TonalPalette::tone_scaled`]),保证同 tone 下 primary/secondary/tertiary 不会撞色,
|
pub fn tone(&self, slot: PaletteSlot, tone: f64) -> Color {
|
||||||
/// 且换强调色时中性表面逐像素不变。
|
self.slot(slot).tone(tone)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,28 +203,98 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn baseline_seed_is_the_primary_palette_hue() {
|
fn official_baseline_uses_the_official_tonal_spot_chromas() {
|
||||||
let palettes = Palettes::baseline();
|
let palettes = Palettes::official_baseline();
|
||||||
let seed_lch = super::super::space::Oklch::from_color(BASELINE_SEED);
|
assert_eq!(palettes.primary.chroma(), 36.0);
|
||||||
let delta = (palettes.primary.hue_deg() - seed_lch.hue_deg).abs();
|
assert_eq!(palettes.secondary.chroma(), 16.0);
|
||||||
assert!(delta < 0.01, "primary 调色板应保持种子色相,偏差 {delta}°");
|
assert_eq!(palettes.tertiary.chroma(), 24.0);
|
||||||
|
assert_eq!(palettes.neutral.chroma(), 6.0);
|
||||||
|
assert_eq!(palettes.neutral_variant.chroma(), 8.0);
|
||||||
|
assert_eq!(palettes.error.hue_deg(), 25.0);
|
||||||
|
assert_eq!(palettes.error.chroma(), 84.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn accent_override_keeps_surfaces() {
|
fn product_default_only_moves_the_named_knobs() {
|
||||||
|
// 产品默认与官方锚点的差别必须**只**在种子色相与中性彩度两处:
|
||||||
|
// primary/secondary/tertiary/error 的取法一模一样(算法没动)。
|
||||||
|
let official = Palettes::official_baseline();
|
||||||
|
let product = Palettes::baseline();
|
||||||
|
assert_eq!(product.primary.chroma(), official.primary.chroma());
|
||||||
|
assert_eq!(product.secondary.chroma(), official.secondary.chroma());
|
||||||
|
assert_eq!(product.tertiary.chroma(), official.tertiary.chroma());
|
||||||
|
assert_eq!(product.error, official.error);
|
||||||
|
assert_eq!(product.neutral.chroma(), DEFAULT_NEUTRAL_CHROMA);
|
||||||
|
assert_eq!(
|
||||||
|
product.neutral_variant.chroma(),
|
||||||
|
DEFAULT_NEUTRAL_CHROMA + NEUTRAL_VARIANT_OFFSET
|
||||||
|
);
|
||||||
|
let seed = super::super::Hct::from_color(DEFAULT_SEED);
|
||||||
|
assert!((product.primary.hue_deg() - seed.hue).abs() < 1.0e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn neutral_chroma_knob_keeps_the_official_ordering() {
|
||||||
|
// 官方关系 neutral(6) < neutral-variant(8) < secondary(16) 在安全区间里必须一直成立,
|
||||||
|
// 否则「表面比次色还艳」或两族撞色。
|
||||||
|
for neutral in [6.0, 8.0, 10.0, 12.0] {
|
||||||
|
let p = Palettes::from_seed_with_neutral_chroma(DEFAULT_SEED, neutral);
|
||||||
|
assert!(p.neutral.chroma() < p.neutral_variant.chroma());
|
||||||
|
assert!(p.neutral_variant.chroma() < p.secondary.chroma());
|
||||||
|
// 中性彩度不影响 tone 轴:tone 98 的 surface 仍是最亮的那个。
|
||||||
|
let surface = p.tone(PaletteSlot::Neutral, 98.0);
|
||||||
|
assert!(surface.relative_luminance() > 0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn product_default_neutral_chroma_is_a_registered_deviation() {
|
||||||
|
// 官方 neutral / neutral-variant 是 6 / 8;产品默认是 12 / 14。
|
||||||
|
// 这**不是官方口径**,是「有意偏离」(见 `color` 模块文档的偏离登记)。
|
||||||
|
// 想把 nv 拉回官方绝对值 8 的人,请先改登记再改这里:直接钉成 8 会让表面(12)
|
||||||
|
// 比中性变体(8)更「有色」,破坏官方的 neutral < neutral-variant 层次。
|
||||||
|
let product = Palettes::baseline();
|
||||||
|
assert_eq!(product.neutral.chroma(), 12.0, "产品默认 neutral 彩度");
|
||||||
|
assert_eq!(
|
||||||
|
product.neutral_variant.chroma(),
|
||||||
|
14.0,
|
||||||
|
"产品默认的 neutral-variant 彩度 14 是**有意偏离**(官方绝对值 8),改前先更新偏离登记"
|
||||||
|
);
|
||||||
|
let official = Palettes::official_baseline();
|
||||||
|
assert_eq!(official.neutral.chroma(), 6.0, "官方锚点仍是 6");
|
||||||
|
assert_eq!(
|
||||||
|
official.neutral_variant.chroma(),
|
||||||
|
8.0,
|
||||||
|
"官方锚点仍是绝对值 8(不是 neutral + 2 的相对式)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn baseline_hue_comes_from_the_seed() {
|
||||||
|
let palettes = Palettes::official_baseline();
|
||||||
|
let seed = super::super::Hct::from_color(BASELINE_SEED);
|
||||||
|
assert!((palettes.primary.hue_deg() - seed.hue).abs() < 1.0e-9);
|
||||||
|
assert!((palettes.neutral.hue_deg() - seed.hue).abs() < 1.0e-9);
|
||||||
|
assert!((palettes.tertiary.hue_deg() - (seed.hue + 60.0)).abs() < 1.0e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seed_chroma_never_leaks_into_the_primary_ramp() {
|
||||||
|
// 官方口径:Tonal Spot 的彩度是常数,纯灰种子也会得到彩度 36 的主色板。
|
||||||
|
let gray = Palettes::from_seed(Color::rgb(0x6E, 0x6E, 0x6E));
|
||||||
|
assert_eq!(gray.primary.chroma(), 36.0);
|
||||||
|
let hot = Palettes::from_seed(Color::rgb(0xFF, 0x00, 0x00));
|
||||||
|
assert_eq!(hot.primary.chroma(), 36.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accent_override_keeps_surfaces_and_error() {
|
||||||
let base = Palettes::baseline();
|
let base = Palettes::baseline();
|
||||||
let accent = Color::rgb(0x00, 0x6A, 0x5C);
|
let swapped = base.with_accent(Color::rgb(0x00, 0x6A, 0x5C));
|
||||||
let swapped = base.with_accent(accent);
|
|
||||||
assert_eq!(swapped.neutral, base.neutral);
|
assert_eq!(swapped.neutral, base.neutral);
|
||||||
assert_eq!(swapped.neutral_variant, base.neutral_variant);
|
assert_eq!(swapped.neutral_variant, base.neutral_variant);
|
||||||
assert_eq!(swapped.error, base.error);
|
assert_eq!(swapped.error, base.error);
|
||||||
assert_ne!(swapped.primary.hue_deg(), base.primary.hue_deg());
|
assert_ne!(swapped.primary.hue_deg(), base.primary.hue_deg());
|
||||||
}
|
assert_eq!(swapped.primary.chroma(), 36.0);
|
||||||
|
|
||||||
#[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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,17 @@
|
|||||||
//! 一套完整的 M3 语义色方案(`Scheme`):角色 → 调色板槽位 → tone 的三段映射。
|
//! 一套完整的 M3 语义色方案(`Scheme`):角色 → 调色板槽位 → tone 的三段映射。
|
||||||
//!
|
//!
|
||||||
//! tone 表取自 M3 基础方案的官方映射(light/dark 各一列)。固定角色
|
//! tone 表取自 **ColorSpec2021** 的官方映射(`MaterialDynamicColors` 的 light/dark 两列,
|
||||||
//! (`*-fixed*`)深浅同值,因为它们的用途就是「跨模式稳定」。
|
//! 见 `NOTICE`)。固定角色(`*-fixed*`)深浅同值,因为它们的用途就是「跨模式稳定」。
|
||||||
|
//!
|
||||||
|
//! ## 只实现了 contrast level = 0(standard)
|
||||||
|
//!
|
||||||
|
//! 官方的 `ColorSpec2021.getTone()` 还有一层机制:`toneDeltaPair`(成对角色保持 tone 差)
|
||||||
|
//! 与 `ContrastCurve`(按对比度等级修正 tone)。在 **contrastLevel = 0** 下这层机制的输入
|
||||||
|
//! 全部是常数:`ContrastCurve.get(0)` 恒等于 normal 档的 tone,而「背景 tone」也都是本表里的
|
||||||
|
//! 常数,所以它对 49 个角色的调整结果**就是这张表本身**。测试
|
||||||
|
//! `tests/official_baseline.rs` 用官方 Java 实现的逐角色输出反证了这一点(每通道误差 ≤1):
|
||||||
|
//! 表错一格,对齐测试就会红。三档对比度(medium/high)不在本 crate 范围内,见
|
||||||
|
//! [`super`] 的 gap 登记。
|
||||||
|
|
||||||
use super::palettes::{PaletteSlot, Palettes};
|
use super::palettes::{PaletteSlot, Palettes};
|
||||||
use super::roles::Role;
|
use super::roles::Role;
|
||||||
@@ -48,33 +58,33 @@ pub const SCRIM_ALPHA: f32 = 0.32;
|
|||||||
struct ToneRow {
|
struct ToneRow {
|
||||||
role: Role,
|
role: Role,
|
||||||
slot: PaletteSlot,
|
slot: PaletteSlot,
|
||||||
light: f32,
|
light: f64,
|
||||||
dark: f32,
|
dark: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 49 个角色的 tone 映射表,**下标必须等于 `Role as usize`**(由测试钉死)。
|
/// 49 个角色的 tone 映射表(ColorSpec2021),**下标必须等于 `Role as usize`**(由测试钉死)。
|
||||||
const TONE_TABLE: [ToneRow; 49] = [
|
const TONE_TABLE: [ToneRow; 49] = [
|
||||||
row(Role::Primary, PaletteSlot::Primary, 40.0, 80.0),
|
row(Role::Primary, PaletteSlot::Primary, 40.0, 80.0),
|
||||||
row(Role::OnPrimary, PaletteSlot::Primary, 100.0, 20.0),
|
row(Role::OnPrimary, PaletteSlot::Primary, 100.0, 20.0),
|
||||||
row(Role::PrimaryContainer, PaletteSlot::Primary, 90.0, 30.0),
|
row(Role::PrimaryContainer, PaletteSlot::Primary, 90.0, 30.0),
|
||||||
row(Role::OnPrimaryContainer, PaletteSlot::Primary, 10.0, 90.0),
|
row(Role::OnPrimaryContainer, PaletteSlot::Primary, 30.0, 90.0),
|
||||||
row(Role::Secondary, PaletteSlot::Secondary, 40.0, 80.0),
|
row(Role::Secondary, PaletteSlot::Secondary, 40.0, 80.0),
|
||||||
row(Role::OnSecondary, PaletteSlot::Secondary, 100.0, 20.0),
|
row(Role::OnSecondary, PaletteSlot::Secondary, 100.0, 20.0),
|
||||||
row(Role::SecondaryContainer, PaletteSlot::Secondary, 90.0, 30.0),
|
row(Role::SecondaryContainer, PaletteSlot::Secondary, 90.0, 30.0),
|
||||||
row(
|
row(
|
||||||
Role::OnSecondaryContainer,
|
Role::OnSecondaryContainer,
|
||||||
PaletteSlot::Secondary,
|
PaletteSlot::Secondary,
|
||||||
10.0,
|
30.0,
|
||||||
90.0,
|
90.0,
|
||||||
),
|
),
|
||||||
row(Role::Tertiary, PaletteSlot::Tertiary, 40.0, 80.0),
|
row(Role::Tertiary, PaletteSlot::Tertiary, 40.0, 80.0),
|
||||||
row(Role::OnTertiary, PaletteSlot::Tertiary, 100.0, 20.0),
|
row(Role::OnTertiary, PaletteSlot::Tertiary, 100.0, 20.0),
|
||||||
row(Role::TertiaryContainer, PaletteSlot::Tertiary, 90.0, 30.0),
|
row(Role::TertiaryContainer, PaletteSlot::Tertiary, 90.0, 30.0),
|
||||||
row(Role::OnTertiaryContainer, PaletteSlot::Tertiary, 10.0, 90.0),
|
row(Role::OnTertiaryContainer, PaletteSlot::Tertiary, 30.0, 90.0),
|
||||||
row(Role::Error, PaletteSlot::Error, 40.0, 80.0),
|
row(Role::Error, PaletteSlot::Error, 40.0, 80.0),
|
||||||
row(Role::OnError, PaletteSlot::Error, 100.0, 20.0),
|
row(Role::OnError, PaletteSlot::Error, 100.0, 20.0),
|
||||||
row(Role::ErrorContainer, PaletteSlot::Error, 90.0, 30.0),
|
row(Role::ErrorContainer, PaletteSlot::Error, 90.0, 30.0),
|
||||||
row(Role::OnErrorContainer, PaletteSlot::Error, 10.0, 90.0),
|
row(Role::OnErrorContainer, PaletteSlot::Error, 30.0, 90.0),
|
||||||
row(Role::Surface, PaletteSlot::Neutral, 98.0, 6.0),
|
row(Role::Surface, PaletteSlot::Neutral, 98.0, 6.0),
|
||||||
row(Role::OnSurface, PaletteSlot::Neutral, 10.0, 90.0),
|
row(Role::OnSurface, PaletteSlot::Neutral, 10.0, 90.0),
|
||||||
row(
|
row(
|
||||||
@@ -89,7 +99,7 @@ const TONE_TABLE: [ToneRow; 49] = [
|
|||||||
30.0,
|
30.0,
|
||||||
80.0,
|
80.0,
|
||||||
),
|
),
|
||||||
row(Role::Background, PaletteSlot::Neutral, 99.0, 10.0),
|
row(Role::Background, PaletteSlot::Neutral, 98.0, 6.0),
|
||||||
row(Role::OnBackground, PaletteSlot::Neutral, 10.0, 90.0),
|
row(Role::OnBackground, PaletteSlot::Neutral, 10.0, 90.0),
|
||||||
row(Role::SurfaceDim, PaletteSlot::Neutral, 87.0, 6.0),
|
row(Role::SurfaceDim, PaletteSlot::Neutral, 87.0, 6.0),
|
||||||
row(Role::SurfaceBright, PaletteSlot::Neutral, 98.0, 24.0),
|
row(Role::SurfaceBright, PaletteSlot::Neutral, 98.0, 24.0),
|
||||||
@@ -151,7 +161,7 @@ const TONE_TABLE: [ToneRow; 49] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// `const` 友好的行构造器(数组初始化里不能调用非 const fn 之外的东西)。
|
/// `const` 友好的行构造器(数组初始化里不能调用非 const fn 之外的东西)。
|
||||||
const fn row(role: Role, slot: PaletteSlot, light: f32, dark: f32) -> ToneRow {
|
const fn row(role: Role, slot: PaletteSlot, light: f64, dark: f64) -> ToneRow {
|
||||||
ToneRow {
|
ToneRow {
|
||||||
role,
|
role,
|
||||||
slot,
|
slot,
|
||||||
@@ -168,12 +178,22 @@ pub struct Scheme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Scheme {
|
impl Scheme {
|
||||||
/// 出厂基线方案(种子 `#6750A4`)。
|
/// **产品出厂默认方案**(种子 [`super::palettes::DEFAULT_SEED`] + 中性彩度
|
||||||
|
/// [`super::palettes::DEFAULT_NEUTRAL_CHROMA`],见 [`Palettes::baseline`])。
|
||||||
pub fn baseline(flavor: SchemeFlavor) -> Self {
|
pub fn baseline(flavor: SchemeFlavor) -> Self {
|
||||||
Self::from_palettes(&Palettes::baseline(), flavor)
|
Self::from_palettes(&Palettes::baseline(), flavor)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 指定种子色的方案(表面色与强调色同源)。
|
/// **官方基线锚点**(种子 `#6750A4` + 官方中性彩度 6,等价官方
|
||||||
|
/// `SchemeTonalSpot(0xFF6750A4)`)。
|
||||||
|
///
|
||||||
|
/// 与 [`Scheme::baseline`] 的区别只有入参:算法、tone 表、gamut 映射完全同一条路径,
|
||||||
|
/// 所以 `tests/official_baseline.rs` 拿它跟官方 Java 输出逐角色对账。
|
||||||
|
pub fn official_baseline(flavor: SchemeFlavor) -> Self {
|
||||||
|
Self::from_palettes(&Palettes::official_baseline(), flavor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 指定种子色的方案(表面色与强调色同源,等价官方 `SchemeTonalSpot`)。
|
||||||
pub fn from_seed(flavor: SchemeFlavor, seed: Color) -> Self {
|
pub fn from_seed(flavor: SchemeFlavor, seed: Color) -> Self {
|
||||||
Self::from_palettes(&Palettes::from_seed(seed), flavor)
|
Self::from_palettes(&Palettes::from_seed(seed), flavor)
|
||||||
}
|
}
|
||||||
@@ -187,17 +207,13 @@ impl Scheme {
|
|||||||
pub fn from_palettes(palettes: &Palettes, flavor: SchemeFlavor) -> Self {
|
pub fn from_palettes(palettes: &Palettes, flavor: SchemeFlavor) -> Self {
|
||||||
let colors = std::array::from_fn(|i| {
|
let colors = std::array::from_fn(|i| {
|
||||||
let entry = &TONE_TABLE[i];
|
let entry = &TONE_TABLE[i];
|
||||||
let tone = match flavor {
|
palettes.tone(entry.slot, Scheme::tone_of(entry.role, flavor))
|
||||||
SchemeFlavor::Light => entry.light,
|
|
||||||
SchemeFlavor::Dark => entry.dark,
|
|
||||||
};
|
|
||||||
palettes.tone(entry.slot, tone)
|
|
||||||
});
|
});
|
||||||
Self { flavor, colors }
|
Self { flavor, colors }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 该角色在指定模式下的 tone 值(M3 语义;文档表格、调试与测试用)。
|
/// 该角色在指定模式下的 tone 值(M3 语义;文档表格、调试与测试用)。
|
||||||
pub fn tone_of(role: Role, flavor: SchemeFlavor) -> f32 {
|
pub fn tone_of(role: Role, flavor: SchemeFlavor) -> f64 {
|
||||||
let entry = &TONE_TABLE[role as usize];
|
let entry = &TONE_TABLE[role as usize];
|
||||||
debug_assert_eq!(entry.role, role, "TONE_TABLE 下标必须等于角色序号");
|
debug_assert_eq!(entry.role, role, "TONE_TABLE 下标必须等于角色序号");
|
||||||
match flavor {
|
match flavor {
|
||||||
@@ -309,5 +325,6 @@ mod tests {
|
|||||||
teal.get(Role::Outline),
|
teal.get(Role::Outline),
|
||||||
"强调色覆盖不该动中性描边"
|
"强调色覆盖不该动中性描边"
|
||||||
);
|
);
|
||||||
|
assert_eq!(base.get(Role::Error), teal.get(Role::Error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+153
-148
@@ -1,198 +1,203 @@
|
|||||||
//! 色彩空间转换:sRGB ↔ 线性 RGB ↔ CIELAB(tone 轴)+ OKLab/OKLCh(色相/彩度轴)。
|
//! 色彩空间底层换算:sRGB ↔ 线性 RGB ↔ CIE XYZ ↔ `L*`(tone 轴)。
|
||||||
//!
|
//!
|
||||||
//! 这里只放**纯数学**,不认识任何语义色角色。口径说明见 [`super`] 的模块文档。
|
//! 只放**纯数学**,不认识任何语义色角色。数值口径逐函数照搬 Google
|
||||||
|
//! `material-color-utilities` 的 `java/utils/ColorUtils.java`(Apache-2.0,见 `NOTICE`):
|
||||||
|
//! 矩阵、阈值、取整方式都一致——差一位小数,HCT 求解器就会与官方差一个通道。
|
||||||
|
//!
|
||||||
|
//! 内部一律用 `f64`(官方实现是 Java `double`)。公开 API 仍以 `u8` 通道与 `f32` 标量对外,
|
||||||
|
//! 因为令牌消费方(绘制层)不需要比 8 bit 更高的精度。
|
||||||
|
|
||||||
use super::Color;
|
use super::Color;
|
||||||
|
|
||||||
/// gamut 判定容差:线性 RGB 允许露出这么点负值/溢出(浮点误差),别因此误判「越界」。
|
/// sRGB → XYZ(D65)。官方用的是 IEC 6-1 的舍入值,不是更精确的 sRGB 矩阵。
|
||||||
const GAMUT_EPS: f32 = 1.0e-4;
|
const SRGB_TO_XYZ: [[f64; 3]; 3] = [
|
||||||
|
[0.412_338_95, 0.357_620_64, 0.180_510_42],
|
||||||
|
[0.2126, 0.7152, 0.0722],
|
||||||
|
[0.019_321_41, 0.119_163_82, 0.950_344_78],
|
||||||
|
];
|
||||||
|
|
||||||
/// sRGB 里能取到的 OKLCh 彩度上限(超过它没有意义,二分搜索的初始上界)。
|
/// D65 白点(XYZ,Y 归一化到 100)。
|
||||||
pub(crate) const GAMUT_CHROMA_CAP: f32 = 0.5;
|
pub(crate) const WHITE_POINT_D65: [f64; 3] = [95.047, 100.0, 108.883];
|
||||||
|
|
||||||
/// sRGB 编码值(0..1)→ 线性光强。
|
/// 行向量 × 3×3 矩阵。
|
||||||
pub(crate) fn srgb_to_linear(c: f32) -> f32 {
|
pub(crate) fn matrix_multiply(row: [f64; 3], matrix: [[f64; 3]; 3]) -> [f64; 3] {
|
||||||
if c <= 0.040_45 {
|
let mul = |m: [f64; 3]| row[0] * m[0] + row[1] * m[1] + row[2] * m[2];
|
||||||
c / 12.92
|
[mul(matrix[0]), mul(matrix[1]), mul(matrix[2])]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// sRGB 编码值(8 bit 通道)→ 线性光强(0..=100,官方标度)。
|
||||||
|
pub(crate) fn linearized(component: u8) -> f64 {
|
||||||
|
let normalized = f64::from(component) / 255.0;
|
||||||
|
if normalized <= 0.040_449_936 {
|
||||||
|
normalized / 12.92 * 100.0
|
||||||
} else {
|
} else {
|
||||||
((c + 0.055) / 1.055).powf(2.4)
|
((normalized + 0.055) / 1.055).powf(2.4) * 100.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 线性光强 → sRGB 编码值(0..1),结果不夹取,越界由调用方处理。
|
/// 线性光强(0..=100)→ sRGB 编码值,四舍五入并夹取到 0..=255。
|
||||||
pub(crate) fn linear_to_srgb(c: f32) -> f32 {
|
pub(crate) fn delinearized(component: f64) -> u8 {
|
||||||
if c <= 0.003_130_8 {
|
let value = true_delinearized(component).round().clamp(0.0, 255.0);
|
||||||
c * 12.92
|
value as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同 [`delinearized`],但不取整也不夹取(求解器二分时需要连续值)。
|
||||||
|
pub(crate) fn true_delinearized(component: f64) -> f64 {
|
||||||
|
let normalized = component / 100.0;
|
||||||
|
let encoded = if normalized <= 0.003_130_8 {
|
||||||
|
normalized * 12.92
|
||||||
} else {
|
} else {
|
||||||
1.055 * c.powf(1.0 / 2.4) - 0.055
|
1.055 * normalized.powf(1.0 / 2.4) - 0.055
|
||||||
}
|
};
|
||||||
|
encoded * 255.0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 线性 RGB → CIE XYZ(D65 白点,sRGB 原色)。
|
/// 线性 RGB(0..=100)→ 不透明颜色。
|
||||||
pub(crate) fn linear_to_xyz(rgb: [f32; 3]) -> [f32; 3] {
|
pub(crate) fn color_from_linrgb(linrgb: [f64; 3]) -> Color {
|
||||||
let [r, g, b] = rgb;
|
Color::rgb(
|
||||||
[
|
delinearized(linrgb[0]),
|
||||||
0.412_456_4 * r + 0.357_576_1 * g + 0.180_437_5 * b,
|
delinearized(linrgb[1]),
|
||||||
0.212_672_9 * r + 0.715_152_2 * g + 0.072_175_0 * b,
|
delinearized(linrgb[2]),
|
||||||
0.019_333_9 * r + 0.119_192 * g + 0.950_304_1 * b,
|
)
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 相对亮度 Y(WCAG 与 CIELAB 共用这条 Y,白点 1.0)。
|
/// 色值 → XYZ(Y 在 0..=100 标度)。
|
||||||
pub(crate) fn luminance(rgb: [f32; 3]) -> f32 {
|
pub(crate) fn xyz_from_color(color: Color) -> [f64; 3] {
|
||||||
linear_to_xyz(rgb)[1]
|
let linear = [
|
||||||
|
linearized(color.r),
|
||||||
|
linearized(color.g),
|
||||||
|
linearized(color.b),
|
||||||
|
];
|
||||||
|
matrix_multiply(linear, SRGB_TO_XYZ)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CIELAB `L*`(M3 的 **tone** 就是这个轴,0 = 黑 / 100 = 白)。
|
/// `L*` 的比较函数(CIE 分段线性/立方根)。
|
||||||
pub(crate) fn lstar_from_luminance(y: f32) -> f32 {
|
fn lab_f(t: f64) -> f64 {
|
||||||
let t = y.max(0.0);
|
const EPSILON: f64 = 216.0 / 24389.0;
|
||||||
if t > 0.008_856 {
|
const KAPPA: f64 = 24389.0 / 27.0;
|
||||||
116.0 * t.cbrt() - 16.0
|
if t > EPSILON {
|
||||||
|
t.powf(1.0 / 3.0)
|
||||||
} else {
|
} else {
|
||||||
903.296_3 * t
|
(KAPPA * t + 16.0) / 116.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `L*` → 相对亮度 Y([`lstar_from_luminance`] 的逆)。
|
/// [`lab_f`] 的逆。
|
||||||
pub(crate) fn luminance_from_lstar(lstar: f32) -> f32 {
|
fn lab_invf(ft: f64) -> f64 {
|
||||||
let l = lstar.clamp(0.0, 100.0);
|
const EPSILON: f64 = 216.0 / 24389.0;
|
||||||
if l > 8.0 {
|
const KAPPA: f64 = 24389.0 / 27.0;
|
||||||
((l + 16.0) / 116.0).powi(3)
|
let cubed = ft * ft * ft;
|
||||||
|
if cubed > EPSILON {
|
||||||
|
cubed
|
||||||
} else {
|
} else {
|
||||||
l / 903.296_3
|
(116.0 * ft - 16.0) / KAPPA
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 线性 RGB → OKLab。
|
/// `L*` → 相对亮度 Y(0..=100)。
|
||||||
pub(crate) fn linear_to_oklab(rgb: [f32; 3]) -> [f32; 3] {
|
pub(crate) fn y_from_lstar(lstar: f64) -> f64 {
|
||||||
let [r, g, b] = rgb;
|
100.0 * lab_invf((lstar + 16.0) / 116.0)
|
||||||
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)。
|
/// 相对亮度 Y(0..=100)→ `L*`。
|
||||||
pub(crate) fn oklab_to_linear(lab: [f32; 3]) -> [f32; 3] {
|
pub(crate) fn lstar_from_y(y: f64) -> f64 {
|
||||||
let [l, a, b] = lab;
|
lab_f(y / 100.0) * 116.0 - 16.0
|
||||||
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。
|
/// 色值 → `L*`(即 M3 的 **tone**)。
|
||||||
pub(crate) fn oklch_to_linear(l: f32, chroma: f32, hue_deg: f32) -> [f32; 3] {
|
pub(crate) fn lstar_from_color(color: Color) -> f64 {
|
||||||
let h = hue_deg.to_radians();
|
lstar_from_y(xyz_from_color(color)[1])
|
||||||
oklab_to_linear([l, chroma * h.cos(), chroma * h.sin()])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 线性 RGB 是否落在 sRGB 立方体内([`GAMUT_EPS`] 容差)。
|
/// `L*` → 中性灰色值(`L*` 与彩度无关,纯灰)。
|
||||||
pub(crate) fn in_gamut(rgb: [f32; 3]) -> bool {
|
pub(crate) fn color_from_lstar(lstar: f64) -> Color {
|
||||||
rgb.iter()
|
let component = delinearized(y_from_lstar(lstar));
|
||||||
.all(|c| *c >= -GAMUT_EPS && *c <= 1.0 + GAMUT_EPS)
|
Color::rgb(component, component, component)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 求「在保证 `L* = lstar` 的前提下,OKLab 明度该取多少」。
|
/// Java `Math.signum` 的口径:负数 −1、零 0、正数 1(**Rust 的 `f64::signum(0.0)` 是 1**,
|
||||||
///
|
/// 直接换会改变中性色的求解结果)。
|
||||||
/// OKLab 的 L 不是 Y 的纯函数(彩色会偏离),所以给定 tone 与色相/彩度时用二分反解,
|
pub(crate) fn signum(value: f64) -> f64 {
|
||||||
/// 让最终颜色的 **CIELAB L\* 精确等于 tone**——M3 的 tone 定义在 L\* 上,这一点必须对齐。
|
if value < 0.0 {
|
||||||
pub(crate) fn oklab_lightness_for_tone(chroma: f32, hue_deg: f32, lstar: f32) -> f32 {
|
-1.0
|
||||||
let target = luminance_from_lstar(lstar);
|
} else if value == 0.0 {
|
||||||
let mut lo = 0.0_f32;
|
0.0
|
||||||
let mut hi = 1.0_f32;
|
} else {
|
||||||
for _ in 0..32 {
|
1.0
|
||||||
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`)。
|
/// 角度归一到 `0.0..360.0`。
|
||||||
pub(crate) fn chroma_in_gamut(lstar: f32, hue_deg: f32, chroma_max: f32) -> f32 {
|
pub(crate) fn sanitize_degrees(degrees: f64) -> f64 {
|
||||||
let hi0 = chroma_max.max(0.0);
|
let wrapped = degrees % 360.0;
|
||||||
if in_gamut(oklch_to_linear(
|
if wrapped < 0.0 {
|
||||||
oklab_lightness_for_tone(hi0, hue_deg, lstar),
|
wrapped + 360.0
|
||||||
hi0,
|
} else {
|
||||||
hue_deg,
|
wrapped
|
||||||
)) {
|
|
||||||
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 的「按比例压彩度」用它做基准)。
|
/// 弧度归一到 `0.0..2π`(官方 `sanitizeRadians`,多加了 8π 保证非负)。
|
||||||
pub(crate) fn max_chroma(lstar: f32, hue_deg: f32) -> f32 {
|
pub(crate) fn sanitize_radians(angle: f64) -> f64 {
|
||||||
chroma_in_gamut(lstar, hue_deg, GAMUT_CHROMA_CAP)
|
(angle + std::f64::consts::PI * 8.0) % (std::f64::consts::PI * 2.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OKLCh 坐标(色相用度,0..360)。
|
/// 线性插值(官方 `lerp`)。
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
pub(crate) fn lerp(start: f64, stop: f64, amount: f64) -> f64 {
|
||||||
pub struct Oklch {
|
(1.0 - amount) * start + amount * stop
|
||||||
/// 明度 0..1(中性灰时 ≈ 相对亮度的立方根)。
|
|
||||||
pub lightness: f32,
|
|
||||||
/// 彩度(0 = 中性灰)。
|
|
||||||
pub chroma: f32,
|
|
||||||
/// 色相角,单位度,0..360。
|
|
||||||
pub hue_deg: f32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Oklch {
|
/// 夹取到 `min..=max`。
|
||||||
/// 从一个 sRGB 颜色取色相/彩度(用作调色板的种子)。
|
pub(crate) fn clamp(min: f64, max: f64, input: f64) -> f64 {
|
||||||
pub fn from_color(color: Color) -> Self {
|
input.clamp(min, max)
|
||||||
let lin = color.to_linear_f32();
|
}
|
||||||
let [l, a, b] = linear_to_oklab(lin);
|
|
||||||
let mut hue = b.atan2(a).to_degrees();
|
#[cfg(test)]
|
||||||
if hue < 0.0 {
|
mod tests {
|
||||||
hue += 360.0;
|
use super::*;
|
||||||
}
|
|
||||||
Self {
|
#[test]
|
||||||
lightness: l,
|
fn lstar_and_luminance_are_inverse() {
|
||||||
chroma: (a * a + b * b).sqrt(),
|
for lstar in [0.0_f64, 8.0, 20.0, 50.0, 87.0, 98.0, 100.0] {
|
||||||
hue_deg: hue,
|
let back = lstar_from_y(y_from_lstar(lstar));
|
||||||
|
assert!((back - lstar).abs() < 1.0e-9, "L* {lstar} 往返成 {back}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 在色相环上旋转(用于派生 tertiary 这类同族但不同色的调色板)。
|
#[test]
|
||||||
pub fn rotated(mut self, deg: f32) -> Self {
|
fn white_and_black_anchor_the_scale() {
|
||||||
self.hue_deg = (self.hue_deg + deg).rem_euclid(360.0);
|
assert_eq!(delinearized(100.0), 255);
|
||||||
self
|
assert_eq!(delinearized(0.0), 0);
|
||||||
|
assert_eq!(color_from_lstar(100.0), Color::WHITE);
|
||||||
|
assert_eq!(color_from_lstar(0.0), Color::BLACK);
|
||||||
|
assert!((lstar_from_color(Color::WHITE) - 100.0).abs() < 1.0e-6);
|
||||||
|
assert!(lstar_from_color(Color::BLACK).abs() < 1.0e-6);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl Color {
|
#[test]
|
||||||
/// 线性光强三元组(0..1,越界不夹取)。
|
fn white_point_matches_d65() {
|
||||||
fn to_linear_f32(self) -> [f32; 3] {
|
// 用官方那组舍入矩阵,纯白必须落在 D65 白点上(容差 1e-3 对应矩阵末位舍入)。
|
||||||
let f = |c: u8| srgb_to_linear(f32::from(c) / 255.0);
|
let xyz = xyz_from_color(Color::WHITE);
|
||||||
[f(self.r), f(self.g), f(self.b)]
|
for (got, want) in xyz.iter().zip(WHITE_POINT_D65) {
|
||||||
|
assert!(
|
||||||
|
(got - want).abs() < 1.0e-3,
|
||||||
|
"{xyz:?} vs D65 {WHITE_POINT_D65:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(xyz_from_color(Color::BLACK).iter().all(|v| *v == 0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signum_matches_java_semantics() {
|
||||||
|
assert_eq!(signum(-1.5), -1.0);
|
||||||
|
assert_eq!(signum(0.0), 0.0);
|
||||||
|
assert_eq!(signum(-0.0), 0.0);
|
||||||
|
assert_eq!(signum(2.5), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn degrees_sanitize_wraps_negatives() {
|
||||||
|
assert!((sanitize_degrees(-30.0) - 330.0).abs() < 1.0e-9);
|
||||||
|
assert!(sanitize_degrees(360.0).abs() < 1.0e-9);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,115 +1,102 @@
|
|||||||
//! tone 调色板:一个种子色 → 一条「固定色相/彩度、tone 任取」的调色板。
|
//! tone 调色板:**固定 HCT 色相 + 固定 HCT 彩度**,沿 tone 轴取色。
|
||||||
//!
|
//!
|
||||||
//! 这是 M3 tonal palette 的**近似实现**:色相恒定、按 tone 取色、按 tone 压彩度进 sRGB gamut。
|
//! 这就是 M3 的 `TonalPalette`:一条调色板只回答一个问题——「我的第 N 号 tone 是什么色」。
|
||||||
//! 口径与已知偏差见 [`super`] 模块文档(OKLCh 而非 CAM16/HCT)。
|
//! 因为 HCT 的 tone 轴是 `L*`,同一条调色板上「tone 差 40 ⇒ 对比度 ≥ 3:1」天然成立;
|
||||||
|
//! 又因为 hue 固定,整条调色板在感知上是同一个色相(这正是 OKLCh 近似做不到的那一点)。
|
||||||
|
//!
|
||||||
|
//! 语义对齐 `java/palettes/TonalPalette.java`(Apache-2.0,见 `NOTICE`)。
|
||||||
|
|
||||||
use super::space::{self, Oklch};
|
use super::{Color, Hct};
|
||||||
use super::Color;
|
|
||||||
|
|
||||||
/// 一条 tone 调色板(色相固定 + 彩度上限)。
|
/// 一条 tone 调色板(HCT 色相 + 彩度上限)。
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub struct TonalPalette {
|
pub struct TonalPalette {
|
||||||
hue_deg: f32,
|
hue_deg: f64,
|
||||||
chroma: f32,
|
chroma: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TonalPalette {
|
impl TonalPalette {
|
||||||
/// 直接给色相(度)与彩度上限(OKLCh 单位,0 = 中性灰)建调色板。
|
/// 直接给 HCT 色相(度)与 HCT 彩度建调色板(彩度 0 = 中性灰)。
|
||||||
pub fn new(hue_deg: f32, chroma: f32) -> Self {
|
pub fn new(hue_deg: f64, chroma: f64) -> Self {
|
||||||
Self {
|
Self {
|
||||||
hue_deg: hue_deg.rem_euclid(360.0),
|
hue_deg: hue_deg.rem_euclid(360.0),
|
||||||
chroma: chroma.max(0.0),
|
chroma: chroma.max(0.0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从种子色取色相与彩度(等价 M3 的 `palette from seed`)。
|
/// 从种子色取 HCT 色相与彩度(等价官方 `TonalPalette.fromInt`)。
|
||||||
pub fn from_color(seed: Color) -> Self {
|
pub fn from_color(seed: Color) -> Self {
|
||||||
let lch = Oklch::from_color(seed);
|
let hct = Hct::from_color(seed);
|
||||||
Self::new(lch.hue_deg, lch.chroma)
|
Self::new(hct.hue, hct.chroma)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 固定色相,改彩度上限(用于把种子彩度压到 secondary/neutral 档)。
|
/// 固定色相,换彩度(M3 用它把种子的彩度压到 secondary/neutral 档)。
|
||||||
pub fn with_chroma(self, chroma: f32) -> Self {
|
pub fn with_chroma(self, chroma: f64) -> Self {
|
||||||
Self::new(self.hue_deg, chroma)
|
Self::new(self.hue_deg, chroma)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 彩度不超过 `limit`(种子本身更灰时保持更灰,不硬拉饱和)。
|
/// 旋转色相(derived hue,例如 tertiary = 种子色相 +60°)。
|
||||||
pub fn limited_chroma(self, limit: f32) -> Self {
|
pub fn rotated(self, deg: f64) -> 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)
|
Self::new(self.hue_deg + deg, self.chroma)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 该调色板的色相(度)。
|
/// 该调色板的 HCT 色相(度)。
|
||||||
pub fn hue_deg(self) -> f32 {
|
pub fn hue_deg(self) -> f64 {
|
||||||
self.hue_deg
|
self.hue_deg
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 该调色板的彩度上限(OKLCh 单位)。
|
/// 该调色板的 HCT 彩度。
|
||||||
pub fn chroma(self) -> f32 {
|
pub fn chroma(self) -> f64 {
|
||||||
self.chroma
|
self.chroma
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 取某个 tone 的颜色(tone 0..100,超出夹取)。
|
/// 取某个 tone 的颜色(tone 超出 0..=100 时自然夹到纯黑/纯白)。
|
||||||
///
|
///
|
||||||
/// 实现口径:CIELAB `L*` 精确等于 tone(M3 的 tone 定义),彩度按该 tone 的 gamut 上限
|
/// 彩度装不下时按官方的 gamut 映射**只压彩度**,色相与 tone 不变。
|
||||||
/// 二分裁剪——所以 tone 0 恒为纯黑、tone 100 恒为纯白,且同 tone 的 WCAG 对比度
|
pub fn tone(self, tone: f64) -> Color {
|
||||||
/// 与色相无关(这是对比度门禁能跨种子稳定的原因)。
|
self.hct(tone).to_color()
|
||||||
pub fn tone(self, tone: f32) -> Color {
|
|
||||||
self.tone_scaled(tone, self.chroma)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 带「共享彩度基准」的取色:**所有调色板按同一比例压彩度进 gamut**。
|
/// 取某个 tone 的 HCT 坐标(`TonalPalette.hct` / 官方 `getHct`)。
|
||||||
///
|
///
|
||||||
/// `reference_chroma` 一般是方案里最饱和那条调色板(primary)的彩度。压缩系数
|
/// 黄相(105°..125°)的 tone 99 是官方的特例:那里 `L*` 与 sRGB 平面不平滑,
|
||||||
/// `f = min(1, C_max(tone,hue) / reference)`,每条调色板取 `C_req × f`——
|
/// 直接横向取自 tone 98 与 tone 100 的中点,避免出一格色带。
|
||||||
/// 这样 `primary` 与 `secondary` 在高 tone(tone 90/96 这类浅容器)上不会被**一起裁到**
|
pub fn hct(self, tone: f64) -> Hct {
|
||||||
/// 同一个 gamut 上限而撞色,`secondary` 永远比 `primary` 更灰(M3 的语义就靠这个)。
|
if tone == 99.0 && Hct::is_yellow(self.hue_deg) {
|
||||||
/// 单独用 [`TonalPalette::tone`] 时没有基准,等价于 `reference = 自身彩度`(直接裁剪)。
|
let low = self.hct(98.0).to_color();
|
||||||
pub fn tone_scaled(self, tone: f32, reference_chroma: f32) -> Color {
|
let high = self.hct(100.0).to_color();
|
||||||
let lstar = tone.clamp(0.0, 100.0);
|
return Hct::from_color(average(low, high));
|
||||||
// 端点直接给纯黑/纯白:近黑处的 gamut 容差会放进微小彩度,导致 tone 0 偏色。
|
|
||||||
if lstar <= 0.0 {
|
|
||||||
return Color::BLACK;
|
|
||||||
}
|
}
|
||||||
if lstar >= 100.0 {
|
Hct::from_components(self.hue_deg, self.chroma, tone)
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 两色的通道中点(官方 `averageArgb`,四舍五入)。
|
||||||
|
fn average(a: Color, b: Color) -> Color {
|
||||||
|
let mid = |x: u8, y: u8| {
|
||||||
|
let sum = u16::from(x) + u16::from(y);
|
||||||
|
u8::try_from(sum.div_ceil(2)).unwrap_or(u8::MAX)
|
||||||
|
};
|
||||||
|
Color::rgb(mid(a.r, b.r), mid(a.g, b.g), mid(a.b, b.b))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
const SEED: Color = Color::rgb(0x67, 0x50, 0xA4);
|
const SEED: Color = Color::rgb(0x67, 0x50, 0xA4);
|
||||||
|
|
||||||
fn lstar_of(color: Color) -> f32 {
|
|
||||||
color.lstar()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tone_matches_lstar_within_one_unit() {
|
fn tone_is_hit_within_quantisation_error() {
|
||||||
let palette = TonalPalette::from_color(SEED);
|
let palette = TonalPalette::from_color(SEED).with_chroma(36.0);
|
||||||
for tone in [
|
for tone in [
|
||||||
0.0_f32, 6.0, 10.0, 30.0, 40.0, 50.0, 80.0, 90.0, 95.0, 98.0, 100.0,
|
4.0_f64, 6.0, 10.0, 12.0, 17.0, 20.0, 30.0, 40.0, 50.0, 80.0, 87.0, 90.0, 92.0, 94.0,
|
||||||
|
95.0, 96.0, 98.0,
|
||||||
] {
|
] {
|
||||||
let got = lstar_of(palette.tone(tone));
|
let got = Hct::from_color(palette.tone(tone)).tone;
|
||||||
assert!(
|
assert!(
|
||||||
(got - tone).abs() <= 1.0,
|
(got - tone).abs() <= 1.0,
|
||||||
"tone {tone} 的 L* 是 {got},偏差超过 1(量化误差上限)"
|
"tone {tone} 的 L* 是 {got},偏差超过 1(8 bit 量化上限)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,40 +111,40 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn seed_hue_is_preserved_across_tones() {
|
fn hue_is_preserved_across_the_whole_ramp() {
|
||||||
let palette = TonalPalette::from_color(SEED);
|
let palette = TonalPalette::new(298.980_997, 36.0);
|
||||||
for tone in [30.0_f32, 50.0, 70.0] {
|
for tone in [10.0_f64, 30.0, 50.0, 70.0, 90.0] {
|
||||||
let lch = Oklch::from_color(palette.tone(tone));
|
let hct = palette.hct(tone);
|
||||||
let delta = (lch.hue_deg - palette.hue_deg()).abs();
|
let delta = (hct.hue - palette.hue_deg()).abs();
|
||||||
let delta = delta.min(360.0 - delta);
|
let delta = delta.min(360.0 - delta);
|
||||||
assert!(delta < 3.0, "tone {tone} 色相偏移 {delta}° 过大");
|
assert!(delta < 1.0, "tone {tone} 色相偏移 {delta}°");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn neutral_palette_is_gray() {
|
fn chroma_zero_is_neutral_gray() {
|
||||||
let neutral = TonalPalette::new(120.0, 0.0);
|
let neutral = TonalPalette::new(120.0, 0.0);
|
||||||
let c = neutral.tone(50.0);
|
for tone in [6.0_f64, 30.0, 50.0, 90.0, 98.0] {
|
||||||
let spread = f32::from(c.r.max(c.g).max(c.b) - c.r.min(c.g).min(c.b));
|
let color = neutral.tone(tone);
|
||||||
assert!(
|
assert!(color.r == color.g && color.g == color.b, "{color:?}");
|
||||||
spread <= 1.0,
|
}
|
||||||
"彩度 0 的调色板应当接近中性灰,实测通道差 {spread}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn chroma_never_leaves_gamut() {
|
fn yellow_tone_99_uses_the_official_average() {
|
||||||
// 高彩度种子在极端 tone 上必须被裁到 gamut 内(不出现「负 RGB」削顶后的色带)。
|
let palette = TonalPalette::new(110.0, 40.0);
|
||||||
let hot = TonalPalette::new(30.0, 0.4);
|
let expected = average(palette.tone(98.0), palette.tone(100.0));
|
||||||
for tone in [5.0_f32, 20.0, 40.0, 60.0, 80.0, 95.0] {
|
assert_eq!(palette.tone(99.0), expected);
|
||||||
let lch = Oklch::from_color(hot.tone(tone));
|
// 非黄相不触发特例:tone 99 走正常求解。
|
||||||
assert!(
|
let normal = TonalPalette::new(300.0, 40.0);
|
||||||
lch.chroma <= 0.4 + 1.0e-3,
|
assert_eq!(normal.tone(99.0), normal.hct(99.0).to_color());
|
||||||
"tone {tone} 彩度 {} 超出请求上限",
|
}
|
||||||
lch.chroma
|
|
||||||
);
|
#[test]
|
||||||
let got = lstar_of(hot.tone(tone));
|
fn requested_chroma_is_respected_when_the_gamut_allows() {
|
||||||
assert!((got - tone).abs() <= 1.0, "tone {tone} 的 L* 是 {got}");
|
// tone 50 是最「宽」的色调面,彩度 36 一定装得下。
|
||||||
}
|
let palette = TonalPalette::new(298.980_997, 36.0);
|
||||||
|
let hct = palette.hct(50.0);
|
||||||
|
assert!((hct.chroma - 36.0).abs() < 0.5, "彩度 {}", hct.chroma);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
//! CAM16 的**观察条件**(viewing conditions)。
|
||||||
|
//!
|
||||||
|
//! HCT 的色相/彩度来自 CAM16,而 CAM16 是「在外界光环境下看起来如何」的模型——同一个 hex,
|
||||||
|
//! 换观察条件就是不同的 hue/chroma。M3 固定用 sRGB 默认条件:D65 白点、200 lux(适应亮度)、
|
||||||
|
//! 背景 `L* = 50`、surround = 2.0、**不做**光源折扣(自发光屏幕)。
|
||||||
|
//!
|
||||||
|
//! 照搬 `java/hct/ViewingConditions.java`(Apache-2.0,见 `NOTICE`)。
|
||||||
|
|
||||||
|
use super::space;
|
||||||
|
|
||||||
|
/// CAM16 观察条件里与颜色无关的那堆中间量(只为避免重复计算)。
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub(crate) struct ViewingConditions {
|
||||||
|
/// 白点响应(achromatic response to the white point)。
|
||||||
|
pub(crate) aw: f64,
|
||||||
|
/// 背景亮度因子。
|
||||||
|
pub(crate) n: f64,
|
||||||
|
/// 亮度指数。
|
||||||
|
pub(crate) nbb: f64,
|
||||||
|
/// 彩度指数。
|
||||||
|
pub(crate) ncb: f64,
|
||||||
|
/// 环绕度决定的对比度系数。
|
||||||
|
pub(crate) c: f64,
|
||||||
|
/// 环绕度。
|
||||||
|
pub(crate) nc: f64,
|
||||||
|
/// 三通道的色适应折扣系数。
|
||||||
|
pub(crate) rgb_d: [f64; 3],
|
||||||
|
/// 亮度适应因子。
|
||||||
|
pub(crate) fl: f64,
|
||||||
|
/// 基函数。
|
||||||
|
pub(crate) z: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 与 XYZ 有关的常量矩阵在 CAM16 里也要用,从 space 引一次保持单一来源。
|
||||||
|
use super::cam16::XYZ_TO_CAM16_RGB;
|
||||||
|
|
||||||
|
impl ViewingConditions {
|
||||||
|
/// 按官方参数构造(`white_point` 是 XYZ,`adapting_luminance` 由 lux 换算)。
|
||||||
|
pub(crate) fn make(
|
||||||
|
white_point: [f64; 3],
|
||||||
|
adapting_luminance: f64,
|
||||||
|
background_lstar: f64,
|
||||||
|
surround: f64,
|
||||||
|
discounting_illuminant: bool,
|
||||||
|
) -> Self {
|
||||||
|
// 纯黑背景是非物理的,会让「任何颜色都看不见」这类无穷大出现,官方取下限 0.1。
|
||||||
|
let background_lstar = background_lstar.max(0.1);
|
||||||
|
let [r_w, g_w, b_w] = space::matrix_multiply(white_point, XYZ_TO_CAM16_RGB);
|
||||||
|
let f = 0.8 + surround / 10.0;
|
||||||
|
let c = if f >= 0.9 {
|
||||||
|
space::lerp(0.59, 0.69, (f - 0.9) * 10.0)
|
||||||
|
} else {
|
||||||
|
space::lerp(0.525, 0.59, (f - 0.8) * 10.0)
|
||||||
|
};
|
||||||
|
let d = if discounting_illuminant {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
let raw = f * (1.0 - (1.0 / 3.6) * ((-adapting_luminance - 42.0) / 92.0).exp());
|
||||||
|
space::clamp(0.0, 1.0, raw)
|
||||||
|
};
|
||||||
|
let nc = f;
|
||||||
|
let rgb_d = [
|
||||||
|
d * (100.0 / r_w) + 1.0 - d,
|
||||||
|
d * (100.0 / g_w) + 1.0 - d,
|
||||||
|
d * (100.0 / b_w) + 1.0 - d,
|
||||||
|
];
|
||||||
|
let k = 1.0 / (5.0 * adapting_luminance + 1.0);
|
||||||
|
let k4 = k * k * k * k;
|
||||||
|
let k4_f = 1.0 - k4;
|
||||||
|
let fl = k4 * adapting_luminance + 0.1 * k4_f * k4_f * (5.0 * adapting_luminance).cbrt();
|
||||||
|
let n = space::y_from_lstar(background_lstar) / white_point[1];
|
||||||
|
let z = 1.48 + n.sqrt();
|
||||||
|
let nbb = 0.725 / n.powf(0.2);
|
||||||
|
let ncb = nbb;
|
||||||
|
let rgb_a_factors = [
|
||||||
|
(fl * rgb_d[0] * r_w / 100.0).powf(0.42),
|
||||||
|
(fl * rgb_d[1] * g_w / 100.0).powf(0.42),
|
||||||
|
(fl * rgb_d[2] * b_w / 100.0).powf(0.42),
|
||||||
|
];
|
||||||
|
let rgb_a = [
|
||||||
|
400.0 * rgb_a_factors[0] / (rgb_a_factors[0] + 27.13),
|
||||||
|
400.0 * rgb_a_factors[1] / (rgb_a_factors[1] + 27.13),
|
||||||
|
400.0 * rgb_a_factors[2] / (rgb_a_factors[2] + 27.13),
|
||||||
|
];
|
||||||
|
let aw = (2.0 * rgb_a[0] + rgb_a[1] + 0.05 * rgb_a[2]) * nbb;
|
||||||
|
Self {
|
||||||
|
aw,
|
||||||
|
n,
|
||||||
|
nbb,
|
||||||
|
ncb,
|
||||||
|
c,
|
||||||
|
nc,
|
||||||
|
rgb_d,
|
||||||
|
fl,
|
||||||
|
z,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 指定背景 `L*` 的 sRGB 默认条件。
|
||||||
|
pub(crate) fn default_with_background_lstar(background_lstar: f64) -> Self {
|
||||||
|
Self::make(
|
||||||
|
space::WHITE_POINT_D65,
|
||||||
|
200.0 / std::f64::consts::PI * space::y_from_lstar(50.0) / 100.0,
|
||||||
|
background_lstar,
|
||||||
|
2.0,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 全仓共用的默认观察条件(sRGB 屏,背景 `L* = 50`)。
|
||||||
|
pub(crate) fn default_conditions() -> &'static ViewingConditions {
|
||||||
|
static DEFAULT: std::sync::OnceLock<ViewingConditions> = std::sync::OnceLock::new();
|
||||||
|
DEFAULT.get_or_init(|| ViewingConditions::default_with_background_lstar(50.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_conditions_match_the_official_constants() {
|
||||||
|
// 官方 material-color-utilities 的 ViewingConditions.DEFAULT 解出来的值(Java 实测,
|
||||||
|
// `hct.Count` 打印)。
|
||||||
|
let vc = default_conditions();
|
||||||
|
assert!(
|
||||||
|
(vc.n - 0.184_186_518_512_444_16).abs() < 1.0e-12,
|
||||||
|
"n = {}",
|
||||||
|
vc.n
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(vc.aw - 29.980_997_194_447_333).abs() < 1.0e-12,
|
||||||
|
"aw = {}",
|
||||||
|
vc.aw
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(vc.nbb - 1.016_919_180_445_875_5).abs() < 1.0e-12,
|
||||||
|
"nbb = {}",
|
||||||
|
vc.nbb
|
||||||
|
);
|
||||||
|
assert!((vc.c - 0.69).abs() < 1.0e-12);
|
||||||
|
assert!((vc.nc - 1.0).abs() < 1.0e-12);
|
||||||
|
assert!(
|
||||||
|
(vc.fl - 0.388_481_453_780_035_3).abs() < 1.0e-12,
|
||||||
|
"fl = {}",
|
||||||
|
vc.fl
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(vc.z - 1.909_169_568_483_652).abs() < 1.0e-12,
|
||||||
|
"z = {}",
|
||||||
|
vc.z
|
||||||
|
);
|
||||||
|
assert!((vc.rgb_d[0] - 1.021_177_702_757_52).abs() < 1.0e-12);
|
||||||
|
assert!((vc.rgb_d[1] - 0.986_307_729_428_012_4).abs() < 1.0e-12);
|
||||||
|
assert!((vc.rgb_d[2] - 0.933_960_508_280_229_9).abs() < 1.0e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn white_point_has_zero_chroma_in_cam16() {
|
||||||
|
// CAM16 的白点在默认条件下应当接近中性(这才是「色适应」的意义)。
|
||||||
|
use super::super::cam16::Cam16;
|
||||||
|
let white = Cam16::from_color(super::super::Color::WHITE);
|
||||||
|
assert!(white.chroma < 5.0, "白点彩度 {}", white.chroma);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
//! 组件级微度量(`ComponentMetrics`):**单个控件内部的几何**,不是窗口骨架。
|
||||||
|
//!
|
||||||
|
//! 为什么要有这个结构(M69 / G10):Checkbox 的 20×20、圆角 2、描边 2、状态层 Ø40 这类值,
|
||||||
|
//! `ShapeScale`(刻度)装不了、`LayoutMetrics`(骨架)也不该装——M66 对账时它们只能写在组件
|
||||||
|
//! 实现里,而 D30 禁止组件层自定义数值。这里给它们一个**唯一落点**。
|
||||||
|
//!
|
||||||
|
//! ## 与 `LayoutMetrics` 的分工(两把尺子,量不同的东西)
|
||||||
|
//!
|
||||||
|
//! | | [`crate::spacing::LayoutMetrics`] | `ComponentMetrics`(本模块) |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | 管什么 | 窗口骨架与布局节奏:标题栏/页头/行高、dock 宽度、命中区 | 单个控件方框内的几何:外框、微圆角、描边/环线宽、状态层、内点、图标偏移 |
|
||||||
|
//! | 随什么变 | 窗口尺寸、列宽、用户折叠导航 | 只随组件自身规格变 |
|
||||||
|
//! | 例子 | `nav_drawer_width = 240`、`search_bar_height = 56` | checkbox 20×20、描边 2、状态层 Ø40 |
|
||||||
|
//! | 判据 | 决定「一块面板/一行占多宽多高」 | 决定「画进这个方框里的那几个像素」 |
|
||||||
|
//!
|
||||||
|
//! 冲突时的规则是**同一个量只许有一份**:搜索条高 56 属骨架级,所以
|
||||||
|
//! [`ComponentMetrics::SEARCH_BAR`] 的 `height` 留 `0.0` 并在文档里指回 LM;drawer 宽同理。
|
||||||
|
//! 测试 `component_metrics_never_duplicate_layout_metrics` 把这条钉死。
|
||||||
|
//!
|
||||||
|
//! 数值来源:`androidx.compose.material3:material3-android:1.4.0` 源码(A 级,M66 逐行核对,
|
||||||
|
//! 见 `research/15` §18~§23)。逐字段的出处写在下面各组件的关联常量上;**团队口径**(官方没有
|
||||||
|
//! token 的)单独注明。
|
||||||
|
|
||||||
|
use super::Component;
|
||||||
|
use crate::icons::IconSize;
|
||||||
|
use crate::shape::ComponentRadius;
|
||||||
|
use crate::spacing::Space;
|
||||||
|
|
||||||
|
/// 单个控件的内部几何。
|
||||||
|
///
|
||||||
|
/// `0.0` 表示**该控件没有这一项**(不是「默认值 0」)——拿 `0.0` 反推数值一定错。
|
||||||
|
/// 骨架级尺寸(条高、面板宽)不在这里,去 [`crate::spacing::LayoutMetrics`] 拿。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct ComponentMetrics {
|
||||||
|
/// 可见外框宽(dp;`0.0` = 由内容或骨架决定)。
|
||||||
|
pub width: f32,
|
||||||
|
/// 可见外框高(dp;`0.0` = 由内容或骨架决定)。
|
||||||
|
pub height: f32,
|
||||||
|
/// 微控件圆角(D32 第二层)。`None` = 该控件的圆角走 `ShapeScale`(见 `ComponentSpec::shape`)。
|
||||||
|
pub radius: Option<ComponentRadius>,
|
||||||
|
/// 描边 / 环线宽度(dp)。**禁用态也不许变细**——官方 disabled 只改透明度。
|
||||||
|
pub stroke: f32,
|
||||||
|
/// 状态层(hover / focus / pressed)圆的直径(dp;`0.0` = 不画状态层)。
|
||||||
|
pub state_layer: f32,
|
||||||
|
/// 内点直径(dp):radio 选中时的实心点,其余控件 `0.0`。
|
||||||
|
pub inner_dot: f32,
|
||||||
|
/// 控件相对容器的外距(dp):drawer 项 = 12,决定选中指示器左右各内缩 12。
|
||||||
|
pub inset: f32,
|
||||||
|
/// 内容起始侧内边距(dp)。**图标不算在内**:图标可视边 = `pad_start + icon_offset`。
|
||||||
|
pub pad_start: f32,
|
||||||
|
/// 内容结束侧内边距(dp)。同起始侧,图标可视边 = `pad_end + icon_offset`。
|
||||||
|
pub pad_end: f32,
|
||||||
|
/// 图标边长(dp;`0.0` = 无图标)。
|
||||||
|
pub icon: f32,
|
||||||
|
/// 图标相对内边距的**额外**偏移(dp)。图标可视边 = 内边距 + 本值(search bar:12 + 4 = 16),
|
||||||
|
/// **不要**把内边距本身当图标边用,也不要再手抄那个 12 / 16。
|
||||||
|
pub icon_offset: f32,
|
||||||
|
/// 图标与文字(或相邻元素)的间距(dp)。
|
||||||
|
pub gap: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ComponentMetrics {
|
||||||
|
/// Checkbox(`research/15` §18 / §23)。
|
||||||
|
///
|
||||||
|
/// A 级出处:`Checkbox.kt:737 CheckboxSize = 20.dp`、`:738 RadiusSize = 2.dp`(圆角走 D32 的
|
||||||
|
/// [`ComponentRadius::Micro`])、`CheckboxTokens.UnselectedOutlineWidth = 2`、
|
||||||
|
/// `StateLayerSize = 40` + `StateLayerShape = CornerFull`。
|
||||||
|
/// 注意 `CheckboxTokens.ContainerWidth/Height = 18` 是**死值**(全仓 0 引用),Compose 实际画
|
||||||
|
/// 20(§26.2 矛盾 ①)——判据取 20。
|
||||||
|
/// 勾选控件 ↔ 标签间距 8 是**团队口径**:官方没有「框 + 标签」组合的 token(§18.9,估)。
|
||||||
|
pub const CHECKBOX: Self = Self {
|
||||||
|
width: 20.0,
|
||||||
|
height: 20.0,
|
||||||
|
radius: Some(ComponentRadius::Micro),
|
||||||
|
stroke: 2.0,
|
||||||
|
state_layer: 40.0,
|
||||||
|
inner_dot: 0.0,
|
||||||
|
inset: 0.0,
|
||||||
|
pad_start: 0.0,
|
||||||
|
pad_end: 0.0,
|
||||||
|
icon: 0.0,
|
||||||
|
icon_offset: 0.0,
|
||||||
|
gap: Space::X2.dp(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Radio(`research/15` §19 / §23)。
|
||||||
|
///
|
||||||
|
/// A 级出处:`RadioButtonTokens.IconSize = 20`、`RadioStrokeWidth = 2.dp`;内点名义
|
||||||
|
/// `RadioButtonDotSize = 12`,但绘制半径 = 12/2 − stroke/2 = 5 → **实际内点直径 10**
|
||||||
|
/// (§19.2:抄 12 会明显偏大)。圆角走 `ShapeScale::Full`(真圆),不是微半径。
|
||||||
|
/// 标签间距 8 同 Checkbox(团队口径)。
|
||||||
|
pub const RADIO: Self = Self {
|
||||||
|
width: 20.0,
|
||||||
|
height: 20.0,
|
||||||
|
radius: None,
|
||||||
|
stroke: 2.0,
|
||||||
|
state_layer: 40.0,
|
||||||
|
inner_dot: 10.0,
|
||||||
|
inset: 0.0,
|
||||||
|
pad_start: 0.0,
|
||||||
|
pad_end: 0.0,
|
||||||
|
icon: 0.0,
|
||||||
|
icon_offset: 0.0,
|
||||||
|
gap: Space::X2.dp(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Search bar(`research/15` §20A / §23)。
|
||||||
|
///
|
||||||
|
/// 高度与宽度**不在这里**:条高 56 是骨架级(`LayoutMetrics::search_bar_height`),
|
||||||
|
/// 最小宽 360 同样属骨架级(尚未进 LM,M69 报告里登记为待办)。这里只装画进 bar 里的几何:
|
||||||
|
/// 图标 24(`IconSize::Toolbar`)、**图标可视边 = `pad_start + icon_offset`** = 12 + 4 = **16**
|
||||||
|
/// (右侧同式为 16,§20.4)、圆角走 `ShapeScale::Full`(56 → 28 = 官方 `SearchBarCornerRadius`),
|
||||||
|
/// 所以 `radius` 是 `None`。
|
||||||
|
///
|
||||||
|
/// 为什么要拆成 12 + 4 而不是直接写 16:官方 = 文本框内容内边距 **12**(`TextFieldImpl` 默认)
|
||||||
|
/// 加上 `SearchBarIconOffsetX = 4`。拆开才能让「文本从 12 起、图标从 16 起」都被消费方拿到;
|
||||||
|
/// 只写 16 的话文本内边距没有落点,消费方只能自己写 `12.0`——正是 G10 要消灭的字面量。
|
||||||
|
/// 图标位置一律按 `pad + icon_offset` 求和,不要单独把 `pad` 当图标边用。
|
||||||
|
pub const SEARCH_BAR: Self = Self {
|
||||||
|
width: 0.0,
|
||||||
|
height: 0.0,
|
||||||
|
radius: None,
|
||||||
|
stroke: 0.0,
|
||||||
|
state_layer: 0.0,
|
||||||
|
inner_dot: 0.0,
|
||||||
|
inset: 0.0,
|
||||||
|
pad_start: Space::X3.dp(),
|
||||||
|
pad_end: Space::X3.dp(),
|
||||||
|
icon: IconSize::Toolbar.px(),
|
||||||
|
icon_offset: 4.0,
|
||||||
|
// 图标 ↔ 文本 8 是**团队口径 [估]**:`research/15` §20.4 只给了图标尺寸/位置与 48 命中框,
|
||||||
|
// 没有「图标 ↔ 文字」的 token(Compose 由 `TextFieldImpl` 内部排版决定)。
|
||||||
|
gap: Space::X2.dp(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Navigation drawer(`research/15` §21 / §23)。
|
||||||
|
///
|
||||||
|
/// 宽度与项高**不在这里**:drawer 240 / rail 88 在 `LayoutMetrics`,项高 44 =
|
||||||
|
/// `LayoutMetrics::nav_item_height`。这里装项内几何(§21.6,A 级:
|
||||||
|
/// `NavigationDrawerItemDefaults.ItemPadding = 12`、项内 start 16 / end 24、
|
||||||
|
/// `IconSize = 24`、图标 ↔ label 12)。
|
||||||
|
/// 容器形状 `CornerLargeEnd`(只有靠内容那一侧两角 16)由 `ComponentSpec::shape` 的
|
||||||
|
/// `Large` + 组件实现按侧位决定哪两个角,度量层不表达「部分角」。
|
||||||
|
pub const NAVIGATION_DRAWER: Self = Self {
|
||||||
|
width: 0.0,
|
||||||
|
height: 0.0,
|
||||||
|
radius: None,
|
||||||
|
stroke: 0.0,
|
||||||
|
state_layer: 0.0,
|
||||||
|
inner_dot: 0.0,
|
||||||
|
inset: 12.0,
|
||||||
|
pad_start: Space::X4.dp(),
|
||||||
|
pad_end: Space::X6.dp(),
|
||||||
|
icon: IconSize::Toolbar.px(),
|
||||||
|
icon_offset: 0.0,
|
||||||
|
gap: Space::X3.dp(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// drawer 选中指示器宽度:容器宽 − 2 × [`ComponentMetrics::inset`]。
|
||||||
|
///
|
||||||
|
/// 240 − 24 = **216**;官方 360 − 24 = 336 是同一个公式(§21.5)——写死 336 就是忘了推导。
|
||||||
|
pub fn indicator_width(&self, container_width: f32) -> f32 {
|
||||||
|
(container_width - self.inset * 2.0).max(0.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 组件 → 组件级微度量。`None` = 该组件没有控件内部几何(只有骨架值,去 LM 拿)。
|
||||||
|
///
|
||||||
|
/// Divider 是 `None` 的显式例子:它的唯一几何量是厚度,而厚度是**一像素线**
|
||||||
|
/// (`LayoutMetrics::divider_thickness` = `stroke::HAIRLINE`),不是「控件内部」的东西。
|
||||||
|
pub(super) const fn metrics_of(component: Component) -> Option<ComponentMetrics> {
|
||||||
|
match component {
|
||||||
|
Component::Checkbox => Some(ComponentMetrics::CHECKBOX),
|
||||||
|
Component::Radio => Some(ComponentMetrics::RADIO),
|
||||||
|
Component::Search => Some(ComponentMetrics::SEARCH_BAR),
|
||||||
|
Component::NavigationDrawer => Some(ComponentMetrics::NAVIGATION_DRAWER),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,16 +8,25 @@
|
|||||||
//! - 形状档位见 [`crate::shape::ShapeScale`],高程见 [`crate::elevation::Elevation`];
|
//! - 形状档位见 [`crate::shape::ShapeScale`],高程见 [`crate::elevation::Elevation`];
|
||||||
//! - `text` 是主要文字角色,`support` 是辅助文字(没有就 `None`);
|
//! - `text` 是主要文字角色,`support` 是辅助文字(没有就 `None`);
|
||||||
//! - `motion` + `duration` 是状态过渡(hover/press/展开),不是页面切换;
|
//! - `motion` + `duration` 是状态过渡(hover/press/展开),不是页面切换;
|
||||||
//! - `morph` 是按下/选中时的形状形变(Expressive 特征),不需要就 `None`。
|
//! - `morph` 是按下/选中时的形状形变(Expressive 特征),不需要就 `None`;
|
||||||
|
//! - 圆角分两层(D32):容器级查 [`crate::shape::ShapeScale`],≤24dp 的微控件查
|
||||||
|
//! [`crate::shape::ComponentRadius`](落在 [`ComponentSpec::micro_radius`])。
|
||||||
|
//!
|
||||||
|
//! 档位(形状/高程/字级/动效)之外,**控件内部的微几何**(20×20、描边 2、状态层 Ø40 …)在
|
||||||
|
//! [`ComponentMetrics`] 里,入口是 [`Component::metrics`]——两者分工见该项的文档
|
||||||
|
//! (`component_map::metrics` 模块)。
|
||||||
//!
|
//!
|
||||||
//! **改这里的值 = 改全站观感**,必须同步 `DevDocs/SubAgentsTalk/design-令牌约定.md`;
|
//! **改这里的值 = 改全站观感**,必须同步 `DevDocs/SubAgentsTalk/design-令牌约定.md`;
|
||||||
//! 页面线不许在页面里覆盖这些档位。
|
//! 页面线不许在页面里覆盖这些档位。
|
||||||
|
|
||||||
|
mod metrics;
|
||||||
mod table;
|
mod table;
|
||||||
|
|
||||||
|
pub use metrics::ComponentMetrics;
|
||||||
|
|
||||||
use crate::elevation::Elevation;
|
use crate::elevation::Elevation;
|
||||||
use crate::motion::{DurationToken, Easing};
|
use crate::motion::{DurationToken, Easing};
|
||||||
use crate::shape::{ShapeMorph, ShapeScale};
|
use crate::shape::{ComponentRadius, ShapeMorph, ShapeScale};
|
||||||
use crate::typography::TypeRole;
|
use crate::typography::TypeRole;
|
||||||
|
|
||||||
/// 组件的排版 + 形状 + 动效档位。
|
/// 组件的排版 + 形状 + 动效档位。
|
||||||
@@ -25,15 +34,24 @@ use crate::typography::TypeRole;
|
|||||||
pub struct ComponentSpec {
|
pub struct ComponentSpec {
|
||||||
/// 该档位属于哪个组件(表自描述,便于测试与文档生成)。
|
/// 该档位属于哪个组件(表自描述,便于测试与文档生成)。
|
||||||
pub component: Component,
|
pub component: Component,
|
||||||
/// 圆角档位。
|
/// 圆角档位(容器级,[`ShapeScale`] 的严格刻度)。
|
||||||
pub shape: ShapeScale,
|
pub shape: ShapeScale,
|
||||||
|
/// 微控件圆角(D32 第二层)。
|
||||||
|
///
|
||||||
|
/// `Some` 时**本字段才是半径真源**,此时 [`ComponentSpec::shape`] 必须是
|
||||||
|
/// [`ShapeScale::None`](占位、不参与渲染)——因为 2dp 不在 `ShapeScale` 刻度里,
|
||||||
|
/// 硬塞进 `shape` 会破坏「圆角只取刻度值」。这层配对由测试钉死。
|
||||||
|
pub micro_radius: Option<ComponentRadius>,
|
||||||
/// 高程级别(决定 surface container 角色与是否画阴影)。
|
/// 高程级别(决定 surface container 角色与是否画阴影)。
|
||||||
pub elevation: Elevation,
|
pub elevation: Elevation,
|
||||||
/// 主要文字角色。
|
/// 主要文字角色。**无文字的组件(如 `Divider`)这里是占位值**,消费方不得据此排版。
|
||||||
pub text: TypeRole,
|
pub text: TypeRole,
|
||||||
/// 辅助文字角色。
|
/// 辅助文字角色。
|
||||||
pub support: Option<TypeRole>,
|
pub support: Option<TypeRole>,
|
||||||
/// 状态过渡缓动。
|
/// 状态过渡缓动。
|
||||||
|
///
|
||||||
|
/// 该字段不是 `Option`,所以**没有动效语义的组件(如 `Divider`)这里是占位值**——
|
||||||
|
/// 读之前先确认组件本身有状态过渡,无过渡的组件不得据此排动效。
|
||||||
pub motion: Easing,
|
pub motion: Easing,
|
||||||
/// 状态过渡时长。
|
/// 状态过渡时长。
|
||||||
pub duration: DurationToken,
|
pub duration: DurationToken,
|
||||||
@@ -41,7 +59,10 @@ pub struct ComponentSpec {
|
|||||||
pub morph: Option<ShapeMorph>,
|
pub morph: Option<ShapeMorph>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 需要固定档位的组件清单(M53 页面骨架里实际用到的那些)。
|
/// 需要固定档位的组件清单(M53 页面骨架 + M69 组件线补的 5 个)。
|
||||||
|
///
|
||||||
|
/// **冻结为只可末尾追加**:判别值当索引用(`table::SPECS[self as usize]`),改名/换位等于
|
||||||
|
/// 让整张表错行。
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum Component {
|
pub enum Component {
|
||||||
/// 顶部标题栏(第一层 chrome)。
|
/// 顶部标题栏(第一层 chrome)。
|
||||||
@@ -84,11 +105,35 @@ pub enum Component {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
/// 空态(图标 + 标题 + 说明 + 标准按钮)。
|
/// 空态(图标 + 标题 + 说明 + 标准按钮)。
|
||||||
EmptyState,
|
EmptyState,
|
||||||
|
/// 复选框(安装选项 / mod 启用 / 日志过滤 / Java 管理勾选)。
|
||||||
|
///
|
||||||
|
/// 圆角 **2dp** 不在 `ShapeScale` 刻度里 → 走 [`ComponentSpec::micro_radius`](D32 第二层);
|
||||||
|
/// 20×20 / 描边 2 / 状态层 Ø40 见 [`ComponentMetrics::CHECKBOX`]。
|
||||||
|
Checkbox,
|
||||||
|
/// 单选框(Java 版本单选 / 安装模式选择)。
|
||||||
|
///
|
||||||
|
/// 真圆(`ShapeScale::Full`);20 / 环线 2 / 内点 10 / 状态层 Ø40 见 [`ComponentMetrics::RADIO`]。
|
||||||
|
Radio,
|
||||||
|
/// 搜索条(实例 / 版本 / mod 搜索,页级入口)。
|
||||||
|
///
|
||||||
|
/// 高 56 是骨架级(`LayoutMetrics::search_bar_height`),圆角 `Full`(56 → 28);
|
||||||
|
/// 图标 24 + 偏移 4 见 [`ComponentMetrics::SEARCH_BAR`]。高程取 **L0**(源码矛盾已登记)。
|
||||||
|
Search,
|
||||||
|
/// 导航抽屉(左侧常驻导航的展开形态,D33)。
|
||||||
|
///
|
||||||
|
/// 形状 `CornerLargeEnd`(只有靠内容那侧两角 16),宽 240 = `LayoutMetrics::nav_drawer_width`;
|
||||||
|
/// 项内几何见 [`ComponentMetrics::NAVIGATION_DRAWER`]。
|
||||||
|
NavigationDrawer,
|
||||||
|
/// 分隔线(列表行分隔 / 区块分隔)。
|
||||||
|
///
|
||||||
|
/// **无高程、无圆角**;厚度是一像素线(`LayoutMetrics::divider_thickness` = `stroke::HAIRLINE`),
|
||||||
|
/// 颜色 `OutlineVariant`(列表内)或 `Outline`(search view 头部分隔,见 §20.8)。
|
||||||
|
Divider,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Component {
|
impl Component {
|
||||||
/// 全部组件。
|
/// 全部组件。
|
||||||
pub const ALL: [Component; 20] = [
|
pub const ALL: [Component; 25] = [
|
||||||
Component::TopAppBar,
|
Component::TopAppBar,
|
||||||
Component::NavigationItem,
|
Component::NavigationItem,
|
||||||
Component::PrimaryButton,
|
Component::PrimaryButton,
|
||||||
@@ -109,6 +154,12 @@ impl Component {
|
|||||||
Component::Snackbar,
|
Component::Snackbar,
|
||||||
Component::Tooltip,
|
Component::Tooltip,
|
||||||
Component::EmptyState,
|
Component::EmptyState,
|
||||||
|
// M69 G9:末尾追加的 5 个(前 20 个次序不许动)。
|
||||||
|
Component::Checkbox,
|
||||||
|
Component::Radio,
|
||||||
|
Component::Search,
|
||||||
|
Component::NavigationDrawer,
|
||||||
|
Component::Divider,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 该组件的档位。
|
/// 该组件的档位。
|
||||||
@@ -116,6 +167,14 @@ impl Component {
|
|||||||
table::SPECS[self as usize]
|
table::SPECS[self as usize]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 该组件的**控件内部**微度量;`None` = 只有骨架值(去 `LayoutMetrics` 拿)。
|
||||||
|
///
|
||||||
|
/// 这一条是 G10 的消费入口:组件线拿到 `Component::Checkbox.metrics()` 就有 20×20 / 圆角 2 /
|
||||||
|
/// 描边 2 / 状态层 Ø40,**不许再在实现里写这些字面量**。
|
||||||
|
pub fn metrics(self) -> Option<ComponentMetrics> {
|
||||||
|
metrics::metrics_of(self)
|
||||||
|
}
|
||||||
|
|
||||||
/// 稳定字符串名(kebab-case)。
|
/// 稳定字符串名(kebab-case)。
|
||||||
pub const fn name(self) -> &'static str {
|
pub const fn name(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
@@ -139,134 +198,15 @@ impl Component {
|
|||||||
Component::Snackbar => "snackbar",
|
Component::Snackbar => "snackbar",
|
||||||
Component::Tooltip => "tooltip",
|
Component::Tooltip => "tooltip",
|
||||||
Component::EmptyState => "empty-state",
|
Component::EmptyState => "empty-state",
|
||||||
|
// M69 G9:末尾追加(`name()` 是持久化字符串 id,同样只许末尾追加)。
|
||||||
|
Component::Checkbox => "checkbox",
|
||||||
|
Component::Radio => "radio",
|
||||||
|
Component::Search => "search",
|
||||||
|
Component::NavigationDrawer => "navigation-drawer",
|
||||||
|
Component::Divider => "divider",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
//!
|
//!
|
||||||
//! 一行一个组件。改值就是在改全站观感——同步协作文档再改。
|
//! 一行一个组件。改值就是在改全站观感——同步协作文档再改。
|
||||||
//!
|
//!
|
||||||
|
//! **末尾 5 行(M69 G9)** 的来源是 `research/15` §18~§23(同一套 `material3-android:1.4.0` 源码):
|
||||||
|
//! Checkbox / Radio / Search / NavigationDrawer / Divider。其中 Checkbox 的 **2dp** 圆角走
|
||||||
|
//! [`ComponentRadius`](D32 第二层,不塞进 `ShapeScale`),Divider 的厚度是一像素线(G12)而不在这张表里。
|
||||||
|
//!
|
||||||
//! **高程/形状取值来源(A 级)**:`androidx.compose.material3.tokens` 的各组件 `<X>Tokens.kt`
|
//! **高程/形状取值来源(A 级)**:`androidx.compose.material3.tokens` 的各组件 `<X>Tokens.kt`
|
||||||
//! (FilledCard/OutlinedCard/ElevatedCard/Menu/Dialog/Snackbar/AppBar/List)。已登记的两处偏离:
|
//! (FilledCard/OutlinedCard/ElevatedCard/Menu/Dialog/Snackbar/AppBar/List)。已登记的两处偏离:
|
||||||
//! - `Tooltip`:`PlainTooltipTokens.kt` 只有 Corner 行、**没有 Elevation 行**,Table 的 Level2 是
|
//! - `Tooltip`:`PlainTooltipTokens.kt` 只有 Corner 行、**没有 Elevation 行**,Table 的 Level2 是
|
||||||
@@ -14,7 +18,7 @@ use crate::elevation::Elevation;
|
|||||||
use crate::motion::{
|
use crate::motion::{
|
||||||
DurationToken, Easing, SPRING_EXPRESSIVE_SPATIAL_DEFAULT, SPRING_EXPRESSIVE_SPATIAL_FAST,
|
DurationToken, Easing, SPRING_EXPRESSIVE_SPATIAL_DEFAULT, SPRING_EXPRESSIVE_SPATIAL_FAST,
|
||||||
};
|
};
|
||||||
use crate::shape::{ShapeMorph, ShapeScale};
|
use crate::shape::{ComponentRadius, ShapeMorph, ShapeScale};
|
||||||
use crate::typography::TypeRole;
|
use crate::typography::TypeRole;
|
||||||
|
|
||||||
/// 一个组件的档位行(`const fn` 便捷构造,字段名在 [`ComponentSpec`] 里)。
|
/// 一个组件的档位行(`const fn` 便捷构造,字段名在 [`ComponentSpec`] 里)。
|
||||||
@@ -31,6 +35,7 @@ const fn spec(
|
|||||||
ComponentSpec {
|
ComponentSpec {
|
||||||
component,
|
component,
|
||||||
shape,
|
shape,
|
||||||
|
micro_radius: None,
|
||||||
elevation,
|
elevation,
|
||||||
text,
|
text,
|
||||||
support,
|
support,
|
||||||
@@ -40,8 +45,35 @@ const fn spec(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 20 个组件的档位表。
|
/// 圆角走「微控件层」(D32)的档位行:`shape` 自动填占位 `None`,半径取白名单值。
|
||||||
pub(super) const SPECS: [ComponentSpec; 20] = [
|
///
|
||||||
|
/// 为什么单独一个构造器:`micro_radius` 与 `shape = None` 必须成对出现,用构造器保证配对,
|
||||||
|
/// 不让 25 行表里出现「填了微半径忘了把 shape 清掉」。
|
||||||
|
const fn spec_micro(
|
||||||
|
component: Component,
|
||||||
|
micro_radius: ComponentRadius,
|
||||||
|
elevation: Elevation,
|
||||||
|
text: TypeRole,
|
||||||
|
support: Option<TypeRole>,
|
||||||
|
motion: Easing,
|
||||||
|
duration: DurationToken,
|
||||||
|
morph: Option<ShapeMorph>,
|
||||||
|
) -> ComponentSpec {
|
||||||
|
ComponentSpec {
|
||||||
|
component,
|
||||||
|
shape: ShapeScale::None,
|
||||||
|
micro_radius: Some(micro_radius),
|
||||||
|
elevation,
|
||||||
|
text,
|
||||||
|
support,
|
||||||
|
motion,
|
||||||
|
duration,
|
||||||
|
morph,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 25 个组件的档位表。
|
||||||
|
pub(super) const SPECS: [ComponentSpec; 25] = [
|
||||||
spec(
|
spec(
|
||||||
Component::TopAppBar,
|
Component::TopAppBar,
|
||||||
ShapeScale::None,
|
ShapeScale::None,
|
||||||
@@ -265,4 +297,75 @@ pub(super) const SPECS: [ComponentSpec; 20] = [
|
|||||||
DurationToken::Medium2,
|
DurationToken::Medium2,
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
|
// ---- M69 G9:末尾追加的 5 个(`research/15` §18~§23;前 20 行次序不许动)----
|
||||||
|
// Checkbox:圆角 **2dp** 不在 `ShapeScale` 刻度里 → 走 `spec_micro`(D32 第二层)。
|
||||||
|
// 高程 L0:`CheckboxTokens` 没有 Elevation 行,容器是「描边/填充」两态而非浮层。
|
||||||
|
// 字级 `BodyLarge` 是**团队口径**:官方没有「框 + 标签」组合 token(§18.9),取列表行同一档。
|
||||||
|
// 动效取 M3 motion scheme 的 fast-effects(100ms standard)——[推导],非 token 原文。
|
||||||
|
spec_micro(
|
||||||
|
Component::Checkbox,
|
||||||
|
ComponentRadius::Micro,
|
||||||
|
Elevation::Level0,
|
||||||
|
TypeRole::BodyLarge,
|
||||||
|
None,
|
||||||
|
Easing::Standard,
|
||||||
|
DurationToken::Short2,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
// Radio:真圆(`RadioButtonTokens.IconSize = 20` + `CornerFull`),内点/环线宽见
|
||||||
|
// `ComponentMetrics::RADIO`。字级同 Checkbox(团队口径)。
|
||||||
|
spec(
|
||||||
|
Component::Radio,
|
||||||
|
ShapeScale::Full,
|
||||||
|
Elevation::Level0,
|
||||||
|
TypeRole::BodyLarge,
|
||||||
|
None,
|
||||||
|
Easing::Standard,
|
||||||
|
DurationToken::Short2,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
// Search bar:圆角 `CornerFull` / `SearchBarCornerRadius = 56/2 = 28`。
|
||||||
|
// **高程取 L0**——`SearchBarTokens.ContainerElevation = Level3` 与
|
||||||
|
// `SearchBarDefaults.TonalElevation/ShadowElevation = Level0` 冲突(§26.2 矛盾 ②),
|
||||||
|
// 判据按观感取「无可见阴影 + tone 分层」,这条已在 `design-令牌约定` 登记。
|
||||||
|
// 无 shape morph:bar ⇄ docked 展开时**半径不变**(28 → 28),只长面积(§20C)。
|
||||||
|
spec(
|
||||||
|
Component::Search,
|
||||||
|
ShapeScale::Full,
|
||||||
|
Elevation::Level0,
|
||||||
|
TypeRole::BodyLarge,
|
||||||
|
Some(TypeRole::BodySmall),
|
||||||
|
Easing::Emphasized,
|
||||||
|
DurationToken::Medium2,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
// Navigation drawer:官方 `ContainerShape = CornerLargeEnd`(只有靠内容那侧两角 16,
|
||||||
|
// 贴边侧直角)。`ShapeScale` 表达不了「部分角」,这里记内侧角的 `Large(16)`,
|
||||||
|
// 哪两个角由组件按侧位决定;写四角 16 = 变成浮动卡片(§21.2 判据)。
|
||||||
|
// 常驻形态 `Surface` + L0(模态才用 `SurfaceContainerLow` + scrim,本项目只在 <600 窗宽兜底)。
|
||||||
|
spec(
|
||||||
|
Component::NavigationDrawer,
|
||||||
|
ShapeScale::Large,
|
||||||
|
Elevation::Level0,
|
||||||
|
TypeRole::LabelLarge,
|
||||||
|
Some(TypeRole::TitleSmall),
|
||||||
|
Easing::Emphasized,
|
||||||
|
DurationToken::Medium2,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
// Divider:无高程、无圆角、**无文字**(`text` 是占位值,消费方不得据此排版);
|
||||||
|
// 厚度是一像素线 `LayoutMetrics::divider_thickness`,颜色 `OutlineVariant`
|
||||||
|
// (search view 头部分隔是 `Outline`,两个不同角色,§20.8)。
|
||||||
|
// **`motion` / `duration` 也是占位值**(`ComponentSpec.motion` 不是 `Option`):Divider 没有
|
||||||
|
// 官方状态过渡令牌,消费方**不得据此排动效**;下轮把该字段改成 `Option` 更彻底。
|
||||||
|
spec(
|
||||||
|
Component::Divider,
|
||||||
|
ShapeScale::None,
|
||||||
|
Elevation::Level0,
|
||||||
|
TypeRole::BodySmall,
|
||||||
|
None,
|
||||||
|
Easing::Standard,
|
||||||
|
DurationToken::Short2,
|
||||||
|
None,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
//! `component_map` 的守门测试:表槽位 ↔ 枚举次序、官方高程/形状、圆角分层(D32)、微度量入口。
|
||||||
|
//!
|
||||||
|
//! 拆成独立文件是为了让 `mod.rs` 留出文档空间(项目硬约束:单文件 ≤400 行)。
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_matches_component_order() {
|
||||||
|
for (i, spec) in table::SPECS.iter().enumerate() {
|
||||||
|
assert_eq!(
|
||||||
|
spec.component as usize, i,
|
||||||
|
"SPECS 第 {i} 行顺序与枚举不一致"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_component_resolves() {
|
||||||
|
assert_eq!(Component::ALL.len(), table::SPECS.len());
|
||||||
|
for component in Component::ALL {
|
||||||
|
let spec = component.spec();
|
||||||
|
assert_eq!(spec.component, component);
|
||||||
|
assert!(spec.text.style().size_px >= 11.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn names_are_unique() {
|
||||||
|
let mut seen = std::collections::BTreeSet::new();
|
||||||
|
for component in Component::ALL {
|
||||||
|
assert!(
|
||||||
|
seen.insert(component.name()),
|
||||||
|
"组件名重复:{}",
|
||||||
|
component.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn elevation_matches_official_component_tokens() {
|
||||||
|
// A 级来源(androidx.compose.material3.tokens):
|
||||||
|
// FilledCardTokens/OutlinedCardTokens.ContainerElevation = Level0
|
||||||
|
// MenuTokens.ContainerElevation = Level2
|
||||||
|
// DialogTokens.ContainerElevation = Level3
|
||||||
|
// SnackbarTokens.ContainerElevation = Level3
|
||||||
|
// AppBarTokens = Level0、ListTokens.ItemContainerElevation = Level0
|
||||||
|
// 偏离只允许出现在有登记的地方(Tooltip 是 B 级:token 文件里没有 Elevation 行)。
|
||||||
|
for (component, expect) in [
|
||||||
|
(Component::FilledCard, Elevation::Level0),
|
||||||
|
(Component::OutlinedCard, Elevation::Level0),
|
||||||
|
(Component::Menu, Elevation::Level2),
|
||||||
|
(Component::Dialog, Elevation::Level3),
|
||||||
|
(Component::Snackbar, Elevation::Level3),
|
||||||
|
(Component::TopAppBar, Elevation::Level0),
|
||||||
|
(Component::ListItem, Elevation::Level0),
|
||||||
|
(Component::TextField, Elevation::Level0),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
component.spec().elevation,
|
||||||
|
expect,
|
||||||
|
"{} 的高程与官方组件令牌不一致",
|
||||||
|
component.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// R4「全屏都在飘」的守门:一屏常见元素里带阴影(>Level0)的只允许是浮层。
|
||||||
|
for component in Component::ALL {
|
||||||
|
if component.spec().elevation > Elevation::Level0 {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
component,
|
||||||
|
Component::Menu | Component::Dialog | Component::Snackbar | Component::Tooltip
|
||||||
|
),
|
||||||
|
"{} 不该有阴影(只有浮层允许)",
|
||||||
|
component.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shapes_match_official_component_tokens() {
|
||||||
|
// 官方:卡片 CornerMedium(12)、对话框 CornerExtraLarge(28)、菜单 CornerExtraSmall(4)、
|
||||||
|
// 按钮/导航指示条 CornerFull、chip CornerSmall(8)。
|
||||||
|
assert_eq!(Component::FilledCard.spec().shape, ShapeScale::Medium);
|
||||||
|
assert_eq!(Component::OutlinedCard.spec().shape, ShapeScale::Medium);
|
||||||
|
assert_eq!(Component::Dialog.spec().shape, ShapeScale::ExtraLarge);
|
||||||
|
assert_eq!(Component::Menu.spec().shape, ShapeScale::ExtraSmall);
|
||||||
|
assert_eq!(Component::PrimaryButton.spec().shape, ShapeScale::Full);
|
||||||
|
assert_eq!(Component::Chip.spec().shape, ShapeScale::Small);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_primary_actions_use_filled_emphasis_and_one_dialog_layer() {
|
||||||
|
// 契约 §1:状态色不复用 accent、每屏最多一个实心 accent → 只有 primary-button 是
|
||||||
|
// 「实心强调」,其余按钮的形态由页面决定但不能是 Full + Level0 + Emphasized 的组合。
|
||||||
|
let primary = Component::PrimaryButton.spec();
|
||||||
|
assert_eq!(primary.shape, ShapeScale::Full);
|
||||||
|
assert_eq!(primary.text, TypeRole::LabelLarge);
|
||||||
|
// 浮层类必须比卡片层高。
|
||||||
|
assert!(Component::Dialog.spec().elevation > Component::OutlinedCard.spec().elevation);
|
||||||
|
assert!(Component::Menu.spec().elevation > Component::FilledCard.spec().elevation);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn morphs_always_expand_upwards() {
|
||||||
|
for component in Component::ALL {
|
||||||
|
if let Some(morph) = component.spec().morph {
|
||||||
|
assert_ne!(
|
||||||
|
morph.from,
|
||||||
|
morph.to,
|
||||||
|
"{} 的形变起止不能相同",
|
||||||
|
component.name()
|
||||||
|
);
|
||||||
|
// 方向必须「沿阶梯胀开」(morph 对 `Full` 也没问题,rank 把它排在末尾),
|
||||||
|
// 不比较像素跨度:`Full` 的半径依赖边长,用固定边长比会被 `None` 之外的大档拉爆。
|
||||||
|
assert!(
|
||||||
|
morph.from.rank() < morph.to.rank(),
|
||||||
|
"{} 的形变方向反了:{:?} → {:?}",
|
||||||
|
component.name(),
|
||||||
|
morph.from,
|
||||||
|
morph.to
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn appended_variants_keep_the_frozen_order() {
|
||||||
|
// 枚举冻结为「只可末尾追加」:前 20 个的次序不能动(M69 只在末尾加 5 个)。
|
||||||
|
assert_eq!(Component::ALL.len(), 25);
|
||||||
|
assert_eq!(Component::ALL[0], Component::TopAppBar);
|
||||||
|
assert_eq!(Component::ALL[19], Component::EmptyState);
|
||||||
|
assert_eq!(Component::ALL[20], Component::Checkbox);
|
||||||
|
assert_eq!(Component::ALL[24], Component::Divider);
|
||||||
|
for (i, component) in Component::ALL.iter().enumerate() {
|
||||||
|
assert_eq!(*component as usize, i, "ALL 的次序与枚举判别值不一致");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn micro_radius_only_on_micro_controls() {
|
||||||
|
// D32:微半径只能给 ≤24dp 的微控件,且此时 `shape` 必须是占位 None(不参与渲染)。
|
||||||
|
let mut owners = Vec::new();
|
||||||
|
for component in Component::ALL {
|
||||||
|
let spec = component.spec();
|
||||||
|
let Some(radius) = spec.micro_radius else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
owners.push(component);
|
||||||
|
assert_eq!(radius, ComponentRadius::Micro, "白名单外的微半径不许过表");
|
||||||
|
assert_eq!(
|
||||||
|
spec.shape,
|
||||||
|
ShapeScale::None,
|
||||||
|
"{} 有微半径时 shape 必须占位 None(2dp 不在刻度里)",
|
||||||
|
component.name()
|
||||||
|
);
|
||||||
|
let metrics = component
|
||||||
|
.metrics()
|
||||||
|
.expect("带微半径的组件必须有 ComponentMetrics");
|
||||||
|
assert!(
|
||||||
|
metrics.width <= crate::shape::MICRO_CONTROL_MAX_SIZE
|
||||||
|
&& metrics.height <= crate::shape::MICRO_CONTROL_MAX_SIZE,
|
||||||
|
"{} 超出微控件尺寸上限",
|
||||||
|
component.name()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
radius.radius_px(metrics.width),
|
||||||
|
Some(radius.dp()),
|
||||||
|
"{} 的微半径按自身尺寸取不到",
|
||||||
|
component.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
owners,
|
||||||
|
vec![Component::Checkbox],
|
||||||
|
"当前只允许 Checkbox 用微半径"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metrics_resolve_only_for_controls_with_inner_geometry() {
|
||||||
|
for component in [
|
||||||
|
Component::Checkbox,
|
||||||
|
Component::Radio,
|
||||||
|
Component::Search,
|
||||||
|
Component::NavigationDrawer,
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
component.metrics().is_some(),
|
||||||
|
"{} 缺 ComponentMetrics",
|
||||||
|
component.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Divider 只有厚度,而厚度是一像素线(LM),不该出现在 CM 里。
|
||||||
|
assert!(Component::Divider.metrics().is_none());
|
||||||
|
// 骨架级尺寸不许在 CM 里重复一份(Single Source of Truth)。
|
||||||
|
for component in [Component::Search, Component::NavigationDrawer] {
|
||||||
|
let metrics = component.metrics().unwrap();
|
||||||
|
assert_eq!(metrics.width, 0.0, "{} 的宽度属骨架级", component.name());
|
||||||
|
assert_eq!(metrics.height, 0.0, "{} 的高度属骨架级", component.name());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,14 +15,16 @@
|
|||||||
//!
|
//!
|
||||||
//! ## 分模块
|
//! ## 分模块
|
||||||
//!
|
//!
|
||||||
//! - [`color`]:M3 语义色角色(49 个)× light/dark 双套 + tone 调色板(OKLCh 近似,口径见模块文档)
|
//! - [`color`]:M3 语义色角色(49 个)× light/dark 双套 + tone 调色板(**真 HCT**:CAM16 色相/彩度
|
||||||
|
//! + CIELAB `L*`,官方 `ColorSpec2021` tone 表,与官方 Java 实现逐通道对齐——口径见模块文档)
|
||||||
//! - [`typography`]:M3 type scale 15 档 + 三平台字体族回退栈
|
//! - [`typography`]:M3 type scale 15 档 + 三平台字体族回退栈
|
||||||
//! - [`shape`]:M3 Expressive 形状刻度 + shape morph 数据
|
//! - [`shape`]:M3 Expressive 形状刻度 + shape morph 数据 + [`shape::ComponentRadius`](微控件圆角层,D32)
|
||||||
//! - [`elevation`]:elevation level 与 surface tint / surface container 规则
|
//! - [`elevation`]:elevation level 与 surface tint / surface container 规则
|
||||||
//! - [`motion`]:时长、easing(可采样)、Expressive 弹簧参数
|
//! - [`motion`]:时长、easing(可采样)、Expressive 弹簧参数
|
||||||
//! - [`spacing`]:4px 栅格与骨架尺寸(原 `metrics.slint` 的框架无关部分)
|
//! - [`spacing`]:4px 栅格、骨架尺寸(原 `metrics.slint` 的框架无关部分)、[`stroke::HAIRLINE`](一像素线)
|
||||||
//! - [`icons`]:24 网格图标几何(SVG path 字符串)+ 描边语义
|
//! - [`icons`]:24 网格图标几何(SVG path 字符串)+ 描边语义
|
||||||
//! - [`component_map`]:组件 → 档位映射表(哪个组件用哪档形状/高程/动效/字体)
|
//! - [`component_map`]:组件 → 档位映射表(哪个组件用哪档形状/高程/动效/字体)
|
||||||
|
//! + [`component_map::ComponentMetrics`](控件内部微几何,与 `LayoutMetrics` 分工见该模块文档)
|
||||||
//!
|
//!
|
||||||
//! ## 最小用法
|
//! ## 最小用法
|
||||||
//!
|
//!
|
||||||
@@ -92,6 +94,7 @@ pub mod typography;
|
|||||||
|
|
||||||
pub use color::{Color, Role, Scheme, SchemeFlavor};
|
pub use color::{Color, Role, Scheme, SchemeFlavor};
|
||||||
pub use motion::MotionScale;
|
pub use motion::MotionScale;
|
||||||
|
pub use spacing::stroke;
|
||||||
pub use typography::TypeRole;
|
pub use typography::TypeRole;
|
||||||
|
|
||||||
/// 设计令牌的整体入口:一份「当前该长什么样」的聚合快照。
|
/// 设计令牌的整体入口:一份「当前该长什么样」的聚合快照。
|
||||||
@@ -109,7 +112,11 @@ pub struct DesignTokens {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DesignTokens {
|
impl DesignTokens {
|
||||||
/// 按模式给一套出厂令牌(baseline seed `#6750A4`,等价 M3 baseline 调色板)。
|
/// 按模式给一套**产品默认**令牌:种子 `#3C8DBC`(M70 ④ 天蓝,用户 2026-11-01 拍板)
|
||||||
|
/// + 中性彩度 12,即 `color::palettes::DEFAULT_SEED` / `DEFAULT_NEUTRAL_CHROMA`。
|
||||||
|
///
|
||||||
|
/// 想要**官方 M3 baseline** 口径(种子 `#6750A4` + 中性彩度 6 / neutral-variant 8)请走
|
||||||
|
/// `color::Scheme::official_baseline(flavor)`——两条路径的算法完全相同,只差入参。
|
||||||
pub fn baseline(flavor: SchemeFlavor) -> Self {
|
pub fn baseline(flavor: SchemeFlavor) -> Self {
|
||||||
Self {
|
Self {
|
||||||
flavor,
|
flavor,
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
//!
|
//!
|
||||||
//! 形状是「唯一真源」的一部分:组件不许写裸半径(`border_radius(8)`),一律引用
|
//! 形状是「唯一真源」的一部分:组件不许写裸半径(`border_radius(8)`),一律引用
|
||||||
//! [`ShapeScale`];需要动起来的(按下、选中、展开)用 [`ShapeMorph`] 的 from→to 数据。
|
//! [`ShapeScale`];需要动起来的(按下、选中、展开)用 [`ShapeMorph`] 的 from→to 数据。
|
||||||
|
//!
|
||||||
|
//! ## 圆角分两层(D32)
|
||||||
|
//!
|
||||||
|
//! 官方实现与官方刻度表并不总一致:Checkbox 的圆角是 **2dp**,但 `ShapeScale` 的刻度是
|
||||||
|
//! `{0,4,8,12,16,20,28,32,48,full}`——2 不在里面。D32 的判决是**分层**而不是放宽判据:
|
||||||
|
//!
|
||||||
|
//! - **容器级**(卡片、弹窗、抽屉、按钮、面板、输入框外壳)只许用 [`ShapeScale`] 的刻度值;
|
||||||
|
//! - **微控件级**(≤24dp 的 Checkbox 等)用 [`ComponentRadius`] 的白名单值。
|
||||||
|
//!
|
||||||
|
//! 一个元素该查哪一层由 [`radius_layer_for`] 判定(按尺寸,唯一入口);微控件层取值走
|
||||||
|
//! [`ComponentRadius::radius_px`],**尺寸超界时返回 `None`**——这不是文档约定,是签名保证。
|
||||||
|
|
||||||
use crate::motion::Spring;
|
use crate::motion::Spring;
|
||||||
|
|
||||||
@@ -87,6 +98,88 @@ impl ShapeScale {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 微控件(≤24dp)的尺寸上限(dp)。
|
||||||
|
///
|
||||||
|
/// 超过这条线的元素属**容器级**,圆角必须查 [`ShapeScale`] 的严格刻度;[`ComponentRadius`]
|
||||||
|
/// 只在这条线以内有效——由 [`ComponentRadius::radius_px`] 在运行期拦住(传大尺寸拿不到微半径)。
|
||||||
|
/// 24 这个门槛不是官方数字,是 D32 给「微控件」划的界(Checkbox 20、指示点、徽标都在以内)。
|
||||||
|
pub const MICRO_CONTROL_MAX_SIZE: f32 = 24.0;
|
||||||
|
|
||||||
|
/// 微控件圆角(D32 第二层):**来源官方组件实现,不是 [`ShapeScale`] 的成员**。
|
||||||
|
///
|
||||||
|
/// 为什么要有这一层:官方 Checkbox 的圆角是 **2dp**(`Checkbox.kt:738 RadiusSize = 2.dp`,
|
||||||
|
/// `CheckboxTokens.ContainerShape = RoundedCornerShape(2)`),而 M3 形状刻度只有
|
||||||
|
/// `{0,4,8,12,16,20,28,32,48,full}`。把 2 塞进刻度会废掉「无任意圆角」这条守门(D30 rubric K1),
|
||||||
|
/// 不收又只能在组件里抄 `2.0` 字面量。D32 的判决是**分层**,不是放宽:
|
||||||
|
///
|
||||||
|
/// | 层 | 管什么 | 取值 |
|
||||||
|
/// |---|---|---|
|
||||||
|
/// | [`ShapeScale`] | 容器级:卡片/弹窗/抽屉/按钮/面板/输入框外壳 | 严格刻度,不许自造 |
|
||||||
|
/// | `ComponentRadius` | 微控件级:Checkbox 等 ≤24dp 的元素 | 白名单,当前只有 [`ComponentRadius::Micro`] |
|
||||||
|
///
|
||||||
|
/// 用法:先 [`radius_layer_for`] 判层,微控件再 `radius_px(size)`。**返回 `None` 说明尺寸超界,
|
||||||
|
/// 必须改查 [`ShapeScale`]**,不许把微半径硬套到容器上。
|
||||||
|
///
|
||||||
|
/// 公开的**唯一取值口是 [`ComponentRadius::radius_px`]**(带尺寸闸):本类型的 `dp()` 是
|
||||||
|
/// `pub(crate)`,故意不对外开放——否则任何调用方都能无条件拿到 `2.0`,「只许 ≤24dp」就成了
|
||||||
|
/// 自觉而不是约束(D32 的原意是拦住,不是提醒)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum ComponentRadius {
|
||||||
|
/// 2dp:官方 Checkbox 容器圆角。
|
||||||
|
Micro,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ComponentRadius {
|
||||||
|
/// 白名单里的全部微半径(D32 当前只有一项;加值必须先在 `design-令牌约定` 登记)。
|
||||||
|
///
|
||||||
|
/// 枚举得出的只是**变体**(`Micro` 这个名字),拿不到数值——取值必须走 [`Self::radius_px`]。
|
||||||
|
pub const ALL: [ComponentRadius; 1] = [ComponentRadius::Micro];
|
||||||
|
|
||||||
|
/// 半径(dp)。**`pub(crate)`:crate 内推导与自己用,不对外开放。**
|
||||||
|
///
|
||||||
|
/// 对任何尺寸都返回同一个数,所以它天然绕开 D32 的尺寸闸;对外只留
|
||||||
|
/// [`Self::radius_px`](`Option<f32>`,>24dp 返回 `None`)。
|
||||||
|
pub(crate) const fn dp(self) -> f32 {
|
||||||
|
match self {
|
||||||
|
ComponentRadius::Micro => 2.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 微控件的实际半径:尺寸超过 [`MICRO_CONTROL_MAX_SIZE`] 时返回 **`None`**。
|
||||||
|
///
|
||||||
|
/// 这是 D32 里「能编译期拦住就别靠自觉」的落点——文档约定拦不住,签名拦得住:
|
||||||
|
/// 拿到 `None` 的调用点没法「顺手用一下 2dp」,只能改查 [`ShapeScale`]。
|
||||||
|
/// 另外半径不超过边长一半(2dp 半径放到更小的元素上会溢成圆)。
|
||||||
|
pub fn radius_px(self, size_px: f32) -> Option<f32> {
|
||||||
|
if size_px > 0.0 && size_px <= MICRO_CONTROL_MAX_SIZE {
|
||||||
|
Some(self.dp().min(size_px / 2.0))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一个元素的圆角该查哪一层([`radius_layer_for`] 的返回)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum RadiusLayer {
|
||||||
|
/// 容器级:查 [`ShapeScale`] 的严格刻度。
|
||||||
|
Scale,
|
||||||
|
/// 微控件级(≤24dp):查 [`ComponentRadius`] 的白名单。
|
||||||
|
Component,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 纯函数:按元素尺寸判定圆角该查哪一层。
|
||||||
|
///
|
||||||
|
/// 「某尺寸的元素该查哪一个」是 D32 里唯一需要判断的地方,所以只留这一个入口——
|
||||||
|
/// 组件层不许自己写 `if size <= 24.0`,那等于把阈值抄了第二份。
|
||||||
|
pub fn radius_layer_for(size_px: f32) -> RadiusLayer {
|
||||||
|
if size_px > 0.0 && size_px <= MICRO_CONTROL_MAX_SIZE {
|
||||||
|
RadiusLayer::Component
|
||||||
|
} else {
|
||||||
|
RadiusLayer::Scale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 一次形状形变(Expressive 的「按下胀开」「展开成药丸」等)。
|
/// 一次形状形变(Expressive 的「按下胀开」「展开成药丸」等)。
|
||||||
///
|
///
|
||||||
/// 数据形态是纯的:起止档位 + 弹簧参数。适配层负责跑时钟,`progress` 由弹簧/缓动给出。
|
/// 数据形态是纯的:起止档位 + 弹簧参数。适配层负责跑时钟,`progress` 由弹簧/缓动给出。
|
||||||
@@ -165,4 +258,45 @@ mod tests {
|
|||||||
assert_eq!(morph.radius_px(200.0, -2.0), 8.0);
|
assert_eq!(morph.radius_px(200.0, -2.0), 8.0);
|
||||||
assert_eq!(morph.radius_px(200.0, 3.0), 20.0);
|
assert_eq!(morph.radius_px(200.0, 3.0), 20.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn component_radius_is_whitelisted_and_refuses_containers() {
|
||||||
|
// D32:微半径白名单当前只有 2dp,且永远不是 ShapeScale 的成员。
|
||||||
|
assert_eq!(ComponentRadius::ALL.len(), 1);
|
||||||
|
assert_eq!(ComponentRadius::Micro.dp(), 2.0);
|
||||||
|
for scale in [
|
||||||
|
ShapeScale::None,
|
||||||
|
ShapeScale::ExtraSmall,
|
||||||
|
ShapeScale::Small,
|
||||||
|
ShapeScale::Medium,
|
||||||
|
ShapeScale::Large,
|
||||||
|
ShapeScale::LargeIncreased,
|
||||||
|
ShapeScale::ExtraLarge,
|
||||||
|
ShapeScale::ExtraLargeIncreased,
|
||||||
|
ShapeScale::ExtraExtraLarge,
|
||||||
|
] {
|
||||||
|
assert_ne!(scale.dp(), 2.0, "{scale:?} 不该等于微半径(2 不在刻度里)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 20×20 的 checkbox 拿 2dp;超 24 的元素必须拿不到。
|
||||||
|
assert_eq!(ComponentRadius::Micro.radius_px(20.0), Some(2.0));
|
||||||
|
assert_eq!(ComponentRadius::Micro.radius_px(24.0), Some(2.0));
|
||||||
|
assert_eq!(ComponentRadius::Micro.radius_px(24.5), None);
|
||||||
|
assert_eq!(ComponentRadius::Micro.radius_px(200.0), None);
|
||||||
|
assert_eq!(ComponentRadius::Micro.radius_px(0.0), None);
|
||||||
|
// 半径不超过边长一半。
|
||||||
|
assert_eq!(ComponentRadius::Micro.radius_px(2.0), Some(1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn radius_layer_follows_the_size_threshold() {
|
||||||
|
assert_eq!(radius_layer_for(20.0), RadiusLayer::Component);
|
||||||
|
assert_eq!(
|
||||||
|
radius_layer_for(MICRO_CONTROL_MAX_SIZE),
|
||||||
|
RadiusLayer::Component
|
||||||
|
);
|
||||||
|
assert_eq!(radius_layer_for(24.5), RadiusLayer::Scale);
|
||||||
|
assert_eq!(radius_layer_for(320.0), RadiusLayer::Scale);
|
||||||
|
assert_eq!(radius_layer_for(0.0), RadiusLayer::Scale);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,37 @@
|
|||||||
//! 形状 / 高程 / 动效之外的「尺子」:4px 栅格与骨架尺寸。
|
//! 形状 / 高程 / 动效之外的「尺子」:4px 栅格、骨架尺寸、一像素线。
|
||||||
//!
|
//!
|
||||||
//! 源流有两条:`DevDocs/ops/slint-reference/crates/app/ui/theme/metrics.slint`(M52 定稿的骨架节奏),
|
//! 源流有两条:`DevDocs/ops/slint-reference/crates/app/ui/theme/metrics.slint`(M52 定稿的骨架节奏),
|
||||||
//! 以及 `ui-integration-contract.md` §3「间距只允许 4/8/12/16/24/32」。M3 同样用 4dp 栅格,
|
//! 以及 `ui-integration-contract.md` §3「间距只允许 4/8/12/16/24/32」。M3 同样用 4dp 栅格,
|
||||||
//! 所以这层**不用改口径**,只是把值搬进唯一真源。
|
//! 所以这层**不用改口径**,只是把值搬进唯一真源。
|
||||||
|
//!
|
||||||
|
//! ## 职责边界:`LayoutMetrics` vs `ComponentMetrics`(M69 / G10)
|
||||||
|
//!
|
||||||
|
//! | 结构 | 管什么 | 例子 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | [`LayoutMetrics`] | **窗口骨架与布局节奏**:标题栏/页头/行的节奏、dock 宽度(导航、AI 面板)、命中区 | [`LayoutMetrics::nav_drawer_width`] / [`LayoutMetrics::search_bar_height`] / `touch_target` |
|
||||||
|
//! | `component_map::ComponentMetrics` | **单个控件内部几何**:外框尺寸、微圆角、描边/环线宽、状态层直径、内点、图标偏移 | checkbox 20×20 + 描边 2 + 状态层 Ø40 |
|
||||||
|
//! | [`stroke::HAIRLINE`] | **一像素线**的唯一真源:分隔线厚度、容器描边、聚焦指示 | [`LayoutMetrics::divider_thickness`] |
|
||||||
|
//!
|
||||||
|
//! 判据:一个值若随窗口/列宽变化、或决定一块面板占多宽,归 LM;若它只在某个控件自己的方框
|
||||||
|
//! 内起作用(画进去的那几个像素),归 CM。**同一个值不许两边各写一份**。
|
||||||
|
|
||||||
|
/// 一像素线(G12):全站 1px 线的**唯一真源**。
|
||||||
|
///
|
||||||
|
/// 为什么要单独开一个命名空间:`1.0` 在项目里同时承担三种语义——分隔线厚度、容器描边宽度、
|
||||||
|
/// 聚焦指示描边宽度。各自抄字面量就会漂成 1.0 / 1.5 / 2.0 混排(M66 的 G12 正是这么来的)。
|
||||||
|
/// 从这里取,语义和数值只有一个落点。
|
||||||
|
///
|
||||||
|
/// iced 侧怎么落地(适配层照此抄,别再自己定 `STROKE_PX`):
|
||||||
|
/// - **容器描边**:`Border { width: unlucky_design::stroke::HAIRLINE, .. }`(卡片/输入框/chip/菜单);
|
||||||
|
/// - **分隔线**:[`LayoutMetrics::divider_thickness`](数值就是 [`stroke::HAIRLINE`])——
|
||||||
|
/// 横向分隔线是容器高度、纵向是宽度,都取它;
|
||||||
|
/// - **聚焦环**:内描边与外描边**都用**它(M3 的聚焦指示是 1dp,做成 2dp 就是加粗);
|
||||||
|
/// - **不用于**:elevation 的 blur / offset(那是 [`crate::elevation`] 的量)、
|
||||||
|
/// 图标描边(图标走 SVG 的 viewBox 单位,见 [`crate::icons::stroke_width_for`])。
|
||||||
|
pub mod stroke {
|
||||||
|
/// 一像素线宽度(逻辑 px @100% DPI = 1dp;DividerTokens.Thickness)。
|
||||||
|
pub const HAIRLINE: f32 = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
/// 栅格基数(dp)。
|
/// 栅格基数(dp)。
|
||||||
pub const GRID: f32 = 4.0;
|
pub const GRID: f32 = 4.0;
|
||||||
@@ -63,10 +92,31 @@ pub struct LayoutMetrics {
|
|||||||
pub titlebar_height: f32,
|
pub titlebar_height: f32,
|
||||||
/// 标题栏按钮宽度(关闭/最大化/最小化)。
|
/// 标题栏按钮宽度(关闭/最大化/最小化)。
|
||||||
pub titlebar_button_width: f32,
|
pub titlebar_button_width: f32,
|
||||||
/// 展开态导航宽度。
|
/// 展开态导航(drawer 形态)宽度。
|
||||||
pub nav_expanded_width: f32,
|
///
|
||||||
/// 收起态导航宽度。
|
/// **G11 语义修正(M69 / D33)**:D16「左侧固定、允许折叠」落地为**同一个常驻容器**的两种
|
||||||
pub nav_collapsed_width: f32,
|
/// 宽度——展开 240(drawer)、折叠 88(rail)。旧字段 `nav_expanded_width = 88` 的名字与值
|
||||||
|
/// 相反(88 其实是 rail),已删除,**不留别名**:两个名字指同一个量等于把选择权还给组件层。
|
||||||
|
///
|
||||||
|
/// 官方 `NavigationDrawerTokens.ContainerWidth = 360`、`MinimumDrawerWidth = 240`;
|
||||||
|
/// 本项目取官方下限 240——360 + AI 面板 320 = 680 = 1180 窗宽的 58%,内容区只剩 500 放不下两列卡片。
|
||||||
|
pub nav_drawer_width: f32,
|
||||||
|
/// 折叠态导航(rail 形态)宽度。
|
||||||
|
///
|
||||||
|
/// 88 = 12b §5.1 对官方 rail 96 的登记偏离(−8):指示器宽 88 − 2×16 = 56 仍成立,
|
||||||
|
/// 再窄就装不下 56 宽的选中指示器。
|
||||||
|
pub nav_rail_width: f32,
|
||||||
|
/// 搜索条高度(G11)。
|
||||||
|
///
|
||||||
|
/// 官方 `SearchBarTokens.ContainerHeight = 56`。**不走 [`LayoutMetrics::control_height`](40)**:
|
||||||
|
/// 搜索条是页级入口而不是密集行控件;56 也顺带 ≥ 命中下限 48。圆角由 `ShapeScale::Full`
|
||||||
|
/// 按边长折算(56 → 28,正好等于官方 `SearchBarCornerRadius`)。
|
||||||
|
pub search_bar_height: f32,
|
||||||
|
/// 分隔线厚度(G11 + G12)。
|
||||||
|
///
|
||||||
|
/// 官方 `DividerTokens.Thickness = 1.0.dp`。值就是 [`stroke::HAIRLINE`]——**一像素线不受 4px
|
||||||
|
/// 栅格约束**,所以它不在栅格测试的表里(测试里显式豁免并写明原因)。
|
||||||
|
pub divider_thickness: f32,
|
||||||
/// 导航项高度。
|
/// 导航项高度。
|
||||||
pub nav_item_height: f32,
|
pub nav_item_height: f32,
|
||||||
/// 导航项间距。
|
/// 导航项间距。
|
||||||
@@ -103,8 +153,14 @@ impl LayoutMetrics {
|
|||||||
pub const DESKTOP: Self = Self {
|
pub const DESKTOP: Self = Self {
|
||||||
titlebar_height: 48.0,
|
titlebar_height: 48.0,
|
||||||
titlebar_button_width: 46.0,
|
titlebar_button_width: 46.0,
|
||||||
nav_expanded_width: 88.0,
|
// G11 / D33:展开 = drawer 240,折叠 = rail 88(旧 `nav_expanded_width=88` /
|
||||||
nav_collapsed_width: 48.0,
|
// `nav_collapsed_width=48` 的名字与值相反,已删)。
|
||||||
|
nav_drawer_width: 240.0,
|
||||||
|
nav_rail_width: 88.0,
|
||||||
|
// G11:官方 SearchBarTokens.ContainerHeight = 56。
|
||||||
|
search_bar_height: 56.0,
|
||||||
|
// G11/G12:官方 DividerTokens.Thickness = 1.0dp → 一像素线的唯一真源。
|
||||||
|
divider_thickness: stroke::HAIRLINE,
|
||||||
nav_item_height: 44.0,
|
nav_item_height: 44.0,
|
||||||
nav_item_gap: 4.0,
|
nav_item_gap: 4.0,
|
||||||
page_header_height: 56.0,
|
page_header_height: 56.0,
|
||||||
@@ -146,8 +202,13 @@ mod tests {
|
|||||||
// 受 4px 栅格约束的字段(间距/高度/宽度类)。
|
// 受 4px 栅格约束的字段(间距/高度/宽度类)。
|
||||||
// `titlebar_button_width` 刻意不在表内:46 对齐 Windows 11 原生 caption 按钮宽度,
|
// `titlebar_button_width` 刻意不在表内:46 对齐 Windows 11 原生 caption 按钮宽度,
|
||||||
// 属 OS 惯例值,单列一条断言钉住,不让它静默漂移。
|
// 属 OS 惯例值,单列一条断言钉住,不让它静默漂移。
|
||||||
|
// `divider_thickness` 同理不在表内:它是**一像素线**(1 不是 4 的倍数),
|
||||||
|
// 归 `stroke::HAIRLINE` 那条规则管,下面单独断言。
|
||||||
for (name, value) in [
|
for (name, value) in [
|
||||||
("titlebar_height", m.titlebar_height),
|
("titlebar_height", m.titlebar_height),
|
||||||
|
("nav_drawer_width", m.nav_drawer_width),
|
||||||
|
("nav_rail_width", m.nav_rail_width),
|
||||||
|
("search_bar_height", m.search_bar_height),
|
||||||
("nav_item_height", m.nav_item_height),
|
("nav_item_height", m.nav_item_height),
|
||||||
("nav_item_gap", m.nav_item_gap),
|
("nav_item_gap", m.nav_item_gap),
|
||||||
("page_header_height", m.page_header_height),
|
("page_header_height", m.page_header_height),
|
||||||
@@ -165,6 +226,37 @@ mod tests {
|
|||||||
m.titlebar_button_width, 46.0,
|
m.titlebar_button_width, 46.0,
|
||||||
"标题栏按钮宽度应对齐 Win11 原生 caption"
|
"标题栏按钮宽度应对齐 Win11 原生 caption"
|
||||||
);
|
);
|
||||||
|
// 一像素线的显式豁免(不是「忘了加进表」,是它本来就不受栅格约束)。
|
||||||
|
assert_eq!(m.divider_thickness, stroke::HAIRLINE);
|
||||||
|
assert_ne!(m.divider_thickness % GRID, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nav_widths_follow_the_two_form_decision() {
|
||||||
|
// D33:左侧导航是同一容器的两种宽度,展开(drawer)必须比折叠(rail)宽。
|
||||||
|
let m = LayoutMetrics::DESKTOP;
|
||||||
|
assert_eq!(m.nav_drawer_width, 240.0, "展开 drawer 取官方下限 240");
|
||||||
|
assert_eq!(m.nav_rail_width, 88.0, "折叠 rail 为 88(登记偏离官方 96)");
|
||||||
|
assert!(m.nav_rail_width < m.nav_drawer_width);
|
||||||
|
// rail 里的选中指示器 = 容器宽 − 2×16;88 − 32 = 56,再窄就装不下 56 宽指示器。
|
||||||
|
assert_eq!(m.nav_rail_width - Space::X4.dp() * 2.0, 56.0);
|
||||||
|
// 内容区下限:1180 窗宽 − drawer − AI 面板 仍 ≥600(够放两列卡片)。
|
||||||
|
assert!(1180.0 - m.nav_drawer_width - m.ai_panel_width >= 600.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_bar_height_is_a_page_level_entry() {
|
||||||
|
let m = LayoutMetrics::DESKTOP;
|
||||||
|
assert_eq!(m.search_bar_height, 56.0, "官方 ContainerHeight = 56");
|
||||||
|
assert!(
|
||||||
|
m.search_bar_height >= m.touch_target,
|
||||||
|
"页级入口必须 ≥ 命中下限"
|
||||||
|
);
|
||||||
|
// 圆角走 ShapeScale::Full,56 → 28,正好是官方 SearchBarCornerRadius。
|
||||||
|
assert_eq!(
|
||||||
|
crate::shape::ShapeScale::Full.radius_px(m.search_bar_height),
|
||||||
|
28.0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -54,13 +54,13 @@ const EXCEPTIONS: [(Role, Role, f32, &str); 3] = [
|
|||||||
Role::Outline,
|
Role::Outline,
|
||||||
Role::Surface,
|
Role::Surface,
|
||||||
AA_NON_TEXT,
|
AA_NON_TEXT,
|
||||||
"输入框/卡片描边是 M3 定义的非文字元素,规范目标 3:1(light 实测 4.29:1、dark 6.5:1 已高于目标)",
|
"输入框/卡片描边是 M3 定义的非文字元素,规范目标 3:1(light 实测 4.26:1、dark 5.89:1 已高于目标)",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
Role::OutlineVariant,
|
Role::OutlineVariant,
|
||||||
Role::Surface,
|
Role::Surface,
|
||||||
AA_NON_TEXT * 0.5,
|
AA_NON_TEXT * 0.5,
|
||||||
"分隔线是纯装饰,M3 不要求对比度下限;1.6:1 是「几乎看不见但要能数出层级」的刻意取值",
|
"分隔线是纯装饰,M3 不要求对比度下限;light 1.62:1 / dark 1.99:1 是「几乎看不见但要能数出层级」的取值",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
Role::SurfaceVariant,
|
Role::SurfaceVariant,
|
||||||
@@ -157,6 +157,62 @@ fn baseline_light_and_dark_both_pass() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sky_seed_candidates_meet_wcag_aa() {
|
||||||
|
// M70:四档默认色候选(种子 + 中性彩度)必须过同一条 26 组门禁。
|
||||||
|
// 中性彩度改变只影响色相/彩度,不影响 tone 轴,所以理论上恒过——这里就是要**证明**它恒过,
|
||||||
|
// 而不是相信它。任何一档不达标,该档直接判为不可用(不许为了好看放宽阈值)。
|
||||||
|
let candidates = [
|
||||||
|
("① 保守蓝", "#2196F3", 6.0_f64),
|
||||||
|
("② 冷调蓝", "#2196F3", 12.0),
|
||||||
|
("③ 浅冷蓝", "#87CEEB", 12.0),
|
||||||
|
("④ 天蓝", "#3C8DBC", 12.0),
|
||||||
|
];
|
||||||
|
let mut failures = Vec::new();
|
||||||
|
let mut worst = (f32::MAX, String::new());
|
||||||
|
let mut checked = 0;
|
||||||
|
for (title, seed_hex, neutral_chroma) in candidates {
|
||||||
|
let seed = Color::from_hex(seed_hex).expect("候选种子");
|
||||||
|
let palettes =
|
||||||
|
unlucky_design::color::Palettes::from_seed_with_neutral_chroma(seed, neutral_chroma);
|
||||||
|
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||||
|
let scheme = Scheme::from_palettes(&palettes, flavor);
|
||||||
|
for (fg, bg) in TEXT_PAIRS {
|
||||||
|
let ratio = scheme.contrast(fg, bg);
|
||||||
|
checked += 1;
|
||||||
|
if ratio < worst.0 {
|
||||||
|
worst = (
|
||||||
|
ratio,
|
||||||
|
format!(
|
||||||
|
"{title}/{} 的 {} on {}",
|
||||||
|
flavor.name(),
|
||||||
|
fg.name(),
|
||||||
|
bg.name()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ratio < AA_TEXT {
|
||||||
|
failures.push(format!(
|
||||||
|
"{title}({seed_hex} / 中性 {neutral_chroma})/ {} / {} on {} = {ratio:.2}",
|
||||||
|
flavor.name(),
|
||||||
|
fg.name(),
|
||||||
|
bg.name()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"候选 WCAG:{checked} 项,最紧的一对 {} = {:.2}:1(阈值 {AA_TEXT})",
|
||||||
|
worst.1, worst.0
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
failures.is_empty(),
|
||||||
|
"以下候选未达 WCAG AA(4.5:1),该档不可用:\n{}",
|
||||||
|
failures.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn accent_override_cannot_break_contrast() {
|
fn accent_override_cannot_break_contrast() {
|
||||||
// 换了强调色之后,primary 族仍必须过 AA——这是「tone 轴固定」带来的结构性保证。
|
// 换了强调色之后,primary 族仍必须过 AA——这是「tone 轴固定」带来的结构性保证。
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
//! 自定义强调色 × 官方算法的**逐角色对齐**(`Scheme::from_accent`)。
|
||||||
|
//!
|
||||||
|
//! 期望值来自官方 Java 实现:`SchemeTonalSpot(seed, isDark, 0, SPEC_2021)` 的强调族角色。
|
||||||
|
//! 判据:每通道误差 ≤1(见 `official_baseline.rs` 的说明)。
|
||||||
|
//!
|
||||||
|
//! 为什么只比强调族(primary/secondary/tertiary 共 12 个角色):本项目的产品语义是
|
||||||
|
//! 「换强调色不动底色」——中性表面与 error 刻意留在 baseline(见 `Palettes::with_accent`),
|
||||||
|
//! 而官方 `from_seed` 会把中性色也染上种子色相。中性派生的正确性由本文件末尾的
|
||||||
|
//! `from_seed_*` 用例单独验证(那一处必须与官方完全一致)。
|
||||||
|
|
||||||
|
use unlucky_design::color::{Color, Role, Scheme, SchemeFlavor};
|
||||||
|
|
||||||
|
/// 每通道允许的最大误差。
|
||||||
|
const CHANNEL_TOLERANCE: i16 = 1;
|
||||||
|
|
||||||
|
/// 一条官方期望值:角色 + light 色值 + dark 色值。
|
||||||
|
type RoleRow = (Role, &'static str, &'static str);
|
||||||
|
|
||||||
|
/// 一个种子 + 它 12 个强调族角色的官方色值。
|
||||||
|
type SeedRows = (&'static str, [RoleRow; 12]);
|
||||||
|
|
||||||
|
/// 八个种子:官方基线紫、纯红/绿/蓝、低彩度灰蓝、高彩度紫,外加纯黑与纯白两个边缘用例
|
||||||
|
/// (无彩色种子的色相是病态的,官方的处理方式见 `Hct`:黑 → 色相 0、白 → 色相 209.5)。
|
||||||
|
/// 每个种子带 12 个强调族角色的官方色值(light, dark)。
|
||||||
|
const ACCENT_SEEDS: [SeedRows; 8] = [
|
||||||
|
(
|
||||||
|
"#6750A4",
|
||||||
|
[
|
||||||
|
(Role::Primary, "#65558F", "#CFBDFE"),
|
||||||
|
(Role::OnPrimary, "#FFFFFF", "#36275D"),
|
||||||
|
(Role::PrimaryContainer, "#E9DDFF", "#4D3D75"),
|
||||||
|
(Role::OnPrimaryContainer, "#4D3D75", "#E9DDFF"),
|
||||||
|
(Role::Secondary, "#625B71", "#CBC2DB"),
|
||||||
|
(Role::OnSecondary, "#FFFFFF", "#332D41"),
|
||||||
|
(Role::SecondaryContainer, "#E8DEF8", "#4A4458"),
|
||||||
|
(Role::OnSecondaryContainer, "#4A4458", "#E8DEF8"),
|
||||||
|
(Role::Tertiary, "#7E5260", "#EFB8C8"),
|
||||||
|
(Role::OnTertiary, "#FFFFFF", "#4A2532"),
|
||||||
|
(Role::TertiaryContainer, "#FFD9E3", "#633B48"),
|
||||||
|
(Role::OnTertiaryContainer, "#633B48", "#FFD9E3"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"#FF0000",
|
||||||
|
[
|
||||||
|
(Role::Primary, "#904B40", "#FFB4A8"),
|
||||||
|
(Role::OnPrimary, "#FFFFFF", "#561E16"),
|
||||||
|
(Role::PrimaryContainer, "#FFDAD4", "#73342A"),
|
||||||
|
(Role::OnPrimaryContainer, "#73342A", "#FFDAD4"),
|
||||||
|
(Role::Secondary, "#775651", "#E7BDB6"),
|
||||||
|
(Role::OnSecondary, "#FFFFFF", "#442925"),
|
||||||
|
(Role::SecondaryContainer, "#FFDAD4", "#5D3F3B"),
|
||||||
|
(Role::OnSecondaryContainer, "#5D3F3B", "#FFDAD4"),
|
||||||
|
(Role::Tertiary, "#705C2E", "#DEC48C"),
|
||||||
|
(Role::OnTertiary, "#FFFFFF", "#3E2E04"),
|
||||||
|
(Role::TertiaryContainer, "#FBDFA6", "#564419"),
|
||||||
|
(Role::OnTertiaryContainer, "#564419", "#FBDFA6"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"#00FF00",
|
||||||
|
[
|
||||||
|
(Role::Primary, "#406836", "#A5D395"),
|
||||||
|
(Role::OnPrimary, "#FFFFFF", "#11380B"),
|
||||||
|
(Role::PrimaryContainer, "#C0EFB0", "#285020"),
|
||||||
|
(Role::OnPrimaryContainer, "#285020", "#C0EFB0"),
|
||||||
|
(Role::Secondary, "#54634D", "#BBCBB2"),
|
||||||
|
(Role::OnSecondary, "#FFFFFF", "#263422"),
|
||||||
|
(Role::SecondaryContainer, "#D7E8CD", "#3C4B37"),
|
||||||
|
(Role::OnSecondaryContainer, "#3C4B37", "#D7E8CD"),
|
||||||
|
(Role::Tertiary, "#386568", "#A0CFD2"),
|
||||||
|
(Role::OnTertiary, "#FFFFFF", "#003739"),
|
||||||
|
(Role::TertiaryContainer, "#BCEBEE", "#1E4D50"),
|
||||||
|
(Role::OnTertiaryContainer, "#1E4D50", "#BCEBEE"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"#0000FF",
|
||||||
|
[
|
||||||
|
(Role::Primary, "#555992", "#BEC2FF"),
|
||||||
|
(Role::OnPrimary, "#FFFFFF", "#272B60"),
|
||||||
|
(Role::PrimaryContainer, "#E0E0FF", "#3E4278"),
|
||||||
|
(Role::OnPrimaryContainer, "#3E4278", "#E0E0FF"),
|
||||||
|
(Role::Secondary, "#5C5D72", "#C5C4DD"),
|
||||||
|
(Role::OnSecondary, "#FFFFFF", "#2E2F42"),
|
||||||
|
(Role::SecondaryContainer, "#E1E0F9", "#444559"),
|
||||||
|
(Role::OnSecondaryContainer, "#444559", "#E1E0F9"),
|
||||||
|
(Role::Tertiary, "#78536B", "#E8B9D5"),
|
||||||
|
(Role::OnTertiary, "#FFFFFF", "#46263B"),
|
||||||
|
(Role::TertiaryContainer, "#FFD8EE", "#5E3C52"),
|
||||||
|
(Role::OnTertiaryContainer, "#5E3C52", "#FFD8EE"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"#6E7C8A",
|
||||||
|
[
|
||||||
|
(Role::Primary, "#2B638B", "#98CCF9"),
|
||||||
|
(Role::OnPrimary, "#FFFFFF", "#003350"),
|
||||||
|
(Role::PrimaryContainer, "#CCE5FF", "#044B71"),
|
||||||
|
(Role::OnPrimaryContainer, "#044B71", "#CCE5FF"),
|
||||||
|
(Role::Secondary, "#50606F", "#B8C8D9"),
|
||||||
|
(Role::OnSecondary, "#FFFFFF", "#23323F"),
|
||||||
|
(Role::SecondaryContainer, "#D4E4F6", "#394856"),
|
||||||
|
(Role::OnSecondaryContainer, "#394856", "#D4E4F6"),
|
||||||
|
(Role::Tertiary, "#66587B", "#D1BFE7"),
|
||||||
|
(Role::OnTertiary, "#FFFFFF", "#372A4A"),
|
||||||
|
(Role::TertiaryContainer, "#ECDCFF", "#4E4162"),
|
||||||
|
(Role::OnTertiaryContainer, "#4E4162", "#ECDCFF"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"#D000FF",
|
||||||
|
[
|
||||||
|
(Role::Primary, "#794F81", "#E9B5EF"),
|
||||||
|
(Role::OnPrimary, "#FFFFFF", "#472150"),
|
||||||
|
(Role::PrimaryContainer, "#FDD6FF", "#603768"),
|
||||||
|
(Role::OnPrimaryContainer, "#603768", "#FDD6FF"),
|
||||||
|
(Role::Secondary, "#6A596C", "#D6C0D6"),
|
||||||
|
(Role::OnSecondary, "#FFFFFF", "#3A2B3C"),
|
||||||
|
(Role::SecondaryContainer, "#F3DBF2", "#524153"),
|
||||||
|
(Role::OnSecondaryContainer, "#524153", "#F3DBF2"),
|
||||||
|
(Role::Tertiary, "#82524C", "#F5B7B0"),
|
||||||
|
(Role::OnTertiary, "#FFFFFF", "#4C2521"),
|
||||||
|
(Role::TertiaryContainer, "#FFDAD6", "#673B36"),
|
||||||
|
(Role::OnTertiaryContainer, "#673B36", "#FFDAD6"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"#000000",
|
||||||
|
[
|
||||||
|
(Role::Primary, "#8C4A60", "#FFB1C8"),
|
||||||
|
(Role::OnPrimary, "#FFFFFF", "#541D32"),
|
||||||
|
(Role::PrimaryContainer, "#FFD9E2", "#703348"),
|
||||||
|
(Role::OnPrimaryContainer, "#703348", "#FFD9E2"),
|
||||||
|
(Role::Secondary, "#74565F", "#E3BDC6"),
|
||||||
|
(Role::OnSecondary, "#FFFFFF", "#422931"),
|
||||||
|
(Role::SecondaryContainer, "#FFD9E2", "#5A3F47"),
|
||||||
|
(Role::OnSecondaryContainer, "#5A3F47", "#FFD9E2"),
|
||||||
|
(Role::Tertiary, "#7C5635", "#EFBD94"),
|
||||||
|
(Role::OnTertiary, "#FFFFFF", "#48290B"),
|
||||||
|
(Role::TertiaryContainer, "#FFDCC1", "#623F20"),
|
||||||
|
(Role::OnTertiaryContainer, "#623F20", "#FFDCC1"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"#FFFFFF",
|
||||||
|
[
|
||||||
|
(Role::Primary, "#006874", "#82D3E0"),
|
||||||
|
(Role::OnPrimary, "#FFFFFF", "#00363D"),
|
||||||
|
(Role::PrimaryContainer, "#9EEFFD", "#004F58"),
|
||||||
|
(Role::OnPrimaryContainer, "#004F58", "#9EEFFD"),
|
||||||
|
(Role::Secondary, "#4A6267", "#B1CBD0"),
|
||||||
|
(Role::OnSecondary, "#FFFFFF", "#1C3438"),
|
||||||
|
(Role::SecondaryContainer, "#CDE7EC", "#334B4F"),
|
||||||
|
(Role::OnSecondaryContainer, "#334B4F", "#CDE7EC"),
|
||||||
|
(Role::Tertiary, "#525E7D", "#BAC6EA"),
|
||||||
|
(Role::OnTertiary, "#FFFFFF", "#24304D"),
|
||||||
|
(Role::TertiaryContainer, "#DAE2FF", "#3B4664"),
|
||||||
|
(Role::OnTertiaryContainer, "#3B4664", "#DAE2FF"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 官方 `SchemeTonalSpot(seed)` 的中性表面(surface 与 surface-container-highest,light/dark)。
|
||||||
|
const SEED_NEUTRALS: [(&str, &str, &str, &str, &str); 8] = [
|
||||||
|
("#6750A4", "#FDF7FF", "#141218", "#E6E0E9", "#36343A"),
|
||||||
|
("#FF0000", "#FFF8F6", "#1A1110", "#F1DFDC", "#3D3230"),
|
||||||
|
("#00FF00", "#F8FBF1", "#11140F", "#E1E4DA", "#32362F"),
|
||||||
|
("#0000FF", "#FBF8FF", "#131318", "#E4E1E9", "#34343A"),
|
||||||
|
("#6E7C8A", "#F7F9FF", "#101418", "#E0E2E8", "#313539"),
|
||||||
|
("#D000FF", "#FFF7FA", "#171217", "#EAE0E7", "#393338"),
|
||||||
|
("#000000", "#FFF8F8", "#191113", "#EFDFE1", "#3C3235"),
|
||||||
|
("#FFFFFF", "#F5FAFB", "#0E1415", "#DEE3E5", "#303637"),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn worst_channel(got: Color, want: Color) -> i16 {
|
||||||
|
[(got.r, want.r), (got.g, want.g), (got.b, want.b)]
|
||||||
|
.iter()
|
||||||
|
.map(|(a, b)| (i16::from(*a) - i16::from(*b)).abs())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accent_schemes_match_official_on_the_accent_families() {
|
||||||
|
let mut worst = 0;
|
||||||
|
let mut off_by_one = 0;
|
||||||
|
let mut checked = 0;
|
||||||
|
let mut failures = Vec::new();
|
||||||
|
for (seed_hex, roles) in ACCENT_SEEDS {
|
||||||
|
let accent = Color::from_hex(seed_hex).expect("合法 hex");
|
||||||
|
for (flavor, is_dark) in [(SchemeFlavor::Light, false), (SchemeFlavor::Dark, true)] {
|
||||||
|
let scheme = Scheme::from_accent(flavor, accent);
|
||||||
|
for (role, want_light, want_dark) in roles {
|
||||||
|
let want_hex = if is_dark { want_dark } else { want_light };
|
||||||
|
let got = scheme.get(role);
|
||||||
|
let want = Color::from_hex(want_hex).expect("官方 hex 必须合法");
|
||||||
|
let delta = worst_channel(got, want);
|
||||||
|
worst = worst.max(delta);
|
||||||
|
checked += 1;
|
||||||
|
if delta > 0 {
|
||||||
|
off_by_one += 1;
|
||||||
|
}
|
||||||
|
if delta > CHANNEL_TOLERANCE {
|
||||||
|
failures.push(format!(
|
||||||
|
"{seed_hex}/{}/{} 是 {},官方 {want_hex}(差 {delta})",
|
||||||
|
flavor.name(),
|
||||||
|
role.name(),
|
||||||
|
got.to_hex()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
failures.is_empty(),
|
||||||
|
"{} 项强调色与官方不一致(容差 {CHANNEL_TOLERANCE}):\n{}",
|
||||||
|
failures.len(),
|
||||||
|
failures.join("\n")
|
||||||
|
);
|
||||||
|
println!("强调色对齐:{checked} 项,最大通道误差 {worst},非零误差 {off_by_one} 项");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accent_scheme_keeps_baseline_surfaces() {
|
||||||
|
// 产品语义:换强调色不许让底色变脏(这是 `from_accent` 与官方 `from_seed` 的唯一差别)。
|
||||||
|
let base = Scheme::baseline(SchemeFlavor::Light);
|
||||||
|
for (seed_hex, _) in ACCENT_SEEDS {
|
||||||
|
let accent = Color::from_hex(seed_hex).expect("合法 hex");
|
||||||
|
let custom = Scheme::from_accent(SchemeFlavor::Light, accent);
|
||||||
|
assert_eq!(custom.get(Role::Surface), base.get(Role::Surface));
|
||||||
|
assert_eq!(custom.get(Role::Outline), base.get(Role::Outline));
|
||||||
|
assert_eq!(custom.get(Role::Error), base.get(Role::Error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_seed_matches_official_neutrals() {
|
||||||
|
// 全量 `from_seed` 的中性派生必须与官方一致(否则「整窗染色」的色相就是错的)。
|
||||||
|
for (seed_hex, light_surface, dark_surface, light_highest, dark_highest) in SEED_NEUTRALS {
|
||||||
|
let seed = Color::from_hex(seed_hex).expect("合法 hex");
|
||||||
|
for (flavor, want_surface, want_highest) in [
|
||||||
|
(SchemeFlavor::Light, light_surface, light_highest),
|
||||||
|
(SchemeFlavor::Dark, dark_surface, dark_highest),
|
||||||
|
] {
|
||||||
|
let scheme = Scheme::from_seed(flavor, seed);
|
||||||
|
for (role, want_hex) in [
|
||||||
|
(Role::Surface, want_surface),
|
||||||
|
(Role::SurfaceContainerHighest, want_highest),
|
||||||
|
] {
|
||||||
|
let got = scheme.get(role);
|
||||||
|
let want = Color::from_hex(want_hex).expect("官方 hex 必须合法");
|
||||||
|
assert!(
|
||||||
|
worst_channel(got, want) <= CHANNEL_TOLERANCE,
|
||||||
|
"{seed_hex}/{}/{} 是 {},官方 {want_hex}",
|
||||||
|
flavor.name(),
|
||||||
|
role.name(),
|
||||||
|
got.to_hex()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_seed_and_from_accent_agree_on_the_accent_families() {
|
||||||
|
// 两个入口的强调族应当逐像素相同:换种子时中性色不同,强调色只取决于种子色相。
|
||||||
|
for (seed_hex, _) in ACCENT_SEEDS {
|
||||||
|
let seed = Color::from_hex(seed_hex).expect("合法 hex");
|
||||||
|
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||||
|
let by_seed = Scheme::from_seed(flavor, seed);
|
||||||
|
let by_accent = Scheme::from_accent(flavor, seed);
|
||||||
|
for role in [
|
||||||
|
Role::Primary,
|
||||||
|
Role::OnPrimary,
|
||||||
|
Role::PrimaryContainer,
|
||||||
|
Role::OnPrimaryContainer,
|
||||||
|
Role::Secondary,
|
||||||
|
Role::SecondaryContainer,
|
||||||
|
Role::Tertiary,
|
||||||
|
Role::TertiaryContainer,
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
by_seed.get(role),
|
||||||
|
by_accent.get(role),
|
||||||
|
"{seed_hex}/{} 的 {} 两个入口不一致",
|
||||||
|
flavor.name(),
|
||||||
|
role.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accent_hue_is_not_clipped_away() {
|
||||||
|
// 旧缺陷②:强调色色相被 gamut 硬裁导致偏移。现在色相由 HCT 保住(只压彩度)。
|
||||||
|
use unlucky_design::color::Hct;
|
||||||
|
for (seed_hex, _) in ACCENT_SEEDS {
|
||||||
|
let seed = Color::from_hex(seed_hex).expect("合法 hex");
|
||||||
|
let seed_hue = Hct::from_color(seed).hue;
|
||||||
|
let primary = Scheme::from_accent(SchemeFlavor::Light, seed).get(Role::Primary);
|
||||||
|
let got = Hct::from_color(primary).hue;
|
||||||
|
let delta = (got - seed_hue).abs().min(360.0 - (got - seed_hue).abs());
|
||||||
|
assert!(delta < 1.0, "{seed_hex} 的 primary 色相偏了 {delta}°");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
//! 与官方 `material-color-utilities` 的**逐角色对齐**(baseline 方案)。
|
||||||
|
//!
|
||||||
|
//! 期望值不是「我们觉得对的颜色」,而是把官方 Java 实现(Apache-2.0)编译跑出来的结果:
|
||||||
|
//! `new SchemeTonalSpot(Hct.fromInt(0xFF6750A4), isDark, 0.0, SpecVersion.SPEC_2021, Platform.PHONE)`
|
||||||
|
//! 配上 `ColorSpec2021` 的 49 个动态色。跑法见 `NOTICE` 的归属声明——期望值表就是那份输出的
|
||||||
|
//! 抄本(`seed,flavor,role,hex`)。
|
||||||
|
//!
|
||||||
|
//! 判据是**每通道误差 ≤1**:8 bit 量化本身就有半格误差,再加上 Java `double` 与 Rust `f64`
|
||||||
|
//! 的末位差异,1 是「同一个算法」的合理上限;2 以上就是实现真的不一样了。
|
||||||
|
|
||||||
|
use unlucky_design::color::{Color, Hct, Role, Scheme, SchemeFlavor, BASELINE_SEED};
|
||||||
|
|
||||||
|
/// 每通道允许的最大误差。
|
||||||
|
const CHANNEL_TOLERANCE: i16 = 1;
|
||||||
|
|
||||||
|
/// 官方 baseline 输出(seed `#6750A4`,Tonal Spot,ColorSpec2021,contrastLevel 0)。
|
||||||
|
const BASELINE: [(Role, &str, &str); 49] = [
|
||||||
|
(Role::Primary, "#65558F", "#CFBDFE"),
|
||||||
|
(Role::OnPrimary, "#FFFFFF", "#36275D"),
|
||||||
|
(Role::PrimaryContainer, "#E9DDFF", "#4D3D75"),
|
||||||
|
(Role::OnPrimaryContainer, "#4D3D75", "#E9DDFF"),
|
||||||
|
(Role::Secondary, "#625B71", "#CBC2DB"),
|
||||||
|
(Role::OnSecondary, "#FFFFFF", "#332D41"),
|
||||||
|
(Role::SecondaryContainer, "#E8DEF8", "#4A4458"),
|
||||||
|
(Role::OnSecondaryContainer, "#4A4458", "#E8DEF8"),
|
||||||
|
(Role::Tertiary, "#7E5260", "#EFB8C8"),
|
||||||
|
(Role::OnTertiary, "#FFFFFF", "#4A2532"),
|
||||||
|
(Role::TertiaryContainer, "#FFD9E3", "#633B48"),
|
||||||
|
(Role::OnTertiaryContainer, "#633B48", "#FFD9E3"),
|
||||||
|
(Role::Error, "#BA1A1A", "#FFB4AB"),
|
||||||
|
(Role::OnError, "#FFFFFF", "#690005"),
|
||||||
|
(Role::ErrorContainer, "#FFDAD6", "#93000A"),
|
||||||
|
(Role::OnErrorContainer, "#93000A", "#FFDAD6"),
|
||||||
|
(Role::Surface, "#FDF7FF", "#141218"),
|
||||||
|
(Role::OnSurface, "#1D1B20", "#E6E0E9"),
|
||||||
|
(Role::SurfaceVariant, "#E7E0EB", "#49454E"),
|
||||||
|
(Role::OnSurfaceVariant, "#49454E", "#CAC4CF"),
|
||||||
|
(Role::Background, "#FDF7FF", "#141218"),
|
||||||
|
(Role::OnBackground, "#1D1B20", "#E6E0E9"),
|
||||||
|
(Role::SurfaceDim, "#DED8E0", "#141218"),
|
||||||
|
(Role::SurfaceBright, "#FDF7FF", "#3B383E"),
|
||||||
|
(Role::SurfaceContainerLowest, "#FFFFFF", "#0F0D13"),
|
||||||
|
(Role::SurfaceContainerLow, "#F8F2FA", "#1D1B20"),
|
||||||
|
(Role::SurfaceContainer, "#F2ECF4", "#211F24"),
|
||||||
|
(Role::SurfaceContainerHigh, "#ECE6EE", "#2B292F"),
|
||||||
|
(Role::SurfaceContainerHighest, "#E6E0E9", "#36343A"),
|
||||||
|
(Role::Outline, "#7A757F", "#948F99"),
|
||||||
|
(Role::OutlineVariant, "#CAC4CF", "#49454E"),
|
||||||
|
(Role::InverseSurface, "#322F35", "#E6E0E9"),
|
||||||
|
(Role::InverseOnSurface, "#F5EFF7", "#322F35"),
|
||||||
|
(Role::InversePrimary, "#CFBDFE", "#65558F"),
|
||||||
|
(Role::Shadow, "#000000", "#000000"),
|
||||||
|
(Role::Scrim, "#000000", "#000000"),
|
||||||
|
(Role::SurfaceTint, "#65558F", "#CFBDFE"),
|
||||||
|
(Role::PrimaryFixed, "#E9DDFF", "#E9DDFF"),
|
||||||
|
(Role::PrimaryFixedDim, "#CFBDFE", "#CFBDFE"),
|
||||||
|
(Role::OnPrimaryFixed, "#201047", "#201047"),
|
||||||
|
(Role::OnPrimaryFixedVariant, "#4D3D75", "#4D3D75"),
|
||||||
|
(Role::SecondaryFixed, "#E8DEF8", "#E8DEF8"),
|
||||||
|
(Role::SecondaryFixedDim, "#CBC2DB", "#CBC2DB"),
|
||||||
|
(Role::OnSecondaryFixed, "#1E192B", "#1E192B"),
|
||||||
|
(Role::OnSecondaryFixedVariant, "#4A4458", "#4A4458"),
|
||||||
|
(Role::TertiaryFixed, "#FFD9E3", "#FFD9E3"),
|
||||||
|
(Role::TertiaryFixedDim, "#EFB8C8", "#EFB8C8"),
|
||||||
|
(Role::OnTertiaryFixed, "#31101D", "#31101D"),
|
||||||
|
(Role::OnTertiaryFixedVariant, "#633B48", "#633B48"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 官方 `Hct.from(hue, chroma, tone)` 的色值(求解器与 gamut 映射的直接证据)。
|
||||||
|
const HCT_PROBES: [(f64, f64, f64, &str); 12] = [
|
||||||
|
(95.0, 36.0, 40.0, "#705D0D"),
|
||||||
|
(95.0, 36.0, 90.0, "#FCE186"),
|
||||||
|
(95.0, 16.0, 90.0, "#F0E2BB"),
|
||||||
|
(96.0, 24.0, 80.0, "#D7C68D"),
|
||||||
|
(25.0, 84.0, 40.0, "#BA1A1A"),
|
||||||
|
(0.0, 0.0, 50.0, "#777777"),
|
||||||
|
(200.0, 120.0, 70.0, "#00BEC3"),
|
||||||
|
(30.0, 0.5, 98.0, "#FDF8F7"),
|
||||||
|
(270.0, 200.0, 10.0, "#001847"),
|
||||||
|
(120.0, 60.0, 99.0, "#FCFFDD"),
|
||||||
|
(60.0, 6.0, 6.0, "#19120C"),
|
||||||
|
(95.0, 36.0, 30.0, "#554500"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 三通道里最大的绝对误差。
|
||||||
|
fn worst_channel(got: Color, want: Color) -> i16 {
|
||||||
|
let pairs = [(got.r, want.r), (got.g, want.g), (got.b, want.b)];
|
||||||
|
pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(a, b)| (i16::from(*a) - i16::from(*b)).abs())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn baseline_matches_official_on_all_forty_nine_roles() {
|
||||||
|
// 注意:对齐的是**官方锚点**(种子 #6750A4 + 中性彩度 6),不是产品默认(M70 起是浅冷蓝)。
|
||||||
|
let light = Scheme::official_baseline(SchemeFlavor::Light);
|
||||||
|
let dark = Scheme::official_baseline(SchemeFlavor::Dark);
|
||||||
|
let mut worst = 0;
|
||||||
|
let mut off_by_one = 0;
|
||||||
|
let mut failures = Vec::new();
|
||||||
|
for (role, want_light, want_dark) in BASELINE {
|
||||||
|
for (flavor, scheme, want_hex) in [
|
||||||
|
(SchemeFlavor::Light, light, want_light),
|
||||||
|
(SchemeFlavor::Dark, dark, want_dark),
|
||||||
|
] {
|
||||||
|
let got = scheme.get(role);
|
||||||
|
let want = Color::from_hex(want_hex).expect("官方 hex 必须合法");
|
||||||
|
let delta = worst_channel(got, want);
|
||||||
|
worst = worst.max(delta);
|
||||||
|
if delta > 0 {
|
||||||
|
off_by_one += 1;
|
||||||
|
}
|
||||||
|
if delta > CHANNEL_TOLERANCE {
|
||||||
|
failures.push(format!(
|
||||||
|
"{}/{} 是 {},官方 {want_hex}(差 {delta})",
|
||||||
|
flavor.name(),
|
||||||
|
role.name(),
|
||||||
|
got.to_hex()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
failures.is_empty(),
|
||||||
|
"{} 个角色与官方不一致(容差 {CHANNEL_TOLERANCE}):\n{}",
|
||||||
|
failures.len(),
|
||||||
|
failures.join("\n")
|
||||||
|
);
|
||||||
|
// 把误差统计打出来(`cargo test -- --nocapture` 可见),报告里要引用。
|
||||||
|
println!("baseline 对齐:98 项,最大通道误差 {worst},非零误差 {off_by_one} 项");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hct_solver_matches_official_color_spec_2021() {
|
||||||
|
for (hue, chroma, tone, want_hex) in HCT_PROBES {
|
||||||
|
let got = Hct::from_components(hue, chroma, tone).to_color();
|
||||||
|
let want = Color::from_hex(want_hex).expect("官方 hex 必须合法");
|
||||||
|
assert!(
|
||||||
|
worst_channel(got, want) <= CHANNEL_TOLERANCE,
|
||||||
|
"HCT({hue}, {chroma}, {tone}) 是 {},官方 {want_hex}",
|
||||||
|
got.to_hex()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn baseline_primary_is_not_the_seed_and_that_is_official() {
|
||||||
|
// 常见误解:官方基线的 primary 应该等于种子 #6750A4。官方不是——Tonal Spot 变体把
|
||||||
|
// primary 的彩度固定成 36,而 #6750A4 的 HCT 彩度是 47.86,所以 primary 被收到 #65558F。
|
||||||
|
// 这也是 Flutter `ColorScheme.fromSeed(0xFF6750A4)` 的 primary 值。
|
||||||
|
let seed = Hct::from_color(BASELINE_SEED);
|
||||||
|
assert!(
|
||||||
|
(seed.chroma - 47.856_526).abs() < 1.0e-4,
|
||||||
|
"种子彩度 {}",
|
||||||
|
seed.chroma
|
||||||
|
);
|
||||||
|
let primary = Scheme::official_baseline(SchemeFlavor::Light).get(Role::Primary);
|
||||||
|
assert_eq!(primary.to_hex(), "#65558F");
|
||||||
|
assert_ne!(primary, BASELINE_SEED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn baseline_tone_axis_is_still_exact_lstar() {
|
||||||
|
// gamut 映射只动彩度,不动 tone:每个角色的实测 L* 与它声明的 tone 相差 ≤1。
|
||||||
|
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||||
|
let scheme = Scheme::official_baseline(flavor);
|
||||||
|
for (role, color) in scheme.iter() {
|
||||||
|
let tone = Scheme::tone_of(role, flavor);
|
||||||
|
let got = f64::from(color.lstar());
|
||||||
|
assert!(
|
||||||
|
(got - tone).abs() <= 1.0,
|
||||||
|
"{}/{} 的 tone 声明 {tone}、实测 {got}",
|
||||||
|
flavor.name(),
|
||||||
|
role.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_family_containers_are_separated_by_hct_chroma() {
|
||||||
|
// 旧缺陷①:light 下 primary-container 与 secondary-container 撞色。
|
||||||
|
// 现在两者彩度不同(36 vs 16),在 tone 90 上都不会被色域裁到同一个上限。
|
||||||
|
let light = Scheme::official_baseline(SchemeFlavor::Light);
|
||||||
|
let primary = light.get(Role::PrimaryContainer);
|
||||||
|
let secondary = light.get(Role::SecondaryContainer);
|
||||||
|
assert_ne!(primary, secondary);
|
||||||
|
let c_primary = Hct::from_color(primary).chroma;
|
||||||
|
let c_secondary = Hct::from_color(secondary).chroma;
|
||||||
|
assert!(
|
||||||
|
c_secondary < c_primary,
|
||||||
|
"secondary-container 彩度 {c_secondary} 应低于 primary-container {c_primary}"
|
||||||
|
);
|
||||||
|
// 深层原因:tone 90 上彩度 36 装得下,所以容器色是「真的有彩度的浅色」而不是被裁到灰。
|
||||||
|
assert!(c_primary > 12.0, "primary-container 彩度只剩 {c_primary}");
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
//! M70:默认色候选(保守蓝 / 冷调蓝 / 浅冷蓝 / 天蓝)——四档的**唯一真源**与 pin。
|
||||||
|
//!
|
||||||
|
//! ## 这档候选到底改了什么
|
||||||
|
//!
|
||||||
|
//! 四档走的是**同一条** HCT 管道(`Palettes::from_seed_with_neutral_chroma` → `ColorSpec2021`
|
||||||
|
//! tone 表 → `HctSolver` gamut 映射),差别只有两个入参:
|
||||||
|
//!
|
||||||
|
//! | 档 | 种子 | 种子 HCT 色相 | 中性调色板彩度 | 表面 | 主色观感 |
|
||||||
|
//! |---|---|---|---|---|---|
|
||||||
|
//! | ① 保守蓝 | `#2196F3` | 253.7° | 6(官方) | 近白/近黑,几乎无色 | 钢蓝 |
|
||||||
|
//! | ② 冷调蓝 | `#2196F3` | 253.7° | 12 | 冷白/冷黑,带淡蓝调 | 钢蓝 |
|
||||||
|
//! | ③ 浅冷蓝(备选) | `#87CEEB`(CSS `skyblue`) | 225.7° | 12 | 浅天蓝 | 偏青(teal) |
|
||||||
|
//! | ④ 天蓝(**出厂默认,用户 2026-11-01 拍板**) | `#3C8DBC` | 239.5° | 12 | 浅天蓝 | 偏蓝的天蓝 |
|
||||||
|
//!
|
||||||
|
//! ③ 与 ④ 是同一件事的两种「蓝」:色相越低越偏青(teal),越高越偏钢蓝;
|
||||||
|
//! 239.5° 正好夹在中间,主色从 ③ 的 `#0C6780` 变成 `#236488`。
|
||||||
|
//!
|
||||||
|
//! 两条必须知道的物理事实(写在测试里,免得下次又有人想「把主色调浅一点」):
|
||||||
|
//!
|
||||||
|
//! 1. **种子彩度是废参数**:Tonal Spot 把 primary 彩度钉在 36,种子只贡献色相。
|
||||||
|
//! 所以「低彩度天蓝种子」得到的色板与「高彩度天蓝种子」**逐像素相同**
|
||||||
|
//! (`seed_chroma_never_leaks_into_the_primary_ramp` 在 `palettes.rs` 里也钉了这条)。
|
||||||
|
//! ③ 与 ② 的真正差别是**色相**(226° vs 254°),不是彩度。
|
||||||
|
//! 2. **primary 的 tone 被钉在 40**,所以四档主色都是「中蓝」,不会更浅。
|
||||||
|
//! 「浅」只能落在中性色上——这就是②/③/④提高中性彩度的原因,也是本项目唯一一处
|
||||||
|
//! 对官方 Tonal Spot 的**有意偏离**(登记在 `color` 模块文档的「出厂默认色」小节)。
|
||||||
|
//!
|
||||||
|
//! ## 换默认色要动哪里
|
||||||
|
//!
|
||||||
|
//! `palettes::DEFAULT_SEED` / `palettes::DEFAULT_NEUTRAL_CHROMA` 两行常量,
|
||||||
|
//! 然后重跑本文件:pin 表会逐角色告诉你哪几个 hex 变了,照抄即可(pin 是**抄本**,不是推导)。
|
||||||
|
|
||||||
|
use unlucky_design::color::{
|
||||||
|
Color, Hct, Palettes, Role, Scheme, SchemeFlavor, DEFAULT_NEUTRAL_CHROMA, DEFAULT_SEED,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 一档候选:只有两个变量(种子 + 中性彩度),其余全是官方常数。
|
||||||
|
struct Candidate {
|
||||||
|
/// 短标签(色卡与 CSV 用)。
|
||||||
|
tag: &'static str,
|
||||||
|
/// 中文档名。
|
||||||
|
title: &'static str,
|
||||||
|
/// 种子 hex。
|
||||||
|
seed_hex: &'static str,
|
||||||
|
/// 中性调色板彩度(官方 Tonal Spot 是 6)。
|
||||||
|
neutral_chroma: f64,
|
||||||
|
/// 9 个展示角色的 **(角色, light, dark)** 抄本。
|
||||||
|
pins: [(Role, &'static str, &'static str); 9],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 四档候选(色卡 `DevDocs/ops/skyseed-candidates/` 用的就是这份数据)。
|
||||||
|
const CANDIDATES: [Candidate; 4] = [
|
||||||
|
Candidate {
|
||||||
|
tag: "C1",
|
||||||
|
title: "① 保守蓝(中性彩度 6 = 官方)",
|
||||||
|
seed_hex: "#2196F3",
|
||||||
|
neutral_chroma: 6.0,
|
||||||
|
pins: [
|
||||||
|
(Role::Primary, "#36618E", "#A0CAFD"),
|
||||||
|
(Role::OnPrimary, "#FFFFFF", "#003258"),
|
||||||
|
(Role::PrimaryContainer, "#D1E4FF", "#194975"),
|
||||||
|
(Role::Secondary, "#535F70", "#BBC7DB"),
|
||||||
|
(Role::Tertiary, "#6B5778", "#D6BEE4"),
|
||||||
|
(Role::Surface, "#F8F9FF", "#111418"),
|
||||||
|
(Role::SurfaceContainerLow, "#F2F3FA", "#191C20"),
|
||||||
|
(Role::SurfaceContainerHighest, "#E1E2E8", "#32353A"),
|
||||||
|
(Role::Outline, "#73777F", "#8D9199"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Candidate {
|
||||||
|
tag: "C2",
|
||||||
|
title: "② 冷调蓝(中性彩度 12)",
|
||||||
|
seed_hex: "#2196F3",
|
||||||
|
neutral_chroma: 12.0,
|
||||||
|
pins: [
|
||||||
|
(Role::Primary, "#36618E", "#A0CAFD"),
|
||||||
|
(Role::OnPrimary, "#FFFFFF", "#003258"),
|
||||||
|
(Role::PrimaryContainer, "#D1E4FF", "#194975"),
|
||||||
|
(Role::Secondary, "#535F70", "#BBC7DB"),
|
||||||
|
(Role::Tertiary, "#6B5778", "#D6BEE4"),
|
||||||
|
(Role::Surface, "#F8F9FF", "#0C141E"),
|
||||||
|
(Role::SurfaceContainerLow, "#EEF4FF", "#141C26"),
|
||||||
|
(Role::SurfaceContainerHighest, "#DBE3F1", "#2D3540"),
|
||||||
|
(Role::Outline, "#6D7887", "#8791A1"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Candidate {
|
||||||
|
tag: "C3",
|
||||||
|
title: "③ 浅冷蓝(中性彩度 12 + 偏青天蓝种子)",
|
||||||
|
seed_hex: "#87CEEB",
|
||||||
|
neutral_chroma: 12.0,
|
||||||
|
pins: [
|
||||||
|
(Role::Primary, "#0C6780", "#89D0ED"),
|
||||||
|
(Role::OnPrimary, "#FFFFFF", "#003544"),
|
||||||
|
(Role::PrimaryContainer, "#BAEAFF", "#004D62"),
|
||||||
|
(Role::Secondary, "#4C626B", "#B3CAD5"),
|
||||||
|
(Role::Tertiary, "#5B5B7E", "#C4C3EB"),
|
||||||
|
(Role::Surface, "#F3FAFF", "#07151B"),
|
||||||
|
(Role::SurfaceContainerLow, "#E7F6FE", "#0F1D23"),
|
||||||
|
(Role::SurfaceContainerHighest, "#D6E5ED", "#29373D"),
|
||||||
|
(Role::Outline, "#687A82", "#81949C"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Candidate {
|
||||||
|
tag: "C4",
|
||||||
|
title: "④ 天蓝(中性彩度 12 + 夹在中间的天蓝种子)",
|
||||||
|
seed_hex: "#3C8DBC",
|
||||||
|
neutral_chroma: 12.0,
|
||||||
|
pins: [
|
||||||
|
(Role::Primary, "#236488", "#93CDF6"),
|
||||||
|
(Role::OnPrimary, "#FFFFFF", "#00344C"),
|
||||||
|
(Role::PrimaryContainer, "#C8E6FF", "#004C6D"),
|
||||||
|
(Role::Secondary, "#4F616E", "#B7C9D8"),
|
||||||
|
(Role::Tertiary, "#63597C", "#CDC0E9"),
|
||||||
|
(Role::Surface, "#F6FAFF", "#09151C"),
|
||||||
|
(Role::SurfaceContainerLow, "#EBF5FF", "#111D25"),
|
||||||
|
(Role::SurfaceContainerHighest, "#D8E4EF", "#2B363F"),
|
||||||
|
(Role::Outline, "#6A7985", "#84939F"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 四档共同点:种子的 HCT 坐标(色相是候选之间**唯一**有效的差别)。
|
||||||
|
fn seed_hct(candidate: &Candidate) -> Hct {
|
||||||
|
Hct::from_color(Color::from_hex(candidate.seed_hex).expect("候选种子必须是合法 hex"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按候选参数走**产品同一条**派生路径。
|
||||||
|
fn scheme(candidate: &Candidate, flavor: SchemeFlavor) -> Scheme {
|
||||||
|
Scheme::from_palettes(
|
||||||
|
&Palettes::from_seed_with_neutral_chroma(
|
||||||
|
seed_hct(candidate).to_color(),
|
||||||
|
candidate.neutral_chroma,
|
||||||
|
),
|
||||||
|
flavor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn candidates_match_their_pins() {
|
||||||
|
let mut drift = Vec::new();
|
||||||
|
for candidate in &CANDIDATES {
|
||||||
|
for (role, want_light, want_dark) in candidate.pins {
|
||||||
|
for (flavor, want_hex) in [
|
||||||
|
(SchemeFlavor::Light, want_light),
|
||||||
|
(SchemeFlavor::Dark, want_dark),
|
||||||
|
] {
|
||||||
|
let got = scheme(candidate, flavor).get(role).to_hex();
|
||||||
|
if got != want_hex {
|
||||||
|
drift.push(format!(
|
||||||
|
"{}{} / {}/{} 实测 {got},pin {want_hex}",
|
||||||
|
candidate.tag,
|
||||||
|
if flavor.is_dark() { "-dark" } else { "" },
|
||||||
|
candidate.title,
|
||||||
|
role.name()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
drift.is_empty(),
|
||||||
|
"候选色值漂了(换默认色后请照抄下面这些 hex 到 pin 表):\n{}",
|
||||||
|
drift.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn candidates_only_move_the_two_declared_inputs() {
|
||||||
|
// 「不绕算法」的证据:四档的调色板彩度与官方 Tonal Spot 逐项一致,
|
||||||
|
// 唯一的差别是中性彩度的入参(以及种子色相)。
|
||||||
|
let official = Palettes::official_baseline();
|
||||||
|
for candidate in &CANDIDATES {
|
||||||
|
let p = Palettes::from_seed_with_neutral_chroma(
|
||||||
|
seed_hct(candidate).to_color(),
|
||||||
|
candidate.neutral_chroma,
|
||||||
|
);
|
||||||
|
assert_eq!(p.primary.chroma(), official.primary.chroma());
|
||||||
|
assert_eq!(p.secondary.chroma(), official.secondary.chroma());
|
||||||
|
assert_eq!(p.tertiary.chroma(), official.tertiary.chroma());
|
||||||
|
assert_eq!(p.error, official.error, "error 族不该被候选影响");
|
||||||
|
assert_eq!(p.neutral.chroma(), candidate.neutral_chroma);
|
||||||
|
assert!(
|
||||||
|
(p.tertiary.hue_deg() - (p.primary.hue_deg() + 60.0)).abs() < 1.0e-9,
|
||||||
|
"{} 的 tertiary 不再是 +60°",
|
||||||
|
candidate.tag
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_candidates_are_actually_distinguishable() {
|
||||||
|
// 「四档不像同一档」的机器判据。注意 light 的 surface(tone 98)在 8 bit 下已经贴顶,
|
||||||
|
// 中性彩度 6→12 会量化到同一个 #F8F9FF——所以 ①② 的判据落在 container 系与 dark 模式上,
|
||||||
|
// 这也是色卡上 ①② 的差别主要体现在卡片/浮层与深色模式的原因。
|
||||||
|
let light = |c: &Candidate| scheme(c, SchemeFlavor::Light);
|
||||||
|
let dark = |c: &Candidate| scheme(c, SchemeFlavor::Dark);
|
||||||
|
let (c1, c2, c3, c4) = (
|
||||||
|
light(&CANDIDATES[0]),
|
||||||
|
light(&CANDIDATES[1]),
|
||||||
|
light(&CANDIDATES[2]),
|
||||||
|
light(&CANDIDATES[3]),
|
||||||
|
);
|
||||||
|
for role in [Role::SurfaceContainerLow, Role::SurfaceContainerHighest] {
|
||||||
|
assert_ne!(
|
||||||
|
c1.get(role),
|
||||||
|
c2.get(role),
|
||||||
|
"①与②的 {} 相同:中性彩度没起作用",
|
||||||
|
role.name()
|
||||||
|
);
|
||||||
|
assert_ne!(c2.get(role), c3.get(role), "②与③的 {} 相同", role.name());
|
||||||
|
// ③④ 的中性彩度相同,差别只允许来自色相
|
||||||
|
assert_ne!(c3.get(role), c4.get(role), "③与④的 {} 相同", role.name());
|
||||||
|
}
|
||||||
|
assert_ne!(
|
||||||
|
c3.get(Role::Primary),
|
||||||
|
c4.get(Role::Primary),
|
||||||
|
"③与④的主色必须真的不同(否则第 4 档白加)"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
dark(&CANDIDATES[0]).get(Role::Surface),
|
||||||
|
dark(&CANDIDATES[1]).get(Role::Surface),
|
||||||
|
"深色模式的 surface 应当能看出中性彩度差别"
|
||||||
|
);
|
||||||
|
let hue_gap = |a: &Candidate, b: &Candidate| {
|
||||||
|
let (x, y) = (seed_hct(a).hue, seed_hct(b).hue);
|
||||||
|
(x - y).abs().min(360.0 - (x - y).abs())
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
hue_gap(&CANDIDATES[0], &CANDIDATES[1]) < 0.01,
|
||||||
|
"①② 应当只差中性彩度"
|
||||||
|
);
|
||||||
|
// ③ 偏青 / ④ 居中的分界靠色相:②→③ 与 ③→④ 都要肉眼可分的量级。
|
||||||
|
for (i, j) in [(1, 2), (2, 3)] {
|
||||||
|
assert!(
|
||||||
|
(10.0..=40.0).contains(&hue_gap(&CANDIDATES[i], &CANDIDATES[j])),
|
||||||
|
"{}→{} 的色相差应当明显(10°~40°),实测 {}",
|
||||||
|
CANDIDATES[i].tag,
|
||||||
|
CANDIDATES[j].tag,
|
||||||
|
hue_gap(&CANDIDATES[i], &CANDIDATES[j])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn product_default_is_the_adopted_candidate() {
|
||||||
|
// 用户 2026-11-01 拍板采用 **④ 天蓝**。这条用例就是「默认值 ↔ 候选表」的绑定器:
|
||||||
|
// 谁改了 `DEFAULT_SEED`/`DEFAULT_NEUTRAL_CHROMA` 却没同步这里(或反过来),它会立刻红。
|
||||||
|
// 断言一律比 **hex / 数值**,这样失败信息里直接能读到「期望 ④、实测几」。
|
||||||
|
let adopted = &CANDIDATES[3];
|
||||||
|
assert_eq!(
|
||||||
|
DEFAULT_SEED.to_hex(),
|
||||||
|
"#3C8DBC",
|
||||||
|
"DEFAULT_SEED 必须仍是已采用的 ④ 天蓝:期望 #3C8DBC(④),实测 {}({})",
|
||||||
|
DEFAULT_SEED.to_hex(),
|
||||||
|
if DEFAULT_SEED.to_hex() == CANDIDATES[2].seed_hex {
|
||||||
|
"③ 浅冷蓝——要改回 ③ 请连偏离登记与 pin 一起改"
|
||||||
|
} else {
|
||||||
|
"不是候选表里的任何一个种子"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
DEFAULT_SEED.to_hex(),
|
||||||
|
adopted.seed_hex,
|
||||||
|
"DEFAULT_SEED({})与已采用档 {} 的种子({})不一致",
|
||||||
|
DEFAULT_SEED.to_hex(),
|
||||||
|
adopted.tag,
|
||||||
|
adopted.seed_hex
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
DEFAULT_NEUTRAL_CHROMA, 12.0,
|
||||||
|
"DEFAULT_NEUTRAL_CHROMA 必须仍是已采用的 12,实测 {}",
|
||||||
|
DEFAULT_NEUTRAL_CHROMA
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
DEFAULT_NEUTRAL_CHROMA, adopted.neutral_chroma,
|
||||||
|
"DEFAULT_NEUTRAL_CHROMA({})与已采用档 {} 的中性彩度({})不一致",
|
||||||
|
DEFAULT_NEUTRAL_CHROMA, adopted.tag, adopted.neutral_chroma
|
||||||
|
);
|
||||||
|
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||||
|
let product = Scheme::baseline(flavor);
|
||||||
|
let expected = scheme(adopted, flavor);
|
||||||
|
for (role, got) in product.iter() {
|
||||||
|
assert_eq!(
|
||||||
|
got.to_hex(),
|
||||||
|
expected.get(role).to_hex(),
|
||||||
|
"产品默认与已采用档 {} 的 {}({} 模式)不一致",
|
||||||
|
adopted.tag,
|
||||||
|
role.name(),
|
||||||
|
flavor.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn surface_tint_stays_a_tint() {
|
||||||
|
// 门禁:中性彩度提高是「淡蓝调」不是「把窗口染成蓝纸」。surface 的 HCT 彩度必须
|
||||||
|
// 明显低于 primary 的 36,且 surface 的 Y 仍 > 0.85(浅色模式还是浅底)。
|
||||||
|
for candidate in &CANDIDATES {
|
||||||
|
let light = scheme(candidate, SchemeFlavor::Light);
|
||||||
|
let surface = light.get(Role::Surface);
|
||||||
|
let chroma = Hct::from_color(surface).chroma;
|
||||||
|
assert!(
|
||||||
|
chroma <= 1.2 * candidate.neutral_chroma,
|
||||||
|
"{} 的 surface 彩度 {chroma} 超过中性彩度入参的两倍",
|
||||||
|
candidate.tag
|
||||||
|
);
|
||||||
|
assert!(surface.relative_luminance() > 0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dump_candidates_for_the_color_card() {
|
||||||
|
// 色卡脚本读的就是这两行格式(`SEED|` / `ROLE|`)。跑法:
|
||||||
|
// cargo test -p unlucky-design --test skyseed_candidates -- --nocapture
|
||||||
|
for candidate in &CANDIDATES {
|
||||||
|
let hct = seed_hct(candidate);
|
||||||
|
println!(
|
||||||
|
"SEED|{}|{}|{}|{}|hue={:.2}|chroma={:.2}",
|
||||||
|
candidate.tag,
|
||||||
|
candidate.title,
|
||||||
|
candidate.seed_hex,
|
||||||
|
candidate.neutral_chroma,
|
||||||
|
hct.hue,
|
||||||
|
hct.chroma
|
||||||
|
);
|
||||||
|
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||||
|
let s = scheme(candidate, flavor);
|
||||||
|
for role in Role::ALL {
|
||||||
|
println!(
|
||||||
|
"ROLE|{}|{}|{}|{}",
|
||||||
|
candidate.tag,
|
||||||
|
flavor.name(),
|
||||||
|
role.name(),
|
||||||
|
s.get(role).to_hex().trim_start_matches('#')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -182,7 +182,8 @@ fn spacing_and_layout_metrics_are_shared() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn component_map_covers_the_page_skeleton() {
|
fn component_map_covers_the_page_skeleton() {
|
||||||
assert_eq!(Component::ALL.len(), 20);
|
// M69(G9)在末尾追加 Checkbox/Radio/Search/NavigationDrawer/Divider:20 → 25。
|
||||||
|
assert_eq!(Component::ALL.len(), 25);
|
||||||
for component in Component::ALL {
|
for component in Component::ALL {
|
||||||
let spec = component.spec();
|
let spec = component.spec();
|
||||||
assert_eq!(spec.component, component);
|
assert_eq!(spec.component, component);
|
||||||
@@ -191,6 +192,9 @@ fn component_map_covers_the_page_skeleton() {
|
|||||||
assert_eq!(Component::PrimaryButton.spec().shape, ShapeScale::Full);
|
assert_eq!(Component::PrimaryButton.spec().shape, ShapeScale::Full);
|
||||||
// 浮层必须高于卡片层。
|
// 浮层必须高于卡片层。
|
||||||
assert!(Component::Dialog.spec().elevation > Component::OutlinedCard.spec().elevation);
|
assert!(Component::Dialog.spec().elevation > Component::OutlinedCard.spec().elevation);
|
||||||
|
// 末尾追加的 5 个不许改动既有次序。
|
||||||
|
assert_eq!(Component::ALL[0], Component::TopAppBar);
|
||||||
|
assert_eq!(Component::ALL[19], Component::EmptyState);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -212,11 +216,11 @@ fn design_tokens_entry_point() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tonal_palette_hits_requested_tones() {
|
fn tonal_palette_hits_requested_tones() {
|
||||||
let palette = TonalPalette::new(150.0, 0.1);
|
let palette = TonalPalette::new(150.0, 36.0);
|
||||||
for tone in [20.0_f32, 40.0, 60.0, 80.0] {
|
for tone in [20.0_f64, 40.0, 60.0, 80.0] {
|
||||||
let color = palette.tone(tone);
|
let color = palette.tone(tone);
|
||||||
assert!(
|
assert!(
|
||||||
(color.lstar() - tone).abs() <= 1.0,
|
(f64::from(color.lstar()) - tone).abs() <= 1.0,
|
||||||
"tone {tone} 实际 {}",
|
"tone {tone} 实际 {}",
|
||||||
color.lstar()
|
color.lstar()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use unlucky_design::icons::IconKind;
|
|||||||
use unlucky_design::motion::{DurationToken, Easing};
|
use unlucky_design::motion::{DurationToken, Easing};
|
||||||
use unlucky_design::shape::ShapeScale;
|
use unlucky_design::shape::ShapeScale;
|
||||||
|
|
||||||
fn expect_tone(role: Role, light: f32, dark: f32) {
|
fn expect_tone(role: Role, light: f64, dark: f64) {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Scheme::tone_of(role, SchemeFlavor::Light),
|
Scheme::tone_of(role, SchemeFlavor::Light),
|
||||||
light,
|
light,
|
||||||
@@ -28,24 +28,24 @@ fn all_forty_nine_tones_are_pinned() {
|
|||||||
expect_tone(Role::Primary, 40.0, 80.0);
|
expect_tone(Role::Primary, 40.0, 80.0);
|
||||||
expect_tone(Role::OnPrimary, 100.0, 20.0);
|
expect_tone(Role::OnPrimary, 100.0, 20.0);
|
||||||
expect_tone(Role::PrimaryContainer, 90.0, 30.0);
|
expect_tone(Role::PrimaryContainer, 90.0, 30.0);
|
||||||
expect_tone(Role::OnPrimaryContainer, 10.0, 90.0);
|
expect_tone(Role::OnPrimaryContainer, 30.0, 90.0);
|
||||||
expect_tone(Role::Secondary, 40.0, 80.0);
|
expect_tone(Role::Secondary, 40.0, 80.0);
|
||||||
expect_tone(Role::OnSecondary, 100.0, 20.0);
|
expect_tone(Role::OnSecondary, 100.0, 20.0);
|
||||||
expect_tone(Role::SecondaryContainer, 90.0, 30.0);
|
expect_tone(Role::SecondaryContainer, 90.0, 30.0);
|
||||||
expect_tone(Role::OnSecondaryContainer, 10.0, 90.0);
|
expect_tone(Role::OnSecondaryContainer, 30.0, 90.0);
|
||||||
expect_tone(Role::Tertiary, 40.0, 80.0);
|
expect_tone(Role::Tertiary, 40.0, 80.0);
|
||||||
expect_tone(Role::OnTertiary, 100.0, 20.0);
|
expect_tone(Role::OnTertiary, 100.0, 20.0);
|
||||||
expect_tone(Role::TertiaryContainer, 90.0, 30.0);
|
expect_tone(Role::TertiaryContainer, 90.0, 30.0);
|
||||||
expect_tone(Role::OnTertiaryContainer, 10.0, 90.0);
|
expect_tone(Role::OnTertiaryContainer, 30.0, 90.0);
|
||||||
expect_tone(Role::Error, 40.0, 80.0);
|
expect_tone(Role::Error, 40.0, 80.0);
|
||||||
expect_tone(Role::OnError, 100.0, 20.0);
|
expect_tone(Role::OnError, 100.0, 20.0);
|
||||||
expect_tone(Role::ErrorContainer, 90.0, 30.0);
|
expect_tone(Role::ErrorContainer, 90.0, 30.0);
|
||||||
expect_tone(Role::OnErrorContainer, 10.0, 90.0);
|
expect_tone(Role::OnErrorContainer, 30.0, 90.0);
|
||||||
expect_tone(Role::Surface, 98.0, 6.0);
|
expect_tone(Role::Surface, 98.0, 6.0);
|
||||||
expect_tone(Role::OnSurface, 10.0, 90.0);
|
expect_tone(Role::OnSurface, 10.0, 90.0);
|
||||||
expect_tone(Role::SurfaceVariant, 90.0, 30.0);
|
expect_tone(Role::SurfaceVariant, 90.0, 30.0);
|
||||||
expect_tone(Role::OnSurfaceVariant, 30.0, 80.0);
|
expect_tone(Role::OnSurfaceVariant, 30.0, 80.0);
|
||||||
expect_tone(Role::Background, 99.0, 10.0);
|
expect_tone(Role::Background, 98.0, 6.0);
|
||||||
expect_tone(Role::OnBackground, 10.0, 90.0);
|
expect_tone(Role::OnBackground, 10.0, 90.0);
|
||||||
expect_tone(Role::SurfaceDim, 87.0, 6.0);
|
expect_tone(Role::SurfaceDim, 87.0, 6.0);
|
||||||
expect_tone(Role::SurfaceBright, 98.0, 24.0);
|
expect_tone(Role::SurfaceBright, 98.0, 24.0);
|
||||||
@@ -241,8 +241,8 @@ fn same_family_containers_do_not_collide() {
|
|||||||
"同族容器撞色(彩度压缩没生效):{primary:?}"
|
"同族容器撞色(彩度压缩没生效):{primary:?}"
|
||||||
);
|
);
|
||||||
// secondary-container 必须比 primary-container 更灰(M3 的「次级」语义)。
|
// secondary-container 必须比 primary-container 更灰(M3 的「次级」语义)。
|
||||||
let c_primary = unlucky_design::color::space::Oklch::from_color(primary).chroma;
|
let c_primary = unlucky_design::color::Hct::from_color(primary).chroma;
|
||||||
let c_secondary = unlucky_design::color::space::Oklch::from_color(secondary).chroma;
|
let c_secondary = unlucky_design::color::Hct::from_color(secondary).chroma;
|
||||||
assert!(
|
assert!(
|
||||||
c_secondary < c_primary,
|
c_secondary < c_primary,
|
||||||
"secondary-container 彩度 {c_secondary} 应低于 primary-container {c_primary}"
|
"secondary-container 彩度 {c_secondary} 应低于 primary-container {c_primary}"
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
//! M69(TOKEXT)的数值钉死:G9~G13 五项缺口逐条关门。
|
||||||
|
//!
|
||||||
|
//! 期望值全部**独立于实现重写**(照 `research/15` §18~§23 与 `00-decision-log.md` D32/D33 抄),
|
||||||
|
//! 实现改一个数、这里不改就红。覆盖:
|
||||||
|
//! - **G13** `ComponentRadius` 白名单 + 尺寸闸(2 不在 `ShapeScale` 里,刻度不许被加宽);
|
||||||
|
//! - **G11** `LayoutMetrics` 的 4 个新骨架值 + nav 语义修正;
|
||||||
|
//! - **G10** `ComponentMetrics` 逐字段;
|
||||||
|
//! - **G9** `Component` 25 个变体与档位表一行对一行;
|
||||||
|
//! - **G12** 一像素线单一真源。
|
||||||
|
|
||||||
|
use unlucky_design::component_map::{Component, ComponentMetrics};
|
||||||
|
use unlucky_design::elevation::Elevation;
|
||||||
|
use unlucky_design::icons::IconSize;
|
||||||
|
use unlucky_design::shape::{radius_layer_for, ComponentRadius, RadiusLayer, ShapeScale};
|
||||||
|
use unlucky_design::spacing::{stroke, LayoutMetrics, Space};
|
||||||
|
use unlucky_design::typography::TypeRole;
|
||||||
|
|
||||||
|
// ---------- G13:圆角分层(D32) ----------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shape_scale_is_unchanged_and_never_contains_two() {
|
||||||
|
// 刻度一个字都不许动(D32 第 1 条:ShapeScale 保持严格)。
|
||||||
|
let ladder: [(ShapeScale, f32); 9] = [
|
||||||
|
(ShapeScale::None, 0.0),
|
||||||
|
(ShapeScale::ExtraSmall, 4.0),
|
||||||
|
(ShapeScale::Small, 8.0),
|
||||||
|
(ShapeScale::Medium, 12.0),
|
||||||
|
(ShapeScale::Large, 16.0),
|
||||||
|
(ShapeScale::LargeIncreased, 20.0),
|
||||||
|
(ShapeScale::ExtraLarge, 28.0),
|
||||||
|
(ShapeScale::ExtraLargeIncreased, 32.0),
|
||||||
|
(ShapeScale::ExtraExtraLarge, 48.0),
|
||||||
|
];
|
||||||
|
for (scale, dp) in ladder {
|
||||||
|
assert_eq!(scale.dp(), dp, "{scale:?} 的刻度被改动了");
|
||||||
|
// 2 是官方 Checkbox 的圆角,但它**不在**刻度里——加进来就等于废掉 K1 守门。
|
||||||
|
assert_ne!(dp, 2.0, "2dp 不许进 ShapeScale(走 ComponentRadius)");
|
||||||
|
}
|
||||||
|
assert_eq!(ShapeScale::Full.radius_px(64.0), 32.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn component_radius_is_a_one_item_whitelist() {
|
||||||
|
assert_eq!(ComponentRadius::ALL, [ComponentRadius::Micro]);
|
||||||
|
// 2dp 只能通过带尺寸闸的 `radius_px` 拿到(`dp()` 是 crate 内部口,外部看不见)。
|
||||||
|
assert_eq!(
|
||||||
|
ComponentRadius::Micro.radius_px(20.0),
|
||||||
|
Some(2.0),
|
||||||
|
"官方 Checkbox RadiusSize = 2.dp"
|
||||||
|
);
|
||||||
|
// 尺寸闸:>24dp 一律拿不到微半径(D32 的「只允许 ≤24dp 微控件」)。
|
||||||
|
assert_eq!(ComponentRadius::Micro.radius_px(24.0), Some(2.0));
|
||||||
|
assert_eq!(ComponentRadius::Micro.radius_px(25.0), None);
|
||||||
|
assert_eq!(ComponentRadius::Micro.radius_px(240.0), None);
|
||||||
|
assert_eq!(ComponentRadius::Micro.radius_px(0.0), None);
|
||||||
|
// 边长小于两倍半径时按边长一半折算(2dp 元素不能有 2dp 圆角)。
|
||||||
|
assert_eq!(ComponentRadius::Micro.radius_px(2.0), Some(1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn radius_layer_for_routes_by_size() {
|
||||||
|
assert_eq!(radius_layer_for(20.0), RadiusLayer::Component);
|
||||||
|
assert_eq!(radius_layer_for(24.0), RadiusLayer::Component);
|
||||||
|
assert_eq!(radius_layer_for(28.0), RadiusLayer::Scale);
|
||||||
|
assert_eq!(radius_layer_for(360.0), RadiusLayer::Scale);
|
||||||
|
// 抽屉/面板这类容器永远不该被判成微控件。
|
||||||
|
for container in [
|
||||||
|
LayoutMetrics::DESKTOP.nav_drawer_width,
|
||||||
|
LayoutMetrics::DESKTOP.ai_panel_width,
|
||||||
|
] {
|
||||||
|
assert_eq!(radius_layer_for(container), RadiusLayer::Scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- G11:骨架值 + nav 语义修正 ----------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layout_metrics_new_skeleton_values_are_pinned() {
|
||||||
|
let m = LayoutMetrics::DESKTOP;
|
||||||
|
// A 级:SearchBarTokens.ContainerHeight = 56。
|
||||||
|
assert_eq!(m.search_bar_height, 56.0);
|
||||||
|
// A 级:NavigationDrawer 下限 240(官方 ContainerWidth = 360,1180 窗下过宽,D33 取 240)。
|
||||||
|
assert_eq!(m.nav_drawer_width, 240.0);
|
||||||
|
// 12b §5.1 登记偏离:官方 rail 96 → 本项目 88。
|
||||||
|
assert_eq!(m.nav_rail_width, 88.0);
|
||||||
|
// A 级:DividerTokens.Thickness = 1.0dp(一像素线,不受 4px 栅格约束)。
|
||||||
|
assert_eq!(m.divider_thickness, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nav_widths_mean_what_their_names_say() {
|
||||||
|
// D33:展开(drawer)比折叠(rail)宽;旧字段 nav_expanded_width=88 / nav_collapsed_width=48
|
||||||
|
// 的名字与值相反,已在 M69 改名并删除,这里钉住「新名字不会再次反向」。
|
||||||
|
let m = LayoutMetrics::DESKTOP;
|
||||||
|
assert!(
|
||||||
|
m.nav_drawer_width > m.nav_rail_width,
|
||||||
|
"展开态(drawer {})必须比折叠态(rail {})宽",
|
||||||
|
m.nav_drawer_width,
|
||||||
|
m.nav_rail_width
|
||||||
|
);
|
||||||
|
// rail 里 56 宽的选中指示器要放得下(88 − 2×16)。
|
||||||
|
assert_eq!(m.nav_rail_width - Space::X4.dp() * 2.0, 56.0);
|
||||||
|
// drawer 里 216 宽的指示器同样由「宽 − 2×12」推出(见 ComponentMetrics::indicator_width)。
|
||||||
|
assert_eq!(
|
||||||
|
ComponentMetrics::NAVIGATION_DRAWER.indicator_width(m.nav_drawer_width),
|
||||||
|
216.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- G10:组件级微度量 ----------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checkbox_metrics_are_pinned() {
|
||||||
|
let cm = ComponentMetrics::CHECKBOX;
|
||||||
|
// 20×20(不是 token 文件里那个死值 18,见 research/15 §26.2 矛盾 ①)。
|
||||||
|
assert_eq!((cm.width, cm.height), (20.0, 20.0));
|
||||||
|
assert_eq!(
|
||||||
|
cm.radius,
|
||||||
|
Some(ComponentRadius::Micro),
|
||||||
|
"圆角 2dp 走 D32 第二层"
|
||||||
|
);
|
||||||
|
assert_eq!(cm.stroke, 2.0, "CheckboxTokens.UnselectedOutlineWidth = 2");
|
||||||
|
assert_eq!(cm.state_layer, 40.0, "StateLayerSize = 40");
|
||||||
|
assert_eq!(cm.inner_dot, 0.0);
|
||||||
|
assert_eq!(cm.gap, Space::X2.dp(), "框 ↔ 标签 8(团队口径)");
|
||||||
|
assert_eq!(
|
||||||
|
Component::Checkbox.metrics(),
|
||||||
|
Some(ComponentMetrics::CHECKBOX)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn radio_metrics_are_pinned() {
|
||||||
|
let cm = ComponentMetrics::RADIO;
|
||||||
|
assert_eq!(
|
||||||
|
(cm.width, cm.height),
|
||||||
|
(20.0, 20.0),
|
||||||
|
"RadioButtonTokens.IconSize = 20"
|
||||||
|
);
|
||||||
|
assert_eq!(cm.stroke, 2.0, "RadioStrokeWidth = 2.dp");
|
||||||
|
// 内点 10 不是 12:12 是含描边前的名义值(绘制半径 = 6 − 1 = 5)。
|
||||||
|
assert_eq!(cm.inner_dot, 10.0);
|
||||||
|
assert_eq!(cm.state_layer, 40.0);
|
||||||
|
assert_eq!(cm.radius, None, "radio 是真圆,圆角走 ShapeScale::Full");
|
||||||
|
assert_eq!(Component::Radio.spec().shape, ShapeScale::Full);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_bar_metrics_are_pinned() {
|
||||||
|
let cm = ComponentMetrics::SEARCH_BAR;
|
||||||
|
// 高 56 属骨架级:CM 里必须留 0,不许重复一份。
|
||||||
|
assert_eq!((cm.width, cm.height), (0.0, 0.0));
|
||||||
|
assert_eq!(cm.icon, IconSize::Toolbar.px(), "leading/trailing 图标 24");
|
||||||
|
// A 级(§20.4):图标可视左缘 16 = 文本框内容内边距 12 + `SearchBarIconOffsetX = 4`。
|
||||||
|
assert_eq!(cm.pad_start, Space::X3.dp(), "内容内边距 12");
|
||||||
|
assert_eq!(cm.icon_offset, 4.0, "SearchBarIconOffsetX = 4");
|
||||||
|
assert_eq!(cm.pad_start + cm.icon_offset, 16.0, "图标可视左缘必须是 16");
|
||||||
|
// 右侧同式:图标右缘也是 16。
|
||||||
|
assert_eq!(cm.pad_end, Space::X3.dp());
|
||||||
|
assert_eq!(cm.pad_end + cm.icon_offset, 16.0, "图标可视右缘必须是 16");
|
||||||
|
// 守门:不许把 pad 本身写成 16(否则 pad + offset = 20,越过 rubric 的 ±2px)。
|
||||||
|
assert_ne!(cm.pad_start, 16.0);
|
||||||
|
assert_ne!(cm.pad_end, 16.0);
|
||||||
|
// 搜索条圆角 = 高/2 = 28,等于官方 SearchBarCornerRadius。
|
||||||
|
assert_eq!(
|
||||||
|
ShapeScale::Full.radius_px(LayoutMetrics::DESKTOP.search_bar_height),
|
||||||
|
28.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn navigation_drawer_metrics_are_pinned() {
|
||||||
|
let cm = ComponentMetrics::NAVIGATION_DRAWER;
|
||||||
|
assert_eq!((cm.width, cm.height), (0.0, 0.0), "宽/项高属骨架级");
|
||||||
|
assert_eq!(
|
||||||
|
cm.inset, 12.0,
|
||||||
|
"NavigationDrawerItemDefaults.ItemPadding = 12"
|
||||||
|
);
|
||||||
|
assert_eq!(cm.pad_start, Space::X4.dp(), "项内 start 16");
|
||||||
|
assert_eq!(cm.pad_end, Space::X6.dp(), "项内 end 24");
|
||||||
|
assert_eq!(cm.icon, IconSize::Toolbar.px());
|
||||||
|
// 与 search bar 同一条公式:图标边 = pad + icon_offset(抽屉的 offset 是 0)。
|
||||||
|
assert_eq!(cm.pad_start + cm.icon_offset, 16.0, "图标起点 = 项内 start");
|
||||||
|
assert_eq!(cm.gap, Space::X3.dp(), "图标 ↔ label 12");
|
||||||
|
// 指示器宽 = 容器宽 − 24;官方 360 − 24 = 336,本项目 240 − 24 = 216。
|
||||||
|
assert_eq!(cm.indicator_width(360.0), 336.0);
|
||||||
|
assert_eq!(cm.indicator_width(240.0), 216.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn component_metrics_never_duplicate_layout_metrics() {
|
||||||
|
// 同一量只许一份:CM 里出现的骨架级尺寸必须留 0。
|
||||||
|
let m = LayoutMetrics::DESKTOP;
|
||||||
|
for component in [Component::Search, Component::NavigationDrawer] {
|
||||||
|
let cm = component.metrics().expect("这两个控件必须有 CM");
|
||||||
|
assert_eq!(cm.width, 0.0, "{} 的宽度属 LM", component.name());
|
||||||
|
assert_eq!(cm.height, 0.0, "{} 的高度属 LM", component.name());
|
||||||
|
assert_ne!(cm.height, m.search_bar_height);
|
||||||
|
assert_ne!(cm.width, m.nav_drawer_width);
|
||||||
|
}
|
||||||
|
// Divider 的厚度也是 LM(一像素线)→ 它没有 CM。
|
||||||
|
assert_eq!(Component::Divider.metrics(), None);
|
||||||
|
assert_eq!(m.divider_thickness, stroke::HAIRLINE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- G12:一像素线 ----------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hairline_is_the_single_source_of_one_pixel_lines() {
|
||||||
|
assert_eq!(stroke::HAIRLINE, 1.0);
|
||||||
|
// 分隔线厚度直接引用它,不是另写一个字面量。
|
||||||
|
assert_eq!(LayoutMetrics::DESKTOP.divider_thickness, stroke::HAIRLINE);
|
||||||
|
// 一像素线不受 4px 栅格约束(栅格测试里显式豁免,这里反向钉一次)。
|
||||||
|
assert_ne!(stroke::HAIRLINE % unlucky_design::spacing::GRID, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- G9:枚举 25 变体 ↔ 表槽位 ----------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn component_enum_is_the_frozen_twenty_five() {
|
||||||
|
let names = [
|
||||||
|
"top-app-bar",
|
||||||
|
"navigation-item",
|
||||||
|
"primary-button",
|
||||||
|
"secondary-button",
|
||||||
|
"text-button",
|
||||||
|
"icon-button",
|
||||||
|
"outlined-card",
|
||||||
|
"filled-card",
|
||||||
|
"list-item",
|
||||||
|
"text-field",
|
||||||
|
"segmented-button",
|
||||||
|
"chip",
|
||||||
|
"switch",
|
||||||
|
"slider",
|
||||||
|
"progress-indicator",
|
||||||
|
"dialog",
|
||||||
|
"menu",
|
||||||
|
"snackbar",
|
||||||
|
"tooltip",
|
||||||
|
"empty-state",
|
||||||
|
// M69 末尾追加的 5 个
|
||||||
|
"checkbox",
|
||||||
|
"radio",
|
||||||
|
"search",
|
||||||
|
"navigation-drawer",
|
||||||
|
"divider",
|
||||||
|
];
|
||||||
|
assert_eq!(Component::ALL.len(), names.len(), "M66 的 G9:20 → 25");
|
||||||
|
for (i, component) in Component::ALL.iter().enumerate() {
|
||||||
|
assert_eq!(component.name(), names[i], "第 {i} 个组件被换位/改名了");
|
||||||
|
// 表槽位与枚举判别值一一对应(`spec()` 就是按下标取的)。
|
||||||
|
assert_eq!(*component as usize, i);
|
||||||
|
assert_eq!(component.spec().component, *component);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_components_carry_the_official_elevation_and_shape() {
|
||||||
|
// Checkbox / Radio / Search / NavigationDrawer / Divider:全部 L0(都不是浮层)。
|
||||||
|
for component in [
|
||||||
|
Component::Checkbox,
|
||||||
|
Component::Radio,
|
||||||
|
Component::Search,
|
||||||
|
Component::NavigationDrawer,
|
||||||
|
Component::Divider,
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
component.spec().elevation,
|
||||||
|
Elevation::Level0,
|
||||||
|
"{} 不该有阴影(R4:只有浮层允许)",
|
||||||
|
component.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Checkbox 的圆角真源是 micro_radius,shape 只是占位 None。
|
||||||
|
let checkbox = Component::Checkbox.spec();
|
||||||
|
assert_eq!(checkbox.micro_radius, Some(ComponentRadius::Micro));
|
||||||
|
assert_eq!(checkbox.shape, ShapeScale::None);
|
||||||
|
// 其余 4 个不碰微半径层。
|
||||||
|
for component in [
|
||||||
|
Component::Radio,
|
||||||
|
Component::Search,
|
||||||
|
Component::NavigationDrawer,
|
||||||
|
Component::Divider,
|
||||||
|
] {
|
||||||
|
assert_eq!(component.spec().micro_radius, None, "{}", component.name());
|
||||||
|
}
|
||||||
|
// NavigationDrawer = CornerLargeEnd 的内侧角 16;Divider 无圆角。
|
||||||
|
assert_eq!(Component::NavigationDrawer.spec().shape, ShapeScale::Large);
|
||||||
|
assert_eq!(Component::Divider.spec().shape, ShapeScale::None);
|
||||||
|
// Checkbox / Radio 的标签字级是团队口径(官方无「框 + 标签」token)。
|
||||||
|
assert_eq!(Component::Checkbox.spec().text, TypeRole::BodyLarge);
|
||||||
|
assert_eq!(Component::Radio.spec().text, TypeRole::BodyLarge);
|
||||||
|
// Search 的字级:输入 BodyLarge、占位/辅助 BodySmall。
|
||||||
|
assert_eq!(Component::Search.spec().text, TypeRole::BodyLarge);
|
||||||
|
assert_eq!(Component::Search.spec().support, Some(TypeRole::BodySmall));
|
||||||
|
}
|
||||||
Reference in new issue
Block a user