Unlucky Launcher v0.2.0
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

首个 iced + Material 3 Expressive 版本的源码快照。
完整历史与日常提交在 dev 分支;main 只放阶段性快照。
This commit is contained in:
AskaEth committed 2026-11-05 14:22:11 +08:00
commit 8a8e5ca0c0
1079 files changed
+208035

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
[package]
name = "unlucky-design"
description = "Unlucky-Launcher 设计令牌:M3 Expressive 色彩/排版/形状/高程/动效/图标,纯数据 + 纯函数,零 UI 依赖"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
rust-version.workspace = true
publish.workspace = true
# 零 UI 依赖是硬约束(D3 纪律 + M61):本 crate 是渲染无关的**数据与纯函数**,
# 不得出现 iced / slint / winit / wgpu / gtk / egui / tauri 等 UI 或渲染框架,
# 也不引入任何第三方 crate(连 serde 都不加——序列化由消费方适配层自己做)。
# 证据命令:cargo tree -p unlucky-design --edges normal
[dependencies]
[lints]
workspace = true
+120
View File
@@ -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,
];
+160
View File
@@ -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));
}
}
+311
View File
@@ -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);
}
}
+335
View File
@@ -0,0 +1,335 @@
//! # 色彩令牌(M3 color roles)
//!
//! 49 个 M3 语义色角色 × light/dark 两套,全部由**一个种子色**派生([`Palettes`]),
//! 再按官方 tone 映射表解析成具体色值([`Scheme`])。
//!
//! ```no_run
//! use unlucky_design::color::{Color, Role, Scheme, SchemeFlavor};
//!
//! let light = Scheme::baseline(SchemeFlavor::Light);
//! let surface = light.get(Role::Surface);
//! let on_surface = light.get(Role::OnSurface);
//! assert!(on_surface.contrast_ratio(surface) > 4.5);
//!
//! // 用户自定义强调色:表面中性色不动,只换强调族。
//! let custom = Scheme::from_accent(SchemeFlavor::Light, Color::from_hex("#006A5C").unwrap());
//! assert_eq!(custom.get(Role::Surface), surface);
//! ```
//!
//! ## 口径声明:真 HCT,不是近似
//!
//! 标准 M3 的调色板建在 **HCT**(CAM16 色相/彩度 + CIELAB 的 `L*`)上。本模块**就是**那套算法:
//! CAM16 正换算(`cam16` 模块)取色相/彩度,`L*` 取 tone,色域外走官方的 `HctSolver`
//! (`hct_solver` 模块:Newton 迭代 + 色调平面上的色相二分,**只压彩度、不硬裁**)。
//!
//! | 轴 | 标准 M3 | 本实现 | 一致性 |
//! |---|---|---|---|
//! | tone | CIELAB `L*` | CIELAB `L*`(同一个 `Y` 轴) | 逐值一致 |
//! | 色相 / 彩度 | CAM16 | CAM16(D65 默认观察条件) | 逐值一致(误差 < 1e-4) |
//! | gamut 映射 | `HctSolver` | 同一套两段求解器,关键平面表原样搬运 | 逐通道一致 |
//! | 调色板彩度 | Tonal Spot 变体:36 / 16 / 24 / 6 / 8,error 25°/84 | 同(primary/secondary/tertiary/neutral-variant/error 逐值一致) | 逐值一致 |
//! | 中性彩度 | 6(几乎无色) | 官方口径 6;**产品默认用 12**(有意偏离,见下节「出厂默认色」) | 仅此处偏离 |
//! | 角色 tone 表 | ColorSpec2021 | 同(49 角色 × light/dark) | 逐值一致 |
//!
//! 对齐证据在 `tests/official_baseline.rs` 与 `tests/official_accent.rs`:期望值全部由
//! `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 色 | **单种子部分已实现**:量化(5 bit 直方图 + CIELAB k-means)与 `Score` 挑主色在 [`crate::palette`](M87)。**多 source 色**(一张图出 4 个种子、每个 `*_container` 换色相)仍未实现——产品口径是「一套主题一个强调色」(D38) |
//! | 换观察条件 | `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::palette`](M87 落地,D40 分层);**本模块只负责**「给定种子 + 中性彩度,
//! 派生出 49 个角色」,不碰像素、不碰路径、不碰回落决策。
//!
//! > ⚠️ **有意偏离登记(两处,同一处偏离的两半)**:
//! >
//! > 1. **neutral 彩度 `6 → 12`**。理由=产品要「浅冷」的冷调表面:官方 6 的表面几乎无色,单靠换
//! > 种子做不到「浅」——primary 的 tone 被官方钉在 40,种子再浅主色也还是中蓝,所以「浅」只能
//! > 落在中性色上。
//! > 2. **neutral-variant 彩度 `8 → 14`**。这是第 1 条的连带结果:官方
//! > `getNeutralVariantPalette` 写死的是**绝对值 8**(不是「neutral + 2」这种相对式),
//! > 本实现为了让层次不倒挂,让它跟着 neutral 联动(`nv = neutral + 2`)。若把 nv 钉回 8,
//! > 表面(12)会比中性变体(8)更「有色」,破坏官方的「neutral < neutral-variant」层次。
//! > 影响面不只表面:`outline` / `outline-variant` / `surface-variant` 三个角色一起偏蓝。
//! >
//! > 除这两处外全部走官方常数(primary 36 / secondary 16 / tertiary 24 且色相 +60° /
//! > error 25°·84),tone 表与 gamut 映射一行未动;[`Scheme::official_baseline`] 仍是官方的
//! > 6(neutral)/ 8(neutral-variant)口径。
//!
//! 官方口径的锚点仍然完整保留:[`Scheme::official_baseline`](种子 `#6750A4` + 中性 6)
//! 是 `tests/official_baseline.rs` 的对账入口,98 项仍是每通道 ≤1;
//! `tests/skyseed_candidates.rs` 把四档候选(保守蓝 / 冷调蓝 / 浅冷蓝 / 天蓝)的色值逐个 pin 住,
//! 换默认时它会指着要改的行。
//!
//! ✅ 默认色已由用户 2026-11-01 拍板:**④ 天蓝 `#3C8DBC`**。备选 ③ 浅冷蓝 `#87CEEB`
//! (主色偏青 `#0C6780`)仍在候选表里,换回去只需改 [`palettes::DEFAULT_SEED`] 一行。
//!
//! ## 对比度纪律(WCAG AA)
//!
//! - 所有 `on-*` 与其承载色(`*-container`、`primary`、`surface`)都满足 **≥ 4.5:1**;
//! 这一条由 `tests/contrast_aa.rs` 全表验证,且因为 tone 轴固定,**换任何种子色都成立**
//! (WCAG 对比度只取决于相对亮度,而 tone 直接钉住 `L*`;中性彩度同理,
//! `sky_seed_candidates_meet_wcag_aa` 逐档验证了 M70 的四档候选:4 档 × 2 模式 × 26 对 = 208 项)。
//! - 例外(显式登记,非缺陷):
//! - [`Role::Outline`] 对 [`Role::Surface`]:M3 定义的**非文字**元素(输入框描边),
//! 目标 3:1,实测 light 4.26:1 / dark 5.89:1;
//! - [`Role::OutlineVariant`] 对 [`Role::Surface`]:纯装饰分隔线,M3 不要求对比度下限;
//! - [`Role::SurfaceVariant`]:它是**容器**色(不是前景),不参与对比度门禁。
//! - 禁用态按 M3 的 alpha 叠加([`state::DISABLED_CONTENT_ALPHA`]),刻意低于 AA。
pub mod hct;
pub mod palettes;
pub mod roles;
pub mod scheme;
pub mod state;
pub mod tonal;
mod cam16;
mod critical_planes;
mod hct_solver;
// `pub(crate)`:取色量化(`crate::palette::quantize`)要在 CIELAB 里算簇距离,复用同一套换算。
pub(crate) mod space;
mod viewing_conditions;
pub use hct::Hct;
pub use palettes::{
PaletteSlot, Palettes, BASELINE_SEED, DEFAULT_NEUTRAL_CHROMA, DEFAULT_SEED, NEUTRAL_HUE_DEG,
};
pub use roles::Role;
pub use scheme::{Scheme, SchemeFlavor, SCRIM_ALPHA, SHADOW_ALPHA};
pub use state::StateLayer;
pub use tonal::TonalPalette;
/// sRGB 颜色(8 bit/通道 + alpha),刻意不用第三方颜色类型:本 crate 零依赖。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Color {
/// 红通道。
pub r: u8,
/// 绿通道。
pub g: u8,
/// 蓝通道。
pub b: u8,
/// alpha(0 = 全透明,255 = 不透明)。
pub a: u8,
}
impl Color {
/// 不透明颜色。
pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b, a: 255 }
}
/// 带 alpha 的颜色(`a` 为 0..=255)。
pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
Self { r, g, b, a }
}
/// 纯黑。
pub const BLACK: Color = Color::rgb(0, 0, 0);
/// 纯白。
pub const WHITE: Color = Color::rgb(255, 255, 255);
/// 从 `#RRGGBB` / `#RGB` / `#AARRGGBB` 解析(不合法返回 `None`)。
///
/// 只给配置读取与测试用——UI 代码里**不许出现色字面量**,一律走 [`Role`]。
pub fn from_hex(text: &str) -> Option<Self> {
let hex = text.trim().trim_start_matches('#');
let parse = |s: &str| u8::from_str_radix(s, 16).ok();
match hex.len() {
3 => {
let d = |i: usize| {
let v = parse(&hex[i..i + 1])?;
Some(v * 17)
};
Some(Self::rgb(d(0)?, d(1)?, d(2)?))
}
6 => Some(Self::rgb(
parse(&hex[0..2])?,
parse(&hex[2..4])?,
parse(&hex[4..6])?,
)),
8 => Some(Self::rgba(
parse(&hex[2..4])?,
parse(&hex[4..6])?,
parse(&hex[6..8])?,
parse(&hex[0..2])?,
)),
_ => None,
}
}
/// 不透明部分转 `#RRGGBB`(alpha < 255 时附 `#AARRGGBB` 形式)。
pub fn to_hex(self) -> String {
if self.a == 255 {
format!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b)
} else {
format!("#{:02X}{:02X}{:02X}{:02X}", self.a, self.r, self.g, self.b)
}
}
/// 改 alpha(`alpha` 0..1,越界夹取)。
pub fn with_alpha(self, alpha: f32) -> Self {
Self {
a: (alpha.clamp(0.0, 1.0) * 255.0).round() as u8,
..self
}
}
/// alpha 归一化到 0..1。
pub fn alpha(self) -> f32 {
f32::from(self.a) / 255.0
}
/// 是否完全不透明。
pub fn is_opaque(self) -> bool {
self.a == 255
}
/// WCAG 相对亮度(0..1,即 XYZ 的 `Y` 除以 100)。
///
/// 与 WCAG 2.x 的定义同一条 `Y` 轴(Rec.709 系数 0.2126/0.7152/0.0722),
/// 所以 [`Color::contrast_ratio`] 就是标准对比度公式。
pub fn relative_luminance(self) -> f32 {
(space::xyz_from_color(self)[1] / 100.0) as f32
}
/// CIELAB `L*`(0..100)——这就是 M3 的 **tone**。
///
/// 调色板按这条轴生成,验收也按这条轴断言;想知道某个色值「是几号 tone」就用它。
pub fn lstar(self) -> f32 {
space::lstar_from_color(self) as f32
}
/// WCAG 对比度(1..21)。`self` 与 `other` 都不透明时才严格;带 alpha 时先算「不计 alpha」的值。
pub fn contrast_ratio(self, other: Self) -> f32 {
let a = self.relative_luminance();
let b = other.relative_luminance();
let (hi, lo) = if a > b { (a, b) } else { (b, a) };
(hi + 0.05) / (lo + 0.05)
}
/// alpha 合成:`self` 叠在 `bg` 之上,返回不透明结果。
///
/// 这是 M3 状态层/禁用态/遮罩的唯一合成口径(非预乘 alpha)。
pub fn over(self, bg: Self) -> Self {
let sa = self.alpha();
if sa >= 1.0 {
return self;
}
let blend = |fg: u8, b: u8| {
let v = f32::from(fg) * sa + f32::from(b) * (1.0 - sa);
v.round().clamp(0.0, 255.0) as u8
};
let a = sa + bg.alpha() * (1.0 - sa);
Self {
r: blend(self.r, bg.r),
g: blend(self.g, bg.g),
b: blend(self.b, bg.b),
a: (a * 255.0).round().clamp(0.0, 255.0) as u8,
}
}
/// 两色线性插值(`t` 0..1),用于动画中间帧(GPU 路径的 shape morph 用得上)。
pub fn lerp(self, other: Self, t: f32) -> Self {
let t = t.clamp(0.0, 1.0);
let mix = |a: u8, b: u8| {
(f32::from(a) + (f32::from(b) - f32::from(a)) * t)
.round()
.clamp(0.0, 255.0) as u8
};
Self {
r: mix(self.r, other.r),
g: mix(self.g, other.g),
b: mix(self.b, other.b),
a: mix(self.a, other.a),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_roundtrip() {
let c = Color::from_hex("#6750A4").expect("合法 hex");
assert_eq!(c, Color::rgb(0x67, 0x50, 0xA4));
assert_eq!(c.to_hex(), "#6750A4");
assert_eq!(Color::from_hex("6750a4"), Some(c));
assert_eq!(Color::from_hex("#FFF"), Some(Color::WHITE));
assert_eq!(Color::from_hex("#80FF0000").map(|c| c.a), Some(0x80));
assert_eq!(Color::from_hex("#12345"), None);
assert_eq!(Color::from_hex("zzzzzz"), None);
}
#[test]
fn contrast_extremes() {
assert!((Color::BLACK.contrast_ratio(Color::WHITE) - 21.0).abs() < 0.01);
assert!((Color::WHITE.contrast_ratio(Color::WHITE) - 1.0).abs() < 0.001);
}
#[test]
fn over_composites_correctly() {
let black_50 = Color::BLACK.with_alpha(0.5);
let got = black_50.over(Color::WHITE);
// with_alpha 量化到 128/255 = 0.50196,所以结果落在 127(不是 128)。
assert_eq!((got.r, got.a), (127, 255));
}
#[test]
fn lerp_hits_endpoints() {
let a = Color::rgb(0, 0, 0);
let b = Color::rgb(255, 255, 255);
assert_eq!(a.lerp(b, 0.0), a);
assert_eq!(a.lerp(b, 1.0), b);
assert_eq!(a.lerp(b, 0.5).r, 128);
}
#[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}");
}
}
}
+400
View File
@@ -0,0 +1,400 @@
//! 六条 tone 调色板的集合(M3 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::Color;
/// M3 baseline 种子色(与 material-color-utilities 的默认基线同值)。
///
/// **不是**产品默认色——产品默认见 [`DEFAULT_SEED`]。
pub const BASELINE_SEED: Color = Color::rgb(0x67, 0x50, 0xA4);
/// 产品出厂默认种子色 —— M70 候选④「天蓝」,**用户 2026-11-01 拍板采用**。
///
/// `#3C8DBC`(CAM16 hue ≈ 239.5°,主色 `#236488`)。注意种子的彩度(42.6)会被 Tonal Spot
/// 丢掉——主色板的彩度恒为 36,种子只贡献**色相**。
///
/// ## 定位(D38):这是「莫奈关闭时的出厂预选色卡」,不是唯一的配色来源
///
/// 用户定了三级优先的配色来源:① 启动器背景图取色(Monet)→ ② 桌面壁纸取色 → ③ 莫奈关闭
/// (默认)时用户从内置多套色卡里选。本常量 + [`DEFAULT_NEUTRAL_CHROMA`] 只定义**第三级里
/// 出厂预选的那一套**(首次启动、用户没选过色卡时的那一套;`palette::DEFAULT_CARD` 的种子即本值)。
///
/// 取色(量化 + 挑主色)、内置色卡库与三级回落都在 [`crate::palette`](M87,分层见 D40);
/// 本模块只负责「给定种子 + 中性彩度,派生出 49 个角色」。
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 第三级),不约束将来的取色结果。
/// 内置多套色卡落地时每张卡各带自己的中性彩度(`palette::Card::neutral_chroma`),八张卡
/// 当前都取本值;再加上中性**色相**固定为 [`NEUTRAL_HUE_DEG`](D43),八张卡的表面色因此
/// 逐值相同——切卡只换强调色。
pub const DEFAULT_NEUTRAL_CHROMA: f64 = 12.0;
/// 中性调色板的**固定色相**(度)——内置色卡共用它,动态取色不用它(D43)。
///
/// ## 出处:这就是「天蓝」档当前用的那个中性色相
///
/// 值等于 [`DEFAULT_SEED`](`#3C8DBC`)的 CAM16 色相 **239.51420577630955°**。天蓝档的中性
/// 调色板本来就是这个色相(`from_seed_with_neutral_chroma` 让中性色跟着种子走),所以它是
/// **沿用**,不是「另选一个冷调」:`Card::scheme` 换用它之后天蓝卡的 49 个角色必须逐值不变
/// (`tests/card_neutral.rs` 钉着)。
///
/// ## 为什么要有这个常量(D43)
///
/// 八张卡原先走 TonalSpot 的「中性色也跟种子染色」,切卡连窗口底色一起换调子:浅色档
/// `surface` 的实测 CAM16 彩度从 **1.29(rose)跨到 12.03(teal)**——tone 98 处暖相色域装
/// 不下彩度 12,「请求值 12」在暖相上只能兑现 1.29,9 倍跳变让用户觉得「每张卡都像换了个
/// 应用」。D43 的处置:**色卡固定中性色相、只换强调色**;动态取色(背景图 / 壁纸)保留逐色
/// 染色,那才是用户要的 Monet 体验。所以本常量只服务色卡路径,**不要**传给 `Seed::scheme`。
///
/// ⚠️ 换 [`DEFAULT_SEED`] 时本常量会「过期」:`neutral_hue_is_the_default_seed_hue` 用
/// 1e-9° 容差钉住两者的绑定(**不用 `to_bits` 位相等**——色相来自跨平台有末位差的 `atan2`),
/// 改了种子它就红——那时要想清楚是「色卡跟着新默认色走」(照抄新色相)还是「色卡保持这条
/// 冷调」(改成显式例外),别静默漂移。
pub const NEUTRAL_HUE_DEG: f64 = 239.514_205_776_309_55;
/// 调色板槽位(M3 的六条 tonal palette)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaletteSlot {
/// 主调色板(种子色相,彩度 36)。
Primary,
/// 次调色板(同色相,彩度 16)。
Secondary,
/// 第三调色板(种子色相 +60°,彩度 24)。
Tertiary,
/// 中性调色板(色相跟种子;**色卡路径**例外——固定为 [`NEUTRAL_HUE_DEG`],D43。
/// 官方彩度 6,产品默认 12——见 [`DEFAULT_NEUTRAL_CHROMA`])。
Neutral,
/// 中性变体调色板(色相同 neutral,彩度 = neutral + 2;官方 8,产品默认 14)。
NeutralVariant,
/// 错误调色板(固定红相 25°,彩度 84)。
Error,
}
/// Tonal Spot 的 primary 彩度(官方常数,与种子彩度无关)。
const PRIMARY_CHROMA: f64 = 36.0;
/// secondary 彩度。
const SECONDARY_CHROMA: f64 = 16.0;
/// tertiary 彩度。
const TERTIARY_CHROMA: f64 = 24.0;
/// 官方 Tonal Spot 的 neutral 彩度(几乎是灰)。产品默认用 [`DEFAULT_NEUTRAL_CHROMA`]。
const TONAL_SPOT_NEUTRAL_CHROMA: f64 = 6.0;
/// neutral-variant 相对 neutral 的彩度偏移。
///
/// ⚠️ 官方 `getNeutralVariantPalette` 写死的是**绝对值 8**,**不是**「neutral + 2」这种相对式;
/// 本实现选择让 neutral-variant 随 neutral 联动,于是产品默认(neutral 12)取到 **14**。
/// 这是**有意偏离**的另一半(见 `color` 模块文档的偏离登记),不是官方口径——
/// 官方的 6 / 8 由 [`Palettes::official_baseline`] 原样保留。
const NEUTRAL_VARIANT_OFFSET: f64 = 2.0;
/// error 的色相(HCT 红相)。
const ERROR_HUE_DEG: f64 = 25.0;
/// error 的彩度。
const ERROR_CHROMA: f64 = 84.0;
/// tertiary 相对种子的色相偏移。
const TERTIARY_HUE_SHIFT_DEG: f64 = 60.0;
/// 一套完整的六条调色板。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Palettes {
/// 主要调色板。
pub primary: TonalPalette,
/// 次要调色板。
pub secondary: TonalPalette,
/// 第三调色板。
pub tertiary: TonalPalette,
/// 中性调色板。
pub neutral: TonalPalette,
/// 中性变体调色板。
pub neutral_variant: TonalPalette,
/// 错误调色板。
pub error: TonalPalette,
}
impl Palettes {
/// **产品出厂默认**:种子 [`DEFAULT_SEED`] + 中性彩度 [`DEFAULT_NEUTRAL_CHROMA`]。
///
/// 消费方(`DesignTokens::baseline`、iced 适配层)拿到的就是这套,所以产品默认色
/// 只需要改那两个常量。
pub fn baseline() -> Self {
Self::from_seed_with_neutral_chroma(DEFAULT_SEED, DEFAULT_NEUTRAL_CHROMA)
}
/// **官方基线的锚点**:种子 [`BASELINE_SEED`] + 官方中性彩度 6(等价官方
/// `SchemeTonalSpot(0xFF6750A4)`)。只给对齐测试与文档表格用。
pub fn official_baseline() -> Self {
Self::from_seed(BASELINE_SEED)
}
/// 从一个种子色派生全套调色板(官方 Tonal Spot 变体,中性彩度取官方 6)。
pub fn from_seed(seed: Color) -> Self {
Self::from_seed_with_neutral_chroma(seed, TONAL_SPOT_NEUTRAL_CHROMA)
}
/// 从一个种子色派生,并显式指定中性调色板彩度(`6.0` 即官方 Tonal Spot)。
///
/// ⚠️ **调用方负责传 `6.0..=13.0`(产品取 12)**。本函数**不做范围校验**,越界的表现是
/// 静默退化而不是 panic:
/// - `neutral_chroma < 0` 或 `NaN` → 彩度被夹到 0(`TonalPalette::new` 里 `.max(0.0)`),
/// 得到一套纯灰的表面(surface ≈ `#F9F9F9`),**不报错**;
/// - `neutral_chroma >= 14` → neutral-variant(= neutral + 2)够到甚至超过 secondary 的 16,
/// 「中性 < 中性变体 < 次色」的层次被破坏(描边会明显发蓝)。
///
/// 除 neutral / neutral-variant 外**全部走官方常数**:primary 恒定 36、secondary 16、
/// tertiary 24(色相 +60°)、error 固定 25°/84。所以这条入口不绕算法,只是把「官方写死在
/// `getNeutralPalette` / `getNeutralVariantPalette` 里的那两个数」变成可调入参
/// (neutral-variant 这里取相对式 `neutral + 2`,属**有意偏离**,见 `NEUTRAL_VARIANT_OFFSET`)。
pub fn from_seed_with_neutral_chroma(seed: Color, neutral_chroma: f64) -> Self {
let hue = super::Hct::from_color(seed).hue;
Self::tonal_spot(hue, 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 {
primary: TonalPalette::new(hue, PRIMARY_CHROMA),
secondary: TonalPalette::new(hue, SECONDARY_CHROMA),
tertiary: TonalPalette::new(hue + TERTIARY_HUE_SHIFT_DEG, TERTIARY_CHROMA),
..self
}
}
/// **内置色卡的派生入口(D43)**:强调三族跟 `accent` 的色相走,中性族(含
/// neutral-variant)**固定在 `neutral_hue_deg`**。
///
/// 与 [`Palettes::from_seed_with_neutral_chroma`](TonalSpot,中性色跟着种子染色)的唯一
/// 差别是中性族色相;与 [`Palettes::with_accent`] 的差别是中性族可以整体搬到一个不属于
/// 基线的色相上(`with_accent` 只能沿用 `self` 的中性族)。
///
/// 产品用法:色卡路径传 [`NEUTRAL_HUE_DEG`](= 出厂默认种子的中性色相),于是八张卡的
/// 表面色完全相同,只有强调色在换。**取色路径不要用本函数**——D43 明确保留「按图像种子
/// 逐色染色」的 Monet 体验(见 [`crate::palette::resolve`])。
pub fn accent_on_fixed_neutral(
accent: Color,
neutral_hue_deg: f64,
neutral_chroma: f64,
) -> Self {
let hue = super::Hct::from_color(accent).hue;
Self::tonal_spot_with_neutral(hue, neutral_hue_deg, neutral_chroma)
}
/// 官方 Tonal Spot 的六条调色板(色相来自种子,彩度取官方常数,neutral 可调)。
fn tonal_spot(hue: f64, neutral_chroma: f64) -> Self {
Self::tonal_spot_with_neutral(hue, hue, neutral_chroma)
}
/// 同一套 Tonal Spot,但**中性族(neutral / neutral-variant)的色相可以与种子不同**。
///
/// 这是 D43「内置色卡固定中性色相」的实现核心:强调三族跟 `accent_hue`,表面族跟
/// `neutral_hue`。其余彩度常数、error 族、tone 表一行未动。
fn tonal_spot_with_neutral(accent_hue: f64, neutral_hue: f64, neutral_chroma: f64) -> Self {
Self {
primary: TonalPalette::new(accent_hue, PRIMARY_CHROMA),
secondary: TonalPalette::new(accent_hue, SECONDARY_CHROMA),
tertiary: TonalPalette::new(accent_hue + TERTIARY_HUE_SHIFT_DEG, TERTIARY_CHROMA),
neutral: TonalPalette::new(neutral_hue, neutral_chroma),
neutral_variant: TonalPalette::new(
neutral_hue,
neutral_chroma + NEUTRAL_VARIANT_OFFSET,
),
error: TonalPalette::new(ERROR_HUE_DEG, ERROR_CHROMA),
}
}
/// 按槽位取调色板。
pub fn slot(&self, slot: PaletteSlot) -> TonalPalette {
match slot {
PaletteSlot::Primary => self.primary,
PaletteSlot::Secondary => self.secondary,
PaletteSlot::Tertiary => self.tertiary,
PaletteSlot::Neutral => self.neutral,
PaletteSlot::NeutralVariant => self.neutral_variant,
PaletteSlot::Error => self.error,
}
}
/// 方案级取色:槽位 + tone → 色值。
pub fn tone(&self, slot: PaletteSlot, tone: f64) -> Color {
self.slot(slot).tone(tone)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn official_baseline_uses_the_official_tonal_spot_chromas() {
let palettes = Palettes::official_baseline();
assert_eq!(palettes.primary.chroma(), 36.0);
assert_eq!(palettes.secondary.chroma(), 16.0);
assert_eq!(palettes.tertiary.chroma(), 24.0);
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]
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 swapped = base.with_accent(Color::rgb(0x00, 0x6A, 0x5C));
assert_eq!(swapped.neutral, base.neutral);
assert_eq!(swapped.neutral_variant, base.neutral_variant);
assert_eq!(swapped.error, base.error);
assert_ne!(swapped.primary.hue_deg(), base.primary.hue_deg());
assert_eq!(swapped.primary.chroma(), 36.0);
}
#[test]
fn neutral_hue_is_the_default_seed_hue() {
// D43 的绑定器。用 1e-9° 容差而非 `to_bits()` 位相等:色相来自 `atan2()`,glibc 与
// UCRT 理论可差 1 ulp(~1e-14°),位相等会在另一个 OS 的 CI 上红;这条要抓的是「换了
// `DEFAULT_SEED` 却没复核常量」(偏几十度)。「逐值不变」的位相等钉子在同平台的
// `card_neutral.rs::sky_card_is_bit_identical_to_the_product_baseline`(两个算出的值
// 互比,不涉及跨平台字面量)。
let seed_hue = super::super::Hct::from_color(DEFAULT_SEED).hue;
assert!(
(NEUTRAL_HUE_DEG - seed_hue).abs() < 1.0e-9,
"NEUTRAL_HUE_DEG = {NEUTRAL_HUE_DEG:?} 与 DEFAULT_SEED 的色相 {seed_hue:?} \
相差超过 1e-9°(换默认种子时:要么照抄新色相,要么把这里改成显式例外)"
);
}
#[test]
fn accent_on_fixed_neutral_only_moves_the_neutral_hue() {
let official = Palettes::official_baseline();
// 中性色相 = 基线色相时,与官方的 from_seed 完全等价(不放宽算法)。
let same = Palettes::accent_on_fixed_neutral(DEFAULT_SEED, NEUTRAL_HUE_DEG, 12.0);
let per_seed = Palettes::from_seed_with_neutral_chroma(DEFAULT_SEED, 12.0);
assert_eq!(
same, per_seed,
"中性色相取种子色相时必须与 TonalSpot 逐项相同"
);
// 换一个中性色相:只有中性两族的色相动,彩度与强调族一律不动。
let moved = Palettes::accent_on_fixed_neutral(DEFAULT_SEED, 120.0, 12.0);
assert_eq!(moved.primary, per_seed.primary);
assert_eq!(moved.secondary, per_seed.secondary);
assert_eq!(moved.tertiary, per_seed.tertiary);
assert_eq!(moved.error, per_seed.error);
assert_eq!(moved.neutral.chroma(), per_seed.neutral.chroma());
assert_eq!(
moved.neutral_variant.chroma(),
per_seed.neutral_variant.chroma()
);
assert_eq!(moved.neutral.hue_deg(), 120.0);
assert_eq!(moved.neutral_variant.hue_deg(), 120.0);
assert_eq!(moved.primary.chroma(), official.primary.chroma());
}
}
+276
View File
@@ -0,0 +1,276 @@
//! M3 语义色角色:`Role` 枚举是**唯一的名字清单**(49 个,M3 基础集全覆盖)。
//!
//! 后续 iced 页面不许另起名字(如 `bg-card` / `fg-primary`)——Fluent 时代的旧名到此为止,
//! 要表达「卡片底」就用 [`Role::SurfaceContainerLow`],要表达「次要文字」就用
//! [`Role::OnSurfaceVariant`](对应关系见 `DevDocs/SubAgentsTalk/design-令牌约定.md`)。
/// M3 语义色角色(49 个)。
///
/// 顺序即 [`Role::ALL`] 的顺序,也是 [`super::Scheme`] 内部数组的下标:
/// **只允许在末尾追加**,插到中间会静默改变已存盘的颜色映射。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Role {
/// 主色:主操作填充、选中指示。
Primary,
/// 主色之上的前景(文字/图标)。
OnPrimary,
/// 主色容器:次级强调块、选中片。
PrimaryContainer,
/// 主色容器之上的前景。
OnPrimaryContainer,
/// 次色:低频强调、图表。
Secondary,
/// 次色之上的前景。
OnSecondary,
/// 次色容器。
SecondaryContainer,
/// 次色容器之上的前景。
OnSecondaryContainer,
/// 第三色:与主/次形成对比的装饰强调。
Tertiary,
/// 第三色之上的前景。
OnTertiary,
/// 第三色容器。
TertiaryContainer,
/// 第三色容器之上的前景。
OnTertiaryContainer,
/// 错误色:错误文字、危险操作填充。
Error,
/// 错误色之上的前景。
OnError,
/// 错误容器:错误提示底。
ErrorContainer,
/// 错误容器之上的前景。
OnErrorContainer,
/// 表面:窗口/内容底色(M3 最新口径里取代 background)。
Surface,
/// 表面之上的主体文字。
OnSurface,
/// 表面变体:中性分隔/次级容器(**不是**文字色)。
SurfaceVariant,
/// 表面变体之上的次级文字/图标。
OnSurfaceVariant,
/// 背景(M3 已并入 surface,保留以兼容旧值)。
Background,
/// 背景之上的文本(同上,保留兼容)。
OnBackground,
/// 变暗的表面(模态背后的压暗层)。
SurfaceDim,
/// 变亮的表面(深色下的抬高底)。
SurfaceBright,
/// 最低层容器(比 surface 更低)。
SurfaceContainerLowest,
/// 低层容器:卡片。
SurfaceContainerLow,
/// 标准容器:卡片/面板。
SurfaceContainer,
/// 高层容器:浮层。
SurfaceContainerHigh,
/// 最高层容器:对话框/菜单。
SurfaceContainerHighest,
/// 描边:输入框边框等(非文字,3:1 即可)。
Outline,
/// 弱描边:分隔线(纯装饰)。
OutlineVariant,
/// 反相表面:Snackbar / Tooltip 底。
InverseSurface,
/// 反相表面之上的文字。
InverseOnSurface,
/// 反相表面之上的强调。
InversePrimary,
/// 阴影色(通常带 alpha,见 [`super::Scheme::shadow`])。
Shadow,
/// 遮罩色(模态背景,通常带 alpha)。
Scrim,
/// 表面着色(M3 旧规范的 elevation tint,现已被 surface container 取代)。
SurfaceTint,
/// 固定主色(深浅模式同值,用于需要跨模式稳定的块)。
PrimaryFixed,
/// 固定主色的暗档。
PrimaryFixedDim,
/// 固定主色之上的前景。
OnPrimaryFixed,
/// 固定主色暗档之上的前景。
OnPrimaryFixedVariant,
/// 固定次色。
SecondaryFixed,
/// 固定次色的暗档。
SecondaryFixedDim,
/// 固定次色之上的前景。
OnSecondaryFixed,
/// 固定次色暗档之上的前景。
OnSecondaryFixedVariant,
/// 固定第三色。
TertiaryFixed,
/// 固定第三色的暗档。
TertiaryFixedDim,
/// 固定第三色之上的前景。
OnTertiaryFixed,
/// 固定第三色暗档之上的前景。
OnTertiaryFixedVariant,
}
impl Role {
/// 全部 49 个角色,按 `Role as usize` 顺序排列。
pub const ALL: [Role; 49] = [
Role::Primary,
Role::OnPrimary,
Role::PrimaryContainer,
Role::OnPrimaryContainer,
Role::Secondary,
Role::OnSecondary,
Role::SecondaryContainer,
Role::OnSecondaryContainer,
Role::Tertiary,
Role::OnTertiary,
Role::TertiaryContainer,
Role::OnTertiaryContainer,
Role::Error,
Role::OnError,
Role::ErrorContainer,
Role::OnErrorContainer,
Role::Surface,
Role::OnSurface,
Role::SurfaceVariant,
Role::OnSurfaceVariant,
Role::Background,
Role::OnBackground,
Role::SurfaceDim,
Role::SurfaceBright,
Role::SurfaceContainerLowest,
Role::SurfaceContainerLow,
Role::SurfaceContainer,
Role::SurfaceContainerHigh,
Role::SurfaceContainerHighest,
Role::Outline,
Role::OutlineVariant,
Role::InverseSurface,
Role::InverseOnSurface,
Role::InversePrimary,
Role::Shadow,
Role::Scrim,
Role::SurfaceTint,
Role::PrimaryFixed,
Role::PrimaryFixedDim,
Role::OnPrimaryFixed,
Role::OnPrimaryFixedVariant,
Role::SecondaryFixed,
Role::SecondaryFixedDim,
Role::OnSecondaryFixed,
Role::OnSecondaryFixedVariant,
Role::TertiaryFixed,
Role::TertiaryFixedDim,
Role::OnTertiaryFixed,
Role::OnTertiaryFixedVariant,
];
/// 稳定字符串名(kebab-case),用于日志、配置、快照对拍与文档表格。
pub const fn name(self) -> &'static str {
match self {
Role::Primary => "primary",
Role::OnPrimary => "on-primary",
Role::PrimaryContainer => "primary-container",
Role::OnPrimaryContainer => "on-primary-container",
Role::Secondary => "secondary",
Role::OnSecondary => "on-secondary",
Role::SecondaryContainer => "secondary-container",
Role::OnSecondaryContainer => "on-secondary-container",
Role::Tertiary => "tertiary",
Role::OnTertiary => "on-tertiary",
Role::TertiaryContainer => "tertiary-container",
Role::OnTertiaryContainer => "on-tertiary-container",
Role::Error => "error",
Role::OnError => "on-error",
Role::ErrorContainer => "error-container",
Role::OnErrorContainer => "on-error-container",
Role::Surface => "surface",
Role::OnSurface => "on-surface",
Role::SurfaceVariant => "surface-variant",
Role::OnSurfaceVariant => "on-surface-variant",
Role::Background => "background",
Role::OnBackground => "on-background",
Role::SurfaceDim => "surface-dim",
Role::SurfaceBright => "surface-bright",
Role::SurfaceContainerLowest => "surface-container-lowest",
Role::SurfaceContainerLow => "surface-container-low",
Role::SurfaceContainer => "surface-container",
Role::SurfaceContainerHigh => "surface-container-high",
Role::SurfaceContainerHighest => "surface-container-highest",
Role::Outline => "outline",
Role::OutlineVariant => "outline-variant",
Role::InverseSurface => "inverse-surface",
Role::InverseOnSurface => "inverse-on-surface",
Role::InversePrimary => "inverse-primary",
Role::Shadow => "shadow",
Role::Scrim => "scrim",
Role::SurfaceTint => "surface-tint",
Role::PrimaryFixed => "primary-fixed",
Role::PrimaryFixedDim => "primary-fixed-dim",
Role::OnPrimaryFixed => "on-primary-fixed",
Role::OnPrimaryFixedVariant => "on-primary-fixed-variant",
Role::SecondaryFixed => "secondary-fixed",
Role::SecondaryFixedDim => "secondary-fixed-dim",
Role::OnSecondaryFixed => "on-secondary-fixed",
Role::OnSecondaryFixedVariant => "on-secondary-fixed-variant",
Role::TertiaryFixed => "tertiary-fixed",
Role::TertiaryFixedDim => "tertiary-fixed-dim",
Role::OnTertiaryFixed => "on-tertiary-fixed",
Role::OnTertiaryFixedVariant => "on-tertiary-fixed-variant",
}
}
/// 该角色是否是「文字/图标前景色」(`on-*` 与 `inverse-on-surface` 一类)。
///
/// 用于对比度门禁区分两类要求:前景对容器 ≥ 4.5:1(WCAG AA 正文),
/// 非文字元素 ≥ 3:1。
pub const fn is_foreground(self) -> bool {
matches!(
self,
Role::OnPrimary
| Role::OnPrimaryContainer
| Role::OnSecondary
| Role::OnSecondaryContainer
| Role::OnTertiary
| Role::OnTertiaryContainer
| Role::OnError
| Role::OnErrorContainer
| Role::OnSurface
| Role::OnSurfaceVariant
| Role::OnBackground
| Role::InverseOnSurface
| Role::InversePrimary
| Role::OnPrimaryFixed
| Role::OnPrimaryFixedVariant
| Role::OnSecondaryFixed
| Role::OnSecondaryFixedVariant
| Role::OnTertiaryFixed
| Role::OnTertiaryFixedVariant
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_is_in_enum_order() {
for (i, role) in Role::ALL.iter().enumerate() {
assert_eq!(*role as usize, i, "Role::ALL 顺序必须等于枚举声明顺序");
}
}
#[test]
fn names_are_unique_and_kebab() {
let mut seen = std::collections::BTreeSet::new();
for role in Role::ALL {
let name = role.name();
assert!(seen.insert(name), "角色名重复:{name}");
assert!(
name.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
"角色名必须是 kebab-case:{name}"
);
}
}
}
+342
View File
@@ -0,0 +1,342 @@
//! 一套完整的 M3 语义色方案(`Scheme`):角色 → 调色板槽位 → tone 的三段映射。
//!
//! tone 表取自 **ColorSpec2021** 的官方映射(`MaterialDynamicColors` 的 light/dark 两列,
//! 见 `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::roles::Role;
use super::Color;
/// 深浅模式。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SchemeFlavor {
/// 浅色(亮底暗字)。
Light,
/// 深色(暗底亮字)。
Dark,
}
impl SchemeFlavor {
/// 是否深色模式。
pub const fn is_dark(self) -> bool {
matches!(self, SchemeFlavor::Dark)
}
/// 反过来那一个。
pub const fn toggled(self) -> Self {
match self {
SchemeFlavor::Light => SchemeFlavor::Dark,
SchemeFlavor::Dark => SchemeFlavor::Light,
}
}
/// 稳定字符串名(`light` / `dark`),写配置与日志用。
pub const fn name(self) -> &'static str {
match self {
SchemeFlavor::Light => "light",
SchemeFlavor::Dark => "dark",
}
}
}
/// 阴影色透明度(M3 阴影 = 纯黑 + 这个 alpha;绘制阴影时叠在背景上)。
pub const SHADOW_ALPHA: f32 = 0.20;
/// 遮罩色透明度(模态背景压暗层)。
pub const SCRIM_ALPHA: f32 = 0.32;
/// 一条「角色 → 槽位 + tone」映射。
struct ToneRow {
role: Role,
slot: PaletteSlot,
light: f64,
dark: f64,
}
/// 49 个角色的 tone 映射表(ColorSpec2021),**下标必须等于 `Role as usize`**(由测试钉死)。
const TONE_TABLE: [ToneRow; 49] = [
row(Role::Primary, PaletteSlot::Primary, 40.0, 80.0),
row(Role::OnPrimary, PaletteSlot::Primary, 100.0, 20.0),
row(Role::PrimaryContainer, PaletteSlot::Primary, 90.0, 30.0),
row(Role::OnPrimaryContainer, PaletteSlot::Primary, 30.0, 90.0),
row(Role::Secondary, PaletteSlot::Secondary, 40.0, 80.0),
row(Role::OnSecondary, PaletteSlot::Secondary, 100.0, 20.0),
row(Role::SecondaryContainer, PaletteSlot::Secondary, 90.0, 30.0),
row(
Role::OnSecondaryContainer,
PaletteSlot::Secondary,
30.0,
90.0,
),
row(Role::Tertiary, PaletteSlot::Tertiary, 40.0, 80.0),
row(Role::OnTertiary, PaletteSlot::Tertiary, 100.0, 20.0),
row(Role::TertiaryContainer, PaletteSlot::Tertiary, 90.0, 30.0),
row(Role::OnTertiaryContainer, PaletteSlot::Tertiary, 30.0, 90.0),
row(Role::Error, PaletteSlot::Error, 40.0, 80.0),
row(Role::OnError, PaletteSlot::Error, 100.0, 20.0),
row(Role::ErrorContainer, PaletteSlot::Error, 90.0, 30.0),
row(Role::OnErrorContainer, PaletteSlot::Error, 30.0, 90.0),
row(Role::Surface, PaletteSlot::Neutral, 98.0, 6.0),
row(Role::OnSurface, PaletteSlot::Neutral, 10.0, 90.0),
row(
Role::SurfaceVariant,
PaletteSlot::NeutralVariant,
90.0,
30.0,
),
row(
Role::OnSurfaceVariant,
PaletteSlot::NeutralVariant,
30.0,
80.0,
),
row(Role::Background, PaletteSlot::Neutral, 98.0, 6.0),
row(Role::OnBackground, PaletteSlot::Neutral, 10.0, 90.0),
row(Role::SurfaceDim, PaletteSlot::Neutral, 87.0, 6.0),
row(Role::SurfaceBright, PaletteSlot::Neutral, 98.0, 24.0),
row(
Role::SurfaceContainerLowest,
PaletteSlot::Neutral,
100.0,
4.0,
),
row(Role::SurfaceContainerLow, PaletteSlot::Neutral, 96.0, 10.0),
row(Role::SurfaceContainer, PaletteSlot::Neutral, 94.0, 12.0),
row(Role::SurfaceContainerHigh, PaletteSlot::Neutral, 92.0, 17.0),
row(
Role::SurfaceContainerHighest,
PaletteSlot::Neutral,
90.0,
22.0,
),
row(Role::Outline, PaletteSlot::NeutralVariant, 50.0, 60.0),
row(
Role::OutlineVariant,
PaletteSlot::NeutralVariant,
80.0,
30.0,
),
row(Role::InverseSurface, PaletteSlot::Neutral, 20.0, 90.0),
row(Role::InverseOnSurface, PaletteSlot::Neutral, 95.0, 20.0),
row(Role::InversePrimary, PaletteSlot::Primary, 80.0, 40.0),
row(Role::Shadow, PaletteSlot::Neutral, 0.0, 0.0),
row(Role::Scrim, PaletteSlot::Neutral, 0.0, 0.0),
row(Role::SurfaceTint, PaletteSlot::Primary, 40.0, 80.0),
row(Role::PrimaryFixed, PaletteSlot::Primary, 90.0, 90.0),
row(Role::PrimaryFixedDim, PaletteSlot::Primary, 80.0, 80.0),
row(Role::OnPrimaryFixed, PaletteSlot::Primary, 10.0, 10.0),
row(
Role::OnPrimaryFixedVariant,
PaletteSlot::Primary,
30.0,
30.0,
),
row(Role::SecondaryFixed, PaletteSlot::Secondary, 90.0, 90.0),
row(Role::SecondaryFixedDim, PaletteSlot::Secondary, 80.0, 80.0),
row(Role::OnSecondaryFixed, PaletteSlot::Secondary, 10.0, 10.0),
row(
Role::OnSecondaryFixedVariant,
PaletteSlot::Secondary,
30.0,
30.0,
),
row(Role::TertiaryFixed, PaletteSlot::Tertiary, 90.0, 90.0),
row(Role::TertiaryFixedDim, PaletteSlot::Tertiary, 80.0, 80.0),
row(Role::OnTertiaryFixed, PaletteSlot::Tertiary, 10.0, 10.0),
row(
Role::OnTertiaryFixedVariant,
PaletteSlot::Tertiary,
30.0,
30.0,
),
];
/// `const` 友好的行构造器(数组初始化里不能调用非 const fn 之外的东西)。
const fn row(role: Role, slot: PaletteSlot, light: f64, dark: f64) -> ToneRow {
ToneRow {
role,
slot,
light,
dark,
}
}
/// 一套语义色(49 个角色,全部已解析成具体颜色)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Scheme {
flavor: SchemeFlavor,
colors: [Color; 49],
}
impl Scheme {
/// **产品出厂默认方案**(种子 [`super::palettes::DEFAULT_SEED`] + 中性彩度
/// [`super::palettes::DEFAULT_NEUTRAL_CHROMA`],见 [`Palettes::baseline`])。
pub fn baseline(flavor: SchemeFlavor) -> Self {
Self::from_palettes(&Palettes::baseline(), flavor)
}
/// **官方基线锚点**(种子 `#6750A4` + 官方中性彩度 6,等价官方
/// `SchemeTonalSpot(0xFF6750A4)`)。
///
/// 与 [`Scheme::baseline`] 的区别只有入参:算法、tone 表、gamut 映射完全同一条路径,
/// 所以 `tests/official_baseline.rs` 拿它跟官方 Java 输出逐角色对账。
pub fn official_baseline(flavor: SchemeFlavor) -> Self {
Self::from_palettes(&Palettes::official_baseline(), flavor)
}
/// 指定种子色的方案(表面色与强调色同源,等价官方 `SchemeTonalSpot`)。
pub fn from_seed(flavor: SchemeFlavor, seed: Color) -> Self {
Self::from_palettes(&Palettes::from_seed(seed), flavor)
}
/// 只覆盖强调色(表面中性色仍走基线,见 [`Palettes::with_accent`])。
pub fn from_accent(flavor: SchemeFlavor, accent: Color) -> Self {
Self::from_palettes(&Palettes::baseline().with_accent(accent), flavor)
}
/// 直接从调色板集合构建(高级入口:整仓测试与自定义 scheme 用)。
pub fn from_palettes(palettes: &Palettes, flavor: SchemeFlavor) -> Self {
let colors = std::array::from_fn(|i| {
let entry = &TONE_TABLE[i];
palettes.tone(entry.slot, Scheme::tone_of(entry.role, flavor))
});
Self { flavor, colors }
}
/// 从 49 个已解析色值直接构造(**底层入口**,下标必须等于 `Role as usize`)。
///
/// 只给两类调用方用:
/// 1. `palette::wcag` 的对比度收敛——它按角色替换个别色值后要重新组装成一套方案;
/// 2. 测试——要能构造「故意不达标」的方案,比较度校验器才有反证。
///
/// 常规路径一律走 [`Scheme::from_palettes`]:那样色值才是 tone 表的推导结果,
/// 而不是随手塞进来的数。
pub fn from_colors(flavor: SchemeFlavor, colors: [Color; 49]) -> Self {
Self { flavor, colors }
}
/// 该角色在指定模式下的 tone 值(M3 语义;文档表格、调试与测试用)。
pub fn tone_of(role: Role, flavor: SchemeFlavor) -> f64 {
let entry = &TONE_TABLE[role as usize];
debug_assert_eq!(entry.role, role, "TONE_TABLE 下标必须等于角色序号");
match flavor {
SchemeFlavor::Light => entry.light,
SchemeFlavor::Dark => entry.dark,
}
}
/// 取角色色值。
pub fn get(&self, role: Role) -> Color {
self.colors[role as usize]
}
/// 当前模式。
pub fn flavor(&self) -> SchemeFlavor {
self.flavor
}
/// 按角色顺序遍历(`for (role, color) in scheme.iter()`)。
pub fn iter(&self) -> impl Iterator<Item = (Role, Color)> + '_ {
Role::ALL.iter().copied().map(|role| (role, self.get(role)))
}
/// 阴影色(纯黑 + [`SHADOW_ALPHA`])。
pub fn shadow(&self) -> Color {
self.get(Role::Shadow).with_alpha(SHADOW_ALPHA)
}
/// 遮罩色(纯黑 + [`SCRIM_ALPHA`])。
pub fn scrim(&self) -> Color {
self.get(Role::Scrim).with_alpha(SCRIM_ALPHA)
}
/// 两个角色的 WCAG 对比度(1..21)。
pub fn contrast(&self, fg: Role, bg: Role) -> f32 {
self.get(fg).contrast_ratio(self.get(bg))
}
}
impl std::ops::Index<Role> for Scheme {
type Output = Color;
fn index(&self, role: Role) -> &Color {
&self.colors[role as usize]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tone_table_matches_role_order() {
for (i, entry) in TONE_TABLE.iter().enumerate() {
assert_eq!(
entry.role as usize,
i,
"TONE_TABLE 第 {i} 行是 {},顺序与 Role 枚举不一致",
entry.role.name()
);
}
}
#[test]
fn fixed_roles_are_identical_across_flavors() {
let light = Scheme::baseline(SchemeFlavor::Light);
let dark = Scheme::baseline(SchemeFlavor::Dark);
for role in [
Role::PrimaryFixed,
Role::PrimaryFixedDim,
Role::OnPrimaryFixed,
Role::OnPrimaryFixedVariant,
Role::SecondaryFixed,
Role::TertiaryFixedDim,
] {
assert_eq!(
light.get(role),
dark.get(role),
"{} 应跨模式同值",
role.name()
);
}
}
#[test]
fn light_and_dark_differ_on_text_pairs() {
let light = Scheme::baseline(SchemeFlavor::Light);
let dark = Scheme::baseline(SchemeFlavor::Dark);
assert_ne!(light.get(Role::OnSurface), dark.get(Role::OnSurface));
assert!(light.get(Role::Surface).relative_luminance() > 0.8);
assert!(dark.get(Role::Surface).relative_luminance() < 0.05);
}
#[test]
fn shadow_and_scrim_carry_alpha() {
let scheme = Scheme::baseline(SchemeFlavor::Light);
assert_eq!(scheme.shadow().a, (SHADOW_ALPHA * 255.0).round() as u8);
assert_eq!(scheme.scrim().a, (SCRIM_ALPHA * 255.0).round() as u8);
}
#[test]
fn accent_override_changes_primary_only() {
let base = Scheme::baseline(SchemeFlavor::Light);
let teal = Scheme::from_accent(SchemeFlavor::Light, Color::rgb(0x00, 0x6A, 0x5C));
assert_ne!(base.get(Role::Primary), teal.get(Role::Primary));
assert_eq!(base.get(Role::Surface), teal.get(Role::Surface));
assert_eq!(
base.get(Role::Outline),
teal.get(Role::Outline),
"强调色覆盖不该动中性描边"
);
assert_eq!(base.get(Role::Error), teal.get(Role::Error));
}
}
+217
View File
@@ -0,0 +1,217 @@
//! 色彩空间底层换算:sRGB ↔ 线性 RGB ↔ CIE XYZ ↔ `L*`(tone 轴)。
//!
//! 只放**纯数学**,不认识任何语义色角色。数值口径逐函数照搬 Google
//! `material-color-utilities` 的 `java/utils/ColorUtils.java`(Apache-2.0,见 `NOTICE`):
//! 矩阵、阈值、取整方式都一致——差一位小数,HCT 求解器就会与官方差一个通道。
//!
//! 内部一律用 `f64`(官方实现是 Java `double`)。公开 API 仍以 `u8` 通道与 `f32` 标量对外,
//! 因为令牌消费方(绘制层)不需要比 8 bit 更高的精度。
use super::Color;
/// sRGB → XYZ(D65)。官方用的是 IEC 6-1 的舍入值,不是更精确的 sRGB 矩阵。
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],
];
/// D65 白点(XYZ,Y 归一化到 100)。
pub(crate) const WHITE_POINT_D65: [f64; 3] = [95.047, 100.0, 108.883];
/// 行向量 × 3×3 矩阵。
pub(crate) fn matrix_multiply(row: [f64; 3], matrix: [[f64; 3]; 3]) -> [f64; 3] {
let mul = |m: [f64; 3]| row[0] * m[0] + row[1] * m[1] + row[2] * m[2];
[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 {
((normalized + 0.055) / 1.055).powf(2.4) * 100.0
}
}
/// 线性光强(0..=100)→ sRGB 编码值,四舍五入并夹取到 0..=255。
pub(crate) fn delinearized(component: f64) -> u8 {
let value = true_delinearized(component).round().clamp(0.0, 255.0);
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 {
1.055 * normalized.powf(1.0 / 2.4) - 0.055
};
encoded * 255.0
}
/// 线性 RGB(0..=100)→ 不透明颜色。
pub(crate) fn color_from_linrgb(linrgb: [f64; 3]) -> Color {
Color::rgb(
delinearized(linrgb[0]),
delinearized(linrgb[1]),
delinearized(linrgb[2]),
)
}
/// 色值 → XYZ(Y 在 0..=100 标度)。
pub(crate) fn xyz_from_color(color: Color) -> [f64; 3] {
let linear = [
linearized(color.r),
linearized(color.g),
linearized(color.b),
];
matrix_multiply(linear, SRGB_TO_XYZ)
}
/// `L*` 的比较函数(CIE 分段线性/立方根)。
fn lab_f(t: f64) -> f64 {
const EPSILON: f64 = 216.0 / 24389.0;
const KAPPA: f64 = 24389.0 / 27.0;
if t > EPSILON {
t.powf(1.0 / 3.0)
} else {
(KAPPA * t + 16.0) / 116.0
}
}
/// [`lab_f`] 的逆。
fn lab_invf(ft: f64) -> f64 {
const EPSILON: f64 = 216.0 / 24389.0;
const KAPPA: f64 = 24389.0 / 27.0;
let cubed = ft * ft * ft;
if cubed > EPSILON {
cubed
} else {
(116.0 * ft - 16.0) / KAPPA
}
}
/// `L*` → 相对亮度 Y(0..=100)。
pub(crate) fn y_from_lstar(lstar: f64) -> f64 {
100.0 * lab_invf((lstar + 16.0) / 116.0)
}
/// 相对亮度 Y(0..=100)→ `L*`。
pub(crate) fn lstar_from_y(y: f64) -> f64 {
lab_f(y / 100.0) * 116.0 - 16.0
}
/// 色值 → `L*`(即 M3 的 **tone**)。
pub(crate) fn lstar_from_color(color: Color) -> f64 {
lstar_from_y(xyz_from_color(color)[1])
}
/// 色值 → CIELAB(`[L*, a*, b*]`)。
///
/// `L*` 与 [`lstar_from_color`] 逐值相同(同一个 `lab_f`);`a*`/`b*` 走 D65 白点归一。
/// 取色量化(`crate::palette::quantize`)用它在感知空间里算簇间距离——sRGB 欧氏距离会把
/// 暗部差异放大、亮部差异压扁,分出来的簇不符合「看起来是一族颜色」。
pub(crate) fn lab_from_color(color: Color) -> [f64; 3] {
let xyz = xyz_from_color(color);
let f = |component: f64, white: f64| lab_f(component / white);
let fx = f(xyz[0], WHITE_POINT_D65[0]);
let fy = f(xyz[1], WHITE_POINT_D65[1]);
let fz = f(xyz[2], WHITE_POINT_D65[2]);
[116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)]
}
/// `L*` → 中性灰色值(`L*` 与彩度无关,纯灰)。
pub(crate) fn color_from_lstar(lstar: f64) -> Color {
let component = delinearized(y_from_lstar(lstar));
Color::rgb(component, component, component)
}
/// Java `Math.signum` 的口径:负数 −1、零 0、正数 1(**Rust 的 `f64::signum(0.0)` 是 1**,
/// 直接换会改变中性色的求解结果)。
pub(crate) fn signum(value: f64) -> f64 {
if value < 0.0 {
-1.0
} else if value == 0.0 {
0.0
} else {
1.0
}
}
/// 角度归一到 `0.0..360.0`。
pub(crate) fn sanitize_degrees(degrees: f64) -> f64 {
let wrapped = degrees % 360.0;
if wrapped < 0.0 {
wrapped + 360.0
} else {
wrapped
}
}
/// 弧度归一到 `0.0..2π`(官方 `sanitizeRadians`,多加了 8π 保证非负)。
pub(crate) fn sanitize_radians(angle: f64) -> f64 {
(angle + std::f64::consts::PI * 8.0) % (std::f64::consts::PI * 2.0)
}
/// 线性插值(官方 `lerp`)。
pub(crate) fn lerp(start: f64, stop: f64, amount: f64) -> f64 {
(1.0 - amount) * start + amount * stop
}
/// 夹取到 `min..=max`。
pub(crate) fn clamp(min: f64, max: f64, input: f64) -> f64 {
input.clamp(min, max)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lstar_and_luminance_are_inverse() {
for lstar in [0.0_f64, 8.0, 20.0, 50.0, 87.0, 98.0, 100.0] {
let back = lstar_from_y(y_from_lstar(lstar));
assert!((back - lstar).abs() < 1.0e-9, "L* {lstar} 往返成 {back}");
}
}
#[test]
fn white_and_black_anchor_the_scale() {
assert_eq!(delinearized(100.0), 255);
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);
}
#[test]
fn white_point_matches_d65() {
// 用官方那组舍入矩阵,纯白必须落在 D65 白点上(容差 1e-3 对应矩阵末位舍入)。
let xyz = xyz_from_color(Color::WHITE);
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);
}
}
+164
View File
@@ -0,0 +1,164 @@
//! M3 状态层(state layer)与禁用态透明度。
//!
//! M3 的 hover/pressed 不是「另一种灰」,而是**内容色自身 + 固定 alpha** 叠在容器上——
//! 所以同一套令牌在任何底色上都成立,不必为每种底色各配一套(这条从 Fluent 时代沿用,
//! 见 `DevDocs/SubAgentsTalk/ui-integration-contract.md` §2 的三态叠加层思路)。
use super::Color;
/// 状态层的四种状态与对应 alpha(M3 规范值)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StateLayer {
/// 悬停:内容色 8%。
Hover,
/// 键盘焦点:内容色 10%。
Focus,
/// 按下:内容色 10%。
Pressed,
/// 拖拽中:内容色 16%。
Dragged,
}
impl StateLayer {
/// 叠加透明度(0..1)。
pub const fn alpha(self) -> f32 {
match self {
StateLayer::Hover => 0.08,
StateLayer::Focus | StateLayer::Pressed => 0.10,
StateLayer::Dragged => 0.16,
}
}
/// 把状态层叠到容器色上,得到**最终不透明色**。
///
/// `content` 一般是容器上的前景角色(例如 FilledButton 用 `on-primary`,
/// 中性表面上的按钮用 `on-surface`)。
pub fn over(self, content: Color, container: Color) -> Color {
content.with_alpha(self.alpha()).over(container)
}
}
/// 禁用态的透明度(M3):**按钮家族**容器 10%、内容 38%。
///
/// 别用「灰化」近似——深色模式下灰化会降低对比度,alpha 叠加两种模式都成立。
///
/// ## 取值依据(每个 token 的消费者都用 `javap -c ButtonDefaults` 数过)
///
/// - **按钮容器 10%**:`FilledButtonTokens` / `ElevatedButtonTokens` / `OutlinedButtonTokens` 的
/// `DisabledContainerOpacity = 0.1f`,且 `ButtonDefaults.<clinit>` 里**真的调了**
/// `getDisabledContainerOpacity()`(各 1 次)——活令牌。
/// `TextButtonTokens` 同值但容器透明(`ButtonDefaults` 只读它的 `DisabledLabel*`);
/// `BaselineButtonTokens` 同值,它是**基准表**、运行时零消费者,只作参照。
/// - ⚠️ `TonalButtonTokens` 也写 0.1f,但它在**整个 AAR 里零消费者**(`ButtonDefaults` 只引用
/// `FilledTonalButtonTokens`;`grep` 命中的 `…TonalButtonTokens` 是后者的子串假象)。
/// **别拿它当依据**——这正是本 mission 反复警告的死令牌坑(同 `ButtonSmallTokens.IconSize = 20`)。
/// - **面状控件(chip 容器/描边、slider 轨道、switch 轨道)12%** 是另一类令牌,口径在
/// `unlucky_app_iced::components::interaction::disabled_colors`。
///
/// ## ⚠️ 已知偏离:filled-tonal 的禁用容器**官方是 0.12f,我们走 0.10**
///
/// A 级事实(`javap -p -constants androidx.compose.material3.tokens.FilledTonalButtonTokens`):
/// `public static final float DisabledContainerOpacity = 0.12f;`
/// ——它是**编译期常量**,被内联进 `ButtonDefaults.getDefaultFilledTonalButtonColors` 的字节码
/// (所以那里看到的是 `ldc_w 0.12f`,`getDisabledContainerOpacity()` 根本不会被调用;
/// 在 `<clinit>` 里也看不到它——那是「没在 clinit 里出现」,**不是「字段不存在」**)。
///
/// 我们的实现在 tonal 与 filled 之间**没有区分**,两者共用本常量 ⇒ **tonal 实际画的是 10%**。
/// 这是**已登记的偏离,不是对齐**:要修得让 `disabled_button_colors` 知道「哪个变体」,
/// 并同步 `gallery/probe` 的逐行断言;本轮(M86)只做令牌一致性收口,不扩到变体分派。
///
/// 曾经的缺陷不是「12 是错的」,而是**同一个常量同时服务这两类**:按钮那半边一直错、另半边一直对。
pub const DISABLED_CONTAINER_ALPHA: f32 = 0.10;
/// 禁用态内容(文字/图标)透明度。
pub const DISABLED_CONTENT_ALPHA: f32 = 0.38;
/// 禁用态的透明度(M3):**面状控件族**容器与描边 12%。
///
/// 这一族是「一整面涂上去的容器或用它画出来的描边/轨道」,官方 token 各写一份但值统一是 `0.12f`:
/// `AssistChipTokens.FlatDisabledOutlineOpacity`、`FilterChipTokens.FlatDisabledSelectedContainerOpacity`
/// / `FlatDisabledUnselectedOutlineOpacity`、`SliderTokens.DisabledInactiveTrackOpacity`、
/// `SwitchTokens.DisabledTrackOpacity`(AAR 字节码 `<clinit>`)。
///
/// **不适用于按钮容器**:按钮是 [`DISABLED_CONTAINER_ALPHA`](10%,`*ButtonTokens.DisabledContainerOpacity`)。
/// 更不适用于 TextField 的 Filled 容器——那是 `FilledTextFieldTokens.FieldDisabledContainerOpacity = 0.04f`,
/// 我们仍在用 12%(既有 D10 偏离,未在本轮收口)。
pub const DISABLED_SURFACE_ALPHA: f32 = 0.12;
/// 禁用态容器色:`on_surface` 10%(按钮家族的 `DisabledContainerOpacity`)叠在 `surface` 上。
pub fn disabled_container(scheme: &super::Scheme) -> Color {
scheme
.get(super::Role::OnSurface)
.with_alpha(DISABLED_CONTAINER_ALPHA)
.over(scheme.get(super::Role::Surface))
}
/// 禁用态内容色:`on_surface` 38% 叠在容器上。
pub fn disabled_content(scheme: &super::Scheme, container: Color) -> Color {
scheme
.get(super::Role::OnSurface)
.with_alpha(DISABLED_CONTENT_ALPHA)
.over(container)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::{Role, Scheme, SchemeFlavor};
#[test]
fn hover_is_lighter_than_pressed_on_dark_container() {
let scheme = Scheme::baseline(SchemeFlavor::Dark);
let container = scheme.get(Role::SurfaceContainer);
let content = scheme.get(Role::OnSurface);
let hover = StateLayer::Hover.over(content, container);
let pressed = StateLayer::Pressed.over(content, container);
assert_eq!(hover.a, 255);
assert!(pressed.relative_luminance() > hover.relative_luminance());
}
#[test]
fn state_layer_alpha_matches_m3() {
assert!((StateLayer::Hover.alpha() - 0.08).abs() < f32::EPSILON);
assert!((StateLayer::Dragged.alpha() - 0.16).abs() < f32::EPSILON);
}
#[test]
fn disabled_content_stays_visible() {
let scheme = Scheme::baseline(SchemeFlavor::Light);
let container = disabled_container(&scheme);
let content = disabled_content(&scheme, container);
// 禁用态不追求 AA(规范就是这么定的),但必须仍可辨(> 1.5:1)。
let ratio = content.contrast_ratio(container);
assert!(ratio > 1.5, "禁用态对比度过低:{ratio}");
assert!(ratio < 4.5, "禁用态不该和正文一样醒目:{ratio}");
}
/// 两族 α 的**令牌层钉值**(M86):期望值硬编码,不从被测常量取——否则改坏常量也照样绿。
///
/// 出处(AAR `material3-android-1.4.0` 的 `<clinit>`):
/// `*ButtonTokens.DisabledContainerOpacity = 0.1f`(按钮族)与
/// `AssistChipTokens.FlatDisabledOutlineOpacity` / `SliderTokens.DisabledInactiveTrackOpacity` /
/// `SwitchTokens.DisabledTrackOpacity = 0.12f`(面状控件族)。
#[test]
fn disabled_alpha_classes_are_pinned_at_the_token_layer() {
const EXPECTED_BUTTON: f32 = 0.10;
const EXPECTED_SURFACE: f32 = 0.12;
const EXPECTED_CONTENT: f32 = 0.38;
let button = DISABLED_CONTAINER_ALPHA;
let surface = DISABLED_SURFACE_ALPHA;
let content = DISABLED_CONTENT_ALPHA;
assert_eq!(
button, EXPECTED_BUTTON,
"按钮族禁用容器 α 必须是官方 0.1f(*ButtonTokens.DisabledContainerOpacity)"
);
assert_eq!(
surface, EXPECTED_SURFACE,
"面状控件族 α 必须是官方 0.12f(chip 描边 / slider 轨道 / switch 轨道)"
);
assert_eq!(content, EXPECTED_CONTENT, "禁用内容 0.38f");
assert!(
surface > button,
"两族必须可区分({surface} > {button});合成一个数就是 M86 要防的回归"
);
}
}
+150
View File
@@ -0,0 +1,150 @@
//! tone 调色板:**固定 HCT 色相 + 固定 HCT 彩度**,沿 tone 轴取色。
//!
//! 这就是 M3 的 `TonalPalette`:一条调色板只回答一个问题——「我的第 N 号 tone 是什么色」。
//! 因为 HCT 的 tone 轴是 `L*`,同一条调色板上「tone 差 40 ⇒ 对比度 ≥ 3:1」天然成立;
//! 又因为 hue 固定,整条调色板在感知上是同一个色相(这正是 OKLCh 近似做不到的那一点)。
//!
//! 语义对齐 `java/palettes/TonalPalette.java`(Apache-2.0,见 `NOTICE`)。
use super::{Color, Hct};
/// 一条 tone 调色板(HCT 色相 + 彩度上限)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TonalPalette {
hue_deg: f64,
chroma: f64,
}
impl TonalPalette {
/// 直接给 HCT 色相(度)与 HCT 彩度建调色板(彩度 0 = 中性灰)。
pub fn new(hue_deg: f64, chroma: f64) -> Self {
Self {
hue_deg: hue_deg.rem_euclid(360.0),
chroma: chroma.max(0.0),
}
}
/// 从种子色取 HCT 色相与彩度(等价官方 `TonalPalette.fromInt`)。
pub fn from_color(seed: Color) -> Self {
let hct = Hct::from_color(seed);
Self::new(hct.hue, hct.chroma)
}
/// 固定色相,换彩度(M3 用它把种子的彩度压到 secondary/neutral 档)。
pub fn with_chroma(self, chroma: f64) -> Self {
Self::new(self.hue_deg, chroma)
}
/// 旋转色相(derived hue,例如 tertiary = 种子色相 +60°)。
pub fn rotated(self, deg: f64) -> Self {
Self::new(self.hue_deg + deg, self.chroma)
}
/// 该调色板的 HCT 色相(度)。
pub fn hue_deg(self) -> f64 {
self.hue_deg
}
/// 该调色板的 HCT 彩度。
pub fn chroma(self) -> f64 {
self.chroma
}
/// 取某个 tone 的颜色(tone 超出 0..=100 时自然夹到纯黑/纯白)。
///
/// 彩度装不下时按官方的 gamut 映射**只压彩度**,色相与 tone 不变。
pub fn tone(self, tone: f64) -> Color {
self.hct(tone).to_color()
}
/// 取某个 tone 的 HCT 坐标(`TonalPalette.hct` / 官方 `getHct`)。
///
/// 黄相(105°..125°)的 tone 99 是官方的特例:那里 `L*` 与 sRGB 平面不平滑,
/// 直接横向取自 tone 98 与 tone 100 的中点,避免出一格色带。
pub fn hct(self, tone: f64) -> Hct {
if tone == 99.0 && Hct::is_yellow(self.hue_deg) {
let low = self.hct(98.0).to_color();
let high = self.hct(100.0).to_color();
return Hct::from_color(average(low, high));
}
Hct::from_components(self.hue_deg, self.chroma, tone)
}
}
/// 两色的通道中点(官方 `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)]
mod tests {
use super::*;
const SEED: Color = Color::rgb(0x67, 0x50, 0xA4);
#[test]
fn tone_is_hit_within_quantisation_error() {
let palette = TonalPalette::from_color(SEED).with_chroma(36.0);
for tone in [
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 = Hct::from_color(palette.tone(tone)).tone;
assert!(
(got - tone).abs() <= 1.0,
"tone {tone} 的 L* 是 {got},偏差超过 1(8 bit 量化上限)"
);
}
}
#[test]
fn extremes_are_pure_black_and_white() {
let palette = TonalPalette::from_color(SEED);
assert_eq!(palette.tone(0.0), Color::rgb(0, 0, 0));
assert_eq!(palette.tone(100.0), Color::rgb(255, 255, 255));
assert_eq!(palette.tone(-5.0), Color::rgb(0, 0, 0));
assert_eq!(palette.tone(120.0), Color::rgb(255, 255, 255));
}
#[test]
fn hue_is_preserved_across_the_whole_ramp() {
let palette = TonalPalette::new(298.980_997, 36.0);
for tone in [10.0_f64, 30.0, 50.0, 70.0, 90.0] {
let hct = palette.hct(tone);
let delta = (hct.hue - palette.hue_deg()).abs();
let delta = delta.min(360.0 - delta);
assert!(delta < 1.0, "tone {tone} 色相偏移 {delta}°");
}
}
#[test]
fn chroma_zero_is_neutral_gray() {
let neutral = TonalPalette::new(120.0, 0.0);
for tone in [6.0_f64, 30.0, 50.0, 90.0, 98.0] {
let color = neutral.tone(tone);
assert!(color.r == color.g && color.g == color.b, "{color:?}");
}
}
#[test]
fn yellow_tone_99_uses_the_official_average() {
let palette = TonalPalette::new(110.0, 40.0);
let expected = average(palette.tone(98.0), palette.tone(100.0));
assert_eq!(palette.tone(99.0), expected);
// 非黄相不触发特例:tone 99 走正常求解。
let normal = TonalPalette::new(300.0, 40.0);
assert_eq!(normal.tone(99.0), normal.hct(99.0).to_color());
}
#[test]
fn requested_chroma_is_respected_when_the_gamut_allows() {
// 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);
}
}
+179
View File
@@ -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,
}
}
+212
View File
@@ -0,0 +1,212 @@
//! 组件 → 档位映射表:哪个组件用哪档形状 / 高程 / 字体 / 动效。
//!
//! 目的不是「规定 M3 官方长什么样」(官方只给组件规格,不给启动器里的档位表),而是
//! **给 iced 页面线一个不需要再谈判的默认值**:同一个组件在五个页面里长得一样,
//! 才有用户要的「UI 整体性」。
//!
//! 口径:
//! - 形状档位见 [`crate::shape::ShapeScale`],高程见 [`crate::elevation::Elevation`];
//! - `text` 是主要文字角色,`support` 是辅助文字(没有就 `None`);
//! - `motion` + `duration` 是状态过渡(hover/press/展开),不是页面切换;
//! - `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`;
//! 页面线不许在页面里覆盖这些档位。
mod metrics;
mod table;
pub use metrics::ComponentMetrics;
use crate::elevation::Elevation;
use crate::motion::{DurationToken, Easing};
use crate::shape::{ComponentRadius, ShapeMorph, ShapeScale};
use crate::typography::TypeRole;
/// 组件的排版 + 形状 + 动效档位。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ComponentSpec {
/// 该档位属于哪个组件(表自描述,便于测试与文档生成)。
pub component: Component,
/// 圆角档位(容器级,[`ShapeScale`] 的严格刻度)。
pub shape: ShapeScale,
/// 微控件圆角(D32 第二层)。
///
/// `Some` 时**本字段才是半径真源**,此时 [`ComponentSpec::shape`] 必须是
/// [`ShapeScale::None`](占位、不参与渲染)——因为 2dp 不在 `ShapeScale` 刻度里,
/// 硬塞进 `shape` 会破坏「圆角只取刻度值」。这层配对由测试钉死。
pub micro_radius: Option<ComponentRadius>,
/// 高程级别(决定 surface container 角色与是否画阴影)。
pub elevation: Elevation,
/// 主要文字角色。**无文字的组件(如 `Divider`)这里是占位值**,消费方不得据此排版。
pub text: TypeRole,
/// 辅助文字角色。
pub support: Option<TypeRole>,
/// 状态过渡缓动。
///
/// 该字段不是 `Option`,所以**没有动效语义的组件(如 `Divider`)这里是占位值**——
/// 读之前先确认组件本身有状态过渡,无过渡的组件不得据此排动效。
pub motion: Easing,
/// 状态过渡时长。
pub duration: DurationToken,
/// 按下/选中时的形状形变。
pub morph: Option<ShapeMorph>,
}
/// 需要固定档位的组件清单(M53 页面骨架 + M69 组件线补的 5 个)。
///
/// **冻结为只可末尾追加**:判别值当索引用(`table::SPECS[self as usize]`),改名/换位等于
/// 让整张表错行。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Component {
/// 顶部标题栏(第一层 chrome)。
TopAppBar,
/// 导航项(侧栏一行)。
NavigationItem,
/// 主操作按钮(实心强调色,每屏最多一个)。
PrimaryButton,
/// 次操作按钮(tonal)。
SecondaryButton,
/// 文本按钮(低权重)。
TextButton,
/// 图标按钮。
IconButton,
/// 描边卡片。
OutlinedCard,
/// 填充卡片(层级高于描边卡)。
FilledCard,
/// 列表行。
ListItem,
/// 文本输入框。
TextField,
/// 分段按钮(磁贴/列表切换、小中大三档)。
SegmentedButton,
/// 贴片/徽标。
Chip,
/// 开关。
Switch,
/// 滑块。
Slider,
/// 进度条。
ProgressIndicator,
/// 对话框。
Dialog,
/// 下拉菜单。
Menu,
/// 底部提示条。
Snackbar,
/// 悬浮提示。
Tooltip,
/// 空态(图标 + 标题 + 说明 + 标准按钮)。
EmptyState,
/// 复选框(安装选项 / 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 {
/// 全部组件。
pub const ALL: [Component; 25] = [
Component::TopAppBar,
Component::NavigationItem,
Component::PrimaryButton,
Component::SecondaryButton,
Component::TextButton,
Component::IconButton,
Component::OutlinedCard,
Component::FilledCard,
Component::ListItem,
Component::TextField,
Component::SegmentedButton,
Component::Chip,
Component::Switch,
Component::Slider,
Component::ProgressIndicator,
Component::Dialog,
Component::Menu,
Component::Snackbar,
Component::Tooltip,
Component::EmptyState,
// M69 G9:末尾追加的 5 个(前 20 个次序不许动)。
Component::Checkbox,
Component::Radio,
Component::Search,
Component::NavigationDrawer,
Component::Divider,
];
/// 该组件的档位。
pub fn spec(self) -> ComponentSpec {
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)。
pub const fn name(self) -> &'static str {
match self {
Component::TopAppBar => "top-app-bar",
Component::NavigationItem => "navigation-item",
Component::PrimaryButton => "primary-button",
Component::SecondaryButton => "secondary-button",
Component::TextButton => "text-button",
Component::IconButton => "icon-button",
Component::OutlinedCard => "outlined-card",
Component::FilledCard => "filled-card",
Component::ListItem => "list-item",
Component::TextField => "text-field",
Component::SegmentedButton => "segmented-button",
Component::Chip => "chip",
Component::Switch => "switch",
Component::Slider => "slider",
Component::ProgressIndicator => "progress-indicator",
Component::Dialog => "dialog",
Component::Menu => "menu",
Component::Snackbar => "snackbar",
Component::Tooltip => "tooltip",
Component::EmptyState => "empty-state",
// M69 G9:末尾追加(`name()` 是持久化字符串 id,同样只许末尾追加)。
Component::Checkbox => "checkbox",
Component::Radio => "radio",
Component::Search => "search",
Component::NavigationDrawer => "navigation-drawer",
Component::Divider => "divider",
}
}
}
#[cfg(test)]
mod tests;
+371
View File
@@ -0,0 +1,371 @@
//! 组件档位数据表(**下标必须等于 `Component as usize`**,由测试钉死)。
//!
//! 一行一个组件。改值就是在改全站观感——同步协作文档再改。
//!
//! **末尾 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`
//! (FilledCard/OutlinedCard/ElevatedCard/Menu/Dialog/Snackbar/AppBar/List)。已登记的两处偏离:
//! - `Tooltip`:`PlainTooltipTokens.kt` 只有 Corner 行、**没有 Elevation 行**,Table 的 Level2 是
//! B 级(沿用 M3 旧规范「浮层 3dp」的直觉),有官方值后以官方为准;
//! - `ListItem`:官方 `ListTokens` 用 `CornerNone`(直角行),本项目取 `Small(8)`——列表行在本
//! 启动器里是**卡片内的独立可点块**(不是贴边通栏行),圆角与卡片同族更协调;偏差 8dp 已登记。
use super::{Component, ComponentSpec};
use crate::elevation::Elevation;
use crate::motion::{
DurationToken, Easing, SPRING_EXPRESSIVE_SPATIAL_DEFAULT, SPRING_EXPRESSIVE_SPATIAL_FAST,
};
use crate::shape::{ComponentRadius, ShapeMorph, ShapeScale};
use crate::typography::TypeRole;
/// 一个组件的档位行(`const fn` 便捷构造,字段名在 [`ComponentSpec`] 里)。
const fn spec(
component: Component,
shape: ShapeScale,
elevation: Elevation,
text: TypeRole,
support: Option<TypeRole>,
motion: Easing,
duration: DurationToken,
morph: Option<ShapeMorph>,
) -> ComponentSpec {
ComponentSpec {
component,
shape,
micro_radius: None,
elevation,
text,
support,
motion,
duration,
morph,
}
}
/// 圆角走「微控件层」(D32)的档位行:`shape` 自动填占位 `None`,半径取白名单值。
///
/// 为什么单独一个构造器:`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(
Component::TopAppBar,
ShapeScale::None,
Elevation::Level0,
TypeRole::TitleLarge,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Medium2,
None,
),
spec(
Component::NavigationItem,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelLarge,
None,
Easing::Emphasized,
DurationToken::Medium2,
Some(ShapeMorph::new(
ShapeScale::Large,
ShapeScale::Full,
SPRING_EXPRESSIVE_SPATIAL_FAST,
)),
),
spec(
Component::PrimaryButton,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelLarge,
None,
Easing::Emphasized,
DurationToken::Short4,
None,
),
spec(
Component::SecondaryButton,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelLarge,
None,
Easing::Standard,
DurationToken::Short4,
None,
),
spec(
Component::TextButton,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelLarge,
None,
Easing::Standard,
DurationToken::Short4,
None,
),
spec(
Component::IconButton,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelMedium,
None,
Easing::Standard,
DurationToken::Short3,
Some(ShapeMorph::new(
ShapeScale::Medium,
ShapeScale::Full,
SPRING_EXPRESSIVE_SPATIAL_FAST,
)),
),
// FilledCardTokens.ContainerElevation = Level0(hover 才到 L1、dragged 到 L3)。
spec(
Component::OutlinedCard,
ShapeScale::Medium,
Elevation::Level0,
TypeRole::TitleMedium,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Medium1,
None,
),
spec(
Component::FilledCard,
ShapeScale::Medium,
Elevation::Level0,
TypeRole::TitleMedium,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Medium1,
Some(ShapeMorph::new(
ShapeScale::Medium,
ShapeScale::Large,
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
)),
),
spec(
Component::ListItem,
ShapeScale::Small,
Elevation::Level0,
TypeRole::BodyLarge,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Short4,
None,
),
spec(
Component::TextField,
ShapeScale::ExtraSmall,
Elevation::Level0,
TypeRole::BodyLarge,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Short4,
None,
),
spec(
Component::SegmentedButton,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelLarge,
None,
Easing::Emphasized,
DurationToken::Medium2,
None,
),
spec(
Component::Chip,
ShapeScale::Small,
Elevation::Level0,
TypeRole::LabelMedium,
None,
Easing::Standard,
DurationToken::Short3,
Some(ShapeMorph::new(
ShapeScale::ExtraSmall,
ShapeScale::Small,
SPRING_EXPRESSIVE_SPATIAL_FAST,
)),
),
spec(
Component::Switch,
ShapeScale::Full,
Elevation::Level0,
TypeRole::BodyMedium,
Some(TypeRole::BodySmall),
Easing::Emphasized,
DurationToken::Medium2,
None,
),
spec(
Component::Slider,
ShapeScale::Full,
Elevation::Level0,
TypeRole::BodyMedium,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Short2,
None,
),
spec(
Component::ProgressIndicator,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelMedium,
None,
Easing::Linear,
DurationToken::Long2,
None,
),
// DialogTokens.ContainerElevation = Level3,CornerExtraLarge = 28。
spec(
Component::Dialog,
ShapeScale::ExtraLarge,
Elevation::Level3,
TypeRole::HeadlineSmall,
Some(TypeRole::BodyMedium),
Easing::EmphasizedDecelerate,
DurationToken::Long4,
Some(ShapeMorph::new(
ShapeScale::Large,
ShapeScale::ExtraLarge,
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
)),
),
// MenuTokens:CornerExtraSmall(4) + Level2。
spec(
Component::Menu,
ShapeScale::ExtraSmall,
Elevation::Level2,
TypeRole::BodyMedium,
None,
Easing::StandardDecelerate,
DurationToken::Short4,
None,
),
spec(
Component::Snackbar,
ShapeScale::ExtraSmall,
Elevation::Level3,
TypeRole::BodyMedium,
None,
Easing::EmphasizedDecelerate,
DurationToken::Medium2,
None,
),
spec(
Component::Tooltip,
ShapeScale::ExtraSmall,
Elevation::Level2,
TypeRole::BodySmall,
None,
Easing::StandardDecelerate,
DurationToken::Short2,
None,
),
spec(
Component::EmptyState,
ShapeScale::Large,
Elevation::Level0,
TypeRole::HeadlineSmall,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Medium2,
None,
),
// ---- 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,
),
];
+202
View File
@@ -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());
}
}
+226
View File
@@ -0,0 +1,226 @@
//! 高程令牌:elevation level、阴影参数、surface container 映射。
//!
//! M3 在 2024 之后改了「怎么表达高程」:**不再**往表面上叠半透明的 surface tint,
//! 而是用 `surface-container-*` 这一族中性容器色表达层级(GPU 路径再叠阴影做空间感)。
//! 本模块两种口径都给出,旧口径显式标为 deprecated,避免页面线混用。
use crate::color::Role;
/// M3 高程级别 0..5(对应 0 / 1 / 3 / 6 / 8 / 12 dp)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Elevation {
/// 0dp:贴底内容(窗口底、内联区)。
Level0,
/// 1dp:卡片、可抬起的列表项。
Level1,
/// 3dp:可交互卡片、导航栏。
Level2,
/// 6dp:FAB、侧栏、浮动工具条。
Level3,
/// 8dp:菜单、popover。
Level4,
/// 12dp:对话框、模态。
Level5,
}
impl Elevation {
/// 全部级别。
pub const ALL: [Elevation; 6] = [
Elevation::Level0,
Elevation::Level1,
Elevation::Level2,
Elevation::Level3,
Elevation::Level4,
Elevation::Level5,
];
/// 标称高度(dp)。
pub const fn dp(self) -> f32 {
match self {
Elevation::Level0 => 0.0,
Elevation::Level1 => 1.0,
Elevation::Level2 => 3.0,
Elevation::Level3 => 6.0,
Elevation::Level4 => 8.0,
Elevation::Level5 => 12.0,
}
}
/// 该级别应使用的表面容器色角色(M3 现行口径)。
///
/// 层级靠**换底色**表达,不靠 tint;浅深模式都成立。
pub const fn surface_role(self) -> Role {
match self {
Elevation::Level0 => Role::SurfaceContainerLowest,
Elevation::Level1 => Role::SurfaceContainerLow,
Elevation::Level2 => Role::SurfaceContainer,
Elevation::Level3 => Role::SurfaceContainerHigh,
Elevation::Level4 | Elevation::Level5 => Role::SurfaceContainerHighest,
}
}
/// 阴影参数(**占位值,待 M63 视觉规格** — 见下面的 TODO)。
///
/// TODO(M63): 取值待视觉规格。官方只给 dp 与设计稿示意,没有可移植的 blur/alpha 公式;
/// 这里的 blur/offset/alpha 是临时占位(按「blur ≈ dp × 1.3 + 1.5」凑的数),
/// M63 的玻璃规格表落地后以那张表为准,别在页面里引用这些绝对值。
pub const fn shadow(self) -> ShadowSpec {
match self {
Elevation::Level0 => ShadowSpec {
blur_px: 0.0,
offset_y_px: 0.0,
alpha: 0.0,
},
Elevation::Level1 => ShadowSpec {
blur_px: 3.0,
offset_y_px: 1.0,
alpha: 0.12,
},
Elevation::Level2 => ShadowSpec {
blur_px: 6.0,
offset_y_px: 2.0,
alpha: 0.14,
},
Elevation::Level3 => ShadowSpec {
blur_px: 9.0,
offset_y_px: 4.0,
alpha: 0.16,
},
Elevation::Level4 => ShadowSpec {
blur_px: 12.0,
offset_y_px: 6.0,
alpha: 0.18,
},
Elevation::Level5 => ShadowSpec {
blur_px: 17.0,
offset_y_px: 8.0,
alpha: 0.20,
},
}
}
/// 旧口径(M3 2022):把 `surface-tint` 按这个 alpha 叠在 `surface` 上表达高程。
///
/// **deprecated**——现行 M3 用 [`Elevation::surface_role`];保留只为读旧稿时能对上数,
/// 新代码不要用。
pub const fn deprecated_tint_alpha(self) -> f32 {
match self {
Elevation::Level0 => 0.0,
Elevation::Level1 => 0.05,
Elevation::Level2 => 0.08,
Elevation::Level3 => 0.11,
Elevation::Level4 => 0.12,
Elevation::Level5 => 0.14,
}
}
}
/// 一层阴影的绘制参数。
///
/// TODO(M63): 取值待视觉规格(这份结构只是「接口预留」,数值由 M63 的玻璃规格表填充)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ShadowSpec {
/// 高斯模糊半径(px)。
pub blur_px: f32,
/// 向下偏移(px)。
pub offset_y_px: f32,
/// 阴影色 alpha(叠在 [`crate::color::Scheme::shadow`] 之上)。
pub alpha: f32,
}
impl ShadowSpec {
/// 是否可见(0 表示这一级不画阴影)。
pub fn is_visible(&self) -> bool {
self.alpha > 0.0 && self.blur_px > 0.0
}
}
/// 三个「材质层」的语义(沿用 `ui-integration-contract.md` §2 的三层,映射到 M3 令牌)。
///
/// 页面线按层取色,不许自己决定「这一层比那一层深一点」。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MaterialLayer {
/// 底层(canvas):窗口底,永远不透明。
Canvas,
/// 卡片层:页面内容/设置分组/AI 面板。
Card,
/// 浮层:对话框、菜单、Toast。
Overlay,
}
impl MaterialLayer {
/// 该层的底色角色。
pub const fn surface_role(self) -> Role {
match self {
MaterialLayer::Canvas => Role::Surface,
MaterialLayer::Card => Role::SurfaceContainerLow,
MaterialLayer::Overlay => Role::SurfaceContainerHigh,
}
}
/// 该层的高程级别。
pub const fn elevation(self) -> Elevation {
match self {
MaterialLayer::Canvas => Elevation::Level0,
MaterialLayer::Card => Elevation::Level1,
MaterialLayer::Overlay => Elevation::Level3,
}
}
/// 该层是否允许半透明(D27:**只有 GPU 路径**才允许;CPU 路径一律纯色)。
pub const fn allows_translucency(self) -> bool {
matches!(self, MaterialLayer::Card | MaterialLayer::Overlay)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn elevation_dp_ladder() {
let ladder: Vec<f32> = Elevation::ALL.iter().map(|e| e.dp()).collect();
assert_eq!(ladder, vec![0.0, 1.0, 3.0, 6.0, 8.0, 12.0]);
}
#[test]
fn shadow_grows_with_elevation() {
let mut prev = Elevation::Level0.shadow();
for level in &Elevation::ALL[1..] {
let cur = level.shadow();
assert!(cur.blur_px > prev.blur_px, "{level:?} 的模糊应更大");
assert!(cur.offset_y_px >= prev.offset_y_px);
assert!(cur.alpha >= prev.alpha);
assert!(cur.is_visible());
prev = cur;
}
assert!(!Elevation::Level0.shadow().is_visible());
}
#[test]
fn higher_levels_use_higher_surface_containers() {
assert_eq!(
Elevation::Level5.surface_role(),
Role::SurfaceContainerHighest
);
assert_eq!(
Elevation::Level0.surface_role(),
Role::SurfaceContainerLowest
);
}
#[test]
fn material_layers_are_ordered() {
assert_eq!(MaterialLayer::Canvas.surface_role(), Role::Surface);
assert_eq!(
MaterialLayer::Card.surface_role(),
Role::SurfaceContainerLow
);
assert_eq!(
MaterialLayer::Overlay.surface_role(),
Role::SurfaceContainerHigh
);
assert!(!MaterialLayer::Canvas.allows_translucency());
assert!(MaterialLayer::Overlay.allows_translucency());
}
}
+184
View File
@@ -0,0 +1,184 @@
//! 图标几何数据:24 网格、1.5px 视觉描边的 SVG path 字符串。
//!
//! 源流:`DevDocs/ops/slint-reference/crates/app/ui/components/icons.slint`(M54 重画过一轮的
//! Fluent 2 线性几何)。命令集沿用 `M/L/H/V/A/C/Q/Z`,与 SVG 路径语法一致,可直接交给
//! 任何 SVG path 解析器(iced 侧是 `iced::widget::canvas` 的 `Path`/`lyon`)。
//!
//! 这里只有数据:不许在数据里写颜色、尺寸、dpi——那些由渲染层决定。
use super::{Icon, IconKind, IconPaint};
/// 全部图标几何,**下标必须等于 `IconKind as usize`**(由测试钉死)。
pub(super) const GLYPHS: [Icon; 28] = [
// 实例:2×2 圆角方块(原 Slint 版是 4 个 Rectangle 描边,这里折成一条 path)
Icon::data(
IconKind::Instances,
"M 5 3 H 8 A 2 2 0 0 1 10 5 V 8 A 2 2 0 0 1 8 10 H 5 A 2 2 0 0 1 3 8 V 5 A 2 2 0 0 1 5 3 Z \
M 16 3 H 19 A 2 2 0 0 1 21 5 V 8 A 2 2 0 0 1 19 10 H 16 A 2 2 0 0 1 14 8 V 5 A 2 2 0 0 1 16 3 Z \
M 5 14 H 8 A 2 2 0 0 1 10 16 V 19 A 2 2 0 0 1 8 21 H 5 A 2 2 0 0 1 3 19 V 16 A 2 2 0 0 1 5 14 Z \
M 16 14 H 19 A 2 2 0 0 1 21 16 V 19 A 2 2 0 0 1 19 21 H 16 A 2 2 0 0 1 14 19 V 16 A 2 2 0 0 1 16 14 Z",
IconPaint::Stroke,
),
// 下载:箭头入托盘
Icon::data(
IconKind::Download,
"M 12 3.5 V 15 M 7 10.5 L 12 15.5 L 17 10.5 M 4.5 20.5 H 19.5",
IconPaint::Stroke,
),
// 日志:卷角文档 + 两行正文(不是三横——三横读作「菜单」)
Icon::data(
IconKind::Logs,
"M 6 3.5 H 14 L 18.5 8 V 20.5 H 6 Z M 14 3.5 V 8 H 18.5 M 9 12.5 H 15.5 M 9 16.5 H 15.5",
IconPaint::Stroke,
),
// 设置:八齿齿轮 + 中心孔
Icon::data(
IconKind::Settings,
"M 18.89 14.11 L 21.51 13.34 L 21.51 10.66 L 18.89 9.89 A 7.2 7.2 0 0 1 18.36 8.62 \
L 19.67 6.22 L 17.78 4.33 L 15.38 5.64 A 7.2 7.2 0 0 1 14.11 5.11 L 13.34 2.49 L 10.66 2.49 \
L 9.89 5.11 A 7.2 7.2 0 0 1 8.62 5.64 L 6.22 4.33 L 4.33 6.22 L 5.64 8.62 A 7.2 7.2 0 0 1 5.11 9.89 \
L 2.49 10.66 L 2.49 13.34 L 5.11 14.11 A 7.2 7.2 0 0 1 5.64 15.38 L 4.33 17.78 L 6.22 19.67 \
L 8.62 18.36 A 7.2 7.2 0 0 1 9.89 18.89 L 10.66 21.51 L 13.34 21.51 L 14.11 18.89 A 7.2 7.2 0 0 1 15.38 18.36 \
L 17.78 19.67 L 19.67 17.78 L 18.36 15.38 A 7.2 7.2 0 0 1 18.89 14.11 Z \
M 8.8 12 A 3.2 3.2 0 1 0 15.2 12 A 3.2 3.2 0 1 0 8.8 12 Z",
IconPaint::Stroke,
),
// AI:四角星(凹边四角星,不是直线菱形)
Icon::data(
IconKind::Ai,
"M 12 3 Q 13.6 10.4 21 12 Q 13.6 13.6 12 21 Q 10.4 13.6 3 12 Q 10.4 10.4 12 3 Z",
IconPaint::Stroke,
),
// 播放 / 停止:实心(与文字并排时不至于太轻)
Icon::data(
IconKind::Play,
"M 7.5 4.5 L 19 12 L 7.5 19.5 Z",
IconPaint::Fill,
),
Icon::data(
IconKind::Stop,
"M 8 7 H 16 A 1 1 0 0 1 17 8 V 16 A 1 1 0 0 1 16 17 H 8 A 1 1 0 0 1 7 16 V 8 A 1 1 0 0 1 8 7 Z",
IconPaint::Fill,
),
// 搜索:放大镜
Icon::data(
IconKind::Search,
"M 10.5 4 A 6.5 6.5 0 1 0 10.5 17 A 6.5 6.5 0 1 0 10.5 4 Z M 15.4 15.4 L 20.5 20.5",
IconPaint::Stroke,
),
Icon::data(
IconKind::Plus,
"M 12 5 V 19 M 5 12 H 19",
IconPaint::Stroke,
),
Icon::data(
IconKind::Close,
"M 6.5 6.5 L 17.5 17.5 M 17.5 6.5 L 6.5 17.5",
IconPaint::Stroke,
),
Icon::data(IconKind::Minimize, "M 6 12 H 18", IconPaint::Stroke),
Icon::data(
IconKind::Maximize,
"M 7.5 5.5 H 16.5 A 2 2 0 0 1 18.5 7.5 V 16.5 A 2 2 0 0 1 16.5 18.5 H 7.5 \
A 2 2 0 0 1 5.5 16.5 V 7.5 A 2 2 0 0 1 7.5 5.5 Z",
IconPaint::Stroke,
),
Icon::data(
IconKind::ChevronLeft,
"M 14.5 5.5 L 8 12 L 14.5 18.5",
IconPaint::Stroke,
),
Icon::data(
IconKind::ChevronRight,
"M 9.5 5.5 L 16 12 L 9.5 18.5",
IconPaint::Stroke,
),
Icon::data(
IconKind::ChevronDown,
"M 5.5 9.5 L 12 16 L 18.5 9.5",
IconPaint::Stroke,
),
// 展开为独立页:双对角外箭头
Icon::data(
IconKind::Expand,
"M 14 4 H 20 V 10 M 20 4 L 13.5 10.5 M 10 20 H 4 V 14 M 4 20 L 10.5 13.5",
IconPaint::Stroke,
),
// 发送:纸飞机
Icon::data(
IconKind::Send,
"M 21 3 L 3 10.5 L 10.2 13.6 L 13.4 20.5 Z M 10.2 13.6 L 21 3",
IconPaint::Stroke,
),
Icon::data(
IconKind::Check,
"M 5 12.5 L 9.8 17.3 L 19 6.5",
IconPaint::Stroke,
),
Icon::data(
IconKind::Warn,
"M 12 4.5 L 20.5 19.5 H 3.5 Z M 12 9.5 V 14 M 12 16.6 V 17",
IconPaint::Stroke,
),
Icon::data(
IconKind::Error,
"M 12 4 A 8 8 0 1 0 12 20 A 8 8 0 1 0 12 4 Z M 12 7.5 V 13 M 12 16.6 V 17",
IconPaint::Stroke,
),
// 更多:三圆点(实心,原 Slint 版是三个 filled Rectangle)
Icon::data(
IconKind::More,
"M 7.5 12 A 1.5 1.5 0 1 0 4.5 12 A 1.5 1.5 0 1 0 7.5 12 Z \
M 13.5 12 A 1.5 1.5 0 1 0 10.5 12 A 1.5 1.5 0 1 0 13.5 12 Z \
M 19.5 12 A 1.5 1.5 0 1 0 16.5 12 A 1.5 1.5 0 1 0 19.5 12 Z",
IconPaint::Fill,
),
// 侧栏:面板框 + 左侧分栏线
Icon::data(
IconKind::Sidebar,
"M 6 5 H 18 A 2 2 0 0 1 20 7 V 17 A 2 2 0 0 1 18 19 H 6 A 2 2 0 0 1 4 17 V 7 \
A 2 2 0 0 1 6 5 Z M 9.5 5.5 V 18.5",
IconPaint::Stroke,
),
// 还原(已最大化时的标题栏按钮):前后两层方框
Icon::data(
IconKind::Restore,
"M 9 4.5 H 19.5 V 15 M 15 8.5 H 4.5 V 19.5 H 15 Z",
IconPaint::Stroke,
),
// 账户占位:头像剪影
Icon::data(
IconKind::User,
"M 12 5.3 A 3.7 3.7 0 1 0 12 12.7 A 3.7 3.7 0 1 0 12 5.3 Z \
M 4.5 20.5 C 4.5 16.9 8 15 12 15 C 16 15 19.5 16.9 19.5 20.5",
IconPaint::Stroke,
),
// ── M61 新增(追加在末尾,不动既有次序;iced 页面需要而 Slint 版没有的)──
// 刷新:顺时针单圈 + 右上角箭头
Icon::data(
IconKind::Refresh,
"M 20 12 A 8 8 0 1 1 12 4 C 14.2 4 16.3 4.9 17.8 6.4 L 20 8.5 \
M 20 4.5 V 8.5 H 16",
IconPaint::Stroke,
),
// 筛选:漏斗
Icon::data(
IconKind::Filter,
"M 4.5 5.5 H 19.5 L 14 12.5 V 19 L 10 16.5 V 12.5 Z",
IconPaint::Stroke,
),
// 目录:文件夹(实例目录 / 日志目录入口)
Icon::data(
IconKind::Folder,
"M 4 7.5 A 2 2 0 0 1 6 5.5 H 9.5 L 11.5 7.5 H 18 A 2 2 0 0 1 20 9.5 V 17 \
A 2 2 0 0 1 18 19 H 6 A 2 2 0 0 1 4 17 Z",
IconPaint::Stroke,
),
// 删除:垃圾桶
Icon::data(
IconKind::Trash,
"M 4.5 7 H 19.5 M 9.5 7 V 4.5 H 14.5 V 7 M 6.8 7 L 7.8 19.5 H 16.2 L 17.2 7 \
M 10.5 10.5 V 16 M 13.5 10.5 V 16",
IconPaint::Stroke,
),
];
+208
View File
@@ -0,0 +1,208 @@
//! `IconKind` 枚举:**API 已冻结,只允许在末尾追加**(序号即持久化 id,插到中间会静默换图)。
use super::glyphs::GLYPHS;
use super::Icon;
/// 图标种类。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub enum IconKind {
/// 实例(2×2 网格)。
Instances,
/// 下载。
Download,
/// 日志(文档 + 正文)。
Logs,
/// 设置(齿轮)。
Settings,
/// AI(四角星)。
Ai,
/// 播放(实心三角)。
Play,
/// 停止(实心圆角方)。
Stop,
/// 搜索(放大镜)。
Search,
/// 加号。
Plus,
/// 关闭(叉)。
Close,
/// 最小化(横线)。
Minimize,
/// 最大化(方框)。
Maximize,
/// 左尖角。
ChevronLeft,
/// 右尖角。
ChevronRight,
/// 下尖角。
ChevronDown,
/// 展开为独立页(双对角箭头)。
Expand,
/// 发送(纸飞机)。
Send,
/// 勾选。
Check,
/// 警告(三角感叹)。
Warn,
/// 错误(圆形感叹)。
Error,
/// 更多(三点,实心)。
More,
/// 侧栏开关。
Sidebar,
/// 还原窗口(双层方框)。
Restore,
/// 账户占位(头像剪影)。
User,
/// 刷新(M61 新增)。
Refresh,
/// 筛选(M61 新增)。
Filter,
/// 目录(M61 新增)。
Folder,
/// 删除(M61 新增)。
Trash,
}
impl IconKind {
/// 全部图标,按枚举声明顺序。
pub const ALL: [IconKind; 28] = [
IconKind::Instances,
IconKind::Download,
IconKind::Logs,
IconKind::Settings,
IconKind::Ai,
IconKind::Play,
IconKind::Stop,
IconKind::Search,
IconKind::Plus,
IconKind::Close,
IconKind::Minimize,
IconKind::Maximize,
IconKind::ChevronLeft,
IconKind::ChevronRight,
IconKind::ChevronDown,
IconKind::Expand,
IconKind::Send,
IconKind::Check,
IconKind::Warn,
IconKind::Error,
IconKind::More,
IconKind::Sidebar,
IconKind::Restore,
IconKind::User,
IconKind::Refresh,
IconKind::Filter,
IconKind::Folder,
IconKind::Trash,
];
/// 取几何数据。
pub fn icon(self) -> Icon {
GLYPHS[self as usize]
}
/// 稳定字符串名(kebab-case):写配置、日志、测试快照都用它,不要用 `Debug` 输出。
pub const fn name(self) -> &'static str {
match self {
IconKind::Instances => "instances",
IconKind::Download => "download",
IconKind::Logs => "logs",
IconKind::Settings => "settings",
IconKind::Ai => "ai",
IconKind::Play => "play",
IconKind::Stop => "stop",
IconKind::Search => "search",
IconKind::Plus => "plus",
IconKind::Close => "close",
IconKind::Minimize => "minimize",
IconKind::Maximize => "maximize",
IconKind::ChevronLeft => "chevron-left",
IconKind::ChevronRight => "chevron-right",
IconKind::ChevronDown => "chevron-down",
IconKind::Expand => "expand",
IconKind::Send => "send",
IconKind::Check => "check",
IconKind::Warn => "warn",
IconKind::Error => "error",
IconKind::More => "more",
IconKind::Sidebar => "sidebar",
IconKind::Restore => "restore",
IconKind::User => "user",
IconKind::Refresh => "refresh",
IconKind::Filter => "filter",
IconKind::Folder => "folder",
IconKind::Trash => "trash",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::icons::IconPaint;
#[test]
fn glyph_table_matches_enum_order() {
for (i, icon) in GLYPHS.iter().enumerate() {
assert_eq!(
icon.kind as usize,
i,
"GLYPHS 第 {i} 项是 {},与 IconKind 顺序不一致",
icon.kind.name()
);
}
}
#[test]
fn all_kinds_resolve() {
assert_eq!(IconKind::ALL.len(), GLYPHS.len());
for kind in IconKind::ALL {
let icon = kind.icon();
assert_eq!(icon.kind, kind);
assert!(!icon.path.is_empty());
}
}
#[test]
fn names_are_unique_and_kebab() {
let mut seen = std::collections::BTreeSet::new();
for kind in IconKind::ALL {
let name = kind.name();
assert!(seen.insert(name), "图标名重复:{name}");
assert!(name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
}
}
#[test]
fn migrated_kinds_keep_their_positions() {
// 前 24 个是 Slint 版迁移来的(次序必须原样),后 4 个是 M61 追加的。
assert_eq!(IconKind::ALL[0], IconKind::Instances);
assert_eq!(IconKind::ALL[4], IconKind::Ai);
assert_eq!(IconKind::ALL[20], IconKind::More);
assert_eq!(IconKind::ALL[23], IconKind::User);
assert_eq!(IconKind::ALL[24], IconKind::Refresh);
assert_eq!(IconKind::ALL[27], IconKind::Trash);
}
#[test]
fn only_solid_icons_are_filled() {
for kind in IconKind::ALL {
let icon = kind.icon();
let expect_fill = matches!(kind, IconKind::Play | IconKind::Stop | IconKind::More);
assert_eq!(
icon.paint,
if expect_fill {
IconPaint::Fill
} else {
IconPaint::Stroke
},
"{} 的 fill/stroke 判定不对",
kind.name()
);
}
}
}
+342
View File
@@ -0,0 +1,342 @@
//! 图标令牌:`IconKind`(一次冻结,只允许在末尾追加)+ 24 网格几何数据。
//!
//! ```no_run
//! use unlucky_design::icons::{IconKind, IconSize};
//!
//! let icon = IconKind::Settings.icon();
//! let w = icon.stroke_width(IconSize::Nav.px());
//! assert!((w - 1.8).abs() < 1e-6);
//! ```
//!
//! ## 几何口径(三线统一,M54 定稿)
//!
//! - **网格 24×24**([`VIEWBOX`]),内容留 2~3 单位边距;
//! - **视觉描边 1.5px**([`VISUAL_STROKE_PX`]):不管渲染多大,线宽看起来都是 1.5 逻辑像素;
//! - **圆头端点 + 圆角连接**(`stroke-linecap: round` / `stroke-linejoin: round`),
//! 适配层设这两个属性,数据里不表达;
//! - **实心图形才用 fill**(播放/停止/三点),其余一律描边,保证与文字并排时重量一致;
//! - 不用字体字符当图标(`+` `→` 一律禁止)。
//!
//! ## ⚠️ 描边语义差异:Slint 与 SVG **不是一回事**
//!
//! - Slint 的 `Path.stroke-width` 是**绝对像素**:只乘 DPI 缩放,**不乘 viewBox**。
//! 所以老代码写 `stroke-width: 1.5px` 意味着「渲染出来就是 1.5px」,viewBox 缩放对它无影响
//! (实证:femtovg 后端 `PathItemRenderer::render` 里的
//! `paint.set_line_width(stroke_width * scale_factor)`——只有 DPI 缩放因子,没有 viewBox 项)。
//! - SVG / iced canvas 的 `stroke-width` 是**用户单位**(viewBox 单位):渲染时会被
//! `render_px / VIEWBOX` 缩放。同样的 `1.5` 在 20px 渲染下只有 1.25px 视觉宽度。
//!
//! 因此 iced 侧必须**按 SVG 语义重算**:要保持 1.5px 视觉线宽,viewBox 里的 stroke-width 得取
//! `1.5 × 24 / render_px`(24px → 1.5、20px → 1.8、18px → 2.0、16px → 2.25)。[`Icon::stroke_width`]
//! 就是这个换算的纯函数——**不要在适配层手抄 1.5 这个数**,那是 Slint 口径。
mod glyphs;
mod kinds;
pub use kinds::IconKind;
/// 图标网格边长(viewBox 宽高)。
pub const VIEWBOX: f32 = 24.0;
/// 视觉描边宽度(逻辑像素,与渲染尺寸无关)。
pub const VISUAL_STROKE_PX: f32 = 1.5;
/// 描边语义换算:给定渲染尺寸与目标视觉线宽,返回 **viewBox 单位**的 stroke-width。
///
/// `render_px <= 0` 时返回 `visual_px`(退化情形不产生除零/无穷大)。
pub fn stroke_width_for(render_px: f32, visual_px: f32) -> f32 {
if render_px <= 0.0 {
return visual_px;
}
visual_px * VIEWBOX / render_px
}
/// 反向换算:viewBox 单位的 stroke-width 在实际渲染尺寸下的视觉线宽。
pub fn visual_stroke_px(render_px: f32, stroke_width: f32) -> f32 {
stroke_width * render_px / VIEWBOX
}
/// 图标的绘制方式。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum IconPaint {
/// 只描边(默认,绝大多数图标)。
Stroke,
/// 填充(播放/停止/三点这类实心图形)。
Fill,
}
/// 图标的标准渲染尺寸(页面只从这几档里取,不许自定义零散值)。
///
/// ## 每一档服务于哪个规格(出处全部是 A 级:`material3-android-1.4.0.aar` 的字节码)
///
/// 判据顺序固定为:**先看官方组件实际消费哪个值,再看 token 类里写了什么**。两者冲突时以
/// 「有消费者的那个」为准——1.4.0 里 `ButtonSmallTokens.IconSize = 20` 就是**零消费者**的死令牌
/// (`javap` 全 AAR 搜 `getIconSize` 调用,没有任何类读它;`ButtonDefaults` 只借了同一
/// token 类的 `IconLabelSpace` / `ContainerHeight` / `ContainerShapeRound`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum IconSize {
/// 12px:行内小图标(徽标内)。
Inline,
/// 16px:**控件内微字形**——Switch 滑块内的勾/图标。
///
/// A 级:`SwitchTokens.SelectedIconSize = 16.0d` / `UnselectedIconSize = 16.0d`。
/// 别拿它当按钮图标用:按钮是 18(见 [`IconSize::Button`]),两者在官方是两个 token。
SwitchThumb,
/// 18px:**按钮内前置/后置图标**,也用于 Chip 图标。
///
/// A 级:`ButtonDefaults.<clinit>` 里 `bipush 18` → `IconSize = 18.dp`(12b 1.6 引的
/// `ButtonDefaults.IconSize=18` 就是这一行);`AssistChipTokens.IconSize = 18.0d`(12b 13.4)。
/// 死令牌说明:`BaselineButtonTokens.IconSize` / `ButtonSmallTokens.IconSize` 都写 20,
/// 但 AAR 里没有任何类调用它们的 `getIconSize`(`FilledTonalButtonTokens` 反而是 18)。
Button,
/// 20px:**目录档,当前无消费**。
///
/// ⚠️ 别按名字拿它当导航/列表图标:官方导航轨与列表行的图标是 **24**
/// (`AppBarTokens.IconSize = 24.0d`、12b 5.5 / 4.4),实现侧也一直是 24([`IconSize::Toolbar`])。
/// 标题栏 caption 按钮的 20 不是这一档,见 [`TITLEBAR_CAPTION_ICON_PX`](那是项目偏离)。
Nav,
/// 24px:卡片头、工具栏、IconButton、标题栏 caption(官方口径)。
///
/// A 级:`SmallIconButtonTokens.IconSize = 24.0d`(12b 2.2 / 16.5)、`AppBarTokens.IconSize = 24.0d`。
Toolbar,
/// 32px:空态图标块。
Empty,
}
impl IconSize {
/// 像素值。
pub const fn px(self) -> f32 {
match self {
IconSize::Inline => 12.0,
IconSize::SwitchThumb => 16.0,
IconSize::Button => 18.0,
IconSize::Nav => 20.0,
IconSize::Toolbar => 24.0,
IconSize::Empty => 32.0,
}
}
/// 全部档位,由小到大。
pub const ALL: [IconSize; 6] = [
IconSize::Inline,
IconSize::SwitchThumb,
IconSize::Button,
IconSize::Nav,
IconSize::Toolbar,
IconSize::Empty,
];
}
/// 标题栏 caption 按钮(最小化 / 最大化 / 关闭)的图标边长——**用户点名的项目偏离**。
///
/// 官方 `SmallIconButtonTokens.IconSize = 24.0d`(AAR 字节码,12b 16.5 同值),我们的 24
/// **本来就是对的**,所以这条不是「对齐规范」,是业主主观调整:用户原话「右上角三个金刚键的
/// 图标显示稍大了些,能不能在**保持其容器尺寸不变**的前提下把图标改小些」。
///
/// 约束:**容器不动**(46×48 仍来自 `LayoutMetrics::titlebar_button_width` /
/// `titlebar_height`),只缩图标;且这条**只适用于 caption 三键**,不许扩散到导航/卡片/表单的
/// 24px 图标(那些是 12b 2.2 / 4.4 / 5.5 / 9.6 / 16.5 的判据)。
pub const TITLEBAR_CAPTION_ICON_PX: f32 = 20.0;
/// 一个图标的几何数据。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Icon {
/// 图标种类。
pub kind: IconKind,
/// SVG path 数据(viewBox `0 0 24 24`,可能含多个子路径)。
pub path: &'static str,
/// 描边还是填充。
pub paint: IconPaint,
}
impl Icon {
/// 表项构造器(只在 [`glyphs`] 的数据表里用)。
pub(super) const fn data(kind: IconKind, path: &'static str, paint: IconPaint) -> Self {
Self { kind, path, paint }
}
/// 该图标在 `render_px` 下的 **viewBox 单位** stroke-width([`IconPaint::Fill`] 返回 0)。
pub fn stroke_width(self, render_px: f32) -> f32 {
match self.paint {
IconPaint::Fill => 0.0,
IconPaint::Stroke => stroke_width_for(render_px, VISUAL_STROKE_PX),
}
}
/// 是否实心填充。
pub fn is_filled(self) -> bool {
self.paint == IconPaint::Fill
}
/// 路径里是否含曲线命令(适配层可选:纯直线路径走不走曲线细分)。
pub fn has_curves(self) -> bool {
self.path
.bytes()
.any(|b| matches!(b, b'C' | b'Q' | b'A' | b'c' | b'q' | b'a'))
}
/// 子路径个数(`M` 命令计数):适配层可以据此一次性分配 `lyon` 的缓冲区。
pub fn subpath_count(self) -> usize {
self.path.matches('M').count() + self.path.matches('m').count()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paths_use_only_svg_commands() {
const ALLOWED: &str = "MmLlHhVvAaCcQqZz0123456789 .,-";
for kind in IconKind::ALL {
let path = kind.icon().path;
assert!(path.starts_with('M'), "{} 的路径必须以 M 开头", kind.name());
for byte in path.bytes() {
assert!(
byte.is_ascii(),
"{} 的路径含非 ASCII(不许用字体字符/中文)",
kind.name()
);
}
for ch in path.chars() {
assert!(
ALLOWED.contains(ch),
"{} 的路径含非法字符 {ch:?}",
kind.name()
);
}
// 不许出现科学计数法(能解析但可读性差、易手误)。
assert!(
!path.contains('e') && !path.contains('E'),
"{} 用了科学计数法",
kind.name()
);
assert!(
path.matches(' ').count() >= 2,
"{} 的坐标分隔异常",
kind.name()
);
}
}
#[test]
fn coordinates_stay_inside_the_grid() {
for kind in IconKind::ALL {
for token in kind.icon().path.split([' ', ',']) {
if let Ok(value) = token.parse::<f32>() {
assert!(
(-1.0..=VIEWBOX + 1.0).contains(&value),
"{} 出现 {value}:超出 24 网格(弧半径/控制点也受这条约束)",
kind.name()
);
}
}
}
}
#[test]
fn subpaths_are_counted() {
assert_eq!(IconKind::Plus.icon().subpath_count(), 2);
assert_eq!(IconKind::Instances.icon().subpath_count(), 4);
assert_eq!(IconKind::Play.icon().subpath_count(), 1);
}
#[test]
fn filled_icons_have_no_stroke() {
for kind in IconKind::ALL {
let icon = kind.icon();
if icon.is_filled() {
assert_eq!(icon.stroke_width(20.0), 0.0, "{}", kind.name());
} else {
assert!(icon.stroke_width(20.0) > 1.0, "{}", kind.name());
}
}
}
#[test]
fn stroke_width_follows_svg_semantics() {
// SVG 语义:viewBox 单位 × (render/24) = 视觉像素。
assert!((stroke_width_for(20.0, VISUAL_STROKE_PX) - 1.8).abs() < 1.0e-6);
assert!((stroke_width_for(16.0, VISUAL_STROKE_PX) - 2.25).abs() < 1.0e-6);
assert!((stroke_width_for(24.0, VISUAL_STROKE_PX) - 1.5).abs() < 1.0e-6);
assert!((stroke_width_for(48.0, VISUAL_STROKE_PX) - 0.75).abs() < 1.0e-6);
// 退化输入不炸、不产生无穷大(Slint 口径的 1.5 绝对值这里不能照抄)。
assert_eq!(stroke_width_for(0.0, VISUAL_STROKE_PX), VISUAL_STROKE_PX);
assert_eq!(stroke_width_for(-4.0, VISUAL_STROKE_PX), VISUAL_STROKE_PX);
// 往返一致。
for size in IconSize::ALL {
let w = stroke_width_for(size.px(), VISUAL_STROKE_PX);
assert!((visual_stroke_px(size.px(), w) - VISUAL_STROKE_PX).abs() < 1.0e-6);
}
}
#[test]
fn icon_sizes_are_ordered() {
let px: Vec<f32> = IconSize::ALL.iter().map(|s| s.px()).collect();
for pair in px.windows(2) {
assert!(pair[0] < pair[1]);
}
}
#[test]
fn curve_flags_are_reported() {
assert!(IconKind::Settings.icon().has_curves());
assert!(!IconKind::Plus.icon().has_curves());
assert!(!IconKind::ChevronDown.icon().has_curves());
}
/// 每一档的像素值都被**钉在官方出处**上:改值必须同时改这条测试,改的人会看到出处。
///
/// 这不是「断言一个常量等于它自己」——它断言的是「我们读到的官方值」,
/// 所以下一个人按 `research/12b` 或按某个 token 类把 18 改回 16/20 时,这条会红并打印出处。
#[test]
fn icon_sizes_are_pinned_to_their_official_sources() {
// A 级:ButtonDefaults.<clinit> 的 `bipush 18` → IconSize = 18.dp
// (`AssistChipTokens.IconSize = 18.0d` 同值)。
assert_eq!(
IconSize::Button.px(),
18.0,
"按钮/Chip 图标 18:AAR ButtonDefaults.IconSize(12b 1.6 同值)。\
ButtonSmallTokens.IconSize=20 是零消费者的死令牌,不要按它改回去"
);
// A 级:SwitchTokens.SelectedIconSize / UnselectedIconSize = 16.0d
assert_eq!(
IconSize::SwitchThumb.px(),
16.0,
"Switch 滑块图标 16:AAR SwitchTokens.Selected/UnselectedIconSize"
);
// A 级:SmallIconButtonTokens.IconSize / AppBarTokens.IconSize = 24.0d
assert_eq!(
IconSize::Toolbar.px(),
24.0,
"IconButton/标题栏/卡片头 24:AAR SmallIconButtonTokens / AppBarTokens"
);
// 目录档(当前无消费):不许被当成 nav 图标用(nav 是 24)。
assert_eq!(IconSize::Nav.px(), 20.0, "目录档 20(无消费)");
assert_eq!(IconSize::Inline.px(), 12.0);
assert_eq!(IconSize::Empty.px(), 32.0);
}
/// 标题栏 caption 的 20 是**项目偏离**,不是规范值;这条把「偏离的是哪两个数」钉住。
#[test]
fn titlebar_caption_icon_is_a_registered_deviation() {
assert_eq!(
TITLEBAR_CAPTION_ICON_PX, 20.0,
"用户点名的偏离:官方 SmallIconButtonTokens.IconSize = 24,caption 三键缩到 20"
);
// 绑定成局部变量再过断言:直接比两个常量会被 clippy 判成「常量断言」(assertions_on_constants)。
let caption = TITLEBAR_CAPTION_ICON_PX;
let official = IconSize::Toolbar.px();
assert!(
caption < official,
"偏离方向是「更小」:{caption} 应 < 官方 {official};若哪天反了,那就不是用户要的那件事了"
);
// 20px 渲染时视觉线宽仍是 1.5(SVG 语义),这条防「缩图标顺手把线宽也带细」。
let stroke = stroke_width_for(caption, VISUAL_STROKE_PX);
assert!(
(stroke - 1.8).abs() < 1.0e-6,
"20px 渲染应取 1.8 个 viewBox 单位"
);
assert!((visual_stroke_px(caption, stroke) - VISUAL_STROKE_PX).abs() < 1.0e-6);
}
}
+147
View File
@@ -0,0 +1,147 @@
//! # unlucky-design · ULL 设计令牌唯一真源
//!
//! 视觉基线 = **Material 3 Expressive**(D26 锁定,风格与框架不再更换)。这个 crate 只装
//! **渲染无关的数据与纯函数**:色彩角色、排版 ramp、形状刻度、高程、动效参数、图标几何。
//! 它不依赖任何 UI / 渲染框架(iced / slint / winit / wgpu / gtk …),也不依赖任何第三方 crate,
//! 因此 iced 迁移、CLI、甚至离屏截图工具都能吃同一套值——这是「UI 整体性」的地基。
//!
//! ## 边界(谁负责什么)
//!
//! | 层 | 负责 | 不负责 |
//! |---|---|---|
//! | `unlucky-design`(本 crate) | 令牌的值与它们之间的纯函数关系(tone→色、type role→字号/行高/字重、shape→半径、spring→时长) | 布局、控件、绘制、字体加载、图片、动画驱动 |
//! | iced 适配层(后续 mission) | 把令牌翻成 iced 的类型(`Color` / `Length` / `Font` / 动画时钟) | 自己发明新令牌名或新取值 |
//! | 页面 mission | 消费令牌与 [`component_map`] 的档位约定 | 硬编码色值 / 字号 / 半径 / 时长 |
//!
//! ## 分模块
//!
//! - [`color`]:M3 语义色角色(49 个)× light/dark 双套 + tone 调色板(**真 HCT**:CAM16 色相/彩度
//! + CIELAB `L*`,官方 `ColorSpec2021` tone 表,与官方 Java 实现逐通道对齐——口径见模块文档)
//! - [`palette`]:配色来源(D38/D40)——图片像素 → 种子 → 完整方案(Monet 式量化 + 打分)、
//! 内置色卡、三级优先与失败回落、对比度收敛。**只吃已解码的像素**,解码在 `crates/palette-extract`
//! - [`typography`]:M3 type scale 15 档 + 三平台字体族回退栈
//! - [`shape`]:M3 Expressive 形状刻度 + shape morph 数据 + [`shape::ComponentRadius`](微控件圆角层,D32)
//! - [`elevation`]:elevation level 与 surface tint / surface container 规则
//! - [`motion`]:时长、easing(可采样)、Expressive 弹簧参数
//! - [`spacing`]:4px 栅格、骨架尺寸(原 `metrics.slint` 的框架无关部分)、[`stroke::HAIRLINE`](一像素线)
//! - [`icons`]:24 网格图标几何(SVG path 字符串)+ 描边语义
//! - [`component_map`]:组件 → 档位映射表(哪个组件用哪档形状/高程/动效/字体)
//! + [`component_map::ComponentMetrics`](控件内部微几何,与 `LayoutMetrics` 分工见该模块文档)
//!
//! ## 最小用法
//!
//! ```
//! use unlucky_design::color::{Role, Scheme, SchemeFlavor};
//! use unlucky_design::typography::{FontFamily, Platform, TypeRole};
//!
//! let light = Scheme::baseline(SchemeFlavor::Light);
//! let ratio = light.contrast(Role::OnSurface, Role::Surface);
//! assert!(ratio >= 4.5, "正文对表面必须满足 WCAG AA");
//!
//! let title = TypeRole::TitleLarge.style();
//! assert_eq!(title.size_px, 22.0);
//! let stack = Platform::current().font_stack(FontFamily::Display);
//! assert!(!stack.is_empty());
//! ```
//!
//! ## baseline MD3 vs M3 Expressive 差异清单
//!
//! 「M3 Expressive」不是「M3 换皮」——它真的加了一批令牌。本 crate 两代都提供,差异如下
//! (来源等级:**A** = androidx 源码 / 官方 token 文件;**B** = m3.material.io 文档表格;**C** = 社区转述)。
//!
//! | 领域 | baseline MD3 | M3 Expressive 新增/不同 | 本 crate 落点 | 等级 |
//! |---|---|---|---|---|
//! | 排版 | 15 档 type role,只有 Regular/Medium 两种字重档 | **每档多一条 `*Emphasized`**:字号/行高不变,字重升到 Medium(500) 或 Bold(700),字距按表微调(display-large −0.2→0、body-large 0.5→0.15、title-medium 0.2→0.15、body-medium 0.2→0.25) | [`typography::TypeRole::emphasized`](官方值);[`typography::TypeRole::emphasized_simplified`] 是我们的「只加一档字重」简化规则,**非官方** | A |
//! | 形状 | ExtraSmall 4 / Small 8 / Medium 12 / Large 16 / **ExtraLarge 28** / Full | 多出 **LargeIncreased 20 / ExtraLargeIncreased 32 / ExtraExtraLarge 48**,并引入 **shape morph**(按下/选中时档位之间弹性过渡) | [`shape::ShapeScale`](10 档)+ [`shape::ShapeMorph`] + [`component_map`] 的 `morph` 列 | A |
//! | 动效 | 16 档 duration + 7 条 cubic-bezier easing(时间驱动) | 把弹簧打包成 **standard / expressive 两套方案**,每套各有 spatial(位移,会回弹)与 effects(颜色/透明度,不回弹)× fast/default/slow。standard spatial ζ=0.9(k 1400/700/300),expressive spatial ζ=0.6~0.8(k 800/380/200),effects 两套同值 ζ=1.0(k 3800/1600/800) | [`motion::spring`] 的 **方案 × 类别 × 速度**二维表(12 格,`StandardMotionTokens`/`ExpressiveMotionTokens` 原值)+ 9 个具名常量;duration/easing 两代共用 | A |
//! | 组件尺寸 | 触控目标 48dp(无障碍下限本来就是 48) | Expressive 把按钮/列表的整体节奏放大(列表项 56/72/88),并明确 48dp 为**可点下限** | [`spacing::LayoutMetrics::DESKTOP`]:`touch_target = 48`(照规范)+ `control_height = 40`(可见高度,上下各 4px 内边距凑满 48)+ `row_height`/`nav_item_height` = 44、`titlebar_height` = 48(M63 §1.4+R13 的桌面档) | A/B |
//! | 字体 | 只有 400/500/700 的「概念」字重,没规定字体族要覆盖几档 | Expressive 的强调档**真的会要 700**(label-*、title-medium/small),中文字体覆盖差异巨大:雅黑只有 400/700(无 500)、苹方到 600 断(无 700)、Noto/思源可变字体全覆盖 | [`typography::FontFace`] 的 `WeightCoverage`(可变区间 / 静态表)+ [`typography::Platform::resolve_weight`] 纯函数给出「原生 / 最近档 / 合成加粗」三档结论 | B |
//! | 色彩 | 49 个角色(primary/secondary/tertiary/error 族、surface-container 系、`*-fixed*` 系、outline、inverse、scrim/shadow) | **没有新增颜色角色**;变化在用法:surface-container 系(Lowest…Highest)取代旧的 elevation tint 叠色,表达层级 | [`color::Role`] 全 49 个;[`elevation::Elevation::surface_role`] 给出层级映射,`deprecated_tint_alpha` 保留旧口径作对照 | A |
//! | 高程 | Level0..5 = 0/1/3/6/8/12dp | 数值不变;组件默认值不同(卡片 L0、菜单 L2、对话框 L3,见 [`component_map`]) | [`elevation::Elevation`] | A |
//!
//! ### 两条由此衍生的项目纪律
//!
//! 1. **shape morph 是克制的状态信号,不是每屏装饰**:Expressive 官方鼓励「形状即状态」,但
//! 桌面端信息密度高,圆角到处变会显得凌乱(社区已有人批评控制中心这么做)。本项目的约束是
//! **一屏最多 1 处形变、且只用于状态切换信号**;静态元素一律用 [`shape::ShapeScale`] 的固定档。
//! [`component_map`] 的 `morph` 列就是允许清单——不在表里的组件不许自己加形变。
//! 2. **中文字重必须真的有三档**:Expressive 的 700 落在中文字体上,雅黑缺 500、苹方缺 700,
//! 缺档就走「合成加粗」= 观感廉价。所以字体栈带 `WeightCoverage`,并在
//! [`typography::Platform::resolve_weight`] 里把降级路径显式化(合成加粗是最后一档)。
//!
//! 已知与文档的 0.05px 级差异:Compose 这一代 `TypeScaleTokens` 的 tracking 与 m3.material.io
//! 网页表格不同(display-large −0.2 vs −0.25、body-medium 0.2 vs 0.25、title-medium 0.2 vs 0.15),
//! 本项目**跟 Compose**(那是真正渲染出来的值,A 级)。
//!
//! ## 未支持的官方能力(gap 登记,不要以为已经有了)
//!
//! - **对比度等级**:Android 16 的「默认 / 中 / 高」三档对比度本 crate **不支持**。将来扩法是
//! **按等级偏移角色 tone 表**(例如高对比把 `on-*` 与承载色的 tone 差拉大 5~10),
//! 而不是新增一批角色——`Role` 的数量保持 49 不变,`Scheme` 多一个 `contrast_level` 维度。
//! (M63 的 G4 只说「高对比模式走 CPU 档」,那是渲染档位,不等价于支持对比度等级。)
//! - **动态壁纸取色**:M3 的多色种子派生(一张图出 4 个 source color、`*_container` 各自换色相)
//! 未实现。本模块只取**一个**主色种子(D38 的产品口径:整套主题一个强调色),
//! 实现落在 [`palette`]。
//! - **M3 的 elevation tint 叠色**:现行 M3 已改用 surface-container 表达层级,旧的
//! `deprecated_tint_alpha()` 只为读旧稿保留。
#![forbid(unsafe_code)]
pub mod color;
pub mod component_map;
pub mod elevation;
pub mod icons;
pub mod motion;
pub mod palette;
pub mod shape;
pub mod spacing;
pub mod typography;
pub use color::{Color, Role, Scheme, SchemeFlavor};
pub use motion::MotionScale;
pub use palette::{Card, CardKey, Level as SourceLevel, Provenance, Seed};
pub use spacing::stroke;
pub use typography::TypeRole;
/// 设计令牌的整体入口:一份「当前该长什么样」的聚合快照。
///
/// iced 适配层只持有这个结构(深浅切换 = 重新 `DesignTokens::baseline(mode)`),
/// 不要在适配层里缓存散装色值——那样深浅热切会有一半画对一半画错。
#[derive(Debug, Clone, Copy)]
pub struct DesignTokens {
/// 当前深浅模式。
pub flavor: SchemeFlavor,
/// 当前模式的 M3 语义色角色全表。
pub colors: Scheme,
/// 动效时长的全局缩放(`1.0` = 标准;用户关掉动画时适配层传 `0.0`)。
pub motion_scale: MotionScale,
}
impl DesignTokens {
/// 按模式给一套**产品默认**令牌:种子 `#3C8DBC`(M70 ④ 天蓝,用户 2026-11-01 拍板)
/// + 中性彩度 12,即 `color::palettes::DEFAULT_SEED` / `DEFAULT_NEUTRAL_CHROMA`。
///
/// 想要**官方 M3 baseline** 口径(种子 `#6750A4` + 中性彩度 6 / neutral-variant 8)请走
/// `color::Scheme::official_baseline(flavor)`——两条路径的算法完全相同,只差入参。
pub fn baseline(flavor: SchemeFlavor) -> Self {
Self {
flavor,
colors: Scheme::baseline(flavor),
motion_scale: MotionScale::STANDARD,
}
}
/// 按用户强调色重建一套令牌(表面中性色仍走 baseline,强调色只驱动 primary/secondary/tertiary)。
pub fn with_accent(flavor: SchemeFlavor, accent: Color) -> Self {
Self {
flavor,
colors: Scheme::from_accent(flavor, accent),
motion_scale: MotionScale::STANDARD,
}
}
/// 便捷取色:`tokens.color(Role::Surface)`。
pub fn color(&self, role: Role) -> Color {
self.colors.get(role)
}
}
+308
View File
@@ -0,0 +1,308 @@
//! 动效令牌:时长、缓动(可采样)、M3 Expressive 弹簧参数。
//!
//! 三件东西分开表达,因为适配层要的接口不同:
//! - [`DurationToken`]:M3 的 short/medium/long/extra-long 十六档,给「固定时长 + easing」用;
//! - [`Easing`]:cubic-bezier 曲线,纯函数采样(适配层自己决定帧率);
//! - [`Spring`](见 [`spring`]):Expressive 的物理参数(stiffness/damping/mass),
//! 可用于实时求解或预估时长。
//!
//! **CPU / 减少动效路径**:把 [`MotionScale`] 设成 [`MotionScale::NONE`],
//! 所有时长归零、`Easing::sample` 仍返回 0/1 端值(适配层按 `t >= 1` 直接落终态)。
mod spring;
pub use spring::{
spring, MotionScale, MotionSchemeKind, Spring, SpringCategory, SpringSpeed,
SPRING_EFFECTS_DEFAULT, SPRING_EFFECTS_FAST, SPRING_EFFECTS_SLOW,
SPRING_EXPRESSIVE_SPATIAL_DEFAULT, SPRING_EXPRESSIVE_SPATIAL_FAST,
SPRING_EXPRESSIVE_SPATIAL_SLOW, SPRING_STANDARD_SPATIAL_DEFAULT, SPRING_STANDARD_SPATIAL_FAST,
SPRING_STANDARD_SPATIAL_SLOW,
};
/// 动效时长档位(M3 动效令牌,单位毫秒)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DurationToken {
/// 50ms:最小状态回弹。
Short1,
/// 100ms:小图标/勾选。
Short2,
/// 150ms:开关、小尺寸 fade。
Short3,
/// 200ms:按钮状态、hover。
Short4,
/// 250ms:小卡片展开。
Medium1,
/// 300ms:导航指示条、tab 切换。
Medium2,
/// 350ms:卡片展开、列表重排。
Medium3,
/// 400ms:面板开合。
Medium4,
/// 450ms:页面切换。
Long1,
/// 500ms:大面板进出。
Long2,
/// 550ms:全屏浮层。
Long3,
/// 600ms:对话框进出。
Long4,
/// 700ms:结构化过渡。
ExtraLong1,
/// 800ms:结构化过渡。
ExtraLong2,
/// 900ms:结构化过渡。
ExtraLong3,
/// 1000ms:整屏形变(shape morph 的慢档)。
ExtraLong4,
}
impl DurationToken {
/// 毫秒值。
pub const fn ms(self) -> u32 {
match self {
DurationToken::Short1 => 50,
DurationToken::Short2 => 100,
DurationToken::Short3 => 150,
DurationToken::Short4 => 200,
DurationToken::Medium1 => 250,
DurationToken::Medium2 => 300,
DurationToken::Medium3 => 350,
DurationToken::Medium4 => 400,
DurationToken::Long1 => 450,
DurationToken::Long2 => 500,
DurationToken::Long3 => 550,
DurationToken::Long4 => 600,
DurationToken::ExtraLong1 => 700,
DurationToken::ExtraLong2 => 800,
DurationToken::ExtraLong3 => 900,
DurationToken::ExtraLong4 => 1000,
}
}
/// 稳定字符串名(`short1` … `extra-long4`),写配置与日志用。
pub const fn name(self) -> &'static str {
match self {
DurationToken::Short1 => "short1",
DurationToken::Short2 => "short2",
DurationToken::Short3 => "short3",
DurationToken::Short4 => "short4",
DurationToken::Medium1 => "medium1",
DurationToken::Medium2 => "medium2",
DurationToken::Medium3 => "medium3",
DurationToken::Medium4 => "medium4",
DurationToken::Long1 => "long1",
DurationToken::Long2 => "long2",
DurationToken::Long3 => "long3",
DurationToken::Long4 => "long4",
DurationToken::ExtraLong1 => "extra-long1",
DurationToken::ExtraLong2 => "extra-long2",
DurationToken::ExtraLong3 => "extra-long3",
DurationToken::ExtraLong4 => "extra-long4",
}
}
}
/// 三次贝塞尔缓动曲线(CSS `cubic-bezier` 同样的参数含义)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CubicBezier {
/// 控制点 1 的 x(0..1)。
pub x1: f32,
/// 控制点 1 的 y。
pub y1: f32,
/// 控制点 2 的 x(0..1)。
pub x2: f32,
/// 控制点 2 的 y。
pub y2: f32,
}
impl CubicBezier {
/// 线性。
pub const LINEAR: Self = Self {
x1: 0.0,
y1: 0.0,
x2: 1.0,
y2: 1.0,
};
/// 给定时间参数 `t`(0..1),返回进度(0..1,标准曲线不越界)。
pub fn sample(self, t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
if t == 0.0 || t == 1.0 {
return t;
}
// 二分求 x(s) = t 的曲线参数 s,再取 y(s)。
let mut lo = 0.0_f32;
let mut hi = 1.0_f32;
let mut s = t;
for _ in 0..24 {
s = 0.5 * (lo + hi);
let x = bezier_axis(self.x1, self.x2, s);
if x < t {
lo = s;
} else {
hi = s;
}
}
bezier_axis(self.y1, self.y2, s)
}
}
/// 一维三次贝塞尔:端点固定为 0/1,控制点为 `c1`/`c2`。
fn bezier_axis(c1: f32, c2: f32, s: f32) -> f32 {
let u = 1.0 - s;
3.0 * u * u * s * c1 + 3.0 * u * s * s * c2 + s * s * s
}
/// M3 缓动档位(值取自 Material 动效令牌)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Easing {
/// 线性(进度指示器)。
Linear,
/// 标准:起止都平滑。
Standard,
/// 标准减速:进场(出现在屏幕上)。
StandardDecelerate,
/// 标准加速:退场。
StandardAccelerate,
/// 强调:注意力转移的主曲线。
Emphasized,
/// 强调减速:强调类进场。
EmphasizedDecelerate,
/// 强调加速:强调类退场。
EmphasizedAccelerate,
}
impl Easing {
/// 曲线参数。
pub const fn curve(self) -> CubicBezier {
match self {
Easing::Linear => CubicBezier::LINEAR,
Easing::Standard | Easing::Emphasized => CubicBezier {
x1: 0.2,
y1: 0.0,
x2: 0.0,
y2: 1.0,
},
Easing::StandardDecelerate => CubicBezier {
x1: 0.0,
y1: 0.0,
x2: 0.0,
y2: 1.0,
},
Easing::StandardAccelerate => CubicBezier {
x1: 0.3,
y1: 0.0,
x2: 1.0,
y2: 1.0,
},
Easing::EmphasizedDecelerate => CubicBezier {
x1: 0.05,
y1: 0.7,
x2: 0.1,
y2: 1.0,
},
Easing::EmphasizedAccelerate => CubicBezier {
x1: 0.3,
y1: 0.0,
x2: 0.8,
y2: 0.15,
},
}
}
/// 采样:`t` 0..1 → 进度 0..1。
pub fn sample(self, t: f32) -> f32 {
self.curve().sample(t)
}
/// 该曲线搭配的常用时长(我们的组件映射口径,非规范值)。
pub const fn default_duration(self) -> DurationToken {
match self {
Easing::Linear => DurationToken::Long2,
_ => DurationToken::Medium2,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const ALL_EASINGS: [Easing; 7] = [
Easing::Linear,
Easing::Standard,
Easing::StandardDecelerate,
Easing::StandardAccelerate,
Easing::Emphasized,
Easing::EmphasizedDecelerate,
Easing::EmphasizedAccelerate,
];
#[test]
fn durations_are_monotone() {
let ladder = [
DurationToken::Short1,
DurationToken::Short2,
DurationToken::Short3,
DurationToken::Short4,
DurationToken::Medium1,
DurationToken::Medium2,
DurationToken::Medium3,
DurationToken::Medium4,
DurationToken::Long1,
DurationToken::Long2,
DurationToken::Long3,
DurationToken::Long4,
DurationToken::ExtraLong1,
DurationToken::ExtraLong2,
DurationToken::ExtraLong3,
DurationToken::ExtraLong4,
];
assert_eq!(ladder.len(), 16);
for pair in ladder.windows(2) {
assert!(pair[0].ms() < pair[1].ms());
}
assert_eq!(DurationToken::Short1.ms(), 50);
assert_eq!(DurationToken::ExtraLong4.ms(), 1000);
// 名字唯一
let mut names = std::collections::BTreeSet::new();
for token in ladder {
assert!(names.insert(token.name()), "时长名重复:{}", token.name());
}
}
#[test]
fn easing_hits_endpoints_and_stays_in_range() {
for easing in ALL_EASINGS {
assert!(
(easing.sample(0.0) - 0.0).abs() < 1.0e-6,
"{easing:?} 起点不为 0"
);
assert!(
(easing.sample(1.0) - 1.0).abs() < 1.0e-6,
"{easing:?} 终点不为 1"
);
let mut prev = 0.0;
for step in 1..=20 {
let v = easing.sample(step as f32 / 20.0);
assert!((-0.001..=1.001).contains(&v), "{easing:?} 采样越界:{v}");
assert!(v + 1.0e-3 >= prev, "{easing:?} 采样非单调:{prev} → {v}");
prev = v;
}
}
}
#[test]
fn emphasized_decelerate_is_faster_early_than_accelerate() {
let early_decel = Easing::EmphasizedDecelerate.sample(0.25);
let early_accel = Easing::EmphasizedAccelerate.sample(0.25);
assert!(early_decel > early_accel);
}
#[test]
fn default_durations_are_registered_tokens() {
for easing in ALL_EASINGS {
assert!(easing.default_duration().ms() >= 250);
}
}
}
+380
View File
@@ -0,0 +1,380 @@
//! 弹簧参数(M3 Expressive 动效的物理表达)与动效时长缩放。
//!
//! **来源 A 级**:`androidx.compose.material3.tokens.ExpressiveMotionTokens`(v0_14_0)给出六组
//! `Spring{Damping, Stiffness}`——注意 Compose 的 `Damping` 是**阻尼比 ζ**(不是绝对阻尼系数),
//! 所以本模块的 [`Spring`] 也按 (stiffness, damping_ratio, mass) 存,两者可直接对照。
//! 阶跃响应用标准二阶系统闭式解(欠阻尼 / 临界 / 过阻尼三分支),是纯数学、无状态。
/// 弹簧参数(Compose 口径:刚度 + 阻尼比 + 质量)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Spring {
/// 刚度 k(越大越快)。
pub stiffness: f32,
/// 阻尼比 ζ(1.0 = 临界阻尼,< 1 会轻微弹过)。
pub damping_ratio: f32,
/// 质量 m。
pub mass: f32,
}
impl Spring {
/// 按 M3 令牌口径构造(刚度 + 阻尼比 + 质量)。
pub const fn from_ratio(stiffness: f32, damping_ratio: f32, mass: f32) -> Self {
Self {
stiffness,
damping_ratio,
mass,
}
}
/// 自然角频率 ωn(rad/s)。
pub fn natural_frequency(self) -> f32 {
(self.stiffness / self.mass).sqrt()
}
/// 绝对阻尼系数 c = 2·ζ·√(k·m)(需要喂给「阻尼系数」式积分器时用)。
pub fn damping_coefficient(self) -> f32 {
2.0 * self.damping_ratio * (self.stiffness * self.mass).sqrt()
}
/// 阶跃响应:`t_ms` 毫秒后的进度(0..1,欠阻尼时会轻微超过 1 = 回弹)。
pub fn sample(self, t_ms: f32) -> f32 {
if t_ms <= 0.0 {
return 0.0;
}
let t = t_ms / 1000.0;
let wn = self.natural_frequency();
let zeta = self.damping_ratio;
if wn <= 0.0 {
return 1.0;
}
if zeta < 1.0 {
let wd = wn * (1.0 - zeta * zeta).sqrt();
let decay = (-zeta * wn * t).exp();
1.0 - decay * ((wd * t).cos() + (zeta * wn / wd) * (wd * t).sin())
} else if (zeta - 1.0).abs() < 1.0e-4 {
let decay = (-wn * t).exp();
1.0 - decay * (1.0 + wn * t)
} else {
let root = (zeta * zeta - 1.0).sqrt();
let r1 = -wn * (zeta - root);
let r2 = -wn * (zeta + root);
let denom = r2 - r1;
if denom.abs() < f32::EPSILON {
return 1.0;
}
1.0 - (r2 * (r1 * t).exp() - r1 * (r2 * t).exp()) / denom
}
}
/// 收敛到 `threshold`(0.1% 是常用的「停下来」判定)所需毫秒数。
pub fn settle_ms(self, threshold: f32) -> f32 {
let wn = self.natural_frequency();
let zeta = self.damping_ratio;
if wn <= 0.0 {
return 0.0;
}
let thr = threshold.clamp(1.0e-6, 0.5);
let seconds = if zeta < 1.0 {
let wd = (1.0 - zeta * zeta).sqrt().max(1.0e-4);
-(thr * wd).ln() / (zeta * wn)
} else if (zeta - 1.0).abs() < 1.0e-4 {
-(thr).ln() / wn
} else {
let root = (zeta * zeta - 1.0).sqrt();
let slow = wn * (zeta - root);
-(thr).ln() / slow
};
(seconds * 1000.0).max(0.0)
}
}
/// 动效方案(Expressive 把弹簧打包成 standard / expressive **两套**,不是一组)。
///
/// - [`MotionSchemeKind::Standard`]:保守、几乎不弹(spatial ζ=0.9);
/// - [`MotionSchemeKind::Expressive`]:D26 的视觉基线,回弹明显(spatial ζ=0.6~0.8)。
///
/// `effects`(透明度/颜色类)在两套方案里数值相同——效应类不该有可见回弹。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MotionSchemeKind {
/// 标准方案(保守)。
Standard,
/// Expressive 方案(本项目默认)。
Expressive,
}
/// 弹簧类别:空间类管**位移/尺寸**,效应类管**颜色/透明度**。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SpringCategory {
/// 位移/尺寸(会产生可见回弹)。
Spatial,
/// 颜色/透明度(不允许过冲)。
Effects,
}
/// 弹簧速度档。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SpringSpeed {
/// 快速:hover/press、小元素。
Fast,
/// 默认:面板、卡片、按下形变。
Default,
/// 慢速:大位移(>120px 的面板/抽屉)。
Slow,
}
/// 二维表查询:`方案 × 类别 × 速度 → 弹簧`(A 级:`StandardMotionTokens` 与
/// `ExpressiveMotionTokens`,经 `MotionScheme.standard()/expressive()` 组装)。
pub const fn spring(
scheme: MotionSchemeKind,
category: SpringCategory,
speed: SpringSpeed,
) -> Spring {
use MotionSchemeKind::{Expressive, Standard};
use SpringCategory::{Effects, Spatial};
use SpringSpeed::{Default, Fast, Slow};
match (scheme, category, speed) {
(Standard, Spatial, Fast) => Spring::from_ratio(1400.0, 0.9, 1.0),
(Standard, Spatial, Default) => Spring::from_ratio(700.0, 0.9, 1.0),
(Standard, Spatial, Slow) => Spring::from_ratio(300.0, 0.9, 1.0),
(Expressive, Spatial, Fast) => Spring::from_ratio(800.0, 0.6, 1.0),
(Expressive, Spatial, Default) => Spring::from_ratio(380.0, 0.8, 1.0),
(Expressive, Spatial, Slow) => Spring::from_ratio(200.0, 0.8, 1.0),
// effects 两套同值:ζ=1.0(临界阻尼,不过冲)。
(_, Effects, Fast) => Spring::from_ratio(3800.0, 1.0, 1.0),
(_, Effects, Default) => Spring::from_ratio(1600.0, 1.0, 1.0),
(_, Effects, Slow) => Spring::from_ratio(800.0, 1.0, 1.0),
}
}
/// 标准方案 · 空间 · 快速。
pub const SPRING_STANDARD_SPATIAL_FAST: Spring = spring(
MotionSchemeKind::Standard,
SpringCategory::Spatial,
SpringSpeed::Fast,
);
/// 标准方案 · 空间 · 默认。
pub const SPRING_STANDARD_SPATIAL_DEFAULT: Spring = spring(
MotionSchemeKind::Standard,
SpringCategory::Spatial,
SpringSpeed::Default,
);
/// 标准方案 · 空间 · 慢速。
pub const SPRING_STANDARD_SPATIAL_SLOW: Spring = spring(
MotionSchemeKind::Standard,
SpringCategory::Spatial,
SpringSpeed::Slow,
);
/// Expressive 方案 · 空间 · 快速(D26 基线的默认选择)。
pub const SPRING_EXPRESSIVE_SPATIAL_FAST: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Spatial,
SpringSpeed::Fast,
);
/// Expressive 方案 · 空间 · 默认:面板、卡片、按下形变。
pub const SPRING_EXPRESSIVE_SPATIAL_DEFAULT: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Spatial,
SpringSpeed::Default,
);
/// Expressive 方案 · 空间 · 慢速:大位移(>120px)。
pub const SPRING_EXPRESSIVE_SPATIAL_SLOW: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Spatial,
SpringSpeed::Slow,
);
/// 效应类 · 快速(两套方案同值):hover/press 颜色。
pub const SPRING_EFFECTS_FAST: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Effects,
SpringSpeed::Fast,
);
/// 效应类 · 默认(两套方案同值)。
pub const SPRING_EFFECTS_DEFAULT: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Effects,
SpringSpeed::Default,
);
/// 效应类 · 慢速(两套方案同值)。
pub const SPRING_EFFECTS_SLOW: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Effects,
SpringSpeed::Slow,
);
/// 动效时长的全局缩放(用户「减少动效」/CPU 路径关动画时用)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MotionScale {
factor: f32,
}
impl MotionScale {
/// 标准(1.0)。
pub const STANDARD: MotionScale = MotionScale { factor: 1.0 };
/// 关闭动效(时长归零;适配层应直接落终态)。
pub const NONE: MotionScale = MotionScale { factor: 0.0 };
/// 半速(弱动效机器/低帧率兜底)。
pub const HALF: MotionScale = MotionScale { factor: 0.5 };
/// 自定义缩放(夹取到 0.0..=1.0;不放大,避免慢到不可用)。
pub fn new(factor: f32) -> Self {
Self {
factor: factor.clamp(0.0, 1.0),
}
}
/// 缩放系数。
pub fn factor(self) -> f32 {
self.factor
}
/// 是否完全关闭(适配层据此跳过动画)。
pub fn is_off(self) -> bool {
self.factor <= 0.0
}
/// 把某一档时长换算成实际毫秒。
pub fn ms(self, token: super::DurationToken) -> f32 {
token.ms() as f32 * self.factor
}
/// 把弹簧的收敛时间换算成实际毫秒。
pub fn spring_ms(self, spring: Spring) -> f32 {
spring.settle_ms(0.001) * self.factor
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::motion::DurationToken;
const PRESETS: [Spring; 9] = [
SPRING_STANDARD_SPATIAL_FAST,
SPRING_STANDARD_SPATIAL_DEFAULT,
SPRING_STANDARD_SPATIAL_SLOW,
SPRING_EXPRESSIVE_SPATIAL_FAST,
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
SPRING_EXPRESSIVE_SPATIAL_SLOW,
SPRING_EFFECTS_FAST,
SPRING_EFFECTS_DEFAULT,
SPRING_EFFECTS_SLOW,
];
#[test]
fn every_cell_of_the_two_dimensional_table_resolves() {
for scheme in [MotionSchemeKind::Standard, MotionSchemeKind::Expressive] {
for category in [SpringCategory::Spatial, SpringCategory::Effects] {
for speed in [SpringSpeed::Fast, SpringSpeed::Default, SpringSpeed::Slow] {
let spring = spring(scheme, category, speed);
assert!(spring.stiffness > 0.0);
assert!(spring.damping_ratio > 0.0);
}
}
}
// effects 两套方案同值(效应类不该有可见回弹差异)。
for speed in [SpringSpeed::Fast, SpringSpeed::Default, SpringSpeed::Slow] {
assert_eq!(
spring(MotionSchemeKind::Standard, SpringCategory::Effects, speed),
spring(MotionSchemeKind::Expressive, SpringCategory::Effects, speed)
);
}
// expressive 的空间类更弹(阻尼比更低)。
for speed in [SpringSpeed::Fast, SpringSpeed::Default, SpringSpeed::Slow] {
assert!(
spring(MotionSchemeKind::Expressive, SpringCategory::Spatial, speed).damping_ratio
< spring(MotionSchemeKind::Standard, SpringCategory::Spatial, speed)
.damping_ratio
);
}
}
#[test]
fn spring_presets_behave() {
for spring in PRESETS {
assert_eq!(spring.sample(0.0), 0.0);
let settle = spring.settle_ms(0.001);
assert!(
(60.0..1500.0).contains(&settle),
"收敛时间 {settle}ms 不合理(k={} ζ={})",
spring.stiffness,
spring.damping_ratio
);
let end = spring.sample(settle);
assert!((end - 1.0).abs() < 0.01, "收敛后应回到 1.0,实测 {end}");
}
}
#[test]
fn effects_presets_are_critically_damped_and_never_overshoot() {
for spring in [
SPRING_EFFECTS_FAST,
SPRING_EFFECTS_DEFAULT,
SPRING_EFFECTS_SLOW,
] {
assert!((spring.damping_ratio - 1.0).abs() < 1.0e-6);
for step in 1..=60 {
let v = spring.sample(step as f32 * 5.0);
assert!(v <= 1.0 + 1.0e-3, "效应类弹簧不应过冲:{v}");
}
}
}
#[test]
fn spatial_presets_overdamp_into_settle_without_wild_overshoot() {
for spring in [
SPRING_EXPRESSIVE_SPATIAL_FAST,
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
SPRING_EXPRESSIVE_SPATIAL_SLOW,
] {
assert!(
spring.damping_ratio < 1.0,
"空间类弹簧欠阻尼(允许轻微过冲)"
);
let mut peak: f32 = 0.0;
for step in 1..=200 {
peak = peak.max(spring.sample(step as f32 * 2.0));
}
assert!(peak < 1.15, "过冲 {peak} 太大");
}
}
#[test]
fn critically_damped_spring_does_not_overshoot() {
let wn = SPRING_EXPRESSIVE_SPATIAL_DEFAULT.natural_frequency();
let critical = Spring::from_ratio(wn * wn, 1.0, 1.0);
for step in 1..=40 {
let v = critical.sample(step as f32 * 5.0);
assert!(v <= 1.0 + 1.0e-3, "临界阻尼不应过冲:{v}");
}
}
#[test]
fn overdamped_spring_converges_slowly_without_overshoot() {
let wn = SPRING_EXPRESSIVE_SPATIAL_DEFAULT.natural_frequency();
let over = Spring::from_ratio(wn * wn, 2.0, 1.0);
let settle = over.settle_ms(0.001);
assert!((over.sample(settle) - 1.0).abs() < 0.01);
for step in 1..=40 {
assert!(over.sample(step as f32 * 10.0) <= 1.0 + 1.0e-3);
}
}
#[test]
fn damping_coefficient_matches_ratio() {
let spring = SPRING_EXPRESSIVE_SPATIAL_DEFAULT;
let c = spring.damping_coefficient();
let zeta = c / (2.0 * (spring.stiffness * spring.mass).sqrt());
assert!((zeta - spring.damping_ratio).abs() < 1.0e-5);
}
#[test]
fn motion_scale_scales_or_kills_duration() {
assert_eq!(MotionScale::STANDARD.ms(DurationToken::Medium2), 300.0);
assert_eq!(MotionScale::HALF.ms(DurationToken::Medium2), 150.0);
assert_eq!(MotionScale::NONE.ms(DurationToken::Medium2), 0.0);
assert!(MotionScale::NONE.is_off());
assert_eq!(MotionScale::new(5.0).factor(), 1.0);
assert_eq!(MotionScale::new(-1.0).factor(), 0.0);
assert!(MotionScale::STANDARD.spring_ms(SPRING_EXPRESSIVE_SPATIAL_DEFAULT) > 0.0);
}
}
+340
View File
@@ -0,0 +1,340 @@
//! 内置色卡(D38 第三级):用户在「关闭莫奈取色」时手动选的那几张。
//!
//! ## `key` 是持久化键,命名就此定死
//!
//! 每张卡的 [`Card::key`] 会被写进用户设置(settings 里存字符串),**改了就等于把用户的选择
//! 抹成出厂默认**。所以:
//!
//! - 只用小写 ASCII + 单段单词,不带动词、不带颜色数值(`sky`,不是 `blue_3c8dbc`);
//! - 显示名([`Card::name`])可以改、可以本地化,`key` 不许改;
//! - 新卡只能**追加**,旧卡永不删除(删了老用户的选择会静默失效 → 见
//! [`CardKey::from_key`] 的回落口径);
//! - `card.rs` 的 `card_keys_are_stable_and_unique` 把 8 个 key 逐字钉住——谁改谁红
//! (测试就在本文件末尾的 `tests` 模块里,没有独立的 `palette/tests.rs`)。
//!
//! ## 换卡只换强调色:中性色相固定(D43)
//!
//! 八张卡共用**一个**中性色相 [`NEUTRAL_HUE_DEG`](= 出厂默认种子 `#3C8DBC` 的 CAM16 色相
//! 239.51°,出处见该常量的文档),中性彩度都取 [`DEFAULT_NEUTRAL_CHROMA`](12,M70 的「浅冷」口径)。
//! 于是这份契约是逐值可验的:
//!
//! - **表面与描边**(`surface` / `surface-container-*` / `background` / `inverse-surface` /
//! `outline` / `outline-variant` / `surface-variant`)**八张卡逐值相同**——切卡不动窗口底色;
//! - **只有强调族**(`primary` / `secondary` / `tertiary` 及其 container、`on-*`、`inverse-primary`、
//! `*-fixed*`、`surface-tint`)跟着卡走;
//! - **天蓝卡(出厂默认)与 [`crate::color::Scheme::baseline`] 逐值相同**:固定的中性色相本来
//! 就等于天蓝档的中性色相,所以这是「沿用」而非「另选一个冷调」(M70 定案的不动点)。
//!
//! 为什么不再让中性色跟着种子染色:TonalSpot 下八张卡的中性色相 = 种子色相,浅色档 `surface`
//! 实测 CAM16 彩度从 **1.29(rose)跨到 12.03(teal)**——tone 98 处暖相色域装不下彩度 12,
//! 同一个「请求值」在暖相上只兑现 1.29。9 倍的底色跳变让用户在设置页逐张试色时觉得「每张卡
//! 都像换了个应用」(M87 实测,D43 据此定案)。现在八张卡的 `surface` 是同一个色值,
//! 那 9 倍差由 `tests/card_neutral.rs` 断言为 1 倍。
//!
//! ⚠️ **只对色卡路径成立**:动态取色(启动器背景图 / 桌面壁纸)仍然让中性色跟着图像种子染色,
//! 那是用户要的 Monet 体验(D38)。见 [`super::resolve`] 的 `try_level`:两条路径刻意不同,
//! 别「统一处理」(`tests/card_neutral.rs` 有反证用例)。
//!
//! ## 卡与取色的关系(D38)
//!
//! 取色成功时,**当前选中的卡仍然参与生成**,但只贡献中性**彩度**(表面色的浓淡);
//! 色相由图像种子决定。所以卡片不是「只在第三级才用到」的备胎,它的固定中性色相
//! **不会**出现在取色方案里。
use super::wcag;
use crate::color::{
Color, Palettes, Scheme, SchemeFlavor, DEFAULT_NEUTRAL_CHROMA, NEUTRAL_HUE_DEG,
};
/// 一张内置色卡。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Card {
/// 持久化键(写进用户设置,**不许改**)。
pub key: &'static str,
/// 显示名(可改、可本地化)。
pub name: &'static str,
/// 种子色(只贡献色相;Tonal Spot 会把彩度收到 36)。
pub seed: Color,
/// 中性调色板彩度(表面色的浓淡;色相固定为 [`NEUTRAL_HUE_DEG`],见模块文档的 D43 契约)。
pub neutral_chroma: f64,
}
impl Card {
/// 用这张卡生成一套方案(与取色路径同一条生成器,只差两处入参:中性色相固定为
/// [`NEUTRAL_HUE_DEG`]、强调色来自卡而不是图像)。
pub fn scheme(self, flavor: SchemeFlavor) -> Scheme {
Scheme::from_palettes(
&Palettes::accent_on_fixed_neutral(self.seed, NEUTRAL_HUE_DEG, self.neutral_chroma),
flavor,
)
}
/// 这张卡的 `on-primary` 对 `primary` 的实测对比度。
///
/// 设置页可以直接显示成「对比度 {:.2}:1」。**2026-11 实测(8 张卡)**:
/// 浅色档 **6.43~6.49**、深色档 **7.70~7.75**——两种模式都远高于 WCAG AA 的 4.5:1。
///
/// 这两个区间**被测试钉住**(不是口头声明):`every_card_scheme_meets_wcag_aa` 对每张卡
/// 断言「下限 [`wcag::AA_TEXT`] + 落在上述区间内」,所以将来改种子/调色板常数把比值
/// 拖出区间会当场红——文档里的数字与测试同源。要改数值请连测试区间一起改。
pub fn primary_contrast(self, flavor: SchemeFlavor) -> f32 {
let scheme = self.scheme(flavor);
scheme.contrast(crate::color::Role::OnPrimary, crate::color::Role::Primary)
}
}
/// 全部内置色卡(下标 0 = 出厂默认)。
///
/// 色相在 CAM16 色环上大致均匀铺开(蓝 240 / 紫 299 / 绿 155 / 橙 65 / 红 10 / 青 185 /
/// 暮蓝 ~275 / 橄榄绿 ~110),让用户「随便挑一张都不会撞色」。`palette::card` 的单测
/// `cards_are_visually_distinct` 钉住「相邻色相间隔 ≥ 20°」。
pub const CARDS: [Card; 8] = [
Card {
key: "sky",
name: "天蓝",
// M70 定案 ④(`palettes::DEFAULT_SEED` 同值)。改这张卡 = 改产品默认色,见 M70 文档。
seed: Color::rgb(0x3C, 0x8D, 0xBC),
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
},
Card {
key: "lavender",
name: "薰衣草紫",
// 官方 M3 baseline 种子,和 `Scheme::official_baseline` 同色,便于对照官方截图。
seed: Color::rgb(0x67, 0x50, 0xA4),
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
},
Card {
key: "forest",
name: "森林绿",
seed: Color::rgb(0x2E, 0x7D, 0x4F),
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
},
Card {
key: "amber",
name: "琥珀橙",
seed: Color::rgb(0xB2, 0x6A, 0x00),
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
},
Card {
key: "rose",
name: "玫瑰红",
seed: Color::rgb(0xB2, 0x3A, 0x48),
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
},
Card {
key: "teal",
name: "青碧",
seed: Color::rgb(0x00, 0x79, 0x6B),
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
},
Card {
key: "dusk",
name: "暮蓝",
seed: Color::rgb(0x4A, 0x5A, 0x9C),
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
},
Card {
key: "olive",
name: "橄榄绿",
// 刻意**没有**「纯灰/石板灰」卡:D40 已实测,零彩度种子的 CAM16 色相恒为 209.49°
// (不是「无色」,而是固定的青),极低彩度段色相还是噪声放大器。所以「想要无彩色界面」
// 不能靠一张灰卡实现——那需要一个饱和度/对比度维度(未来若做,是另一条轴,不是色卡)。
seed: Color::rgb(0x5F, 0x6B, 0x2E),
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
},
];
/// 出厂默认卡(首次启动、用户没选过卡时的那一张)。
pub const DEFAULT_CARD: Card = CARDS[0];
/// 色卡的类型化键(`Card::key` 的枚举形态)。
///
/// 加了它是因为「存字符串」这件事必须只有一处口径:设置读回来时走 [`CardKey::from_key`],
/// 写出去时走 [`CardKey::key`],两边不会漂。新卡追加在末尾,**不重排**。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum CardKey {
/// 天蓝(出厂默认,M70 ④)。
Sky,
/// 薰衣草紫(官方 M3 baseline)。
Lavender,
/// 森林绿。
Forest,
/// 琥珀橙。
Amber,
/// 玫瑰红。
Rose,
/// 青碧。
Teal,
/// 暮蓝。
Dusk,
/// 橄榄绿。
Olive,
}
impl CardKey {
/// 全部键(顺序与 [`CARDS`] 一致)。
pub const ALL: [CardKey; 8] = [
CardKey::Sky,
CardKey::Lavender,
CardKey::Forest,
CardKey::Amber,
CardKey::Rose,
CardKey::Teal,
CardKey::Dusk,
CardKey::Olive,
];
/// 持久化键(与 [`Card::key`] 逐字相同)。
pub const fn key(self) -> &'static str {
CARDS[self.index()].key
}
/// 这张卡。
pub const fn card(self) -> Card {
CARDS[self.index()]
}
/// 显示名。
pub const fn name(self) -> &'static str {
CARDS[self.index()].name
}
const fn index(self) -> usize {
match self {
CardKey::Sky => 0,
CardKey::Lavender => 1,
CardKey::Forest => 2,
CardKey::Amber => 3,
CardKey::Rose => 4,
CardKey::Teal => 5,
CardKey::Dusk => 6,
CardKey::Olive => 7,
}
}
/// 从持久化键解析;**未知键返回 `None`**(调用方回落 [`DEFAULT_CARD`] 并可以提示
/// 「设置里的色卡名已失效」——绝不静默当成默认卡用掉,那样用户会以为设置没保存)。
pub fn from_key(key: &str) -> Option<Self> {
Self::ALL.into_iter().find(|card| card.key() == key)
}
}
impl From<CardKey> for Card {
fn from(key: CardKey) -> Self {
key.card()
}
}
/// 卡片方案是否过 WCAG(给测试与设置页复用;正常路径由 [`super::resolve`] 兜底)。
pub fn card_scheme_is_readable(card: Card, flavor: SchemeFlavor) -> bool {
wcag::check(&card.scheme(flavor)).is_clean()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn at_least_six_cards_and_default_is_sky() {
assert!(CARDS.len() >= 6, "只有 {} 张卡", CARDS.len());
assert_eq!(DEFAULT_CARD.key, "sky");
assert_eq!(CARDS[0].seed, crate::color::palettes::DEFAULT_SEED);
}
#[test]
fn card_keys_are_stable_and_unique() {
// ⚠️ 这些字符串是持久化键:改一个就等于把老用户的选择抹成出厂默认。
const EXPECTED: [&str; 8] = [
"sky", "lavender", "forest", "amber", "rose", "teal", "dusk", "olive",
];
for (card, expected) in CARDS.iter().zip(EXPECTED) {
assert_eq!(card.key, expected);
}
let mut keys: Vec<&str> = CARDS.iter().map(|card| card.key).collect();
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), CARDS.len(), "key 有重复");
for card in CARDS {
assert!(
card.key.chars().all(|c| c.is_ascii_lowercase()),
"{} 不是小写 ASCII",
card.key
);
assert!(!card.name.is_empty());
}
}
#[test]
fn card_key_roundtrips_and_rejects_unknown() {
for key in CardKey::ALL {
assert_eq!(CardKey::from_key(key.key()), Some(key));
assert_eq!(key.card().key, key.key());
}
assert_eq!(CardKey::from_key("no-such-card"), None);
assert_eq!(CardKey::from_key("SKY"), None, "key 大小写敏感");
}
#[test]
fn every_card_scheme_meets_wcag_aa() {
for card in CARDS {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
assert!(
card_scheme_is_readable(card, flavor),
"{} / {flavor:?} 未过门禁",
card.key
);
let ratio = card.primary_contrast(flavor);
assert!(
ratio >= wcag::AA_TEXT,
"{} / {flavor:?} primary 对 {} 只有 {ratio:.2}",
card.key,
card.name
);
// 上半区间:文档(`Card::primary_contrast`)向用户承诺的就是这两个区间,
// 只钉「≥ 4.5」等于没钉——比值掉到 5.0 也全绿,而文档会继续说 6.43~6.49。
// 边界取实测极值 ±0.01:浅色 6.4324~6.4906、深色 7.7024~7.7504。
let (low, high) = match flavor {
SchemeFlavor::Light => (6.43, 6.50),
SchemeFlavor::Dark => (7.70, 7.76),
};
assert!(
(low..=high).contains(&ratio),
"{} / {flavor:?} primary 对比度 {ratio:.4} 超出文档区间 {low}~{high}",
card.key
);
}
}
}
#[test]
fn cards_are_visually_distinct() {
// 色相间隔 ≥ 20°:八张卡的种子在两套方案里都不该撞成同一套配色。
let mut hues: Vec<f64> = CARDS
.iter()
.map(|card| crate::color::Hct::from_color(card.seed).hue)
.collect();
hues.sort_by(|a, b| a.partial_cmp(b).expect("有限色相"));
for pair in hues.windows(2) {
let gap = pair[1] - pair[0];
assert!(gap >= 20.0, "色相挤在一起:{pair:?}");
}
// 色相是环:排序后「最大 − 最小」不是真实间隔,**回绕那一侧**也要检查。
// 漏了这条的话,将来加一张 350° 的卡(与 rose 15.85° 实际只差 25.7°)会被算成
// 334.2° 而静默通过。
let wrap_gap = 360.0 - (hues[hues.len() - 1] - hues[0]);
assert!(
wrap_gap >= 20.0,
"色相环回绕处挤在一起:{:.2}° ↔ {:.2}°(间隔 {wrap_gap:.2}°)",
hues[hues.len() - 1],
hues[0]
);
for card in CARDS {
let light = card.scheme(SchemeFlavor::Light);
let dark = card.scheme(SchemeFlavor::Dark);
assert_ne!(
light.get(crate::color::Role::Primary),
dark.get(crate::color::Role::Primary)
);
}
}
}
+60
View File
@@ -0,0 +1,60 @@
//! # 配色来源(D38 / D40):图片取色 · 内置色卡 · 三级回落 · WCAG 收敛
//!
//! 用户在 2026-11-01 定的配色来源是三级(D38):
//!
//! ```text
//! ① 启动器背景图取色(用户设了背景图时)
//! ↓ 没有背景图 / 取色失败
//! ② 桌面壁纸取色(用户开了「莫奈取色」但没设背景图时)
//! ↓ 没有壁纸 / 取色失败 / 对比度收敛失败
//! ③ 内置色卡(用户手动选的那张;没选过就是出厂默认「天蓝」)
//! ```
//!
//! 取色失败**不静默**:本模块的 [`Provenance`] 会把「最终用了哪一级」和「前几级为什么没用」
//! 都带回来,设置页据此显示提示;解码失败也在里面(它不在这里发生,但原因是这里记录的)。
//! 契约(UI 怎么调、文案怎么映射)见 `DevDocs/SubAgentsTalk/palette-source-contract.md`。
//!
//! ## 分层(D40):本模块只做纯数学
//!
//! | 层 | 在哪 | 职责 | 依赖 |
//! |---|---|---|---|
//! | 纯数学 | **本模块**(`crates/design`) | 像素 → 种子 → 49 角色;色卡;回落决策;对比度收敛 | 无(零第三方依赖) |
//! | 解码 | `crates/palette-extract` | 路径/字节 → RGBA 像素 | `image`(唯一引入处) |
//! | 调用侧 | 后续 appearance/settings mission | 拿路径、把 [`Provenance`] 变文案 | UI 框架 |
//!
//! 所以本模块的「图片输入」永远是**已经解码好的像素**,从来没有路径、没有 IO。
//!
//! ## 四个部分
//!
//! - [`seed`]:`seed_from_rgba(pixels, w, h)`(D40 的入口形状)。量化在 [`quantize`],
//! 挑主色在 [`score`]——**不是取平均色**,理由见 [`quantize`] 的模块文档。
//! - [`card`]:内置色卡(≥6 张,含出厂默认「天蓝」)。卡的 `key` 是**持久化键**,写进用户设置。
//! - [`wcag`]:对任意一套方案跑对比度断言;不达标就沿 tone 轴修正,并记录「改了什么」。
//! - [`resolve`]:纯函数 `resolve_source(...) -> (Scheme, Provenance)`,实现三级优先与回落。
//!
//! ## D40 的两条实测事实(不是风格偏好,是物理事实)
//!
//! 1. **零彩度种子的 CAM16 色相是常数 209.49°**,不是 NaN。所以「灰白壁纸」硬取色会得到
//! 一个凭空上色的青色(`#006874`),且极低彩度段色相是噪声放大器(`#808080` → 209.5°、
//! `#7A7C7E` → 231.2°)。→ [`seed::MIN_SEED_CHROMA`] = 8 的种子彩度门槛,低于它就回落色卡。
//! 2. **动态色也必须过 WCAG**。候选色卡的 208 项门禁(`tests/contrast_aa.rs`)不覆盖取色结果,
//! 所以 [`wcag`] 对**任何**来源的方案都跑同一套检查,不达标就收敛或回落。
pub mod card;
pub mod resolve;
pub mod seed;
pub mod wcag;
mod quantize;
mod score;
pub use card::{Card, CardKey, CARDS, DEFAULT_CARD};
pub use resolve::{resolve_source, ImageInput, ImageSource, Level, Outcome, Provenance, Skip};
pub use seed::{
analyze_rgba, seed_from_rgba, Failure, Seed, MAX_SAMPLES, MIN_PIXELS, MIN_SEED_CHROMA,
MUTED_SEED_CHROMA,
};
pub use wcag::{converge, Adjustment, WcagReport, AA_NON_TEXT, AA_TEXT, TEXT_PAIRS};
/// 一套方案的来源层级([`Level`] 的别名,读起来更清楚)。
pub use resolve::Level as SourceLevel;
+338
View File
@@ -0,0 +1,338 @@
//! 像素 → 主色簇:Monet(Material You)取色管线里**量化那一半**(打分那一半在 [`super::score`])。
//!
//! ## 为什么不是「取平均色」
//!
//! 平均色是两个独立错误的合成:
//!
//! 1. **它不存在的颜色**。平均会把「天空 60% + 树叶 30% + 纯白 10%」混成一块脏灰绿,
//! 这个色值在图上**一个像素都不是**,拿它当种子,用户看到的整套配色跟壁纸不像。
//! 2. **它没有「主色」概念**。一张 95% 是米白、5% 是珊瑚的图,平均色几乎等于米白,
//! 而人眼记住的、能当强调色用的是那 5%。官方 Monet 的立场正好相反:**按面积 + 彩度
//! 挑一个「有人气又有色」的代表色**(`Score`),不是把整体摊平。
//!
//! ## 采用的策略(三层)
//!
//! | 步 | 做什么 | 官方对应 | 等级 |
//! |---|---|---|---|
//! | 1 | 5 bit/通道直方图(32³ = 32768 桶),桶内记像素数与各通道原值和 | `QuantizerWu` 的 `INDEX_BITS = 5` | A |
//! | 2 | 桶当带权点,在 **CIELAB** 里跑 k-means(≤10 轮;簇心用线性 RGB 加权均值更新) | `QuantizerWsmeans`(`MAX_ITERATIONS = 10`、`MIN_DELTA_E = 3.0`) | A(结构)/ B(用直方图桶代替 Wu 的分割盒播种) |
//! | 3 | 每簇的代表色 = 簇内像素最多的那个桶里**像素的原值平均** | `QuantizerCelebi` = 量化 + `Score` | A(管线)/ B(代表色取桶内均值) |
//!
//! 也就是说:本模块 = `QuantizerCelebi` 的形状(直方图量化 + k-means 精修),
//! 与官方的差别在**播种方式**——官方用 `QuantizerWu` 的动态规划分割盒当初始簇心,
//! 这里用「像素数最多的 24 个桶」。差别可测量(见各模块单测:5% 面积的强调色簇不会被
//! 95% 的底色吞掉),换来的是一条可全单测、零依赖、确定性的实现。
//!
//! ## 代表色为什么要「桶内原值平均」而不是桶中心
//!
//! 只取桶中心(5 bit 量化点)会让纯色 `#3C8DBC` 的图取出 `#3B8BBB`——量化误差 1~3/通道。
//! 单看数字无所谓,但这是**用户能看出来**的:「我选的是这个蓝,凭什么给我的强调色差一点?」
//! 桶内原值平均对纯色图精确等于输入色(误差 0),对复杂图就是该桶的真实均值,
//! 仍然不是「全图平均色」(只有一个桶那么多像素参与)。
//!
//! ## 来源等级
//!
//! - **A**:`material-color-utilities`(Apache-2.0)里确实有 `QuantizerWu` / `QuantizerWsmeans` /
//! `QuantizerCelebi` / `Score` 四个类,且 `INDEX_BITS = 5`、`MAX_ITERATIONS = 10`、
//! `MIN_DELTA_E = 3.0` 是官方常数——这些是「照抄口径」的部分。
//! - **B**:用「top-N 桶」代替 Wu 的分割盒当播种;代表色取桶内原值均值。
//! - **C**:不做。拿不准的常数一律写成带注释的「我们的选择」,不假装是官方值。
use crate::color::space;
use crate::color::Color;
/// 每通道保留的位数:官方 `QuantizerWu` 用 5 bit/通道。
pub(crate) const INDEX_BITS: u32 = 5;
/// 直方图桶数(32³)。
const BIN_COUNT: usize = 1 << (INDEX_BITS as usize * 3);
/// 每通道的右移量(8 − 5 = 3)。
const SHIFT: u32 = 8 - INDEX_BITS;
/// k-means 最多迭代轮数(官方 `QuantizerWsmeans.MAX_ITERATIONS`)。
const MAX_ITERATIONS: usize = 10;
/// 收敛阈值:簇心在 CIELAB 里移动小于这个距离就停(官方 `MIN_DELTA_E`)。
const MIN_DELTA_E: f64 = 3.0;
/// 最多保留的簇数(喂给打分的候选数量)。官方 `Score` 通常拿到 256 个候选;
/// 24 个足够覆盖「底色 + 两三个强调色簇」,也让色相直方图不被噪声塞满。
pub(crate) const MAX_CLUSTERS: usize = 24;
/// 一个主色簇:图里**真实存在**的颜色 + 被归到它名下的像素数。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Cluster {
/// 簇的代表色(簇内像素最多的桶里,像素原值的平均)。
pub color: Color,
/// 归到这个簇的采样像素数。
pub population: u32,
}
/// 一个非空桶。
#[derive(Debug, Clone, Copy)]
struct Bin {
index: usize,
color: Color,
count: u32,
}
/// 把采样像素量化成主色簇(按像素数降序,同数按色值排序 → 结果确定)。
pub(crate) fn quantize(samples: &[Color]) -> Vec<Cluster> {
let bins = histogram(samples);
if bins.is_empty() {
return Vec::new();
}
let mut clusters = if bins.len() <= MAX_CLUSTERS {
bins.iter()
.map(|bin| Cluster {
color: bin.color,
population: bin.count,
})
.collect()
} else {
kmeans(&bins)
};
clusters.sort_by(|a, b| {
b.population
.cmp(&a.population)
.then_with(|| packed(a.color).cmp(&packed(b.color)))
});
clusters
}
/// 直方图(按桶下标升序,空的桶不出现)。
fn histogram(samples: &[Color]) -> Vec<Bin> {
let mut counts = vec![0u32; BIN_COUNT];
// 每桶各通道的原值和(`u32` 够用:单桶最多 65536 个采样点 × 255 < 2^32)。
let mut sums = vec![[0u32; 3]; BIN_COUNT];
for color in samples {
let index = bin_index(*color);
counts[index] = counts[index].saturating_add(1);
for (channel, value) in [color.r, color.g, color.b].into_iter().enumerate() {
sums[index][channel] = sums[index][channel].saturating_add(u32::from(value));
}
}
counts
.iter()
.zip(&sums)
.enumerate()
.filter(|(_, (count, _))| **count > 0)
.map(|(index, (count, sum))| Bin {
index,
color: average_color(*count, *sum),
count: *count,
})
.collect()
}
fn average_color(count: u32, sum: [u32; 3]) -> Color {
let channel = |value: u32| ((f64::from(value) / f64::from(count)).round()) as u8;
Color::rgb(channel(sum[0]), channel(sum[1]), channel(sum[2]))
}
fn bin_index(color: Color) -> usize {
(usize::from(color.r >> SHIFT) << (INDEX_BITS * 2))
| (usize::from(color.g >> SHIFT) << INDEX_BITS)
| usize::from(color.b >> SHIFT)
}
/// CIELAB 欧氏距离的平方。
fn distance_squared(a: [f64; 3], b: [f64; 3]) -> f64 {
let dl = a[0] - b[0];
let da = a[1] - b[1];
let db = a[2] - b[2];
dl * dl + da * da + db * db
}
/// 带权 k-means(在 CIELAB 里分配、在线性 RGB 里更新簇心)。
fn kmeans(bins: &[Bin]) -> Vec<Cluster> {
let mut by_population: Vec<usize> = (0..bins.len()).collect();
by_population.sort_by(|&a, &b| {
bins[b]
.count
.cmp(&bins[a].count)
.then_with(|| bins[a].index.cmp(&bins[b].index))
});
by_population.truncate(MAX_CLUSTERS);
let mut centers: Vec<Color> = by_population.iter().map(|&i| bins[i].color).collect();
let mut assignments = vec![usize::MAX; bins.len()];
for _ in 0..MAX_ITERATIONS {
let center_lab: Vec<[f64; 3]> = centers.iter().map(|c| space::lab_from_color(*c)).collect();
let mut changed = false;
for (i, bin) in bins.iter().enumerate() {
let lab = space::lab_from_color(bin.color);
let mut best = 0;
let mut best_distance = f64::INFINITY;
for (k, center) in center_lab.iter().enumerate() {
let distance = distance_squared(lab, *center);
if distance < best_distance {
best_distance = distance;
best = k;
}
}
if assignments[i] != best {
changed = true;
assignments[i] = best;
}
}
let mut sums = vec![[0.0_f64; 3]; centers.len()];
let mut weights = vec![0.0_f64; centers.len()];
for (i, bin) in bins.iter().enumerate() {
let weight = f64::from(bin.count);
let linear = [
space::linearized(bin.color.r),
space::linearized(bin.color.g),
space::linearized(bin.color.b),
];
let k = assignments[i];
for channel in 0..3 {
sums[k][channel] += linear[channel] * weight;
}
weights[k] += weight;
}
let mut shift = 0.0_f64;
for (k, center) in centers.iter_mut().enumerate() {
if weights[k] <= 0.0 {
continue;
}
let mean = [
sums[k][0] / weights[k],
sums[k][1] / weights[k],
sums[k][2] / weights[k],
];
let next = space::color_from_linrgb(mean);
shift = shift.max(distance_squared(space::lab_from_color(next), center_lab[k]).sqrt());
*center = next;
}
if !changed || shift < MIN_DELTA_E {
break;
}
}
// 代表色取「簇内像素数最多的真实桶」,**不是**簇心均值:均值色往往在图上不存在,
// 用它当种子会让色卡与壁纸看起来不像(见模块文档第 1 条理由)。
let mut representatives: Vec<Option<(u32, Color)>> = vec![None; centers.len()];
for (i, bin) in bins.iter().enumerate() {
let k = assignments[i];
let better = match representatives[k] {
None => true,
Some((best, _)) => bin.count > best,
};
if better {
representatives[k] = Some((bin.count, bin.color));
}
}
let mut totals = vec![0u32; centers.len()];
for (i, bin) in bins.iter().enumerate() {
totals[assignments[i]] = totals[assignments[i]].saturating_add(bin.count);
}
let mut clusters: Vec<Cluster> = representatives
.iter()
.enumerate()
.filter_map(|(k, entry)| {
entry.map(|(_, color)| Cluster {
color,
population: totals[k],
})
})
.collect();
// 不同簇可能选出同一个代表桶(极少见),合并以免重复喂给打分。
clusters.sort_by_key(|cluster| packed(cluster.color));
clusters.dedup_by(|a, b| {
if a.color == b.color {
b.population = b.population.saturating_add(a.population);
true
} else {
false
}
});
clusters
}
fn packed(color: Color) -> u32 {
(u32::from(color.r) << 16) | (u32::from(color.g) << 8) | u32::from(color.b)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bin_index_groups_neighbours() {
// 同一个 5 bit 桶内的像素共享桶下标(桶宽 8)。
assert_eq!(
bin_index(Color::rgb(0x50, 0x80, 0x10)),
bin_index(Color::rgb(0x57, 0x87, 0x17))
);
assert_ne!(
bin_index(Color::rgb(0x50, 0x80, 0x10)),
bin_index(Color::rgb(0x60, 0x80, 0x10))
);
}
#[test]
fn solid_color_keeps_its_exact_value() {
// 桶内原值平均 → 纯色图取回的种子与输入色**逐通道相等**(量化误差被平均消掉)。
for hex in ["#3C8DBC", "#C0392B", "#00F0FF", "#123456"] {
let color = Color::from_hex(hex).expect("合法 hex");
let clusters = quantize(&[color; 500]);
assert_eq!(clusters.len(), 1);
assert_eq!(clusters[0].color, color, "{hex}");
assert_eq!(clusters[0].population, 500);
}
}
#[test]
fn empty_input_has_no_cluster() {
assert!(quantize(&[]).is_empty());
}
#[test]
fn cluster_populations_add_up() {
let mut samples = Vec::new();
for i in 0..60 {
let shade = (i * 4) as u8;
samples.push(Color::rgb(shade, 40, 200));
samples.push(Color::rgb(200, shade, 40));
samples.push(Color::rgb(40, 200, shade));
}
let clusters = quantize(&samples);
let total: u32 = clusters.iter().map(|c| c.population).sum();
assert_eq!(total, samples.len() as u32);
assert!(clusters.len() <= MAX_CLUSTERS, "{} 簇", clusters.len());
}
#[test]
fn kmeans_split_is_deterministic() {
let mut samples = Vec::new();
for i in 0..90 {
let shade = (i * 2) as u8;
samples.push(Color::rgb(shade, 40, 200));
samples.push(Color::rgb(200, shade, 40));
samples.push(Color::rgb(40, 200, shade));
}
assert_eq!(quantize(&samples), quantize(&samples));
}
#[test]
fn kmeans_keeps_a_small_distinct_cluster() {
// 3% 面积的红点 + 97% 的蓝底:k-means 不能把红点并进蓝簇。
let mut samples = vec![Color::rgb(0x20, 0x40, 0xC0); 970];
samples.extend(vec![Color::rgb(0xD0, 0x30, 0x20); 30]);
let clusters = quantize(&samples);
assert!(
clusters
.iter()
.any(|c| c.color.r > 0xB0 && c.color.b < 0x60),
"{clusters:?}"
);
assert!(
clusters
.iter()
.any(|c| c.color.b > 0xA0 && c.color.r < 0x60),
"{clusters:?}"
);
}
}
+205
View File
@@ -0,0 +1,205 @@
//! 三级来源编排:**纯函数**,一次调用算完「用哪一级的颜色、为什么不是别的级」。
//!
//! D38 的优先序:① 启动器背景图 → ② 桌面壁纸 → ③ 内置色卡。
//! 本模块不做任何 IO:像素是调用方解码好传进来的(D40 分层),壁纸路径探测是 M73 的事。
//!
//! ```no_run
//! use unlucky_design::palette::{resolve_source, CardKey, ImageInput, ImageSource, Level};
//! use unlucky_design::color::SchemeFlavor;
//!
//! let pixels = [0x3C, 0x8D, 0xBC, 0xFF].repeat(16); // 4×4 天蓝图(示例)
//! let bg = ImageInput::Pixels(ImageSource { pixels: &pixels, width: 4, height: 4 });
//! let (scheme, provenance) = resolve_source(Some(bg), None, Some(CardKey::Forest), SchemeFlavor::Light);
//! assert_eq!(provenance.level, Level::BackgroundImage);
//! assert!(provenance.hint_key().is_none(), "一级成功时没有提示");
//! ```
//!
//! ## 失败链:每一级都可能「有输入但不合格」
//!
//! | 情形 | 结果 | `Provenance` 里留下 |
//! |---|---|---|
//! | 没有背景图 | 跳到 ② | `Outcome::Absent` |
//! | 更高级已经成功 | 不再看这一级 | `Outcome::NotReached`(**不等于**「用户没提供」) |
//! | 背景图解码失败 | 跳到 ② | `Outcome::Failed(Skip::Undecodable)` |
//! | 背景图太小 / 像素缓冲不对 / 全透明 | 跳到 ② | `… TooSmall / BadDimensions / NoOpaquePixels` |
//! | 背景图彩度不足(D40 的 `< 8`) | 跳到 ② | `… LowChroma { chroma }` |
//! | 背景图取色成功但对比度收敛失败 | 跳到 ② | `… Contrast { failures }`(理论上不会发生,仍要留) |
//! | 三级都不成立 | 用色卡,**一定**有结果 | `Level::Preset` |
//!
//! 三条纪律:
//!
//! 1. **不许静默**。任何一次回落都在 `Provenance` 里留下原因码,[`Provenance::hint_key`]
//! 给 UI 一个稳定的提示键(文案映射见 `DevDocs/SubAgentsTalk/palette-source-contract.md`)。
//! 2. **不许静默变黑白**。彩度不足的图**不**会被「压成灰方案」,而是直接回落到用户选的那张色卡。
//! 3. **不许静默换成默认卡**。回落的落点是「用户当前选中的卡」(参数 `preset`),
//! 只有用户从没选过卡(`None`)才用 [`super::DEFAULT_CARD`]。
//!
//! ## 文件划分
//!
//! [`provenance`] 放「结果解释」的数据类型(`Level` / `Outcome` / `Skip` / `Provenance`),
//! 本文件放编排函数与测试。拆开只是为了单文件 ≤400 行的纪律,对外路径仍是
//! `palette::resolve::*`。
use super::card::{Card, CardKey, DEFAULT_CARD};
use super::seed::{analyze_rgba, Seed};
use super::wcag::{check, converge, Adjustment};
use crate::color::{Scheme, SchemeFlavor};
mod provenance;
pub use provenance::{Level, Outcome, Provenance, Skip};
/// 已解码的图片:借用像素,不拷贝(解码在 `crates/palette-extract`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ImageSource<'a> {
/// RGBA8 像素,`width × height × 4` 字节。
pub pixels: &'a [u8],
/// 宽(像素)。
pub width: u32,
/// 高(像素)。
pub height: u32,
}
/// 某一级来源的输入。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageInput<'a> {
/// 解码好的像素。
Pixels(ImageSource<'a>),
/// 有这张图,但解码失败(文件损坏 / 格式不支持 / 读不到)。
///
/// 这个状态由适配层传进来——不传就等于「没有这张图」,UI 会说错话。
Undecodable,
}
impl<'a> ImageInput<'a> {
/// 便捷构造:从像素缓冲包一个输入。
pub fn pixels(pixels: &'a [u8], width: u32, height: u32) -> Self {
ImageInput::Pixels(ImageSource {
pixels,
width,
height,
})
}
}
/// 三级来源编排(纯函数)。
///
/// 参数:
/// - `background`:用户设的启动器背景图(`None` = 没设)。解码失败传
/// [`ImageInput::Undecodable`],**不要**传 `None`(那样 UI 分不清「没设」和「坏了」)。
/// - `wallpaper`:桌面壁纸(`None` = 没开莫奈 / 探测不到壁纸文件)。
/// - `preset`:用户当前选中的色卡(`None` = 从没选过 → 用 [`super::DEFAULT_CARD`])。
/// - `flavor`:深浅模式。
///
/// 返回值永远有一套可用方案:三级全失败时落到色卡,并带上原因。
pub fn resolve_source(
background: Option<ImageInput<'_>>,
wallpaper: Option<ImageInput<'_>>,
preset: Option<CardKey>,
flavor: SchemeFlavor,
) -> (Scheme, Provenance) {
let card = preset.map(Card::from).unwrap_or(DEFAULT_CARD);
// 先假定「没轮到」,逐级评估时再改成 Absent / Failed / Used。
let mut outcomes = [Outcome::NotReached; 3];
for (level, input) in [
(Level::BackgroundImage, background),
(Level::Wallpaper, wallpaper),
] {
match try_level(input, card, flavor) {
Attempt::Absent => outcomes[level.index()] = Outcome::Absent,
Attempt::Failed(skip) => outcomes[level.index()] = Outcome::Failed(skip),
Attempt::Used {
scheme,
seed,
adjustments,
} => {
outcomes[level.index()] = Outcome::Used;
return (
*scheme,
Provenance {
level,
card: card_key(card),
seed: Some(seed),
flavor,
adjustments,
outcomes,
},
);
}
}
}
// 第三级:色卡。收敛仍然要跑(色卡也可能因为将来改常数而越线),但落点是它自己。
let (scheme, adjustments) = converge(card.scheme(flavor));
let report = check(&scheme);
outcomes[Level::Preset.index()] = if report.is_clean() {
Outcome::Used
} else {
Outcome::Failed(Skip::Contrast {
failures: report.failures.len(),
})
};
(
scheme,
Provenance {
level: Level::Preset,
card: card_key(card),
seed: None,
flavor,
adjustments,
outcomes,
},
)
}
/// 单级的处理结果。
///
/// `scheme` 装箱只是为了枚举大小(`Scheme` 是 49 个色值 ≈ 200 字节,逐级在栈上传会白白拷两次);
/// 这条路径每次配色只走一次,堆分配的开销可忽略。
enum Attempt {
Absent,
Failed(Skip),
Used {
scheme: Box<Scheme>,
seed: Seed,
adjustments: Vec<Adjustment>,
},
}
fn try_level(input: Option<ImageInput<'_>>, card: Card, flavor: SchemeFlavor) -> Attempt {
let source = match input {
None => return Attempt::Absent,
Some(ImageInput::Undecodable) => return Attempt::Failed(Skip::Undecodable),
Some(ImageInput::Pixels(source)) => source,
};
let seed = match analyze_rgba(source.pixels, source.width, source.height) {
Ok(seed) => seed,
Err(failure) => return Attempt::Failed(Skip::from(failure)),
};
// 取色方案的表面色**跟着图像种子染色**,只借当前色卡的中性彩度定浓淡(D43):
// 这是与色卡路径刻意不同的一处——色卡固定中性色相、只换强调色,取色保留整幅染色
// (那是用户要的 Monet 体验)。别把两条路径「统一处理」:这条接线的守护测试是
// `tests/card_neutral.rs::resolved_image_scheme_keeps_tinting_from_the_seed`(走的就是
// 本函数所在的 `resolve_source`;只测 `Seed::scheme` 的那条覆盖不到这里,复审 r1 p1-1)。
let (scheme, adjustments) = converge(seed.scheme(flavor, card.neutral_chroma));
let report = check(&scheme);
if !report.is_clean() {
return Attempt::Failed(Skip::Contrast {
failures: report.failures.len(),
});
}
Attempt::Used {
scheme: Box::new(scheme),
seed,
adjustments,
}
}
fn card_key(card: Card) -> CardKey {
CardKey::from_key(card.key).unwrap_or(CardKey::Sky)
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,261 @@
//! 一次配色的「结果解释」:最终用了哪一级、前几级为什么没用、对比度被改了什么。
//!
//! 这些类型是**给 UI 看的契约**([`Provenance::hint_key`] 的键 → 文案的映射见
//! `DevDocs/SubAgentsTalk/palette-source-contract.md`),所以刻意做得比内部需要更细:
//! 每一种失败都带足以写出一句具体提示的数据(彩度是多少、图有多小、哪一级坏了)。
//!
//! 与 [`super`] 拆开只为单文件 ≤400 行;对外路径仍是 `palette::resolve::*`。
use super::super::card::CardKey;
use super::super::seed::{Failure, Seed};
use super::super::wcag::Adjustment;
use crate::color::{Color, SchemeFlavor};
/// 方案的来源层级(D38 的三级)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Level {
/// ① 启动器背景图取色。
BackgroundImage,
/// ② 桌面壁纸取色。
Wallpaper,
/// ③ 内置色卡。
Preset,
}
impl Level {
/// 稳定码(日志/设置里用,别用 `Debug` 输出)。
pub const fn code(self) -> &'static str {
match self {
Level::BackgroundImage => "background-image",
Level::Wallpaper => "wallpaper",
Level::Preset => "preset",
}
}
/// 在本模块内部当索引用(0 = ①)。
///
/// `pub(super)`:`resolve` 的编排函数要按下标填 `Provenance` 的三个格子,
/// 但它不该出现在对外 API 里(外部只该问「哪一级」,不该拿索引当协议)。
pub(super) const fn index(self) -> usize {
match self {
Level::BackgroundImage => 0,
Level::Wallpaper => 1,
Level::Preset => 2,
}
}
}
/// 某一级的处置结果。
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Outcome {
/// 用了这一级。
Used,
/// 用户没提供这一级(没设背景图 / 没开莫奈取色)。
///
/// 注意与 [`Outcome::NotReached`] 的区别:`Absent` 是「用户没有这一级的输入」,
/// 而「用户提供了、但更高级已经成功所以没轮到」是 `NotReached`。两者对 UI 的含义完全不同——
/// 把后者说成「未选择」,用户明明选了卡却被告知「色卡:未选择」。
Absent,
/// 有输入、但因为更高级已经成功,**没轮到**这一级。
///
/// 只可能出现在低优先级那一侧:背景图成功 → 壁纸与色卡都是 `NotReached`。
/// 色卡即使 `NotReached` 也可能参与生成(它的中性彩度决定表面色温),所以 UI 要看
/// [`Provenance::card`] 而不是把 `NotReached` 当成「没用它」。
NotReached,
/// 有输入但不合格,已跳过。
Failed(Skip),
}
/// 「有输入但不合格」的具体原因——每一条都要能变成一句人话。
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Skip {
/// 解码失败(原因在适配层,这里只知道失败)。
Undecodable,
/// 图太小(< 4 像素)。
TooSmall {
/// 宽。
width: u32,
/// 高。
height: u32,
},
/// 像素缓冲长度与宽高不符(调用方给错了数据)。
BadDimensions {
/// 期望字节数。
expected: usize,
/// 实际字节数。
actual: usize,
},
/// 全是透明像素。
NoOpaquePixels,
/// 彩度不足(D40:`< MIN_SEED_CHROMA`),硬取色会凭空上色。
LowChroma {
/// 实测彩度。
chroma: f64,
/// 最接近的候选色(提示文案里显示成色块)。
color: Color,
},
/// 取色成功但对比度收敛后仍不达标。
Contrast {
/// 仍不达标的对数。
failures: usize,
},
}
impl Skip {
/// 稳定原因码(`Provenance::hint_key` 的后半段)。
pub const fn code(self) -> &'static str {
match self {
Skip::Undecodable => "undecodable",
Skip::TooSmall { .. } => "too-small",
Skip::BadDimensions { .. } => "bad-dimensions",
Skip::NoOpaquePixels => "no-opaque-pixels",
Skip::LowChroma { .. } => "low-chroma",
Skip::Contrast { .. } => "contrast",
}
}
}
impl From<Failure> for Skip {
fn from(failure: Failure) -> Self {
match failure {
Failure::BadDimensions {
expected, actual, ..
} => Skip::BadDimensions { expected, actual },
Failure::TooSmall { width, height } => Skip::TooSmall { width, height },
Failure::NoOpaquePixels => Skip::NoOpaquePixels,
Failure::LowChroma { color, chroma } => Skip::LowChroma { color, chroma },
}
}
}
/// 这次配色「最后用了什么、前几级为什么没用」。
#[derive(Debug, Clone, PartialEq)]
pub struct Provenance {
/// 最终生效的层级。
pub level: Level,
/// 当前生效的色卡(取色成功时它仍然参与:中性彩度来自它)。
pub card: CardKey,
/// 取色成功时的种子(色卡档为 `None`)。
pub seed: Option<Seed>,
/// 深浅模式。
pub flavor: SchemeFlavor,
/// 对比度收敛改了哪些角色(空 = 没动)。
pub adjustments: Vec<Adjustment>,
/// 三级各自的处置结果(`pub(super)`:只能由 `resolve::resolve_source` 填,
/// 外部一律通过 [`Provenance::outcome`] / [`Provenance::background`] 等访问器读)。
pub(super) outcomes: [Outcome; 3],
}
impl Provenance {
/// 某一级发生了什么。
pub fn outcome(&self, level: Level) -> Outcome {
self.outcomes[level.index()]
}
/// 背景图那一级。
pub fn background(&self) -> Outcome {
self.outcome(Level::BackgroundImage)
}
/// 壁纸那一级。
pub fn wallpaper(&self) -> Outcome {
self.outcome(Level::Wallpaper)
}
/// 色卡那一级。
pub fn preset(&self) -> Outcome {
self.outcome(Level::Preset)
}
/// 这次配色是否发生过回落(用了 `level` 以下的层级)。
pub fn did_fall_back(&self) -> bool {
(0..self.level.index()).any(|i| !matches!(self.outcomes[i], Outcome::Absent))
}
/// 第一个「有输入但不合格」的层级(提示文案优先说它)。
pub fn first_failure(&self) -> Option<(Level, Skip)> {
const LEVELS: [Level; 3] = [Level::BackgroundImage, Level::Wallpaper, Level::Preset];
LEVELS
.into_iter()
.find_map(|level| match self.outcome(level) {
Outcome::Failed(skip) => Some((level, skip)),
_ => None,
})
}
/// UI 提示键(`None` = 无需提示)。
///
/// 形如 `palette.hint.background.low-chroma`,`key` 稳定、可持久化进日志;
/// 本 crate 不产出人话文案(文案属于 UI 层,D40 的分层纪律),UI 只做查表:
///
/// | 键 | 建议文案(`{card}` = `provenance.card.name()`) |
/// |---|---|
/// | `palette.hint.background.undecodable` | 背景图读取失败,已改用{card}配色 |
/// | `palette.hint.background.too-small` | 背景图太小,无法取色,已改用{card}配色 |
/// | `palette.hint.background.bad-dimensions` | 背景图数据异常,已改用{card}配色 |
/// | `palette.hint.background.no-opaque-pixels` | 背景图几乎全透明,无法取色,已改用{card}配色 |
/// | `palette.hint.background.low-chroma` | 背景图颜色太淡,取不出主题色,已改用{card}配色 |
/// | `palette.hint.background.contrast` | 背景图配色达不到可读性要求,已改用{card}配色 |
/// | `palette.hint.wallpaper.*` | 同上,把「背景图」换成「桌面壁纸」 |
/// | `palette.hint.preset.contrast` | 当前配色可读性不足,已自动微调 |
/// | `palette.hint.adjusted` | 为可读性微调了少量颜色(`adjustments` 非空但无失败) |
///
/// 完整契约(含可选补充提示、持久化纪律、性能账)见
/// `DevDocs/SubAgentsTalk/palette-source-contract.md`(该目录是本地文档,不入库)。
///
/// 优先级:① 先解释「为什么回落」(第一个失败的层级)→ ② 再说「微调过对比度」。
/// 前者对用户更重要:用户看到的是「我的壁纸怎么没生效」,而不是「颜色被调了 2%」。
pub fn hint_key(&self) -> Option<&'static str> {
if let Some((level, skip)) = self.first_failure() {
return Some(match (level, skip) {
(Level::BackgroundImage, Skip::Undecodable) => {
"palette.hint.background.undecodable"
}
(Level::BackgroundImage, Skip::TooSmall { .. }) => {
"palette.hint.background.too-small"
}
(Level::BackgroundImage, Skip::BadDimensions { .. }) => {
"palette.hint.background.bad-dimensions"
}
(Level::BackgroundImage, Skip::NoOpaquePixels) => {
"palette.hint.background.no-opaque-pixels"
}
(Level::BackgroundImage, Skip::LowChroma { .. }) => {
"palette.hint.background.low-chroma"
}
(Level::BackgroundImage, Skip::Contrast { .. }) => {
"palette.hint.background.contrast"
}
(Level::Wallpaper, Skip::Undecodable) => "palette.hint.wallpaper.undecodable",
(Level::Wallpaper, Skip::TooSmall { .. }) => "palette.hint.wallpaper.too-small",
(Level::Wallpaper, Skip::BadDimensions { .. }) => {
"palette.hint.wallpaper.bad-dimensions"
}
(Level::Wallpaper, Skip::NoOpaquePixels) => {
"palette.hint.wallpaper.no-opaque-pixels"
}
(Level::Wallpaper, Skip::LowChroma { .. }) => "palette.hint.wallpaper.low-chroma",
(Level::Wallpaper, Skip::Contrast { .. }) => "palette.hint.wallpaper.contrast",
(Level::Preset, Skip::Contrast { .. }) => "palette.hint.preset.contrast",
// 色卡档不存在「解码/彩度」问题;落到这里说明是将来新增的原因,别乱编键。
(Level::Preset, _) => "palette.hint.preset.unavailable",
});
}
if !self.adjustments.is_empty() {
return Some("palette.hint.adjusted");
}
None
}
/// 原因码列表(`[级别码, 原因码]`),写日志/上报用;给不了键的细节都在这里。
pub fn reason_codes(&self) -> Vec<(&'static str, &'static str)> {
const LEVELS: [Level; 3] = [Level::BackgroundImage, Level::Wallpaper, Level::Preset];
LEVELS
.into_iter()
.filter_map(|level| match self.outcome(level) {
Outcome::Failed(skip) => Some((level.code(), skip.code())),
_ => None,
})
.collect()
}
}
+242
View File
@@ -0,0 +1,242 @@
//! `resolve` 的单测:三级优先序、失败回落、以及「回落落点是用户选的卡」这三类断言。
//!
//! 与 `mod.rs` 拆开只为单文件 ≤400 行;测试仍属于 `palette::resolve::tests`。
use super::*;
use crate::color::Color;
fn solid(color: Color, width: u32, height: u32) -> Vec<u8> {
let mut out = Vec::with_capacity((width * height * 4) as usize);
for _ in 0..(width * height) {
out.extend_from_slice(&[color.r, color.g, color.b, color.a]);
}
out
}
#[test]
fn level_one_wins_when_background_is_good() {
let pixels = solid(Color::rgb(0xC0, 0x39, 0x2B), 4, 4);
let (scheme, provenance) = resolve_source(
Some(ImageInput::pixels(&pixels, 4, 4)),
None,
Some(CardKey::Forest),
SchemeFlavor::Light,
);
assert_eq!(provenance.level, Level::BackgroundImage);
assert_eq!(provenance.background(), Outcome::Used);
assert_eq!(provenance.preset(), Outcome::NotReached);
assert!(provenance.hint_key().is_none());
assert!(check(&scheme).is_clean());
assert!(provenance.seed.is_some());
// 取色方案的表面色温来自当前色卡(森林绿),不是默认卡。
assert_eq!(provenance.card, CardKey::Forest);
}
#[test]
fn gray_background_falls_back_to_wallpaper_with_reason() {
// 反证 1:灰白背景图 → 不许凭空上色,必须带着 low-chroma 原因回落。
let gray = solid(Color::rgb(0xF2, 0xF2, 0xF0), 8, 8);
let teal = solid(Color::rgb(0x00, 0x79, 0x6B), 8, 8);
let (_, provenance) = resolve_source(
Some(ImageInput::pixels(&gray, 8, 8)),
Some(ImageInput::pixels(&teal, 8, 8)),
Some(CardKey::Sky),
SchemeFlavor::Light,
);
assert_eq!(provenance.level, Level::Wallpaper);
match provenance.background() {
Outcome::Failed(Skip::LowChroma { chroma, .. }) => assert!(chroma < 8.0),
other => panic!("背景图应当是低彩度失败,实际 {other:?}"),
}
assert_eq!(
provenance.hint_key(),
Some("palette.hint.background.low-chroma")
);
assert!(provenance.did_fall_back());
assert_eq!(
provenance.reason_codes(),
vec![("background-image", "low-chroma")]
);
}
#[test]
fn undecodable_and_tiny_inputs_are_distinguished_from_absent() {
let tiny = solid(Color::rgb(0xFF, 0x00, 0x00), 1, 2);
let (_, provenance) = resolve_source(
Some(ImageInput::Undecodable),
Some(ImageInput::pixels(&tiny, 1, 2)),
None,
SchemeFlavor::Dark,
);
assert_eq!(provenance.level, Level::Preset);
assert_eq!(provenance.background(), Outcome::Failed(Skip::Undecodable));
assert_eq!(
provenance.wallpaper(),
Outcome::Failed(Skip::TooSmall {
width: 1,
height: 2
})
);
assert_eq!(
provenance.hint_key(),
Some("palette.hint.background.undecodable")
);
// 从没选过卡 → 出厂默认。
assert_eq!(provenance.card, CardKey::Sky);
}
#[test]
fn nothing_provided_uses_default_card_without_hint() {
let (scheme, provenance) = resolve_source(None, None, None, SchemeFlavor::Light);
assert_eq!(provenance.level, Level::Preset);
assert_eq!(provenance.preset(), Outcome::Used);
assert!(!provenance.did_fall_back());
assert!(provenance.adjustments.is_empty());
assert!(provenance.hint_key().is_none());
assert_eq!(scheme, DEFAULT_CARD.scheme(SchemeFlavor::Light));
}
#[test]
fn preset_selection_decides_the_fallback_colour() {
// 反证 2:回落落点是「用户选的那张卡」,不是默认卡。
let gray = solid(Color::rgb(0x80, 0x80, 0x80), 4, 4);
let (scheme, provenance) = resolve_source(
Some(ImageInput::pixels(&gray, 4, 4)),
None,
Some(CardKey::Rose),
SchemeFlavor::Light,
);
assert_eq!(provenance.level, Level::Preset);
assert_eq!(provenance.card, CardKey::Rose);
assert_eq!(scheme, CardKey::Rose.card().scheme(SchemeFlavor::Light));
}
#[test]
fn background_beats_wallpaper_when_both_work() {
let bg = solid(Color::rgb(0xB2, 0x6A, 0x00), 8, 8);
let wp = solid(Color::rgb(0x00, 0x79, 0x6B), 8, 8);
let (_, provenance) = resolve_source(
Some(ImageInput::pixels(&bg, 8, 8)),
Some(ImageInput::pixels(&wp, 8, 8)),
Some(CardKey::Teal),
SchemeFlavor::Dark,
);
assert_eq!(provenance.level, Level::BackgroundImage);
assert_eq!(
provenance.wallpaper(),
Outcome::NotReached,
"② 有输入但根本没轮到——不等于「没提供」"
);
assert!(!provenance.did_fall_back());
}
#[test]
fn transparent_background_reports_its_own_reason() {
let transparent = solid(Color::rgba(0x3C, 0x8D, 0xBC, 0), 4, 4);
let (_, provenance) = resolve_source(
Some(ImageInput::pixels(&transparent, 4, 4)),
None,
None,
SchemeFlavor::Light,
);
assert_eq!(
provenance.background(),
Outcome::Failed(Skip::NoOpaquePixels)
);
assert_eq!(
provenance.hint_key(),
Some("palette.hint.background.no-opaque-pixels")
);
}
#[test]
fn faint_colour_falls_back_to_the_selected_card() {
// 复审 p2-3:D40 的门槛是 8,落在 6~8 之间的图必须**回落**,且落点是用户选中的那张卡。
// 变异反证:`MIN_SEED_CHROMA` 8.0 → 6.0 时,这个种子会通过门槛,
// `level` 变成 `BackgroundImage`,本条的第一条断言变红。
let faint = Color::rgb(0x7E, 0x84, 0x89); // CAM16 chroma ≈ 7.23
let pixels = solid(faint, 16, 16);
let (scheme, provenance) = resolve_source(
Some(ImageInput::pixels(&pixels, 16, 16)),
None,
Some(CardKey::Amber),
SchemeFlavor::Light,
);
assert_eq!(provenance.level, Level::Preset, "低彩度图必须回落");
assert_eq!(provenance.card, CardKey::Amber, "落点是用户选中的卡");
assert_eq!(
scheme,
CardKey::Amber.card().scheme(SchemeFlavor::Light),
"方案必须逐值等于选中卡"
);
match provenance.background() {
Outcome::Failed(Skip::LowChroma { chroma, .. }) => {
assert!((6.0..8.0).contains(&chroma), "实测彩度 {chroma}");
}
other => panic!("应当是低彩度失败,实际 {other:?}"),
}
}
#[test]
fn a_five_percent_accent_drives_level_one() {
// 端到端反证(与 `palette::seed::tests::end_to_end_picks_the_five_percent_accent` 配对):
// 95% 米白 + 5% 珊瑚的背景图**必须**在一级取色成功,且种子里就是那 5% 的珊瑚。
// 若 `analyze_rgba` 退化成「不透明像素求算术平均」,这条会变成「回落色卡」而红。
let beige = Color::rgb(0xF5, 0xF1, 0xEA);
let coral = Color::rgb(0xFF, 0x6B, 0x5A);
let mut pixels = Vec::with_capacity(40 * 40 * 4);
for index in 0..40 * 40 {
let color = if index < 40 * 40 - 80 { beige } else { coral };
pixels.extend_from_slice(&[color.r, color.g, color.b, color.a]);
}
let (scheme, provenance) = resolve_source(
Some(ImageInput::pixels(&pixels, 40, 40)),
None,
Some(CardKey::Rose),
SchemeFlavor::Light,
);
assert_eq!(provenance.level, Level::BackgroundImage, "{provenance:?}");
assert_eq!(provenance.background(), Outcome::Used);
let seed = provenance.seed.expect("一级成功必须带种子");
assert_eq!(seed.color, coral, "种子应当是 5% 的珊瑚");
assert!(provenance.hint_key().is_none());
// 方案来自珊瑚,而不是「回落到用户选的 Rose 卡」——两者 primary 不同。
assert_ne!(scheme, CardKey::Rose.card().scheme(SchemeFlavor::Light));
assert!(crate::palette::wcag::check(&scheme).is_clean());
}
#[test]
fn not_reached_is_distinguishable_from_not_provided() {
// 复审(色彩 r1 p2-2):`Absent` 曾同时表示「用户没提供」和「更高级成功所以没轮到」,
// UI 照字面写「色卡:未选择」就会与事实相反(用户明明选了卡,而且它的中性彩度参与了生成)。
let pixels = solid(Color::rgb(0xC0, 0x39, 0x2B), 4, 4);
// 一级成功:壁纸与色卡都是「没轮到」,但 card 仍然是用户选的那张。
let (_, provenance) = resolve_source(
Some(ImageInput::pixels(&pixels, 4, 4)),
Some(ImageInput::Undecodable), // 壁纸**提供了**,只是没轮到
Some(CardKey::Forest),
SchemeFlavor::Light,
);
assert_eq!(provenance.level, Level::BackgroundImage);
assert_eq!(provenance.wallpaper(), Outcome::NotReached);
assert_eq!(provenance.preset(), Outcome::NotReached);
assert_eq!(provenance.card, CardKey::Forest, "没轮到 ≠ 没选择");
// 真的没提供:背景图 `None` → Absent;壁纸成功 → 色卡 NotReached。
let (_, provenance) = resolve_source(
None,
Some(ImageInput::pixels(&pixels, 4, 4)),
Some(CardKey::Forest),
SchemeFlavor::Light,
);
assert_eq!(provenance.background(), Outcome::Absent);
assert_eq!(provenance.preset(), Outcome::NotReached);
assert_eq!(
provenance.reason_codes(),
Vec::<(&'static str, &'static str)>::new(),
"没提供 / 没轮到都不是「失败」,不该进原因码"
);
}
+207
View File
@@ -0,0 +1,207 @@
//! 主色簇 → 排序后的候选种子:官方 `Score` 的口径。
//!
//! ## 官方怎么挑「最好的那个颜色」
//!
//! `Score` 不是「像素最多的颜色」,而是三件事的加权:
//!
//! 1. **有人气**:颜色所在**色相桶**(1 度一桶)占全图的像素比例要够大
//! (`CUTOFF_EXCITED_PROPORTION = 1%` 以下的色相直接出局)。用色相桶而不是单色占比,
//! 是为了把「同一个色相的一族明暗」合成人气,而不是让它们互相拆票。
//! 2. **有颜色**:`CUTOFF_CHROMA = 5` 以下的颜色出局——灰、白、黑再多也不该当种子。
//! 3. **彩度越高越优先**,但**封顶**:`TARGET_CHROMA = 48` 以上不再加分(否则一张荧光图会被
//! 推到色域边缘,整套配色的 on-color 会难看得像报警灯)。
//!
//! 打分式(官方形状):
//!
//! ```text
//! excited = 该色所在色相桶的像素占比
//! chroma_weight = clamp((chroma − 5) / (48 − 5), 0, 1)
//! score = 0.7 × excited + 0.3 × chroma_weight
//! ```
//!
//! ## 来源等级
//!
//! - **A**:四个常数(`CUTOFF_CHROMA = 5`、`CUTOFF_EXCITED_PROPORTION = 0.01`、
//! `TARGET_CHROMA = 48`、`WEIGHT_PROPORTION/WEIGHT_CHROMA = 0.7/0.3`)与「按色相桶计人气」
//! 的做法都来自 `material-color-utilities` 的 `score/Score.java`。
//! - **B**:把 `excited` 直接当 `proportion` 项用(官方的 `Score` 内部还有一层
//! `hueOffset`/`huePopulation` 的除法归一,口径相同、写法不同)。本实现的选择是
//! 「只看色相桶占比」,因为它是能全单测的最简形式,且与官方在样例图上选出同一个色相。
//!
//! 注意与 D40 的彩度门槛([`super::MIN_SEED_CHROMA`] = 8)的分工:
//! **这里的 5 是「要不要把它当候选」,那里的 8 是「这个结果够不够格当种子」**。
//! 一张偏灰的图会有一堆彩度 5~7 的候选通过 `Score`,然后被 8 的门槛挡下并回落到色卡——
//! 这正是 D40 要的行为(宁可回落,不许凭空上色)。
use super::quantize::Cluster;
use crate::color::Hct;
/// 彩度下限:低于它的颜色不参与打分(官方 `Score.CUTOFF_CHROMA`)。
pub(crate) const CUTOFF_CHROMA: f64 = 5.0;
/// 色相桶人气下限(官方 `Score.CUTOFF_EXCITED_PROPORTION`)。
pub(crate) const CUTOFF_EXCITED_PROPORTION: f64 = 0.01;
/// 彩度加分封顶(官方 `Score.TARGET_CHROMA`)。
pub(crate) const TARGET_CHROMA: f64 = 48.0;
/// 人气项的权重(官方 `Score.WEIGHT_PROPORTION`)。
const WEIGHT_PROPORTION: f64 = 0.7;
/// 彩度项的权重(官方 `Score.WEIGHT_CHROMA`)。
const WEIGHT_CHROMA: f64 = 0.3;
/// 一个候选种子(含打分所需的中间量,方便测试与提示文案)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Ranked {
/// 候选簇。
pub cluster: Cluster,
/// 全图像素占比(0..1)。
pub share: f64,
/// 所在色相桶的像素占比(0..1,官方所谓 excited proportion)。
pub excited: f64,
/// CAM16 彩度。
pub chroma: f64,
/// CAM16 色相角。
pub hue: f64,
/// 最终打分(越大越优先)。
pub score: f64,
}
/// 按官方 `Score` 口径排序候选;被过滤掉的簇不出现在结果里。
pub(crate) fn rank(clusters: &[Cluster]) -> Vec<Ranked> {
let total: u64 = clusters.iter().map(|c| u64::from(c.population)).sum();
if total == 0 {
return Vec::new();
}
let hcts: Vec<Hct> = clusters.iter().map(|c| Hct::from_color(c.color)).collect();
let mut hue_population = [0u64; 360];
for (cluster, hct) in clusters.iter().zip(&hcts) {
hue_population[hue_bucket(hct.hue)] += u64::from(cluster.population);
}
let mut ranked: Vec<Ranked> = clusters
.iter()
.zip(&hcts)
.filter_map(|(cluster, hct)| {
if hct.chroma < CUTOFF_CHROMA {
return None;
}
let share = f64::from(cluster.population) / total as f64;
let excited = hue_population[hue_bucket(hct.hue)] as f64 / total as f64;
if excited < CUTOFF_EXCITED_PROPORTION {
return None;
}
let chroma_weight =
((hct.chroma - CUTOFF_CHROMA) / (TARGET_CHROMA - CUTOFF_CHROMA)).clamp(0.0, 1.0);
Some(Ranked {
cluster: *cluster,
share,
excited,
chroma: hct.chroma,
hue: hct.hue,
score: WEIGHT_PROPORTION * excited + WEIGHT_CHROMA * chroma_weight,
})
})
.collect();
ranked.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
// 同分时像素多的优先,再同则按色值定序 → 结果完全确定。
.then_with(|| b.cluster.population.cmp(&a.cluster.population))
.then_with(|| a.cluster.color.r.cmp(&b.cluster.color.r))
.then_with(|| a.cluster.color.g.cmp(&b.cluster.color.g))
.then_with(|| a.cluster.color.b.cmp(&b.cluster.color.b))
});
ranked
}
/// 彩度最高的那个簇——被 `Score` 全部过滤时用它报「这张图最鲜艳的颜色」。
///
/// ⚠️ 它**不是**「最接近及格的那一族」:`Score` 的过滤有两道(彩度 ≥5、色相桶人气 ≥1%),
/// 面积不足 1% 的高彩度颜料会被**人气**刷掉——例如 1592 个米白 + 8 个纯红的图,
/// 候选为空而这里的「最鲜艳」彩度是 **113.36**。所以调用方的文案要按
/// 「全图最鲜艳的那一族」,不是「差一点就及格的那一族」。
///
/// 取色失败(`Failure::LowChroma`)时**色值与彩度都从这一个簇取**,保证二者成对:
/// 之前的两条路径一条报 `Score` 榜首、一条报「彩度最高」,而后者还拿着**另一个**簇的色值,
/// 于是同一个 `LowChroma` 里的 `color` 与 `chroma` 可能不是一回事(复审 r1 p2-4.2)。
///
/// 同彩度时取像素多的那一个(结果确定,不受簇顺序影响)。
pub(crate) fn most_chromatic(clusters: &[Cluster]) -> Option<&Cluster> {
clusters.iter().max_by(|a, b| {
let key = |cluster: &Cluster| (Hct::from_color(cluster.color).chroma, cluster.population);
let (ca, pa) = key(a);
let (cb, pb) = key(b);
ca.partial_cmp(&cb)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| pa.cmp(&pb))
})
}
fn hue_bucket(hue: f64) -> usize {
let wrapped = hue.rem_euclid(360.0);
(wrapped.round() as usize) % 360
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::Color;
fn cluster(hex: &str, population: u32) -> Cluster {
Cluster {
color: Color::from_hex(hex).expect("合法 hex"),
population,
}
}
#[test]
fn accent_with_small_area_but_high_chroma_transfers_to_seed() {
// 95% 米白 + 5% 珊瑚:米白被彩度门槛挡掉,珊瑚当种子。
let clusters = [cluster("#F5F1EA", 950), cluster("#FF6B5A", 50)];
let ranked = rank(&clusters);
assert_eq!(ranked.len(), 1, "{ranked:?}");
assert_eq!(ranked[0].cluster.color.to_hex(), "#FF6B5A");
}
#[test]
fn grayscale_clusters_are_all_filtered() {
let clusters = [
cluster("#FFFFFF", 1000),
cluster("#808080", 400),
cluster("#0A0A0A", 300),
];
assert!(rank(&clusters).is_empty());
let most = most_chromatic(&clusters).expect("灰图也有簇");
assert!(Hct::from_color(most.color).chroma < CUTOFF_CHROMA);
}
#[test]
fn tie_break_is_deterministic() {
let clusters = [cluster("#3366CC", 100), cluster("#2B6DD0", 100)];
let first = rank(&clusters);
let second = rank(&clusters);
assert_eq!(first.len(), 2);
assert_eq!(
first.iter().map(|r| r.cluster.color).collect::<Vec<_>>(),
second.iter().map(|r| r.cluster.color).collect::<Vec<_>>()
);
}
#[test]
fn tiny_population_hue_is_ignored() {
// 0.5% 的青绿被 excited proportion 门槛(1%)挡掉。
let clusters = [cluster("#FF0000", 995), cluster("#00C8B4", 5)];
let ranked = rank(&clusters);
assert_eq!(ranked.len(), 1);
assert_eq!(ranked[0].cluster.color.to_hex(), "#FF0000");
}
#[test]
fn hue_bucket_wraps() {
assert_eq!(hue_bucket(0.0), 0);
assert_eq!(hue_bucket(359.6), 0);
assert_eq!(hue_bucket(-1.0), 359);
assert_eq!(hue_bucket(720.4), 0);
}
}
+267
View File
@@ -0,0 +1,267 @@
//! 已解码的 RGBA 像素 → 一个**种子色**(D38 第一/二级的入口,D40 的 `crates/design` 半边)。
//!
//! 这是整个取色链在 `crates/design` 里的**唯一公开入口**:零 IO、零第三方依赖,
//! 只吃「调用方已经解码好的像素」。解码(PNG/JPEG/WebP…)在 `crates/palette-extract`,
//! 分层理由见 D40。
//!
//! ```no_run
//! use unlucky_design::color::{Scheme, SchemeFlavor};
//! use unlucky_design::palette::seed_from_rgba;
//!
//! // 一张 2×2 的天蓝图(调用方负责解码)
//! let pixels: [u8; 16] = [
//! 0x3C, 0x8D, 0xBC, 0xFF, 0x3C, 0x8D, 0xBC, 0xFF,
//! 0x3C, 0x8D, 0xBC, 0xFF, 0x3C, 0x8D, 0xBC, 0xFF,
//! ];
//! let seed = seed_from_rgba(&pixels, 2, 2).expect("纯色图有确定的种子");
//! let scheme = seed.scheme(SchemeFlavor::Light, 12.0);
//! assert!(scheme.contrast(unlucky_design::color::Role::OnPrimary, unlucky_design::color::Role::Primary) >= 4.5);
//! ```
//!
//! ## 五类输入的处置(每类都有单测)
//!
//! | 输入 | 处置 | 为什么 |
//! |---|---|---|
//! | 过小图(< 4 像素) | [`Failure::TooSmall`] | 1~3 个像素没有「主色」概念,任一个噪点都会变成整套配色 |
//! | 纯色图(有彩度) | 正常取色 | 单色图只要够鲜艳就是合格的种子(不做特殊化) |
//! | 灰度/灰白图 | [`Failure::LowChroma`](**不是**返回青灰色) | D40 实测:零彩度种子的 CAM16 色相恒为 209.49°,硬取色会把灰白壁纸「凭空上色」成青色 `#006874`;极低彩度段色相还是噪声放大器(`#808080` → 209.5°、`#7A7C7E` → 231.2°,摆 22°) |
//! | 超大图(>65536 像素) | 先按 4^n 块做**块平均**降采样,再量化 | 取色只需要「色彩分布」,不需要分辨率;块平均能保住细纹理的平均色,避免等距抽样在条纹/网格图上走样 |
//! | 混合透明度(一半不透明一半全透明) | **先按 alpha 过滤、再块平均**(整块全透明则跳过) | 「先平均再比门槛」会把 `255/2 = 127 < 128` 判成全透明,于是 13 万个不透明像素被报成 `NoOpaquePixels`——复审实测踩到的坑 |
//! | 解码失败 | 不在本 crate:见 `crates/palette-extract` | D40 分层 |
//!
//! 阈值 [`MIN_SEED_CHROMA`] = 8 是 **D40 的定案值**(`seed chroma < ~8 视为无信息量`),
//! 与 [`super::score::CUTOFF_CHROMA`] = 5 分工不同:5 决定「能不能当候选」,8 决定
//! 「这个候选够不够格当种子」。落在这条缝里(5~8)的图会有一堆候选,最终仍然回落色卡。
use super::quantize;
use super::score;
use crate::color::{Color, Palettes, Scheme, SchemeFlavor};
/// 最小像素数:低于它的图没有「主色」可言。
pub const MIN_PIXELS: u32 = 4;
/// 参与量化的最大采样点数(块的边长按它反推)。
pub const MAX_SAMPLES: usize = 65_536;
/// D40 的种子彩度门槛:低于它视为「这张图没有可用的颜色信息」。
pub const MIN_SEED_CHROMA: f64 = 8.0;
/// 「低彩度但可用」的第二道线:落在这条线下的种子会被标成 [`Seed::is_muted`],
/// UI 可以提示「从这张图取到的颜色偏灰」。
pub const MUTED_SEED_CHROMA: f64 = 16.0;
/// alpha 低于它的像素不参与取色(半透明叠加会让颜色变成「混了背景的假色」)。
const ALPHA_THRESHOLD: u8 = 128;
/// 取色结果:种子色 + 足以让 UI 解释「为什么是这个颜色」的观感数据。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Seed {
/// 种子色。
///
/// **只对「传进 [`analyze_rgba`] 的那个缓冲」作保证**:像素数 ≤ [`MAX_SAMPLES`] 时不做块
/// 平均,种子是缓冲里真实存在的颜色(代表色取「簇内像素最多的桶里像素原值平均」,
/// 纯色图逐通道精确);更大的缓冲会先块平均,此时可能是平均出来的新颜色。
///
/// ⚠️ 整条链上**任何一次缩放**都会破坏「真实颜色」,不只是这一层:`crates/palette-extract`
/// 在超过 `MAX_EDGE` 时先用 `thumbnail` 缩放。实测 400×400 红蓝棋盘(每像素交替):
/// `MAX_EDGE = 256`(适配层缩放)与 `MAX_EDGE = 512`(本层块平均)都取到 `#704070`,
/// 这个色值在原图上**一个像素都不是**。降采样本来就要平均(顺带降噪),所以这是
/// **有意的行为**;不要把它当「绝不出现平均色」的保证,也别拿它判断「这个色在壁纸里存在」。
pub color: Color,
/// 种子的 CAM16 色相角(度)。
pub hue: f64,
/// 种子的 CAM16 彩度。
pub chroma: f64,
/// 该色所在色相族占**有效采样点**的比例(0..1)。
///
/// 分母是采样点(块平均后的点、且已剔除透明像素),不是全图像素数。所以只有
/// 「无透明 + 像素数 ≤ [`MAX_SAMPLES`]」时它才等于「占全图比例」;大图(有降采样)
/// 或有透明区域时只能说「占有效采样点的比例」。
pub share: f64,
}
impl Seed {
/// 种子彩度偏低(可用但发灰)——UI 可提示「这张图颜色较淡」。
pub fn is_muted(self) -> bool {
self.chroma < MUTED_SEED_CHROMA
}
/// 用这个种子生成一套完整调色板(中性彩度由调用方给,通常沿用用户当前选中色卡的取值)。
pub fn palettes(self, neutral_chroma: f64) -> Palettes {
Palettes::from_seed_with_neutral_chroma(self.color, neutral_chroma)
}
/// 用这个种子生成一套完整方案(49 个角色)。
pub fn scheme(self, flavor: SchemeFlavor, neutral_chroma: f64) -> Scheme {
Scheme::from_palettes(&self.palettes(neutral_chroma), flavor)
}
}
/// 取色失败的原因。**每一种都要能变成一句人话**(Provenance 用它生成提示)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Failure {
/// 像素缓冲长度与 `width × height × 4` 不符(调用方给错了数据)。
BadDimensions {
/// 宽。
width: u32,
/// 高。
height: u32,
/// 期望的字节数(`width × height × 4`)。
expected: usize,
/// 实际拿到的字节数。
actual: usize,
},
/// 图太小(< [`MIN_PIXELS`] 像素)。
TooSmall {
/// 宽。
width: u32,
/// 高。
height: u32,
},
/// 全是透明/半透明像素,没有可用的不透明像素。
NoOpaquePixels,
/// 彩度不足:`chroma < MIN_SEED_CHROMA`,硬取色会凭空上色。
///
/// `color` 与 `chroma` **永远来自同一个簇**(复审 r1 p2-4.2 定的口径):
/// - 有候选(通过 `Score` 的彩度 ≥5 与人气 ≥1% 过滤)但最强候选仍 `< 8` →
/// 报 `Score` **榜首**的色值与彩度(它是「本次能拿到的最好候选」);
/// - 一个候选都没有 → 报**彩度最高**的那一簇。
/// 这条**不是「灰图专属」**:`Score` 除了彩度 ≥5 还要求所在色相桶的人气 ≥1%,
/// 所以「高彩度但面积太小」也会走到这里。实测(复审探针):40×40、1592 个米白
/// `#F5F1EA` + 8 个纯红 `#FF0000`(0.5% 面积)→ 报回来的彩度是 **113.36**
/// (`#FF0000` 的 CAM16 彩度,见 `color::hct` 的对账表)。
///
/// ⚠️ 因此这个数字**不受候选门槛 5 约束**,可能远高于 8。UI 别把它写成
/// 「彩度 X < 门槛 8」:真正的失败原因是「没有一族**面积够大**的颜色」。文案用
/// 「颜色太淡 / 太杂」即可;要显示数字也只在 `chroma < CUTOFF_CHROMA(5)` 时显示
/// (那时的含义才是「整张图没颜色」,即纯灰图)。
LowChroma {
/// 取色失败时最接近可用的候选色(提示文案里可以显示成一个小色块)。
///
/// 注意它不一定「能用」:高彩度但面积很小的颜料(如 0.5% 面积的纯红)也会出现在这里,
/// 见 [`Failure::LowChroma`] 的说明。
color: Color,
/// 该色的实测 CAM16 彩度(与 `color` 成对)。
chroma: f64,
},
}
impl Failure {
/// 稳定原因码(写日志 / 存设置 / 做提示映射的键,别用 `Debug` 输出)。
pub const fn code(self) -> &'static str {
match self {
Failure::BadDimensions { .. } => "bad-dimensions",
Failure::TooSmall { .. } => "too-small",
Failure::NoOpaquePixels => "no-opaque-pixels",
Failure::LowChroma { .. } => "low-chroma",
}
}
}
/// 取色并给出**完整原因**(推荐入口:失败时能生成提示)。
pub fn analyze_rgba(pixels: &[u8], width: u32, height: u32) -> Result<Seed, Failure> {
let total = (width as usize).saturating_mul(height as usize);
if total < MIN_PIXELS as usize {
return Err(Failure::TooSmall { width, height });
}
let expected = total.saturating_mul(4);
if pixels.len() != expected {
return Err(Failure::BadDimensions {
width,
height,
expected,
actual: pixels.len(),
});
}
// 采样点已经是「只由不透明像素算出的块平均」(见 [`sample_blocks`]),这里不再按 alpha 过滤。
let samples = sample_blocks(pixels, width as usize, height as usize);
if samples.is_empty() {
return Err(Failure::NoOpaquePixels);
}
let clusters = quantize::quantize(&samples);
let ranked = score::rank(&clusters);
match ranked.first() {
Some(best) if best.chroma >= MIN_SEED_CHROMA => Ok(Seed {
color: best.cluster.color,
hue: best.hue,
chroma: best.chroma,
share: best.excited,
}),
// 有候选但都不够鲜艳:报 `Score` 榜首的色值与彩度(`Score` 已经按人气 × 彩度排过序,
// 它就是「本次能拿到的最好候选」)。
Some(best) => Err(Failure::LowChroma {
color: best.cluster.color,
chroma: best.chroma,
}),
// 一个候选都没有:报**彩度最高**的那一族,色值与彩度成对。
// 这不等于「整张图没颜色」——1592 米白 + 8 个纯红的图也走这里(红色面积 0.5% 被人气
// 门槛淘汰),报回来的彩度是 113.36。真实失败原因是「没有一族面积够大的颜色」。
None => {
let closest = score::most_chromatic(&clusters);
Err(Failure::LowChroma {
color: closest
.map(|cluster| cluster.color)
.unwrap_or(Color::rgb(0x80, 0x80, 0x80)),
chroma: closest
.map(|cluster| crate::color::Hct::from_color(cluster.color).chroma)
.unwrap_or(0.0),
})
}
}
}
/// D40 的入口形状:`Option<Seed>`(`None` = 这张图没有可用的颜色信息)。
///
/// 需要知道「为什么没有」就用 [`analyze_rgba`]。
pub fn seed_from_rgba(pixels: &[u8], width: u32, height: u32) -> Option<Seed> {
analyze_rgba(pixels, width, height).ok()
}
/// 块平均降采样:块边长按「不超过 [`MAX_SAMPLES`] 个采样点」反推,小图不降采样(块边长 1)。
///
/// **先按 alpha 过滤、再求平均**:块内只累加 `a >= ALPHA_THRESHOLD` 的像素,整块没有有效像素
/// 就直接跳过(不产出采样点)。
///
/// 为什么不能「先平均、再拿平均 alpha 跟门槛比」:块内 alpha 用整数除法截断,
/// 一半不透明一半透明的块平均下来是 `255/2 = 127 < 128`,于是**整张图被判成「没有不透明像素」**
/// (复审实测:512×512 里 131072 个完全不透明像素 → `Err(NoOpaquePixels)`)。
/// 副作用是「半透明像素的颜色」也被排除在外——这是有意的:半透明像素的 RGB 常常是
/// 未预乘的原始值(有些解码器给的是 0,0,0),混进来会把种子拽向黑色。
fn sample_blocks(pixels: &[u8], width: usize, height: usize) -> Vec<Color> {
let mut block = 1usize;
while width.div_ceil(block) * height.div_ceil(block) > MAX_SAMPLES {
block += 1;
}
let mut samples = Vec::with_capacity(width.div_ceil(block) * height.div_ceil(block));
let mut top = 0usize;
while top < height {
let bottom = (top + block).min(height);
let mut left = 0usize;
while left < width {
let right = (left + block).min(width);
let mut sum = [0u64; 3];
let mut count = 0u64;
for y in top..bottom {
for x in left..right {
let offset = (y * width + x) * 4;
if pixels[offset + 3] < ALPHA_THRESHOLD {
continue;
}
for channel in 0..3 {
sum[channel] += u64::from(pixels[offset + channel]);
}
count += 1;
}
}
if count > 0 {
let average = |channel: usize| (sum[channel] / count) as u8;
samples.push(Color::rgb(average(0), average(1), average(2)));
}
left = right;
}
top = bottom;
}
samples
}
#[cfg(test)]
mod tests;
+254
View File
@@ -0,0 +1,254 @@
//! `seed` 模块的单测:五类输入各自的处置 + **端到端反证**(不许退化成平均色)。
//!
//! 为什么端到端测试必须在这里(而不是只测 `quantize`/`score`):那两条单测手工构造
//! `Cluster` 直接喂中间层,**绕过了 `analyze_rgba` 的接线**。把 `analyze_rgba` 换成
//! 「不透明像素求算术平均」时,中间层单测照样绿,只有这里会红。
use super::*;
/// 生成 `width × height` 的全 `color` 图。
fn solid(color: Color, width: u32, height: u32) -> Vec<u8> {
let mut out = Vec::with_capacity((width * height * 4) as usize);
for _ in 0..(width * height) {
out.extend_from_slice(&[color.r, color.g, color.b, color.a]);
}
out
}
/// 生成「其余是 `base`,`accent_pixels` 个像素是 `accent`」的图(强调色排在末尾)。
///
/// 这是**反证「不许取平均色」**的输入构造器:强调色面积小到平均色会被底色吃掉,
/// 但它是人眼会记住、能当强调色用的那一小撮。
fn two_tone(width: u32, height: u32, base: Color, accent: Color, accent_pixels: usize) -> Vec<u8> {
let total = (width * height) as usize;
assert!(accent_pixels <= total);
let mut out = Vec::with_capacity(total * 4);
for index in 0..total {
let color = if index < total - accent_pixels {
base
} else {
accent
};
out.extend_from_slice(&[color.r, color.g, color.b, color.a]);
}
out
}
#[test]
fn end_to_end_picks_the_five_percent_accent() {
// ⚠️ 端到端反证:这条必须走 `analyze_rgba` 的**完整管线**(量化 → 打分 → 彩度门槛),
// 不许像 `quantize`/`score` 的单测那样手工构造 `Cluster` 直接喂中间层。
// 如果 `analyze_rgba` 被改成「不透明像素求算术平均」,平均值会是米白(chroma ≈4)→
// 返回 `LowChroma` 而不是种子,这条立刻红。
let beige = Color::from_hex("#F5F1EA").expect("合法 hex");
let coral = Color::from_hex("#FF6B5A").expect("合法 hex");
let pixels = two_tone(40, 40, beige, coral, 80); // 95% 米白 + 5% 珊瑚
let seed = analyze_rgba(&pixels, 40, 40).expect("5% 的珊瑚应当被选中当种子");
assert_eq!(
seed.color,
coral,
"种子必须是珊瑚(实际 {});米白占比 95% 但它不是「主色」",
seed.color.to_hex()
);
assert!(seed.chroma > 40.0, "珊瑚彩度应当很高,实测 {}", seed.chroma);
// 与「平均色」泾渭分明:原图的算术平均值是米白,色相/彩度都差一整个量级。
assert!(!seed.is_muted());
}
#[test]
fn end_to_end_picks_the_three_percent_accent() {
// 面积再降到 3%(48/1600):`Score` 的 1% 人气门槛仍然放行,平均色更不可能靠巧合命中。
let beige = Color::from_hex("#F5F1EA").expect("合法 hex");
let orange = Color::from_hex("#FF8800").expect("合法 hex");
let pixels = two_tone(40, 40, beige, orange, 48);
let seed = analyze_rgba(&pixels, 40, 40).expect("3% 的橙应当被选中当种子");
assert_eq!(seed.color, orange, "实际 {}", seed.color.to_hex());
assert!(seed.chroma > 40.0, "彩度 {}", seed.chroma);
}
#[test]
fn solid_vivid_image_yields_seed() {
let pixels = solid(Color::rgb(0x3C, 0x8D, 0xBC), 4, 4);
let seed = analyze_rgba(&pixels, 4, 4).expect("天蓝纯色图有种子");
assert_eq!(seed.color, Color::from_hex("#3C8DBC").expect("合法 hex"));
assert!(seed.chroma > MIN_SEED_CHROMA);
assert!((seed.share - 1.0).abs() < 1.0e-6);
assert!(!seed.is_muted());
}
#[test]
fn gray_white_image_is_rejected_with_measured_chroma() {
// D40 的反证:灰白壁纸硬取色会得到青色 #006874,这里必须拒绝并报出彩度。
let pixels = solid(Color::rgb(0xF2, 0xF2, 0xF0), 8, 8);
match analyze_rgba(&pixels, 8, 8) {
Err(Failure::LowChroma { chroma, .. }) => {
assert!(chroma < MIN_SEED_CHROMA, "彩度 {chroma} 不该过门槛");
}
other => panic!("灰白图必须被拒,实际 {other:?}"),
}
assert_eq!(seed_from_rgba(&pixels, 8, 8), None);
}
#[test]
fn neutral_gray_hash_rejects_instead_of_inventing_cyan() {
// 与 M70 复审实测的坏味道对齐:#808080 的 CAM16 色相 209.5°,硬取色会得到青色。
let pixels = solid(Color::rgb(0x80, 0x80, 0x80), 4, 4);
assert_eq!(seed_from_rgba(&pixels, 4, 4), None);
}
#[test]
fn tiny_images_are_too_small() {
let pixels = solid(Color::rgb(0xFF, 0x00, 0x00), 1, 3);
assert_eq!(
analyze_rgba(&pixels, 1, 3),
Err(Failure::TooSmall {
width: 1,
height: 3
})
);
assert_eq!(
Failure::TooSmall {
width: 1,
height: 3
}
.code(),
"too-small"
);
}
#[test]
fn wrong_buffer_length_is_reported() {
let pixels = solid(Color::rgb(0xFF, 0x00, 0x00), 2, 2);
match analyze_rgba(&pixels[..12], 2, 2) {
Err(Failure::BadDimensions {
expected, actual, ..
}) => {
assert_eq!((expected, actual), (16, 12));
}
other => panic!("应当报维度错误,实际 {other:?}"),
}
}
#[test]
fn fully_transparent_image_has_no_seed() {
let pixels = solid(Color::rgba(0x3C, 0x8D, 0xBC, 0), 4, 4);
assert_eq!(analyze_rgba(&pixels, 4, 4), Err(Failure::NoOpaquePixels));
}
#[test]
fn half_transparent_image_keeps_its_opaque_colour() {
// 复审 p2-1 的反证:512×512、一半完全不透明砖红、一半全透明(RGB=0)交错。
// 修复前「先块平均、再拿平均 alpha 比 128」:块内 255 与 0 平均被整数除法截成 127 < 128,
// 于是全部 131072 个不透明像素被判成「没有不透明像素」→ Err(NoOpaquePixels)。
// 修复后(先过滤再平均)必须取到砖红本身。
let (width, height) = (512usize, 512usize);
let mut pixels = Vec::with_capacity(width * height * 4);
for index in 0..width * height {
if index % 2 == 0 {
pixels.extend_from_slice(&[0xC0, 0x39, 0x2B, 0xFF]);
} else {
pixels.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
}
}
let seed = analyze_rgba(&pixels, 512, 512).expect("一半不透明像素必须能取色");
assert_eq!(seed.color, Color::rgb(0xC0, 0x39, 0x2B));
assert!(seed.chroma > 40.0, "彩度 {}", seed.chroma);
}
#[test]
fn mostly_transparent_image_still_samples_the_visible_part() {
// 90% 全透明 + 10% 不透明绿:可见部分够鲜艳就该取到它,而不是报「没有不透明像素」。
let (width, height) = (64usize, 64usize);
let mut pixels = Vec::with_capacity(width * height * 4);
for index in 0..width * height {
if index % 10 == 0 {
pixels.extend_from_slice(&[0x2E, 0x7D, 0x4F, 0xFF]);
} else {
pixels.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
}
}
let seed = analyze_rgba(&pixels, 64, 64).expect("10% 可见像素必须能取色");
assert_eq!(seed.color, Color::rgb(0x2E, 0x7D, 0x4F));
}
#[test]
fn large_image_is_downsampled_and_still_deterministic() {
let (width, height) = (512_u32, 512_u32);
let mut pixels = Vec::with_capacity((width * height * 4) as usize);
for y in 0..height {
for x in 0..width {
// 细网格:等距抽样会走样,块平均能得到稳定的底色。
let checker = (x + y) % 2 == 0;
pixels.extend_from_slice(if checker {
&[0xD2, 0x3B, 0x2A, 0xFF]
} else {
&[0x2A, 0x3B, 0xD2, 0xFF]
});
}
}
let first = analyze_rgba(&pixels, width, height).expect("大图有种子");
let second = analyze_rgba(&pixels, width, height).expect("可复现");
assert_eq!(first, second);
let samples = sample_blocks(&pixels, width as usize, height as usize);
assert!(
samples.len() <= MAX_SAMPLES,
"采样点 {} 超标",
samples.len()
);
// 块平均把红蓝网格合成偏紫的中间色,而不是撞上纯红或纯蓝之一。
assert!(first.chroma > MIN_SEED_CHROMA, "{first:?}");
}
#[test]
fn block_sampling_averages_instead_of_picking() {
// 2×2 红蓝棋盘,块边长 1(图很小)→ 4 个采样点;只验证采样点数量与顺序稳定。
let pixels = [
0xFF, 0x00, 0x00, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x00, 0x00,
0xFF,
];
let samples = sample_blocks(&pixels, 2, 2);
assert_eq!(samples.len(), 4);
assert_eq!(samples[0], Color::rgb(0xFF, 0x00, 0x00));
assert_eq!(samples[1], Color::rgb(0x00, 0x00, 0xFF));
}
#[test]
fn seed_scheme_meets_wcag_on_the_primary_pair() {
let pixels = solid(Color::rgb(0xC0, 0x39, 0x2B), 4, 4);
let seed = analyze_rgba(&pixels, 4, 4).expect("砖红有种子");
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let scheme = seed.scheme(flavor, 12.0);
let ratio = scheme.contrast(crate::color::Role::OnPrimary, crate::color::Role::Primary);
assert!(ratio >= 4.5, "{flavor:?} 只有 {ratio}");
}
}
#[test]
fn chroma_just_below_the_gate_is_rejected() {
// 把 D40 的口径(`MIN_SEED_CHROMA` = 8,「< ~8 视为无信息量」)钉成可执行的断言:
// 图里的颜色实测 chroma ≈7.23 —— 落在 `Score` 的候选门槛(5)之上、D40 的种子门槛(8)之下。
// 这类图必须**回落**,而不是「勉强取一个低彩度种子」。
//
// 变异反证:把 `MIN_SEED_CHROMA` 从 8.0 改成 6.0,这个种子会通过门槛 → 本条变红。
let faint = Color::from_hex("#7E8489").expect("合法 hex");
let chroma = crate::color::Hct::from_color(faint).chroma;
assert!(
(6.0..8.0).contains(&chroma),
"这条测试依赖「彩度落在 6~8 之间」,实测 {chroma}"
);
let pixels = solid(faint, 16, 16);
match analyze_rgba(&pixels, 16, 16) {
Err(Failure::LowChroma {
chroma: measured, ..
}) => {
assert!(
(6.0..MIN_SEED_CHROMA).contains(&measured),
"报回来的彩度 {measured} 应当仍在门槛之下"
);
}
other => panic!("彩度 {chroma} 必须回落,实际 {other:?}"),
}
}
+281
View File
@@ -0,0 +1,281 @@
//! 对比度校验与**收敛**:任意一套方案(不管种子来自图片还是色卡)都要过同一道门。
//!
//! 为什么需要这一层:`tests/contrast_aa.rs` 的 208 项门禁只覆盖**候选色卡**(它拿 6 个手挑的
//! 种子逐对断言)。D38/D40 之后「方案生成」还可能吃图片来的种子,而远程证据不能只靠
//! 「tone 轴固定所以一定达标」这句话——那条性质对本 crate 的生成器成立,但一旦有人手动改角色、
//! 或者将来接别的生成器(比如非 TonalSpot 变体),门禁必须当场发现。所以:
//!
//! 1. [`check`] 对**任何** [`Scheme`] 跑全表(26 对文字/图标对,AA ≥ 4.5:1);
//! 2. [`converge`] 不达标就**沿 tone 轴修正前景色**(保住色相,只动明度),并把每一处修正
//! 连同「改前/改后的实测对比度」记进 [`Adjustment`],让 UI 能说「为了可读性微调了强调色」;
//! 3. 修正后仍然不达标的方案,由 [`super::resolve`] 回落到色卡并在 `Provenance` 里登记原因。
//!
//! ## 为什么只改前景、不改背景
//!
//! 背景(`surface` / `*-container`)决定整个界面的骨架与层级,改它会让「同一套方案的两个页面
//! 底色不一样」;前景(`on-*`)本来就是为可读性服务的,动它代价最小。方向也不是猜的:
//! 背景 tone ≥ 50(浅底)就压暗前景,否则提亮前景——修正方向永远朝着「离背景更远」的那一侧。
//!
//! ## 只做 4.5:1 这一档,`outline` 不在门禁里
//!
//! M3 里 `outline` 是非文字元素(输入框描边),规范目标是 3:1,且它按设计就贴着 surface;
//! `outline-variant` 是纯装饰分隔线。这两个的例外登记在 `crates/design/src/color/mod.rs` 的
//! 「对比度纪律」小节,本模块不重复断言(重复断言只会让两边口径打架)。
use crate::color::{Color, Hct, Role, Scheme};
/// 正文/图标对的 AA 门槛(WCAG 2.x:普通文字 4.5:1)。
pub const AA_TEXT: f32 = 4.5;
/// 非文字元素的门槛(描边/图标块 3:1)——本模块不自动收敛这一档,只提供常量给调用方参考。
pub const AA_NON_TEXT: f32 = 3.0;
/// 文字/图标对(前景, 承载色)。与 `tests/contrast_aa.rs` 的 `TEXT_PAIRS` 同表。
pub const TEXT_PAIRS: [(Role, Role); 26] = [
(Role::OnPrimary, Role::Primary),
(Role::OnPrimaryContainer, Role::PrimaryContainer),
(Role::OnSecondary, Role::Secondary),
(Role::OnSecondaryContainer, Role::SecondaryContainer),
(Role::OnTertiary, Role::Tertiary),
(Role::OnTertiaryContainer, Role::TertiaryContainer),
(Role::OnError, Role::Error),
(Role::OnErrorContainer, Role::ErrorContainer),
(Role::OnSurface, Role::Surface),
(Role::OnSurfaceVariant, Role::SurfaceVariant),
(Role::OnBackground, Role::Background),
(Role::InverseOnSurface, Role::InverseSurface),
(Role::InversePrimary, Role::InverseSurface),
(Role::OnPrimaryFixed, Role::PrimaryFixed),
(Role::OnPrimaryFixedVariant, Role::PrimaryFixedDim),
(Role::OnSecondaryFixed, Role::SecondaryFixed),
(Role::OnSecondaryFixedVariant, Role::SecondaryFixedDim),
(Role::OnTertiaryFixed, Role::TertiaryFixed),
(Role::OnTertiaryFixedVariant, Role::TertiaryFixedDim),
(Role::OnSurface, Role::SurfaceContainerLowest),
(Role::OnSurface, Role::SurfaceContainerLow),
(Role::OnSurface, Role::SurfaceContainer),
(Role::OnSurface, Role::SurfaceContainerHigh),
(Role::OnSurface, Role::SurfaceContainerHighest),
(Role::OnSurfaceVariant, Role::SurfaceContainerHighest),
(Role::OnSurfaceVariant, Role::SurfaceContainerLow),
];
/// 一处不达标。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Failure {
/// 前景角色。
pub fg: Role,
/// 承载色角色。
pub bg: Role,
/// 实测对比度。
pub ratio: f32,
/// 要求的下限(目前恒为 [`AA_TEXT`])。
pub required: f32,
}
/// 一套方案的对比度体检结果。
#[derive(Debug, Clone, PartialEq)]
pub struct WcagReport {
/// 全部不达标项(空 = 通过)。
pub failures: Vec<Failure>,
/// 全表里最低的对比度(用来观察「刚好卡线」的方案)。
pub worst: f32,
}
impl WcagReport {
/// 是否全部达标。
pub fn is_clean(&self) -> bool {
self.failures.is_empty()
}
}
/// 对任意方案跑全表断言。
pub fn check(scheme: &Scheme) -> WcagReport {
let mut failures = Vec::new();
let mut worst = f32::INFINITY;
for (fg, bg) in TEXT_PAIRS {
let ratio = scheme.contrast(fg, bg);
worst = worst.min(ratio);
if ratio < AA_TEXT {
failures.push(Failure {
fg,
bg,
ratio,
required: AA_TEXT,
});
}
}
WcagReport { failures, worst }
}
/// 一处自动修正的记录(UI 可以拿去写「为可读性微调了强调色」)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Adjustment {
/// 被改的角色。
pub role: Role,
/// 为了跟谁达标而改的(承载色角色)。
pub against: Role,
/// 改前的色值。
pub before: Color,
/// 改后的色值。
pub after: Color,
/// 改前的对比度。
pub before_ratio: f32,
/// 改后的对比度。
pub after_ratio: f32,
}
/// 不达标就沿 tone 轴修正,返回(修正后的方案, 修正记录)。
///
/// 最多三遍:一遍修正后可能让「同一个前景对另一个承载色」变得不达标(例如 `on-surface`
/// 同时压在 6 个 surface-container 上),多遍保证收敛。修不动的情况(理论上不存在:纯黑/纯白
/// 对任意背景都 ≥ 4.58:1)会保留原值,由调用方用 [`check`] 复核后决定是否回落。
pub fn converge(scheme: Scheme) -> (Scheme, Vec<Adjustment>) {
let mut colors = [Color::BLACK; 49];
for (role, color) in scheme.iter() {
colors[role as usize] = color;
}
let mut adjustments = Vec::new();
for _ in 0..3 {
let mut changed = false;
for (fg, bg) in TEXT_PAIRS {
let before = colors[fg as usize];
let background = colors[bg as usize];
let before_ratio = before.contrast_ratio(background);
if before_ratio >= AA_TEXT {
continue;
}
let Some(after) = shift_away_from(before, background, AA_TEXT) else {
continue;
};
colors[fg as usize] = after;
adjustments.push(Adjustment {
role: fg,
against: bg,
before,
after,
before_ratio,
after_ratio: after.contrast_ratio(background),
});
changed = true;
}
if !changed {
break;
}
}
(Scheme::from_colors(scheme.flavor(), colors), adjustments)
}
/// 保住色相、压掉彩度地往「离背景更远」的 tone 走,直到达标或到尽头。
fn shift_away_from(foreground: Color, background: Color, min_ratio: f32) -> Option<Color> {
let hct = Hct::from_color(foreground);
let darken = background.lstar() >= 50.0;
let step = if darken { -4.0 } else { 4.0 };
let mut tone = hct.tone.round();
for _ in 0..=25 {
tone += step;
if !(0.0..=100.0).contains(&tone) {
break;
}
// 修正是「为了可读性」而不是「换个品牌色」:色相不动,彩度让求解器自己压到色域内。
let candidate = Hct::from_components(hct.hue, hct.chroma, tone).to_color();
if candidate.contrast_ratio(background) >= min_ratio {
return Some(candidate);
}
}
// 尽头兜底:纯黑/纯白对任何背景都在 4.58:1 以上(中灰处的理论最低点)。
let extreme = if darken { Color::BLACK } else { Color::WHITE };
(extreme.contrast_ratio(background) >= min_ratio).then_some(extreme)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::SchemeFlavor;
#[test]
fn baseline_is_clean() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let report = check(&Scheme::baseline(flavor));
assert!(report.is_clean(), "{flavor:?}: {:?}", report.failures);
assert!(report.worst >= AA_TEXT, "{flavor:?} worst {}", report.worst);
}
}
#[test]
fn hostile_scheme_is_detected() {
// 反证:把 on-surface 换成 surface(对比度 1:1),校验器必须报出来。
let base = Scheme::baseline(SchemeFlavor::Light);
let mut colors = [Color::BLACK; 49];
for (role, color) in base.iter() {
colors[role as usize] = color;
}
colors[Role::OnSurface as usize] = base.get(Role::Surface);
let hostile = Scheme::from_colors(SchemeFlavor::Light, colors);
let report = check(&hostile);
assert!(!report.is_clean());
assert!(report
.failures
.iter()
.any(|failure| failure.fg == Role::OnSurface && failure.bg == Role::Surface));
}
#[test]
fn converge_repairs_a_hostile_scheme() {
let base = Scheme::baseline(SchemeFlavor::Light);
let mut colors = [Color::BLACK; 49];
for (role, color) in base.iter() {
colors[role as usize] = color;
}
colors[Role::OnSurface as usize] = base.get(Role::Surface);
colors[Role::OnPrimary as usize] = base.get(Role::Primary);
let hostile = Scheme::from_colors(SchemeFlavor::Light, colors);
let (fixed, adjustments) = converge(hostile);
assert!(!adjustments.is_empty(), "应当记录了修正");
assert!(check(&fixed).is_clean(), "{:?}", check(&fixed).failures);
for adjustment in &adjustments {
assert!(adjustment.after_ratio >= AA_TEXT);
assert!(adjustment.after_ratio > adjustment.before_ratio);
}
}
#[test]
fn converge_is_a_no_op_on_a_healthy_scheme() {
let clean = Scheme::baseline(SchemeFlavor::Dark);
let (fixed, adjustments) = converge(clean);
assert!(adjustments.is_empty());
assert_eq!(fixed, clean);
}
#[test]
fn shift_darkens_on_light_background_and_lightens_on_dark() {
let light_bg = Scheme::baseline(SchemeFlavor::Light);
let surface = light_bg.get(Role::Surface);
let fixed = shift_away_from(surface, surface, 4.5).expect("浅底上前景可以压暗");
assert!(
fixed.lstar() < surface.lstar() - 20.0,
"压暗后 tone = {}",
fixed.lstar()
);
assert!(fixed.contrast_ratio(surface) >= 4.5);
let dark_bg = Scheme::baseline(SchemeFlavor::Dark);
let surface = dark_bg.get(Role::Surface);
let fixed = shift_away_from(surface, surface, 4.5).expect("深底上前景可以提亮");
assert!(
fixed.lstar() > surface.lstar() + 20.0,
"提亮后 tone = {}",
fixed.lstar()
);
assert!(fixed.contrast_ratio(surface) >= 4.5);
// 色相不跟着动:修正只动明度(改前改后的 CAM16 色相差 < 2°)。
let before = Hct::from_color(surface);
let after = Hct::from_color(fixed);
let delta = (before.hue - after.hue)
.abs()
.min(360.0 - (before.hue - after.hue).abs());
assert!(delta < 2.0, "色相被改了 {delta}°");
}
}
+313
View File
@@ -0,0 +1,313 @@
//! M3 Expressive 形状刻度 + shape morph 数据。
//!
//! 形状是「唯一真源」的一部分:组件不许写裸半径(`border_radius(8)`),一律引用
//! [`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`**——这不是文档约定,是签名保证。
//!
//! ## 窗口**外框**圆角不在这里(M116)
//!
//! `ShapeScale` 管的是**我们画的东西**:卡片、按钮、浮层、面板。窗口的最外层圆角由操作系统
//! 决定(Win11 / Server 2025 走 DWM 合成器,macOS 走 `layer.cornerRadius`,X11/Wayland 看合成器),
//! 系统**不暴露**那个半径值,我们也不自己定一个数字(用户定案:不做 8/12/16 档)。
//! 所以窗口圆角没有令牌:判据在 `unlucky-app-iced` 的 `platform::plan_corner`,
//! 落点在 `platform/win32/transparency`。
//!
//! 反过来说:**别**为了「让窗口看起来是圆的」去给根容器加 `ShapeScale::ExtraLarge`——那会把
//! 四角裁掉内容(背景图/标题栏底色露出方角),而外框仍然是方的。
use crate::motion::Spring;
/// M3 形状刻度(含 Expressive 新增档)。
///
/// 基础档(M3):none 0 / extra-small 4 / small 8 / medium 12 / large 16 / extra-large 28 / full。
/// Expressive 新增档:`large-increased` 20 / `extra-large-increased` 32 / `extra-extra-large` 48。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ShapeScale {
/// 直角(0dp):仅用于贴边容器/分隔。
None,
/// 4dp:小控件(chip、小徽标)。
ExtraSmall,
/// 8dp:控件(按钮、输入框、菜单项)。
Small,
/// 12dp:卡片、列表容器(Expressive 里卡片默认升到这一档)。
Medium,
/// 16dp:大卡片、面板。
Large,
/// 20dp(Expressive):按下/选中时的「胀开」档。
LargeIncreased,
/// 28dp:浮层、对话框。
ExtraLarge,
/// 32dp(Expressive):大浮层、FAB 展开态。
ExtraLargeIncreased,
/// 48dp(Expressive):超大容器、模态全屏卡。
ExtraExtraLarge,
/// 药丸/圆:按边长取半径一半(导航指示条、头像、switch 轨道)。
Full,
}
impl ShapeScale {
/// 半径(dp;[`ShapeScale::Full`] 返回 `f32::INFINITY`,由 [`ShapeScale::radius_px`] 折算)。
pub const fn dp(self) -> f32 {
match self {
ShapeScale::None => 0.0,
ShapeScale::ExtraSmall => 4.0,
ShapeScale::Small => 8.0,
ShapeScale::Medium => 12.0,
ShapeScale::Large => 16.0,
ShapeScale::LargeIncreased => 20.0,
ShapeScale::ExtraLarge => 28.0,
ShapeScale::ExtraLargeIncreased => 32.0,
ShapeScale::ExtraExtraLarge => 48.0,
ShapeScale::Full => f32::INFINITY,
}
}
/// 实际渲染半径:`Full` 取边长一半,其余不超过边长一半(避免方角变圆角溢出)。
pub fn radius_px(self, size_px: f32) -> f32 {
let half = size_px.max(0.0) / 2.0;
if self == ShapeScale::Full {
half
} else {
self.dp().min(half)
}
}
/// 是否是 Expressive 才有的档位(基础 M3 没有)。
pub const fn is_expressive(self) -> bool {
matches!(
self,
ShapeScale::LargeIncreased
| ShapeScale::ExtraLargeIncreased
| ShapeScale::ExtraExtraLarge
)
}
/// 在形状阶梯上的序号(0 = None,9 = Full),用于断言形变方向是「胀开」而不是「收紧」。
pub const fn rank(self) -> u8 {
match self {
ShapeScale::None => 0,
ShapeScale::ExtraSmall => 1,
ShapeScale::Small => 2,
ShapeScale::Medium => 3,
ShapeScale::Large => 4,
ShapeScale::LargeIncreased => 5,
ShapeScale::ExtraLarge => 6,
ShapeScale::ExtraLargeIncreased => 7,
ShapeScale::ExtraExtraLarge => 8,
ShapeScale::Full => 9,
}
}
}
/// 微控件(≤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 的「按下胀开」「展开成药丸」等)。
///
/// 数据形态是纯的:起止档位 + 弹簧参数。适配层负责跑时钟,`progress` 由弹簧/缓动给出。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ShapeMorph {
/// 静息档位。
pub from: ShapeScale,
/// 目标档位。
pub to: ShapeScale,
/// 驱动这次形变的弹簧。
pub spring: Spring,
}
impl ShapeMorph {
/// 恒定弹簧的形变。
pub const fn new(from: ShapeScale, to: ShapeScale, spring: Spring) -> Self {
Self { from, to, spring }
}
/// 在 `progress`(0..1,已含缓动)处的半径。`Full` 端点按 `size_px` 折算。
pub fn radius_px(&self, size_px: f32, progress: f32) -> f32 {
let t = progress.clamp(0.0, 1.0);
let a = self.from.radius_px(size_px);
let b = self.to.radius_px(size_px);
a + (b - a) * t
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::motion::SPRING_EXPRESSIVE_SPATIAL_DEFAULT;
#[test]
fn scale_ladder_is_monotone() {
let ladder = [
ShapeScale::None,
ShapeScale::ExtraSmall,
ShapeScale::Small,
ShapeScale::Medium,
ShapeScale::Large,
ShapeScale::LargeIncreased,
ShapeScale::ExtraLarge,
ShapeScale::ExtraLargeIncreased,
ShapeScale::ExtraExtraLarge,
];
for pair in ladder.windows(2) {
assert!(
pair[0].dp() < pair[1].dp(),
"{:?} 应小于 {:?}",
pair[0],
pair[1]
);
}
}
#[test]
fn full_is_half_of_edge_and_clamped() {
assert_eq!(ShapeScale::Full.radius_px(48.0), 24.0);
assert_eq!(ShapeScale::ExtraLarge.radius_px(20.0), 10.0);
assert_eq!(ShapeScale::ExtraLarge.radius_px(200.0), 28.0);
assert_eq!(ShapeScale::None.radius_px(200.0), 0.0);
}
#[test]
fn morph_hits_both_endpoints() {
let morph = ShapeMorph::new(
ShapeScale::Small,
ShapeScale::LargeIncreased,
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
);
assert_eq!(morph.radius_px(200.0, 0.0), 8.0);
assert_eq!(morph.radius_px(200.0, 1.0), 20.0);
assert_eq!(morph.radius_px(200.0, 0.5), 14.0);
// 越界 progress 夹取,不产生负半径/超大半径。
assert_eq!(morph.radius_px(200.0, -2.0), 8.0);
assert_eq!(morph.radius_px(200.0, 3.0), 20.0);
}
#[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);
}
}
+290
View File
@@ -0,0 +1,290 @@
//! 形状 / 高程 / 动效之外的「尺子」:4px 栅格、骨架尺寸、一像素线。
//!
//! 源流有两条:`DevDocs/ops/slint-reference/crates/app/ui/theme/metrics.slint`(M52 定稿的骨架节奏),
//! 以及 `ui-integration-contract.md` §3「间距只允许 4/8/12/16/24/32」。M3 同样用 4dp 栅格,
//! 所以这层**不用改口径**,只是把值搬进唯一真源。
//!
//! ## 职责边界:`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)。
pub const GRID: f32 = 4.0;
/// 间距档位(M3/M2 通用 4px 栅格)。禁止 6/10/14 这类非整倍数值。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Space {
/// 4dp:图标与文字之间。
X1,
/// 8dp:控件内边距、行内元素间距。
X2,
/// 12dp:控件间距。
X3,
/// 16dp:区块内行距、卡片内边距下限。
X4,
/// 24dp:区块间距、页面内边距。
X6,
/// 32dp:页级大留白。
X8,
}
impl Space {
/// 间距值(dp)。
pub const fn dp(self) -> f32 {
match self {
Space::X1 => 4.0,
Space::X2 => 8.0,
Space::X3 => 12.0,
Space::X4 => 16.0,
Space::X6 => 24.0,
Space::X8 => 32.0,
}
}
/// 全部档位,由小到大。
pub const ALL: [Space; 6] = [
Space::X1,
Space::X2,
Space::X3,
Space::X4,
Space::X6,
Space::X8,
];
}
/// 把任意数值对齐到 4px 栅格(最近档)。
pub fn snap_to_grid(value: f32) -> f32 {
(value / GRID).round() * GRID
}
/// 骨架尺寸:窗口结构(标题栏、导航、页头、行高)的单一来源。
///
/// 这些值不是 M3 规范值(M3 只规定组件内部节奏),而是本启动器的**布局契约**,
/// 由页面/组件两条线共同消费——任何页面自己写 `56px` 都是重复定义。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LayoutMetrics {
/// 标题栏高度。
pub titlebar_height: f32,
/// 标题栏按钮宽度(关闭/最大化/最小化)。
pub titlebar_button_width: f32,
/// 展开态导航(drawer 形态)宽度。
///
/// **G11 语义修正(M69 / D33)**:D16「左侧固定、允许折叠」落地为**同一个常驻容器**的两种
/// 宽度——展开 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_gap: f32,
/// 单行页头高度。
pub page_header_height: f32,
/// 两行页头高度(日志页)。
pub page_header_tall_height: f32,
/// 运行中状态条高度。
pub running_strip_height: f32,
/// 列表行高(桌面密集列表;见模块文档的「触控目标」说明)。
pub row_height: f32,
/// 标准控件可见高度(按钮/输入框)。
pub control_height: f32,
/// 最小**命中**区域(M3 Expressive 的无障碍下限)。
pub touch_target: f32,
/// 卡片内边距。
pub card_padding: f32,
/// AI 面板宽度。
pub ai_panel_width: f32,
}
impl LayoutMetrics {
/// 桌面基线(1180×760 窗口)。
///
/// **触控目标的换算依据**(reviewer P3-1:不许和 `metrics.slint` 沉默地不一致):
/// - 规范:M3 Expressive 要求可点目标 ≥**48dp**(无障碍下限,手机与桌面同标准)。
/// - 我们取 `touch_target = 48`(照规范),`control_height = 40`(**可见**高度):
/// 两者差 8,正好由控件上下各 4px 的内边距补足,于是「看起来 40 高的按钮」有 48 的命中区。
/// - `row_height = 44` / `nav_item_height = 44` 是 M63 §R13 + §1.4 表定的桌面密集档
/// (`metrics.slint` 的导航 56 是 Fluent 2 时代的旧节奏,D25 已终止):可见高度 44,
/// 点击命中也按 `touch_target`(48)判定,不靠行高本身。
/// - `titlebar_height = 48` 取自 M63 §1.4 的 S2 面板表(旧值 40 是 Fluent 口径)。
pub const DESKTOP: Self = Self {
titlebar_height: 48.0,
titlebar_button_width: 46.0,
// G11 / D33:展开 = drawer 240,折叠 = rail 88(旧 `nav_expanded_width=88` /
// `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_gap: 4.0,
page_header_height: 56.0,
page_header_tall_height: 84.0,
running_strip_height: 32.0,
row_height: 44.0,
control_height: 40.0,
touch_target: 48.0,
card_padding: 20.0,
ai_panel_width: 320.0,
};
}
impl LayoutMetrics {
/// 页头是否两行(日志页),决定主区与 AI 面板分隔线对齐的高度。
pub fn header_height(&self, two_line: bool) -> f32 {
if two_line {
self.page_header_tall_height
} else {
self.page_header_height
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_space_is_on_the_grid() {
for space in Space::ALL {
assert_eq!(space.dp() % GRID, 0.0, "{space:?} 不在 4px 栅格上");
}
}
#[test]
fn layout_values_are_on_the_grid() {
let m = LayoutMetrics::DESKTOP;
// 受 4px 栅格约束的字段(间距/高度/宽度类)。
// `titlebar_button_width` 刻意不在表内:46 对齐 Windows 11 原生 caption 按钮宽度,
// 属 OS 惯例值,单列一条断言钉住,不让它静默漂移。
// `divider_thickness` 同理不在表内:它是**一像素线**(1 不是 4 的倍数),
// 归 `stroke::HAIRLINE` 那条规则管,下面单独断言。
for (name, value) in [
("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_gap", m.nav_item_gap),
("page_header_height", m.page_header_height),
("page_header_tall_height", m.page_header_tall_height),
("running_strip_height", m.running_strip_height),
("row_height", m.row_height),
("control_height", m.control_height),
("touch_target", m.touch_target),
("card_padding", m.card_padding),
("ai_panel_width", m.ai_panel_width),
] {
assert_eq!(value % GRID, 0.0, "{name} = {value} 不在 4px 栅格上");
}
assert_eq!(
m.titlebar_button_width, 46.0,
"标题栏按钮宽度应对齐 Win11 原生 caption"
);
// 一像素线的显式豁免(不是「忘了加进表」,是它本来就不受栅格约束)。
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]
fn touch_target_meets_the_expressive_48dp_minimum() {
let m = LayoutMetrics::DESKTOP;
// M3 Expressive 无障碍下限:可点目标 ≥48dp。
assert_eq!(m.touch_target, 48.0);
// 可见控件高 + 上下各 4px 内边距 = 命中区。
assert_eq!(m.control_height + 2.0 * Space::X1.dp(), m.touch_target);
// 密集行(M63 §1.4/R13 的桌面档)可见高度 44,命中仍按 touch_target。
assert_eq!(m.row_height, 44.0);
assert_eq!(m.nav_item_height, 44.0);
assert!(m.row_height <= m.touch_target);
// M63 §1.4 的 S2 面板表:标题栏 48。
assert_eq!(m.titlebar_height, 48.0);
}
#[test]
fn snap_rounds_to_nearest_grid_step() {
assert_eq!(snap_to_grid(5.0), 4.0);
assert_eq!(snap_to_grid(6.5), 8.0);
assert_eq!(snap_to_grid(0.0), 0.0);
}
#[test]
fn header_height_follows_line_count() {
let m = LayoutMetrics::DESKTOP;
assert_eq!(m.header_height(false), 56.0);
assert_eq!(m.header_height(true), 84.0);
}
}
@@ -0,0 +1,88 @@
//! 官方 M3 **Expressive** 强调排版表(15 档,A 级来源)。
//!
//! 来源:`androidx.compose.material3.tokens.TypeScaleTokens` v0_103 的
//! `<Role>Emphasized{Size,LineHeight,Tracking,Weight}` 四元组。要点:
//!
//! - **字号与行高与 baseline 完全相同**(Expressive 的强调不改变排版高度);
//! - 只动**字重**与**字距**:display/headline/title-large/body-* → Medium(500);
//! title-medium / title-small / label-* → Bold(700)(baseline 是 500);
//! - display-large 的字距从 −0.2 回 0;body-large 0.5 → 0.15;title-medium 0.2 → 0.15;
//! body-medium 0.2 → 0.25(唯一一个「强调后字距变大」的档)。
use super::{style, FontFamily, TypeRole, TypeStyle, Weight};
/// 取某档的官方强调样式。非 [`TypeRole`] 的每个变体都有值(15/15,无缺口)。
pub(super) const fn style_of(role: TypeRole) -> TypeStyle {
match role {
TypeRole::DisplayLarge => style(57.0, 64.0, Weight::Medium, 0.0, FontFamily::Display),
TypeRole::DisplayMedium => style(45.0, 52.0, Weight::Medium, 0.0, FontFamily::Display),
TypeRole::DisplaySmall => style(36.0, 44.0, Weight::Medium, 0.0, FontFamily::Display),
TypeRole::HeadlineLarge => style(32.0, 40.0, Weight::Medium, 0.0, FontFamily::Display),
TypeRole::HeadlineMedium => style(28.0, 36.0, Weight::Medium, 0.0, FontFamily::Display),
TypeRole::HeadlineSmall => style(24.0, 32.0, Weight::Medium, 0.0, FontFamily::Display),
// TitleLarge 官方用 Brand 族(与 Display 同栈)。
TypeRole::TitleLarge => style(22.0, 28.0, Weight::Medium, 0.0, FontFamily::Display),
TypeRole::TitleMedium => style(16.0, 24.0, Weight::Bold, 0.15, FontFamily::Text),
TypeRole::TitleSmall => style(14.0, 20.0, Weight::Bold, 0.1, FontFamily::Text),
TypeRole::BodyLarge => style(16.0, 24.0, Weight::Medium, 0.15, FontFamily::Text),
TypeRole::BodyMedium => style(14.0, 20.0, Weight::Medium, 0.25, FontFamily::Text),
TypeRole::BodySmall => style(12.0, 16.0, Weight::Medium, 0.4, FontFamily::Text),
TypeRole::LabelLarge => style(14.0, 20.0, Weight::Bold, 0.1, FontFamily::Text),
TypeRole::LabelMedium => style(12.0, 16.0, Weight::Bold, 0.5, FontFamily::Text),
TypeRole::LabelSmall => style(11.0, 16.0, Weight::Bold, 0.5, FontFamily::Text),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn emphasized_keeps_size_and_line_height() {
for role in TypeRole::ALL {
let base = role.style();
let emph = style_of(role);
assert_eq!(
base.size_px,
emph.size_px,
"{} 强调档不该改字号",
role.name()
);
assert_eq!(
base.line_height_px,
emph.line_height_px,
"{} 强调档不该改行高",
role.name()
);
}
}
#[test]
fn emphasized_weights_follow_the_official_split() {
// 官方两档:正文/展示类 → Medium(500);小标题与标签类 → Bold(700)。
for role in TypeRole::ALL {
let expect = match role {
TypeRole::TitleMedium
| TypeRole::TitleSmall
| TypeRole::LabelLarge
| TypeRole::LabelMedium
| TypeRole::LabelSmall => Weight::Bold,
_ => Weight::Medium,
};
assert_eq!(
style_of(role).weight,
expect,
"{} 强调档字重不对",
role.name()
);
}
}
#[test]
fn display_large_tracking_returns_to_zero() {
assert_eq!(TypeRole::DisplayLarge.style().letter_spacing_px, -0.2);
assert_eq!(TypeRole::DisplayLarge.emphasized().letter_spacing_px, 0.0);
assert_eq!(TypeRole::BodyLarge.style().letter_spacing_px, 0.5);
assert_eq!(TypeRole::BodyLarge.emphasized().letter_spacing_px, 0.15);
}
}
+304
View File
@@ -0,0 +1,304 @@
//! 三平台字体族回退栈(Win10+ / macOS 12+ / Linux)+ **字重覆盖**模型。
//!
//! 只写族名,不写文件路径、不内嵌字体(D14 + D28)。每条栈的**最后一项必须是通用族名**,
//! 否则干净安装的系统上会退化成豆腐块。
//!
//! ## 为什么要表达「这个族有哪几档字重」
//!
//! M3 Expressive 的强调档会要 **700**(title-medium/title-small/label-*)与 **500**
//! (display/headline/body-*)。而中文字体的字重覆盖差别很大:
//! **Windows 的 Microsoft YaHei UI 只有 400 / 700 两档**,没有 500;macOS 苹方到 600 就断了、
//! 没有 700;只有 Linux 常见的 Noto Sans CJK / 思源黑体(可变字体)才 100~900 全覆盖。
//!
//! 所以栈项不是「一个族名」,而是 `(族名, 字重覆盖)`([`FontFace`]),并且提供一个纯函数
//! [`Platform::resolve_weight`] 说明「这一档字重最终落到哪个族、是原生还是降级」:
//!
//! | 结果 | 含义 |
//! |---|---|
//! | [`WeightMatch::Native`] | 该族有这一档(可变字体在区间内也算原生) |
//! | [`WeightMatch::Nearest`] | 该族没有这一档,取最近的静态档(视觉上是「淡一点/粗一点」,不假) |
//! | [`WeightMatch::Synthetic`] | 最近的档差 ≥2 级(如只有 400 却要 700)→ 引擎**合成加粗**,观感廉价 |
//!
//! **合成加粗是降级,不是正常路径**(用户令:不许拿合成加粗当「还原到位」)。
//! iced/cosmic-text 侧机制:`Font { family, weight, .. }` 由 cosmic-text 按 (family, weight)
//! 选面,同族缺档时选最近字重;glyphon 另可用 swash 的合成(fake bold)兜底。
//! 中文缺档时的降级顺序:**① 换下一个有该档的族 → ② 用可变字体的 weight 轴 → ③ 最近静态档
//! → ④ 最后才是合成加粗**(并且不要用 700 排长中文正文)。
//!
//! ## 待确认(D28 联动,不在本 mission 拍板)
//!
//! 将来从 CDN 下载中文字体时,「一个 Regular 文件」不够——至少要 400/500/700 三档子集,
//! 或者一个可变字体(体积更大)。这个差异登记在这里,等 D28 的字体下载 mission 决定。
#[cfg(test)]
mod tests;
/// 平台(决定字体族回退栈)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Platform {
/// Windows 10/11。
Windows,
/// macOS 12+。
MacOs,
/// Linux(X11/Wayland,桌面发行版)。
Linux,
}
/// 字重覆盖(一个族实际提供了哪些字重)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WeightCoverage {
/// 可变字体:一个连续区间(weight 轴)。
Variable {
/// 最细一档。
min: u16,
/// 最粗一档。
max: u16,
},
/// 静态字体:离散字重表。
Static(&'static [u16]),
}
impl WeightCoverage {
/// 是否原生覆盖某档字重。
pub fn supports(&self, weight: u16) -> bool {
match self {
WeightCoverage::Variable { min, max } => (*min..=*max).contains(&weight),
WeightCoverage::Static(list) => list.contains(&weight),
}
}
/// 最近的可选字重(静态表取绝对值最近;可变字体夹取到区间内)。
pub fn nearest(&self, weight: u16) -> u16 {
match self {
WeightCoverage::Variable { min, max } => weight.clamp(*min, *max),
WeightCoverage::Static(list) => {
let mut best = list[0];
let mut best_delta = u16::MAX;
for candidate in *list {
let delta = candidate.abs_diff(weight);
if delta < best_delta {
best = *candidate;
best_delta = delta;
}
}
best
}
}
}
}
/// 一条字体栈项:族名 + 字重覆盖。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FontFace {
/// 族名(只写名字,不带路径/文件名)。
pub family: &'static str,
/// 该族提供的字重。
pub weights: WeightCoverage,
}
impl FontFace {
/// 构造。
pub const fn new(family: &'static str, weights: WeightCoverage) -> Self {
Self { family, weights }
}
/// 该族是否原生覆盖某档字重。
pub fn supports(&self, weight: crate::typography::Weight) -> bool {
self.weights.supports(weight.value())
}
}
/// 字重匹配的质量。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WeightMatch {
/// 原生有这一档。
Native,
/// 同族最近档(差 <2 级)。
Nearest,
/// 差 ≥2 级,引擎只能合成加粗/减细(降级)。
Synthetic,
}
/// 字重解析结果:最终用哪个族、哪个字重、是原生还是降级。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FontChoice {
/// 选中的族名。
pub family: &'static str,
/// 请求的字重。
pub requested: u16,
/// 实际可用的字重。
pub available: u16,
/// 匹配质量。
pub quality: WeightMatch,
}
impl FontChoice {
/// 是否走了合成加粗(降级路径)。
pub fn is_synthetic(&self) -> bool {
self.quality == WeightMatch::Synthetic
}
}
/// 字体族的用途分类(决定取哪条栈)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FontFamily {
/// 大字号展示(display/headline/title-large):光学尺寸更大。
Display,
/// 正文(title/body/label)。
Text,
/// 等宽(日志、代码、版本号)。
Mono,
}
// 字重覆盖来源等级:**B**(各平台字体规格/厂商文档;没有一份官方 token 文件能一次给全,
// 所以按「常见安装」标定,并用测试钉死——改值要连带改测试与协作文档)。
const W_VARIABLE_SEGOE: WeightCoverage = WeightCoverage::Variable { min: 300, max: 700 };
const W_VARIABLE_SF: WeightCoverage = WeightCoverage::Variable { min: 100, max: 900 };
const W_VARIABLE_NOTO: WeightCoverage = WeightCoverage::Variable { min: 100, max: 900 };
const W_VARIABLE_CASCADIA: WeightCoverage = WeightCoverage::Variable { min: 200, max: 700 };
const W_VARIABLE_JETBRAINS: WeightCoverage = WeightCoverage::Variable { min: 100, max: 800 };
const W_STATIC_SEGOE: WeightCoverage = WeightCoverage::Static(&[300, 400, 600, 700]);
const W_STATIC_YAHEI: WeightCoverage = WeightCoverage::Static(&[400, 700]);
const W_STATIC_HELVETICA: WeightCoverage = WeightCoverage::Static(&[300, 400, 500, 700]);
const W_STATIC_PINGFANG: WeightCoverage = WeightCoverage::Static(&[100, 200, 300, 400, 500, 600]);
const W_STATIC_MENLO: WeightCoverage = WeightCoverage::Static(&[400, 700]);
const W_STATIC_DEJAVU: WeightCoverage = WeightCoverage::Static(&[400, 700]);
// 各平台字体栈(写成 const 数组,好让 `font_stack` 的 `const fn` 直接返回 `&'static [_]`)。
const WIN_DISPLAY: [FontFace; 5] = [
FontFace::new("Segoe UI Variable Display", W_VARIABLE_SEGOE),
FontFace::new("Segoe UI", W_STATIC_SEGOE),
FontFace::new("Microsoft YaHei UI", W_STATIC_YAHEI),
FontFace::new("Microsoft YaHei", W_STATIC_YAHEI),
FontFace::new("sans-serif", W_VARIABLE_NOTO),
];
const WIN_TEXT: [FontFace; 5] = [
FontFace::new("Segoe UI Variable Text", W_VARIABLE_SEGOE),
FontFace::new("Segoe UI", W_STATIC_SEGOE),
FontFace::new("Microsoft YaHei UI", W_STATIC_YAHEI),
FontFace::new("Microsoft YaHei", W_STATIC_YAHEI),
FontFace::new("sans-serif", W_VARIABLE_NOTO),
];
const WIN_MONO: [FontFace; 4] = [
FontFace::new("Cascadia Mono", W_VARIABLE_CASCADIA),
FontFace::new("Consolas", W_STATIC_SEGOE),
FontFace::new("Courier New", W_STATIC_MENLO),
FontFace::new("monospace", W_STATIC_DEJAVU),
];
const MAC_DISPLAY: [FontFace; 4] = [
FontFace::new("SF Pro Display", W_VARIABLE_SF),
FontFace::new("Helvetica Neue", W_STATIC_HELVETICA),
FontFace::new("PingFang SC", W_STATIC_PINGFANG),
FontFace::new("sans-serif", W_VARIABLE_NOTO),
];
const MAC_TEXT: [FontFace; 4] = [
FontFace::new("SF Pro Text", W_VARIABLE_SF),
FontFace::new("Helvetica Neue", W_STATIC_HELVETICA),
FontFace::new("PingFang SC", W_STATIC_PINGFANG),
FontFace::new("sans-serif", W_VARIABLE_NOTO),
];
const MAC_MONO: [FontFace; 4] = [
FontFace::new("SF Mono", W_VARIABLE_SF),
FontFace::new("Menlo", W_STATIC_MENLO),
FontFace::new("Monaco", W_STATIC_MENLO),
FontFace::new("monospace", W_STATIC_DEJAVU),
];
const LINUX_SANS: [FontFace; 5] = [
FontFace::new("Roboto", W_VARIABLE_NOTO),
FontFace::new("Noto Sans", W_VARIABLE_NOTO),
FontFace::new("Noto Sans CJK SC", W_VARIABLE_NOTO),
FontFace::new("DejaVu Sans", W_STATIC_DEJAVU),
FontFace::new("sans-serif", W_VARIABLE_NOTO),
];
const LINUX_MONO: [FontFace; 4] = [
FontFace::new("JetBrains Mono", W_VARIABLE_JETBRAINS),
FontFace::new("Noto Sans Mono", W_VARIABLE_NOTO),
FontFace::new("DejaVu Sans Mono", W_STATIC_DEJAVU),
FontFace::new("monospace", W_STATIC_DEJAVU),
];
impl Platform {
/// 当前编译目标对应的平台(运行时用户覆盖请显式传 [`Platform`])。
pub const fn current() -> Self {
if cfg!(target_os = "windows") {
Platform::Windows
} else if cfg!(target_os = "macos") {
Platform::MacOs
} else {
Platform::Linux
}
}
/// 稳定字符串名。
pub const fn name(self) -> &'static str {
match self {
Platform::Windows => "windows",
Platform::MacOs => "macos",
Platform::Linux => "linux",
}
}
/// 字体族回退栈(由首选到兜底,最后一项是通用族名)。
pub const fn font_stack(self, family: FontFamily) -> &'static [FontFace] {
match (self, family) {
// Win10 起 Segoe UI 一定在;Win11 有光学尺寸分离的 Variable 版。
(Platform::Windows, FontFamily::Display) => &WIN_DISPLAY,
(Platform::Windows, FontFamily::Text) => &WIN_TEXT,
(Platform::Windows, FontFamily::Mono) => &WIN_MONO,
// macOS 12+:SF Pro 分 Display/Text 光学尺寸;中文走苹方(只到 600)。
(Platform::MacOs, FontFamily::Display) => &MAC_DISPLAY,
(Platform::MacOs, FontFamily::Text) => &MAC_TEXT,
(Platform::MacOs, FontFamily::Mono) => &MAC_MONO,
// Linux:不假设发行版预装什么,Roboto/Noto 是最常见的两套(Display 与 Text 同栈)。
(Platform::Linux, FontFamily::Display | FontFamily::Text) => &LINUX_SANS,
(Platform::Linux, FontFamily::Mono) => &LINUX_MONO,
}
}
/// 该平台的**中文**兜底族(CJK 字形实际落到的那个)。
///
/// 注意:栈里的 CJK 族只是「缺字形时的候选」,最终由文本引擎逐字形回退决定;
/// 这个函数把它单独暴露出来,好让调用方按模块文档的降级顺序处理字重。
pub const fn cjk_face(self) -> FontFace {
match self {
Platform::Windows => FontFace::new("Microsoft YaHei UI", W_STATIC_YAHEI),
Platform::MacOs => FontFace::new("PingFang SC", W_STATIC_PINGFANG),
Platform::Linux => FontFace::new("Noto Sans CJK SC", W_VARIABLE_NOTO),
}
}
/// 按栈顺序解析某档字重的落点(纯函数,不查系统)。
///
/// 规则:**优先原生**(栈里第一个真有的族)→ 都做不到时取首选族的最近档,
/// 差距 ≥200 判为 [`WeightMatch::Synthetic`]。
pub fn resolve_weight(
self,
family: FontFamily,
weight: crate::typography::Weight,
) -> FontChoice {
let stack = self.font_stack(family);
let requested = weight.value();
for face in stack {
if face.weights.supports(requested) {
return FontChoice {
family: face.family,
requested,
available: requested,
quality: WeightMatch::Native,
};
}
}
let first = stack[0];
let available = first.weights.nearest(requested);
FontChoice {
family: first.family,
requested,
available,
quality: if available.abs_diff(requested) >= 200 {
WeightMatch::Synthetic
} else {
WeightMatch::Nearest
},
}
}
}
+149
View File
@@ -0,0 +1,149 @@
//! 字体栈与字重解析的测试(独立文件是为了守住单文件 ≤400 行的硬约束)。
use super::*;
use crate::typography::Weight;
const PLATFORMS: [Platform; 3] = [Platform::Windows, Platform::MacOs, Platform::Linux];
const FAMILIES: [FontFamily; 3] = [FontFamily::Display, FontFamily::Text, FontFamily::Mono];
#[test]
fn font_stacks_are_well_formed() {
for platform in PLATFORMS {
for family in FAMILIES {
let stack = platform.font_stack(family);
assert!(
!stack.is_empty(),
"{} 的 {family:?} 栈为空",
platform.name()
);
let last = stack[stack.len() - 1].family;
let generic = match family {
FontFamily::Mono => "monospace",
_ => "sans-serif",
};
assert_eq!(
last,
generic,
"{} 的 {family:?} 栈必须以通用族名兜底",
platform.name()
);
for face in stack {
assert!(!face.family.is_empty());
assert!(
!face.family.contains(['/', '\\', '.', ':']),
"字体栈只允许族名,不得出现路径/文件名:{}",
face.family
);
assert!(
face.family.is_ascii(),
"族名应为 ASCII(非 ASCII 名交给系统匹配):{}",
face.family
);
// 每条栈项都必须声明字重覆盖(连 400 都没有的族写进栈没有意义)。
assert!(
face.supports(Weight::Regular),
"{} 连 400 都没有,写它没意义",
face.family
);
}
}
}
}
#[test]
fn display_and_text_stacks_differ_on_windows_and_macos() {
assert_ne!(
Platform::Windows.font_stack(FontFamily::Display)[0].family,
Platform::Windows.font_stack(FontFamily::Text)[0].family
);
assert_ne!(
Platform::MacOs.font_stack(FontFamily::Display)[0].family,
Platform::MacOs.font_stack(FontFamily::Text)[0].family
);
}
#[test]
fn chinese_fallback_is_present_but_not_first() {
// 中文族名必须在栈里,但不能排第一(纯英文环境不加载中文字体,D28)。
for platform in PLATFORMS {
let stack = platform.font_stack(FontFamily::Text);
let cjk = ["Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC"];
assert!(
stack.iter().any(|face| cjk.contains(&face.family)),
"{} 的正文栈缺 CJK 兜底",
platform.name()
);
assert!(
!cjk.contains(&stack[0].family),
"{} 的首选字体不该是 CJK 族",
platform.name()
);
}
}
#[test]
fn cjk_weight_coverage_is_registered() {
// 用户点名的坑:中文字重覆盖三平台差别很大。
let win = Platform::Windows.cjk_face();
assert!(win.supports(Weight::Regular) && win.supports(Weight::Bold));
assert!(
!win.supports(Weight::Medium),
"雅黑没有 500——这条断言就是「中文 500 会掉到降级路径」的登记"
);
let mac = Platform::MacOs.cjk_face();
assert!(mac.supports(Weight::Medium));
assert!(
!mac.supports(Weight::Bold),
"苹方到 600 为止,700 要合成/换族"
);
let linux = Platform::Linux.cjk_face();
for weight in [Weight::Regular, Weight::Medium, Weight::Bold] {
assert!(
linux.supports(weight),
"思源黑体/Noto CJK 应覆盖 400/500/700"
);
}
}
#[test]
fn resolve_prefers_native_and_flags_synthetic() {
// Windows 正文 700:Segoe UI Variable 覆盖 → 原生。
let bold = Platform::Windows.resolve_weight(FontFamily::Text, Weight::Bold);
assert_eq!(bold.quality, WeightMatch::Native);
assert_eq!(bold.available, 700);
assert!(!bold.is_synthetic());
// Linux 正文 500:Roboto 可变 → 原生。
assert_eq!(
Platform::Linux
.resolve_weight(FontFamily::Text, Weight::Medium)
.quality,
WeightMatch::Native
);
// 静态族缺档:只有 400 的族请求 700 → nearest 400,差 300 → 判为合成降级。
let only_regular = FontFace::new("Courier New", WeightCoverage::Static(&[400]));
assert_eq!(only_regular.weights.nearest(700), 400);
let synthetic = FontChoice {
family: only_regular.family,
requested: 700,
available: only_regular.weights.nearest(700),
quality: WeightMatch::Synthetic,
};
assert!(synthetic.is_synthetic());
}
#[test]
fn nearest_picks_closest_static_weight() {
let coverage = WeightCoverage::Static(&[300, 400, 600, 700]);
assert_eq!(coverage.nearest(500), 400);
assert_eq!(coverage.nearest(650), 600);
assert_eq!(coverage.nearest(900), 700);
let variable = WeightCoverage::Variable { min: 100, max: 900 };
assert_eq!(variable.nearest(500), 500);
assert_eq!(variable.nearest(50), 100);
}
#[test]
fn current_platform_returns_a_stack() {
let platform = Platform::current();
assert!(!platform.font_stack(FontFamily::Text).is_empty());
}
+346
View File
@@ -0,0 +1,346 @@
//! M3 type scale(15 档,baseline + Expressive 强调两套)+ 三平台字体族回退栈。
//!
//! **数值来源 A 级**:`androidx.compose.material3.tokens.TypeScaleTokens`(v0_103)。
//! baseline 四元组取 `<Role>{Size,LineHeight,Tracking,Weight}`,Expressive 强调档取
//! `<Role>Emphasized{...}`(见 [`emphasized`] 模块)。注意 Compose 这一代 token 的 tracking 与
//! m3.material.io 网页表格有 0.05px 级别的差异(display-large −0.2 vs −0.25、body-medium 0.2 vs
//! 0.25、title-medium 0.2 vs 0.15),本项目跟 **Compose(实际渲染值)**,差异登记在
//! crate 文档「baseline vs Expressive 差异清单」里。
//!
//! **只写字体族名,不内嵌任何字体文件**(Segoe UI / SF Pro / Roboto 都受各自许可约束,
//! 内嵌再分发是违规的;引用系统已安装的族名是合规且体积为零的做法——这是 D14 与 D28 的共同要求)。
//! 中文由系统字体匹配:栈里显式列出 CJK 族名,纯英文环境不会去加载任何中文字体(D28)。
mod emphasized;
mod fonts;
pub use fonts::{FontChoice, FontFace, FontFamily, Platform, WeightCoverage, WeightMatch};
/// 字重(M3 只用这四档;可变字体按连续值渲染时取 [`Weight::value`])。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Weight {
/// 400:正文。
Regular,
/// 500:label / title-medium / Expressive 强调的正文与展示类。
Medium,
/// 600:本项目的「区块标题」档(介于 Medium 与 Bold 之间)。
SemiBold,
/// 700:页面标题 / 空态主文案 / Expressive 强调的 label 与 title-small-medium。
Bold,
}
impl Weight {
/// 数值字重(CSS/OpenType 口径)。
pub const fn value(self) -> u16 {
match self {
Weight::Regular => 400,
Weight::Medium => 500,
Weight::SemiBold => 600,
Weight::Bold => 700,
}
}
/// 再重一档(最重封顶 Bold)。
pub const fn heavier(self) -> Self {
match self {
Weight::Regular => Weight::Medium,
Weight::Medium => Weight::SemiBold,
Weight::SemiBold | Weight::Bold => Weight::Bold,
}
}
}
/// 一条排版样式(已解析成渲染要用的四个数)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TypeStyle {
/// 字号(px;1.0 缩放时与 dp 同值)。
pub size_px: f32,
/// 行高(px)。
pub line_height_px: f32,
/// 字重。
pub weight: Weight,
/// 字间距(px;M3 的 type scale 用 px 表达,中文场景下正字距更易读)。
pub letter_spacing_px: f32,
/// 用哪条字体栈。
pub family: FontFamily,
}
impl TypeStyle {
/// 按用户字号缩放(`factor` 1.0 = 标准;行高与字距同比例,夹取到 0.5..2.0)。
pub fn scaled(self, factor: f32) -> Self {
let f = factor.clamp(0.5, 2.0);
Self {
size_px: self.size_px * f,
line_height_px: self.line_height_px * f,
letter_spacing_px: self.letter_spacing_px * f,
..self
}
}
/// 该样式的字体族回退栈(栈项带字重覆盖,见 [`FontFace`])。
pub fn font_stack(self, platform: Platform) -> &'static [FontFace] {
platform.font_stack(self.family)
}
/// 该样式在这台平台上「字重最终落到哪个族」——中文缺档时看它判断是否走降级。
pub fn resolve(self, platform: Platform) -> FontChoice {
platform.resolve_weight(self.family, self.weight)
}
}
/// M3 type scale 的 15 个角色。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TypeRole {
/// 57/64:落地页大标题(本启动器基本不用)。
DisplayLarge,
/// 45/52。
DisplayMedium,
/// 36/44。
DisplaySmall,
/// 32/40:空态主标题的大档。
HeadlineLarge,
/// 28/36。
HeadlineMedium,
/// 24/32。
HeadlineSmall,
/// 22/28:页面标题(导航栏下的主标题)。
TitleLarge,
/// 16/24:卡片标题。
TitleMedium,
/// 14/20:行标题。
TitleSmall,
/// 16/24:正文大档(说明文字)。
BodyLarge,
/// 14/20:默认正文。
BodyMedium,
/// 12/16:辅助说明。
BodySmall,
/// 14/20:按钮文字。
LabelLarge,
/// 12/16:标签、徽标。
LabelMedium,
/// 11/16:最小标签(谨慎使用,中文 11px 可读性差)。
LabelSmall,
}
impl TypeRole {
/// 全部 15 档,按 M3 的族序排列。
pub const ALL: [TypeRole; 15] = [
TypeRole::DisplayLarge,
TypeRole::DisplayMedium,
TypeRole::DisplaySmall,
TypeRole::HeadlineLarge,
TypeRole::HeadlineMedium,
TypeRole::HeadlineSmall,
TypeRole::TitleLarge,
TypeRole::TitleMedium,
TypeRole::TitleSmall,
TypeRole::BodyLarge,
TypeRole::BodyMedium,
TypeRole::BodySmall,
TypeRole::LabelLarge,
TypeRole::LabelMedium,
TypeRole::LabelSmall,
];
/// 稳定字符串名(`display-large` … `label-small`)。
pub const fn name(self) -> &'static str {
match self {
TypeRole::DisplayLarge => "display-large",
TypeRole::DisplayMedium => "display-medium",
TypeRole::DisplaySmall => "display-small",
TypeRole::HeadlineLarge => "headline-large",
TypeRole::HeadlineMedium => "headline-medium",
TypeRole::HeadlineSmall => "headline-small",
TypeRole::TitleLarge => "title-large",
TypeRole::TitleMedium => "title-medium",
TypeRole::TitleSmall => "title-small",
TypeRole::BodyLarge => "body-large",
TypeRole::BodyMedium => "body-medium",
TypeRole::BodySmall => "body-small",
TypeRole::LabelLarge => "label-large",
TypeRole::LabelMedium => "label-medium",
TypeRole::LabelSmall => "label-small",
}
}
/// **baseline MD3** 样式(官方 type scale 值:字号 / 行高 / 字重 / 字距 + 字体族分类)。
pub const fn style(self) -> TypeStyle {
match self {
TypeRole::DisplayLarge => style(57.0, 64.0, Weight::Regular, -0.2, FontFamily::Display),
TypeRole::DisplayMedium => style(45.0, 52.0, Weight::Regular, 0.0, FontFamily::Display),
TypeRole::DisplaySmall => style(36.0, 44.0, Weight::Regular, 0.0, FontFamily::Display),
TypeRole::HeadlineLarge => style(32.0, 40.0, Weight::Regular, 0.0, FontFamily::Display),
TypeRole::HeadlineMedium => {
style(28.0, 36.0, Weight::Regular, 0.0, FontFamily::Display)
}
TypeRole::HeadlineSmall => style(24.0, 32.0, Weight::Regular, 0.0, FontFamily::Display),
// 官方 TitleLarge 用 Brand 族(与 Display 同栈),其余 title 用 Plain(Text)。
TypeRole::TitleLarge => style(22.0, 28.0, Weight::Regular, 0.0, FontFamily::Display),
TypeRole::TitleMedium => style(16.0, 24.0, Weight::Medium, 0.2, FontFamily::Text),
TypeRole::TitleSmall => style(14.0, 20.0, Weight::Medium, 0.1, FontFamily::Text),
TypeRole::BodyLarge => style(16.0, 24.0, Weight::Regular, 0.5, FontFamily::Text),
TypeRole::BodyMedium => style(14.0, 20.0, Weight::Regular, 0.2, FontFamily::Text),
TypeRole::BodySmall => style(12.0, 16.0, Weight::Regular, 0.4, FontFamily::Text),
TypeRole::LabelLarge => style(14.0, 20.0, Weight::Medium, 0.1, FontFamily::Text),
TypeRole::LabelMedium => style(12.0, 16.0, Weight::Medium, 0.5, FontFamily::Text),
TypeRole::LabelSmall => style(11.0, 16.0, Weight::Medium, 0.5, FontFamily::Text),
}
}
/// **M3 Expressive** 官方强调档(15/15 全有值,A 级来源)。
///
/// 规则(官方):字号与行高不变;字重升到 500(展示/正文类)或 700(label 与小标题);
/// 字距按官方表微调(display-large −0.2→0、body-large 0.5→0.15、title-medium 0.2→0.15、
/// body-medium 0.2→0.25)。这是**官方值,不是我们的近似**。
pub const fn emphasized(self) -> TypeStyle {
emphasized::style_of(self)
}
/// **简化强调规则(非官方)**:只把字重升一级,字号/行高/字距全不动。
///
/// 用途:需要在「同一 role 内加强语气但不想引入任何字距变化」的场合(例如同屏多行标题
/// 需要严格对齐基线网格时)。**优先用 [`TypeRole::emphasized`]**——那才是 Expressive 的定义。
pub const fn emphasized_simplified(self) -> TypeStyle {
let base = self.style();
TypeStyle {
weight: base.weight.heavier(),
..base
}
}
}
/// `const fn` 用的构造器。
const fn style(
size: f32,
line_height: f32,
weight: Weight,
tracking_px: f32,
family: FontFamily,
) -> TypeStyle {
TypeStyle {
size_px: size,
line_height_px: line_height,
weight,
letter_spacing_px: tracking_px,
family,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_role_has_sane_metrics() {
for role in TypeRole::ALL {
let s = role.style();
assert!(s.size_px >= 11.0, "{} 字号过小", role.name());
assert!(
s.line_height_px >= s.size_px,
"{} 行高小于字号({} < {})",
role.name(),
s.line_height_px,
s.size_px
);
assert!(
(1.1..=1.8).contains(&(s.line_height_px / s.size_px)),
"{} 行高倍数 {} 超出 1.1..1.8(M3 的行高是 1.12~1.5 量级)",
role.name(),
s.line_height_px / s.size_px
);
assert!(s.letter_spacing_px.abs() < 1.0, "{} 字距过大", role.name());
}
}
#[test]
fn each_family_is_monotone_by_size() {
// M3 的 type scale 是「五族 × 三档」,跨族不保证单调(title-small 14 → body-large 16),
// 但族内必须由大到小。
for family in [
[
TypeRole::DisplayLarge,
TypeRole::DisplayMedium,
TypeRole::DisplaySmall,
],
[
TypeRole::HeadlineLarge,
TypeRole::HeadlineMedium,
TypeRole::HeadlineSmall,
],
[
TypeRole::TitleLarge,
TypeRole::TitleMedium,
TypeRole::TitleSmall,
],
[
TypeRole::BodyLarge,
TypeRole::BodyMedium,
TypeRole::BodySmall,
],
[
TypeRole::LabelLarge,
TypeRole::LabelMedium,
TypeRole::LabelSmall,
],
] {
for pair in family.windows(2) {
assert!(
pair[0].style().size_px > pair[1].style().size_px,
"{} 应大于 {}",
pair[0].name(),
pair[1].name()
);
}
}
}
#[test]
fn names_are_unique_and_kebab() {
let mut seen = std::collections::BTreeSet::new();
for role in TypeRole::ALL {
let name = role.name();
assert!(seen.insert(name), "排版角色名重复:{name}");
assert!(name.chars().all(|c| c.is_ascii_lowercase() || c == '-'));
}
}
#[test]
fn simplified_emphasis_only_bumps_weight() {
for role in TypeRole::ALL {
let base = role.style();
let emph = role.emphasized_simplified();
assert_eq!(base.size_px, emph.size_px);
assert_eq!(base.line_height_px, emph.line_height_px);
assert_eq!(base.letter_spacing_px, emph.letter_spacing_px);
assert!(emph.weight.value() > base.weight.value() || base.weight == Weight::Bold);
}
}
#[test]
fn official_emphasis_is_at_least_as_heavy_as_baseline() {
for role in TypeRole::ALL {
assert!(
role.emphasized().weight.value() >= role.style().weight.value(),
"{} 的官方强调档不该比 baseline 更轻",
role.name()
);
assert_ne!(
role.emphasized(),
role.style(),
"{} 强调档与 baseline 相同",
role.name()
);
}
}
#[test]
fn scaling_is_proportional_and_bounded() {
let base = TypeRole::BodyMedium.style();
let big = base.scaled(1.25);
assert!((big.size_px - 17.5).abs() < 1.0e-4);
assert!((big.line_height_px - 25.0).abs() < 1.0e-4);
assert_eq!(base.scaled(10.0).size_px, base.size_px * 2.0);
assert_eq!(base.scaled(0.1).size_px, base.size_px * 0.5);
}
}
+400
View File
@@ -0,0 +1,400 @@
//! D43:内置色卡的**固定中性色相**——切卡只换强调色,窗口底色的冷暖不跟着换。
//!
//! ## 这条测试要钉住的五件事
//!
//! 1. **天蓝卡(出厂默认)逐值不变**:固定的中性色相等于天蓝档的中性色相,所以天蓝卡必须与
//! [`Scheme::baseline`] 的 49 个角色**逐个相等**(M70 定案的不动点,p1 级硬约束)。
//! 2. **八张卡的表面色完全相同**:`surface` 的 CAM16 色相一致(±1°)、实测彩度落在窄带内
//! (±1.5),且改前那条「9 倍差」被同时量出来当反证(见 `the_old_per_card_tinting...`)。
//! 3. **动态路径不许被顺手改成固定中性**:这条必须走 **`resolve_source` 公开入口**测——
//! `Card::scheme` 那一层根本没有「取色」这回事,测它等于测空气(复审 r1 p1-1 就是这个:
//! 把 `resolve::try_level` 的取色分支改成固定中性,8 条测试全绿)。见
//! `resolved_image_scheme_keeps_tinting_from_the_seed`。
//! 4. **WCAG 不因换中性色相而掉档**:8 张卡 × 浅/深两套 × 26 对文字/图标对全部 ≥ 4.5:1。
//! 5. 想让「八卡同底色」的带宽松掉,`the_old_per_card_tinting...` 会红(它量的是改前路径)。
//!
//! ## 为什么容差是这个带宽(不是随手取整)
//!
//! - **色相 ±1°**:八张卡现在共用一个中性调色板,`surface` 是**同一个 hex**,跨卡差实测 0.00°。
//! 容差留 1° 是为了挡住「将来某张卡偷偷换回 TonalSpot」——那种回退的最小偏离是 **43°**
//! (dusk 280.31° vs 236.86°),1° 的带宽足以把 0 与 43° 分开,同时不误报 8 bit 量化。
//! - **彩度 ±1.5(CAM16,单位与请求值 12 同刻度)**:tone 98 的 8 bit 量化会把「请求 12」
//! 读回成 6.41(浅)/11.70(深),这层压缩是**同一个 hex 的常数偏移**,跨卡比较时抵消;
//! 真正的危险是暖相回退——rose 1.29、amber 1.89,比基线低 4.5~5.1,远超 1.5 的带宽;
//! 紫相(lavender 5.87)只低 0.54,靠上面的色相断言抓(298° 偏 61°)。带宽取 1.5 = 请求
//! 彩度的 1/8,比「改前最小的一张卡偏离基线」还小,所以「9 倍差回来了」一定红。
//!
//! 改前/改后的 `surface` 实测表在子模块 [`dump`](那里同时是验收图的数据源)。
// 子模块放在 `tests/card_neutral/` 目录里(cargo 只把 `tests/*.rs` 顶层文件当一个 target,
// 目录里的文件不会被当成独立 target;crate 根的子模块默认在 `tests/` 下找,所以显式 `#[path]`)。
#[path = "card_neutral/common.rs"]
mod common;
#[path = "card_neutral/dump.rs"]
mod dump;
use common::{before_d43, flavors, hue_gap};
use unlucky_design::color::{
Hct, Role, Scheme, SchemeFlavor, DEFAULT_NEUTRAL_CHROMA, NEUTRAL_HUE_DEG,
};
use unlucky_design::palette::card::card_scheme_is_readable;
use unlucky_design::palette::{
resolve_source, Card, CardKey, ImageInput, Level, CARDS, DEFAULT_CARD, TEXT_PAIRS,
};
/// 天蓝卡就是产品默认色:固定的中性色相**沿用**它,所以 49 个角色必须一位不差。
///
/// 这条是 M88 的 p1 级硬约束。它红了只说明一件事:把「沿用天蓝的中性」实现成了「另选一个中性」。
#[test]
fn sky_card_is_bit_identical_to_the_product_baseline() {
for flavor in flavors() {
let baseline = Scheme::baseline(flavor);
let sky = DEFAULT_CARD.scheme(flavor);
for (role, got) in sky.iter() {
assert_eq!(
got,
baseline.get(role),
"天蓝卡(出厂默认)的 {} / {} 变了:{} → {}",
role.name(),
flavor.name(),
baseline.get(role).to_hex(),
got.to_hex()
);
}
}
}
/// 八张卡的 `surface` 共用同一个中性调色板:色相一致(±1°)、彩度落在窄带(±1.5)。
#[test]
fn preset_cards_share_one_neutral_hue_and_chroma() {
for flavor in flavors() {
let hcts: Vec<(&str, Hct)> = CARDS
.iter()
.map(|card| {
(
card.key,
Hct::from_color(card.scheme(flavor).get(Role::Surface)),
)
})
.collect();
let reference = hcts[0].1;
for (key, hct) in &hcts {
assert!(
hue_gap(hct.hue, reference.hue) <= 1.0,
"{key} / {} 的 surface 色相 {:.2}° 偏离基线 {:.2}° 超过 ±1°",
flavor.name(),
hct.hue,
reference.hue
);
assert!(
(hct.chroma - reference.chroma).abs() <= 1.5,
"{key} / {} 的 surface 实测彩度 {:.2} 偏离基线 {:.2} 超过 ±1.5",
flavor.name(),
hct.chroma,
reference.chroma
);
}
// 更强的口径:八张卡的 surface 是**同一个色值**(不只是「相近」)。
let surfaces: Vec<String> = CARDS
.iter()
.map(|card| card.scheme(flavor).get(Role::Surface).to_hex())
.collect();
assert!(
surfaces.iter().all(|hex| hex == &surfaces[0]),
"{} 模式下八张卡的 surface 不再逐值相同:{surfaces:?}",
flavor.name()
);
}
}
/// 反证:上面那条断言**量得出**问题。改前的路径浅色档彩度差 9 倍,改成固定中性后只剩 1 倍。
///
/// 没有这条的话,「±1.5 的带宽」只是一句自我声明:谁把断言写松(比如改成 ±20),
/// `preset_cards_share_one_neutral_hue_and_chroma` 照样绿,而窗口又开始逐卡染色。
///
/// ⚠️ **9 倍差只出现在浅色档**:tone 98(近白)的暖相色域装不下彩度 12,rose 只兑现到 1.29;
/// 深色档的 tone 6 反而每张卡都装得下(11.63~12.45,只差 1.07 倍)。深色档的问题不是彩度,
/// 而是**色相**(14.66°~300.57°,整整一个色环)——它在深底上表现为「有的卡偏紫、有的偏绿」。
/// 所以两个模式的反证分开断言,别用一条 `> 8` 覆盖两个模式(那样深色档会红,且说不清为什么)。
#[test]
fn the_old_per_card_tinting_really_was_a_ninefold_gap() {
let surface_hct = |scheme: &dyn Fn(Card) -> Scheme| {
CARDS
.iter()
.map(|card| Hct::from_color(scheme(*card).get(Role::Surface)))
.collect::<Vec<_>>()
};
let chroma_ratio = |hcts: &[Hct]| {
let max = hcts.iter().map(|h| h.chroma).fold(f64::MIN, f64::max);
let min = hcts.iter().map(|h| h.chroma).fold(f64::MAX, f64::min);
max / min
};
let hue_span = |hcts: &[Hct]| {
// 色相是环:这里只关心「跨了多少」,用最大凸包跨度(简单排序后首尾差)就够粗判。
let mut hues: Vec<f64> = hcts.iter().map(|h| h.hue).collect();
hues.sort_by(|a, b| a.partial_cmp(b).expect("有限色相"));
hues[hues.len() - 1] - hues[0]
};
// 浅色档:彩度 9 倍差(改前)→ 1 倍(改后)。
let old_light = surface_hct(&|card| before_d43(card, SchemeFlavor::Light));
assert!(
chroma_ratio(&old_light) > 8.0,
"改前(浅色)的 surface 彩度差应当能量出「9 倍级」跳变,实测 {:.2} 倍——\
这条反证已经失效,上面的 ±1.5 带宽就不可信了",
chroma_ratio(&old_light)
);
let new_light = surface_hct(&|card| card.scheme(SchemeFlavor::Light));
assert!(
chroma_ratio(&new_light) < 1.5,
"固定中性后(浅色)八张卡的 surface 彩度差还有 {:.2} 倍",
chroma_ratio(&new_light)
);
// 深色档:彩度本来就一致,问题是色相跨了整个环 → 改后色相跨度收敛到 0。
let old_dark = surface_hct(&|card| before_d43(card, SchemeFlavor::Dark));
assert!(
hue_span(&old_dark) > 180.0,
"改前(深色)的 surface 色相应当横跨大半个色环,实测 {:.2}°",
hue_span(&old_dark)
);
assert!(
chroma_ratio(&old_dark) < 1.5,
"改前(深色)的彩度本来就一致(tone 6 装得下彩度 12),实测 {:.2} 倍",
chroma_ratio(&old_dark)
);
let new_dark = surface_hct(&|card| card.scheme(SchemeFlavor::Dark));
assert!(
hue_span(&new_dark) < 1.0,
"改后(深色)色相跨度 {:.2}°",
hue_span(&new_dark)
);
assert!(chroma_ratio(&new_dark) < 1.5);
}
/// 换卡只换强调族:不随强调色变的角色必须逐值相同,强调族必须真的换。
///
/// 「哪些角色属于强调族」不靠手抄清单,而是**实测**:拿另一个强调色再生成一套方案,值会变的
/// 那些角色就是强调族(primary/secondary/tertiary 全族 + `surface-tint` + `inverse-primary` +
/// 12 个 `*-fixed*` = 26 个),不随强调色变的 23 个(中性表面、描边、error、shadow/scrim)
/// 则必须留在基线——这样将来往 tone 表里加角色也不会漏掉分类。
///
/// ⚠️ 浅色档只有 **23** 个会变:`on-primary` / `on-secondary` / `on-tertiary` 的 tone 是 100,
/// 任何色相都解析成纯白,所以强调色换了它们也不变(深色档的 tone 是 20,会变)。
#[test]
fn cards_only_change_the_accent_families() {
for flavor in flavors() {
let baseline = Scheme::baseline(flavor);
let recolored = Scheme::from_accent(flavor, CardKey::Teal.card().seed);
let (mut accent_roles, mut neutral_roles) = (Vec::new(), Vec::new());
for role in Role::ALL {
if recolored.get(role) == baseline.get(role) {
neutral_roles.push(role);
} else {
accent_roles.push(role);
}
}
assert_eq!(
(accent_roles.len(), neutral_roles.len()),
if flavor.is_dark() { (26, 23) } else { (23, 26) },
"强调族 / 中性族的划分变了({} 模式下换强调色会动 {} 个)",
flavor.name(),
accent_roles.len()
);
let sky = DEFAULT_CARD.scheme(flavor);
for card in CARDS {
let scheme = card.scheme(flavor);
for role in &neutral_roles {
assert_eq!(
scheme.get(*role),
sky.get(*role),
"{} / {} 的 {}(不随强调色变的角色)不该跟着卡变",
card.key,
flavor.name(),
role.name()
);
}
if card.key != DEFAULT_CARD.key {
assert_ne!(
scheme.get(Role::Primary),
sky.get(Role::Primary),
"{} / {} 的主色与天蓝卡相同:卡没生效",
card.key,
flavor.name()
);
}
}
}
}
/// 卡方案 = 用该卡种子做「自定义强调色」:中性表面留在基线,强调族与官方 `from_seed` 对得上。
///
/// 这条把 D43 接到已有的 `tests/official_accent.rs`(192 项官方对账)上:色卡的强调族不是
/// 另一套算法,就是那条已经对过账的路径。
#[test]
fn card_scheme_matches_from_accent_when_chroma_is_the_default() {
for card in CARDS {
if card.neutral_chroma != DEFAULT_NEUTRAL_CHROMA {
continue; // 将来的卡可以带别的中性彩度;那时它的表面色与 baseline 不同是有意的
}
for flavor in flavors() {
assert_eq!(
card.scheme(flavor),
Scheme::from_accent(flavor, card.seed),
"{} / {} 的色卡方案与 from_accent 不一致",
card.key,
flavor.name()
);
}
}
}
/// **生成器层**:同一张图,两张不同色卡下强调色相同、底色由图像 seed 决定。
///
/// ⚠️ 只测 `Seed::scheme` 这一层,**不覆盖接线**:取色分支在实际入口
/// `palette::resolve::try_level` 里,接线由 `resolved_image_scheme_keeps_tinting_from_the_seed`
/// 守(复审 r1 p1-1 的病根就是这条用例看着像守护、其实测不到接线)。
#[test]
fn image_scheme_ignores_the_preset_neutral_hue() {
// 4×4 纯 `#B23A48`(rose 卡的种子色)——色相远离固定的 239.5°,好坏都能分辨。
let pixels = [0xB2u8, 0x3A, 0x48, 0xFF].repeat(16);
let seed = unlucky_design::palette::seed_from_rgba(&pixels, 4, 4).expect("纯色图有种子");
let cards = [CardKey::Sky.card(), CardKey::Teal.card()];
let schemes: Vec<Scheme> = cards
.iter()
.map(|card| seed.scheme(SchemeFlavor::Light, card.neutral_chroma))
.collect();
assert_eq!(
schemes[0].get(Role::Primary),
schemes[1].get(Role::Primary),
"取色方案的主色只由图像种子决定,不该跟着当前色卡变"
);
assert_eq!(
schemes[0].get(Role::Surface),
schemes[1].get(Role::Surface),
"取色方案的底色由图像种子决定(当前八张卡的中性彩度相同,所以两套方案的表面色相同)"
);
let surface_hue = Hct::from_color(schemes[0].get(Role::Surface)).hue;
// 精确口径:中性**调色板**的色相必须来自图像种子(不是量化后的表面色——tone 98 的
// 8 bit 量化会让实测色相漂 2.7°~7°,见模块文档;拿它做等式断言只会引入假红)。
for card in cards {
let neutral_hue = seed.palettes(card.neutral_chroma).neutral.hue_deg();
assert!(
(neutral_hue - seed.hue).abs() < 1.0e-9,
"取色路径的中性色相 {neutral_hue:.2}° 没有跟着图像种子 {:.2}° 走",
seed.hue
);
assert!(
hue_gap(neutral_hue, NEUTRAL_HUE_DEG) > 100.0,
"取色路径的中性色相被拉到了色卡的固定中性色相——动态路径的逐色染色被弄丢了(D43 保留它)"
);
}
// 观感层:实测的表面色相明显靠近图像种子,而不是靠色卡的冷调中性色相。
assert!(
hue_gap(surface_hue, seed.hue) < hue_gap(surface_hue, NEUTRAL_HUE_DEG),
"取色方案的 surface 色相 {surface_hue:.2}° 更接近色卡中性色相而不是图像种子 {:.2}°",
seed.hue
);
for card in cards {
assert_ne!(
schemes[0].get(Role::Surface),
card.scheme(SchemeFlavor::Light).get(Role::Surface),
"取色方案的表面色与色卡路径相同,说明取色路径没在按图染色"
);
}
}
/// **接线的**守护测试(复审 r1 p1-1):走产品公开入口 [`resolve_source`],不是 `Card::scheme`。
///
/// 上一版只有生成器层那条用例:实测把 `palette::resolve::try_level` 的取色分支改成固定中性
/// 时**8 条测试全绿**,而同一张 `#B23A48` 图的 `surface` 已从 `#FFF8F7`(暖白,跟着图走)
/// 退化成 `#F6FAFF`(色卡冷白)。断言测 A 层、接线在 B 层——这条把整条链跑通。
#[test]
fn resolved_image_scheme_keeps_tinting_from_the_seed() {
// 4×4 纯 `#B23A48`:种子色相 15.5°(底色 8 bit 量化后读回 22.09°),离 239.51° 很远。
let pixels = [0xB2u8, 0x3A, 0x48, 0xFF].repeat(16);
let flavor = SchemeFlavor::Light;
let mut resolved: Vec<(CardKey, Scheme)> = Vec::new();
for key in [CardKey::Sky, CardKey::Teal] {
let (scheme, provenance) = resolve_source(
Some(ImageInput::pixels(&pixels, 4, 4)),
None,
Some(key),
flavor,
);
assert_eq!(
provenance.level,
Level::BackgroundImage,
"{key:?}:背景图这一级没被用上(回落链本身变了)"
);
resolved.push((key, scheme));
}
let surface = resolved[0].1.get(Role::Surface);
// ① 底色在按图染色:必须是暖白,而不是色卡那条冷调中性。这一格就是「变异检测器」。
assert_eq!(
surface.to_hex(),
"#FFF8F7",
"取色方案的 surface 被拉回了色卡中性色相"
);
let surface_hue = Hct::from_color(surface).hue;
assert!(
hue_gap(surface_hue, 22.0) < 5.0,
"surface 色相 {surface_hue:.2}° 没跟着图走"
);
assert!(
hue_gap(surface_hue, NEUTRAL_HUE_DEG) > 100.0,
"surface 色相 {surface_hue:.2}° 贴到了色卡固定的 {NEUTRAL_HUE_DEG:.2}°"
);
// ② 两张卡下强调色与底色都相同:底色只由图像决定,色卡只贡献中性彩度。
assert_eq!(
(resolved[0].1.get(Role::Primary), surface),
(
resolved[1].1.get(Role::Primary),
resolved[1].1.get(Role::Surface)
),
"同一张图在两张卡下的主色与底色都该相同(八张卡的中性彩度都是 12)"
);
// ③ 与色卡路径明确不同:否则「取色」就等于「色卡 + 换强调色」。
assert_ne!(
surface,
DEFAULT_CARD.scheme(flavor).get(Role::Surface),
"取色方案的 surface 与天蓝卡的相同——取色路径退化成色卡路径了"
);
}
/// 8 张卡 × 浅/深 × 26 对:换中性色相后仍全部过 WCAG AA(`wcag::check` 全表),
/// 并把最受中性色相影响的一对(on-surface / surface)钉在实测量级上。
#[test]
fn every_card_scheme_meets_wcag_aa_under_the_fixed_neutral() {
for card in CARDS {
for flavor in flavors() {
let scheme = card.scheme(flavor);
assert!(
card_scheme_is_readable(card, flavor),
"{} / {} 未过 WCAG AA 全表({} 对逐对断言在 `palette::wcag::check`)",
card.key,
flavor.name(),
TEXT_PAIRS.len()
);
// 中性色相变了,最受影响的一对是 on-surface / surface(改前逐卡 16.23~16.44 浅 /
// 14.30~14.42 深)。改后八张卡同一个底色,这一对也逐卡相同,钉住量级免得漂移。
// 区间按模式分开:写成 `(浅区间) || (深区间)` 会让浅/深两个值互换也照样绿。
let body = scheme.contrast(Role::OnSurface, Role::Surface);
let band = match flavor {
SchemeFlavor::Light => 16.30..=16.40,
SchemeFlavor::Dark => 14.28..=14.36,
};
assert!(
band.contains(&body),
"{} / {}:on-surface/surface = {body:.3},超出 {} 模式的实测区间 {band:?}",
card.key,
flavor.name(),
flavor.name()
);
}
}
}
@@ -0,0 +1,27 @@
//! `card_neutral` 测试组共用的帮手:改前路径的复原、模式列表、色相环距离。
//!
//! 拆成子模块是为了单文件 ≤400 行的纪律(`card_neutral.rs` 是 crate 根,`common` / `dump`
//! 只是它的子模块;cargo 只把 `tests/card_neutral.rs` 当一个 test target)。
use unlucky_design::color::{Palettes, Scheme, SchemeFlavor};
use unlucky_design::palette::Card;
/// 改前的路径(M87 及之前):中性调色板跟着种子染色(官方 TonalSpot)。
///
/// 产品代码已经不用它了,但公开 API 仍在(取色路径就靠它逐色染色),所以这里能原样复现「改前」。
pub fn before_d43(card: Card, flavor: SchemeFlavor) -> Scheme {
Scheme::from_palettes(
&Palettes::from_seed_with_neutral_chroma(card.seed, card.neutral_chroma),
flavor,
)
}
pub fn flavors() -> [SchemeFlavor; 2] {
[SchemeFlavor::Light, SchemeFlavor::Dark]
}
/// 色相环上的最短距离(度)。
pub fn hue_gap(a: f64, b: f64) -> f64 {
let raw = (a - b).abs();
raw.min(360.0 - raw)
}
+68
View File
@@ -0,0 +1,68 @@
//! 出图脚本读的 hex 行(无断言,纯打印)+ D43 的实测表(本文件就是那张表的来源)。
//!
//! 跑法:`cargo test -p unlucky-design --test card_neutral -- --nocapture`
//!
//! ## 改前 / 改后 `surface` 实测(2026-11)
//!
//! | 卡 | 改前 `surface`(浅) | 改后 `surface`(浅) | 改前 HCT(浅) | 改后 HCT(浅) |
//! |---|---|---|---|---|
//! | sky | `#F6FAFF` | `#F6FAFF` | 236.86° / 6.41 | 236.86° / 6.41 |
//! | lavender | `#FDF7FF` | `#F6FAFF` | 298.10° / 5.87 | 236.86° / 6.41 |
//! | forest | `#EEFDEE` | `#F6FAFF` | 157.01° / 11.77 | 236.86° / 6.41 |
//! | amber | `#FFF8F4` | `#F6FAFF` | 71.48° / 1.89 | 236.86° / 6.41 |
//! | rose | `#FFF8F7` | `#F6FAFF` | 22.09° / 1.29 | 236.86° / 6.41 |
//! | teal | `#EBFEF8` | `#F6FAFF` | 182.42° / 12.03 | 236.86° / 6.41 |
//! | dusk | `#FBF8FF` | `#F6FAFF` | 280.31° / 5.44 | 236.86° / 6.41 |
//! | olive | `#F9FBE1` | `#F6FAFF` | 120.99° / 11.84 | 236.86° / 6.41 |
//!
//! 深色档同理:改前 `#09151C`~`#1F0F10`、彩度 11.63~12.45,改后八张卡统一 `#09151C`
//! (235.70° / 11.70)。
//!
//! ## 这条用例为什么没有断言
//!
//! 它是**数据导出**(`CARD|` 行给 `/d/tmp/m88/render.py` 排版成验收图),断言在同目录的
//! `card_neutral.rs` 里。放这里是为了让根文件留在 ≤400 行以内。
use super::common::{before_d43, flavors};
use unlucky_design::color::Role;
use unlucky_design::palette::CARDS;
/// 出图脚本读的 hex 行(改前 + 改后并排),跑法:
/// `cargo test -p unlucky-design --test card_neutral -- --nocapture`。
///
/// 格式 `CARD|key|flavor|before|role=hex;role=hex;…`(承接那张「8 张卡 surface 色块并排 +
/// 切 sky→teal 窗口底色」的验收图;渲染脚本 `/d/tmp/m88/render.py` 只做排版,数据全在这里)。
#[test]
fn dump_card_palette_for_the_color_card() {
const SHOWN: [Role; 10] = [
Role::Surface,
Role::OnSurface,
Role::OnSurfaceVariant,
Role::SurfaceVariant,
Role::SurfaceContainer,
Role::SurfaceContainerHigh,
Role::Outline,
Role::Primary,
Role::OnPrimary,
Role::PrimaryContainer,
];
for card in CARDS {
for flavor in flavors() {
for (tag, scheme) in [
("before", before_d43(card, flavor)),
("after", card.scheme(flavor)),
] {
let pairs: Vec<String> = SHOWN
.iter()
.map(|role| format!("{}={}", role.name(), scheme.get(*role).to_hex()))
.collect();
println!(
"CARD|{}|{}|{tag}|{}",
card.key,
flavor.name(),
pairs.join(";")
);
}
}
}
}
+255
View File
@@ -0,0 +1,255 @@
//! WCAG AA 对比度门禁(M61 任务:「on-* 与其承载色至少满足 4.5:1,不达标的角色要显式标注例外」)。
//!
//! 三个性质让这条门禁可以做到「**换任何种子色都成立**」,而不是只对出厂紫成立:
//! 1. 颜色建在 CIELAB `L*`(tone)轴上,`TonalPalette::tone` 精确命中 tone(误差 ≤1);
//! 2. WCAG 相对亮度只取决于同一条 Y 轴(`L*` ↔ Y 是双射);
//! 3. 角色 → tone 的映射与色相/彩度无关。
//!
//! 所以对比度主要由「两个角色的 tone 差」决定,与 user 选的强调色无关——下面对多组种子
//! 逐一验证,就是在钉这个性质。
use unlucky_design::color::{Color, Role, Scheme, SchemeFlavor};
/// 正文文字:要求 ≥ 4.5:1(WCAG AA normal text)。
const AA_TEXT: f32 = 4.5;
/// 非文字元素(描边、图标块):要求 ≥ 3:1(WCAG AA non-text)。
const AA_NON_TEXT: f32 = 3.0;
/// 文字/图标对(前景, 承载色)。两个模式都要过。
const TEXT_PAIRS: [(Role, Role); 26] = [
(Role::OnPrimary, Role::Primary),
(Role::OnPrimaryContainer, Role::PrimaryContainer),
(Role::OnSecondary, Role::Secondary),
(Role::OnSecondaryContainer, Role::SecondaryContainer),
(Role::OnTertiary, Role::Tertiary),
(Role::OnTertiaryContainer, Role::TertiaryContainer),
(Role::OnError, Role::Error),
(Role::OnErrorContainer, Role::ErrorContainer),
(Role::OnSurface, Role::Surface),
(Role::OnSurfaceVariant, Role::SurfaceVariant),
(Role::OnBackground, Role::Background),
(Role::InverseOnSurface, Role::InverseSurface),
(Role::InversePrimary, Role::InverseSurface),
(Role::OnPrimaryFixed, Role::PrimaryFixed),
(Role::OnPrimaryFixedVariant, Role::PrimaryFixedDim),
(Role::OnSecondaryFixed, Role::SecondaryFixed),
(Role::OnSecondaryFixedVariant, Role::SecondaryFixedDim),
(Role::OnTertiaryFixed, Role::TertiaryFixed),
(Role::OnTertiaryFixedVariant, Role::TertiaryFixedDim),
// 卡片/浮层容器上的文字(M3 现行口径:层级靠 surface-container-* 表达)
(Role::OnSurface, Role::SurfaceContainerLowest),
(Role::OnSurface, Role::SurfaceContainerLow),
(Role::OnSurface, Role::SurfaceContainer),
(Role::OnSurface, Role::SurfaceContainerHigh),
(Role::OnSurface, Role::SurfaceContainerHighest),
(Role::OnSurfaceVariant, Role::SurfaceContainerHighest),
(Role::OnSurfaceVariant, Role::SurfaceContainerLow),
];
/// 显式登记的例外:`(前景, 承载色, 目标阈值, 原因)`。
///
/// 这些不是缺陷,是 M3 定义决定的(非文字元素 3:1、装饰件无要求)。
const EXCEPTIONS: [(Role, Role, f32, &str); 3] = [
(
Role::Outline,
Role::Surface,
AA_NON_TEXT,
"输入框/卡片描边是 M3 定义的非文字元素,规范目标 3:1(light 实测 4.26:1、dark 5.89:1 已高于目标)",
),
(
Role::OutlineVariant,
Role::Surface,
AA_NON_TEXT * 0.5,
"分隔线是纯装饰,M3 不要求对比度下限;light 1.62:1 / dark 1.99:1 是「几乎看不见但要能数出层级」的取值",
),
(
Role::SurfaceVariant,
Role::Surface,
1.0,
"surface-variant 是容器色而不是前景色,不参与对比度门禁(M3 已不推荐用它做容器,保留仅为兼容旧值)",
),
];
fn seeds() -> Vec<Color> {
vec![
// 出厂基线(M3 baseline 紫)
Color::from_hex("#6750A4").expect("基线种子"),
// 冷蓝(原 Fluent 强调色,用户大概率会选这一族)
Color::from_hex("#0078D4").expect("蓝"),
// 青绿
Color::from_hex("#006A5C").expect("青"),
// 暖橙(高彩度,gamut 压力大)
Color::from_hex("#E8710A").expect("橙"),
// 亮黄(最容易被白字吃掉的极端)
Color::from_hex("#FFD400").expect("黄"),
// 近灰(低彩度种子,检验 neutral 不塌)
Color::from_hex("#6E6E6E").expect("灰"),
]
}
#[test]
fn text_pairs_meet_wcag_aa_for_every_seed_and_flavor() {
let mut failures = Vec::new();
for seed in seeds() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let scheme = Scheme::from_seed(flavor, seed);
for (fg, bg) in TEXT_PAIRS {
let ratio = scheme.contrast(fg, bg);
if ratio < AA_TEXT {
failures.push(format!(
"seed {} / {} / {} on {} = {ratio:.2}",
seed.to_hex(),
flavor.name(),
fg.name(),
bg.name()
));
}
}
}
}
assert!(
failures.is_empty(),
"以下角色对未达 WCAG AA(4.5:1):\n{}",
failures.join("\n")
);
}
#[test]
fn exception_list_is_honest_about_its_thresholds() {
// 例外必须**真的**是「非文字级」,不能悄悄把低于 3:1 的文字对塞进来。
for seed in seeds() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let scheme = Scheme::from_seed(flavor, seed);
for (fg, bg, threshold, reason) in EXCEPTIONS {
assert!(
!fg.is_foreground(),
"例外 {} 不该是文字前景色({reason})",
fg.name()
);
let ratio = scheme.contrast(fg, bg);
assert!(
ratio >= threshold,
"例外 {} on {} 实测 {ratio:.2},低于登记阈值 {threshold}(seed {} / {})",
fg.name(),
bg.name(),
seed.to_hex(),
flavor.name()
);
}
}
}
}
#[test]
fn baseline_light_and_dark_both_pass() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let scheme = Scheme::baseline(flavor);
for (fg, bg) in TEXT_PAIRS {
let ratio = scheme.contrast(fg, bg);
assert!(
ratio >= AA_TEXT,
"baseline {} 的 {} on {} 只有 {ratio:.2}",
flavor.name(),
fg.name(),
bg.name()
);
}
}
}
#[test]
fn sky_seed_candidates_meet_wcag_aa() {
// M70:四档默认色候选(种子 + 中性彩度)必须过同一条 26 组门禁。
// 中性彩度改变只影响色相/彩度,不影响 tone 轴,所以理论上恒过——这里就是要**证明**它恒过,
// 而不是相信它。任何一档不达标,该档直接判为不可用(不许为了好看放宽阈值)。
let candidates = [
("① 保守蓝", "#2196F3", 6.0_f64),
("② 冷调蓝", "#2196F3", 12.0),
("③ 浅冷蓝", "#87CEEB", 12.0),
("④ 天蓝", "#3C8DBC", 12.0),
];
let mut failures = Vec::new();
let mut worst = (f32::MAX, String::new());
let mut checked = 0;
for (title, seed_hex, neutral_chroma) in candidates {
let seed = Color::from_hex(seed_hex).expect("候选种子");
let palettes =
unlucky_design::color::Palettes::from_seed_with_neutral_chroma(seed, neutral_chroma);
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let scheme = Scheme::from_palettes(&palettes, flavor);
for (fg, bg) in TEXT_PAIRS {
let ratio = scheme.contrast(fg, bg);
checked += 1;
if ratio < worst.0 {
worst = (
ratio,
format!(
"{title}/{} 的 {} on {}",
flavor.name(),
fg.name(),
bg.name()
),
);
}
if ratio < AA_TEXT {
failures.push(format!(
"{title}({seed_hex} / 中性 {neutral_chroma})/ {} / {} on {} = {ratio:.2}",
flavor.name(),
fg.name(),
bg.name()
));
}
}
}
}
println!(
"候选 WCAG:{checked} 项,最紧的一对 {} = {:.2}:1(阈值 {AA_TEXT})",
worst.1, worst.0
);
assert!(
failures.is_empty(),
"以下候选未达 WCAG AA(4.5:1),该档不可用:\n{}",
failures.join("\n")
);
}
#[test]
fn accent_override_cannot_break_contrast() {
// 换了强调色之后,primary 族仍必须过 AA——这是「tone 轴固定」带来的结构性保证。
let base = Scheme::baseline(SchemeFlavor::Light);
for seed in seeds() {
let custom = Scheme::from_accent(SchemeFlavor::Light, seed);
for (fg, bg) in [
(Role::OnPrimary, Role::Primary),
(Role::OnPrimaryContainer, Role::PrimaryContainer),
(Role::OnSecondary, Role::Secondary),
(Role::OnTertiary, Role::Tertiary),
] {
let ratio = custom.contrast(fg, bg);
assert!(
ratio >= AA_TEXT,
"强调色 {} 下 {} on {} 只有 {ratio:.2}",
seed.to_hex(),
fg.name(),
bg.name()
);
}
// 表面色不受强调色影响(这条同时是 `from_accent` 的语义断言)。
assert_eq!(custom.get(Role::Surface), base.get(Role::Surface));
}
}
#[test]
fn outline_is_registered_as_non_text_exception() {
// 把「为什么 outline 不是 AA 例外失败项」钉死:它在 light 下 4.29:1(低于 4.5 但高于 3:1)。
let light = Scheme::baseline(SchemeFlavor::Light);
let ratio = light.contrast(Role::Outline, Role::Surface);
assert!(
ratio < AA_TEXT,
"light 的 outline 若已达 4.5 应把例外删掉:{ratio:.2}"
);
assert!(
ratio >= AA_NON_TEXT,
"light 的 outline 低于非文字 3:1:{ratio:.2}"
);
}
+307
View File
@@ -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}°");
}
}
+198
View File
@@ -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}");
}
+341
View File
@@ -0,0 +1,341 @@
//! M70:默认色候选(保守蓝 / 冷调蓝 / 浅冷蓝 / 天蓝)——四档的**唯一真源**与 pin。
//!
//! ## 这档候选到底改了什么
//!
//! 四档走的是**同一条** HCT 管道(`Palettes::from_seed_with_neutral_chroma` → `ColorSpec2021`
//! tone 表 → `HctSolver` gamut 映射),差别只有两个入参:
//!
//! | 档 | 种子 | 种子 HCT 色相 | 中性调色板彩度 | 表面 | 主色观感 |
//! |---|---|---|---|---|---|
//! | ① 保守蓝 | `#2196F3` | 253.7° | 6(官方) | 近白/近黑,几乎无色 | 钢蓝 |
//! | ② 冷调蓝 | `#2196F3` | 253.7° | 12 | 冷白/冷黑,带淡蓝调 | 钢蓝 |
//! | ③ 浅冷蓝(备选) | `#87CEEB`(CSS `skyblue`) | 225.7° | 12 | 浅天蓝 | 偏青(teal) |
//! | ④ 天蓝(**出厂默认,用户 2026-11-01 拍板**) | `#3C8DBC` | 239.5° | 12 | 浅天蓝 | 偏蓝的天蓝 |
//!
//! ③ 与 ④ 是同一件事的两种「蓝」:色相越低越偏青(teal),越高越偏钢蓝;
//! 239.5° 正好夹在中间,主色从 ③ 的 `#0C6780` 变成 `#236488`。
//!
//! 两条必须知道的物理事实(写在测试里,免得下次又有人想「把主色调浅一点」):
//!
//! 1. **种子彩度是废参数**:Tonal Spot 把 primary 彩度钉在 36,种子只贡献色相。
//! 所以「低彩度天蓝种子」得到的色板与「高彩度天蓝种子」**逐像素相同**
//! (`seed_chroma_never_leaks_into_the_primary_ramp` 在 `palettes.rs` 里也钉了这条)。
//! ③ 与 ② 的真正差别是**色相**(226° vs 254°),不是彩度。
//! 2. **primary 的 tone 被钉在 40**,所以四档主色都是「中蓝」,不会更浅。
//! 「浅」只能落在中性色上——这就是②/③/④提高中性彩度的原因,也是本项目唯一一处
//! 对官方 Tonal Spot 的**有意偏离**(登记在 `color` 模块文档的「出厂默认色」小节)。
//!
//! ## 换默认色要动哪里
//!
//! `palettes::DEFAULT_SEED` / `palettes::DEFAULT_NEUTRAL_CHROMA` 两行常量,
//! 然后重跑本文件:pin 表会逐角色告诉你哪几个 hex 变了,照抄即可(pin 是**抄本**,不是推导)。
use unlucky_design::color::{
Color, Hct, Palettes, Role, Scheme, SchemeFlavor, DEFAULT_NEUTRAL_CHROMA, DEFAULT_SEED,
};
/// 一档候选:只有两个变量(种子 + 中性彩度),其余全是官方常数。
struct Candidate {
/// 短标签(色卡与 CSV 用)。
tag: &'static str,
/// 中文档名。
title: &'static str,
/// 种子 hex。
seed_hex: &'static str,
/// 中性调色板彩度(官方 Tonal Spot 是 6)。
neutral_chroma: f64,
/// 9 个展示角色的 **(角色, light, dark)** 抄本。
pins: [(Role, &'static str, &'static str); 9],
}
/// 四档候选(色卡 `DevDocs/ops/skyseed-candidates/` 用的就是这份数据)。
const CANDIDATES: [Candidate; 4] = [
Candidate {
tag: "C1",
title: "① 保守蓝(中性彩度 6 = 官方)",
seed_hex: "#2196F3",
neutral_chroma: 6.0,
pins: [
(Role::Primary, "#36618E", "#A0CAFD"),
(Role::OnPrimary, "#FFFFFF", "#003258"),
(Role::PrimaryContainer, "#D1E4FF", "#194975"),
(Role::Secondary, "#535F70", "#BBC7DB"),
(Role::Tertiary, "#6B5778", "#D6BEE4"),
(Role::Surface, "#F8F9FF", "#111418"),
(Role::SurfaceContainerLow, "#F2F3FA", "#191C20"),
(Role::SurfaceContainerHighest, "#E1E2E8", "#32353A"),
(Role::Outline, "#73777F", "#8D9199"),
],
},
Candidate {
tag: "C2",
title: "② 冷调蓝(中性彩度 12)",
seed_hex: "#2196F3",
neutral_chroma: 12.0,
pins: [
(Role::Primary, "#36618E", "#A0CAFD"),
(Role::OnPrimary, "#FFFFFF", "#003258"),
(Role::PrimaryContainer, "#D1E4FF", "#194975"),
(Role::Secondary, "#535F70", "#BBC7DB"),
(Role::Tertiary, "#6B5778", "#D6BEE4"),
(Role::Surface, "#F8F9FF", "#0C141E"),
(Role::SurfaceContainerLow, "#EEF4FF", "#141C26"),
(Role::SurfaceContainerHighest, "#DBE3F1", "#2D3540"),
(Role::Outline, "#6D7887", "#8791A1"),
],
},
Candidate {
tag: "C3",
title: "③ 浅冷蓝(中性彩度 12 + 偏青天蓝种子)",
seed_hex: "#87CEEB",
neutral_chroma: 12.0,
pins: [
(Role::Primary, "#0C6780", "#89D0ED"),
(Role::OnPrimary, "#FFFFFF", "#003544"),
(Role::PrimaryContainer, "#BAEAFF", "#004D62"),
(Role::Secondary, "#4C626B", "#B3CAD5"),
(Role::Tertiary, "#5B5B7E", "#C4C3EB"),
(Role::Surface, "#F3FAFF", "#07151B"),
(Role::SurfaceContainerLow, "#E7F6FE", "#0F1D23"),
(Role::SurfaceContainerHighest, "#D6E5ED", "#29373D"),
(Role::Outline, "#687A82", "#81949C"),
],
},
Candidate {
tag: "C4",
title: "④ 天蓝(中性彩度 12 + 夹在中间的天蓝种子)",
seed_hex: "#3C8DBC",
neutral_chroma: 12.0,
pins: [
(Role::Primary, "#236488", "#93CDF6"),
(Role::OnPrimary, "#FFFFFF", "#00344C"),
(Role::PrimaryContainer, "#C8E6FF", "#004C6D"),
(Role::Secondary, "#4F616E", "#B7C9D8"),
(Role::Tertiary, "#63597C", "#CDC0E9"),
(Role::Surface, "#F6FAFF", "#09151C"),
(Role::SurfaceContainerLow, "#EBF5FF", "#111D25"),
(Role::SurfaceContainerHighest, "#D8E4EF", "#2B363F"),
(Role::Outline, "#6A7985", "#84939F"),
],
},
];
/// 四档共同点:种子的 HCT 坐标(色相是候选之间**唯一**有效的差别)。
fn seed_hct(candidate: &Candidate) -> Hct {
Hct::from_color(Color::from_hex(candidate.seed_hex).expect("候选种子必须是合法 hex"))
}
/// 按候选参数走**产品同一条**派生路径。
fn scheme(candidate: &Candidate, flavor: SchemeFlavor) -> Scheme {
Scheme::from_palettes(
&Palettes::from_seed_with_neutral_chroma(
seed_hct(candidate).to_color(),
candidate.neutral_chroma,
),
flavor,
)
}
#[test]
fn candidates_match_their_pins() {
let mut drift = Vec::new();
for candidate in &CANDIDATES {
for (role, want_light, want_dark) in candidate.pins {
for (flavor, want_hex) in [
(SchemeFlavor::Light, want_light),
(SchemeFlavor::Dark, want_dark),
] {
let got = scheme(candidate, flavor).get(role).to_hex();
if got != want_hex {
drift.push(format!(
"{}{} / {}/{} 实测 {got},pin {want_hex}",
candidate.tag,
if flavor.is_dark() { "-dark" } else { "" },
candidate.title,
role.name()
));
}
}
}
}
assert!(
drift.is_empty(),
"候选色值漂了(换默认色后请照抄下面这些 hex 到 pin 表):\n{}",
drift.join("\n")
);
}
#[test]
fn candidates_only_move_the_two_declared_inputs() {
// 「不绕算法」的证据:四档的调色板彩度与官方 Tonal Spot 逐项一致,
// 唯一的差别是中性彩度的入参(以及种子色相)。
let official = Palettes::official_baseline();
for candidate in &CANDIDATES {
let p = Palettes::from_seed_with_neutral_chroma(
seed_hct(candidate).to_color(),
candidate.neutral_chroma,
);
assert_eq!(p.primary.chroma(), official.primary.chroma());
assert_eq!(p.secondary.chroma(), official.secondary.chroma());
assert_eq!(p.tertiary.chroma(), official.tertiary.chroma());
assert_eq!(p.error, official.error, "error 族不该被候选影响");
assert_eq!(p.neutral.chroma(), candidate.neutral_chroma);
assert!(
(p.tertiary.hue_deg() - (p.primary.hue_deg() + 60.0)).abs() < 1.0e-9,
"{} 的 tertiary 不再是 +60°",
candidate.tag
);
}
}
#[test]
fn the_candidates_are_actually_distinguishable() {
// 「四档不像同一档」的机器判据。注意 light 的 surface(tone 98)在 8 bit 下已经贴顶,
// 中性彩度 6→12 会量化到同一个 #F8F9FF——所以 ①② 的判据落在 container 系与 dark 模式上,
// 这也是色卡上 ①② 的差别主要体现在卡片/浮层与深色模式的原因。
let light = |c: &Candidate| scheme(c, SchemeFlavor::Light);
let dark = |c: &Candidate| scheme(c, SchemeFlavor::Dark);
let (c1, c2, c3, c4) = (
light(&CANDIDATES[0]),
light(&CANDIDATES[1]),
light(&CANDIDATES[2]),
light(&CANDIDATES[3]),
);
for role in [Role::SurfaceContainerLow, Role::SurfaceContainerHighest] {
assert_ne!(
c1.get(role),
c2.get(role),
"①与②的 {} 相同:中性彩度没起作用",
role.name()
);
assert_ne!(c2.get(role), c3.get(role), "②与③的 {} 相同", role.name());
// ③④ 的中性彩度相同,差别只允许来自色相
assert_ne!(c3.get(role), c4.get(role), "③与④的 {} 相同", role.name());
}
assert_ne!(
c3.get(Role::Primary),
c4.get(Role::Primary),
"③与④的主色必须真的不同(否则第 4 档白加)"
);
assert_ne!(
dark(&CANDIDATES[0]).get(Role::Surface),
dark(&CANDIDATES[1]).get(Role::Surface),
"深色模式的 surface 应当能看出中性彩度差别"
);
let hue_gap = |a: &Candidate, b: &Candidate| {
let (x, y) = (seed_hct(a).hue, seed_hct(b).hue);
(x - y).abs().min(360.0 - (x - y).abs())
};
assert!(
hue_gap(&CANDIDATES[0], &CANDIDATES[1]) < 0.01,
"①② 应当只差中性彩度"
);
// ③ 偏青 / ④ 居中的分界靠色相:②→③ 与 ③→④ 都要肉眼可分的量级。
for (i, j) in [(1, 2), (2, 3)] {
assert!(
(10.0..=40.0).contains(&hue_gap(&CANDIDATES[i], &CANDIDATES[j])),
"{}→{} 的色相差应当明显(10°~40°),实测 {}",
CANDIDATES[i].tag,
CANDIDATES[j].tag,
hue_gap(&CANDIDATES[i], &CANDIDATES[j])
);
}
}
#[test]
fn product_default_is_the_adopted_candidate() {
// 用户 2026-11-01 拍板采用 **④ 天蓝**。这条用例就是「默认值 ↔ 候选表」的绑定器:
// 谁改了 `DEFAULT_SEED`/`DEFAULT_NEUTRAL_CHROMA` 却没同步这里(或反过来),它会立刻红。
// 断言一律比 **hex / 数值**,这样失败信息里直接能读到「期望 ④、实测几」。
let adopted = &CANDIDATES[3];
assert_eq!(
DEFAULT_SEED.to_hex(),
"#3C8DBC",
"DEFAULT_SEED 必须仍是已采用的 ④ 天蓝:期望 #3C8DBC(④),实测 {}({})",
DEFAULT_SEED.to_hex(),
if DEFAULT_SEED.to_hex() == CANDIDATES[2].seed_hex {
"③ 浅冷蓝——要改回 ③ 请连偏离登记与 pin 一起改"
} else {
"不是候选表里的任何一个种子"
}
);
assert_eq!(
DEFAULT_SEED.to_hex(),
adopted.seed_hex,
"DEFAULT_SEED({})与已采用档 {} 的种子({})不一致",
DEFAULT_SEED.to_hex(),
adopted.tag,
adopted.seed_hex
);
assert_eq!(
DEFAULT_NEUTRAL_CHROMA, 12.0,
"DEFAULT_NEUTRAL_CHROMA 必须仍是已采用的 12,实测 {}",
DEFAULT_NEUTRAL_CHROMA
);
assert_eq!(
DEFAULT_NEUTRAL_CHROMA, adopted.neutral_chroma,
"DEFAULT_NEUTRAL_CHROMA({})与已采用档 {} 的中性彩度({})不一致",
DEFAULT_NEUTRAL_CHROMA, adopted.tag, adopted.neutral_chroma
);
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let product = Scheme::baseline(flavor);
let expected = scheme(adopted, flavor);
for (role, got) in product.iter() {
assert_eq!(
got.to_hex(),
expected.get(role).to_hex(),
"产品默认与已采用档 {} 的 {}({} 模式)不一致",
adopted.tag,
role.name(),
flavor.name()
);
}
}
}
#[test]
fn surface_tint_stays_a_tint() {
// 门禁:中性彩度提高是「淡蓝调」不是「把窗口染成蓝纸」。surface 的 HCT 彩度必须
// 明显低于 primary 的 36,且 surface 的 Y 仍 > 0.85(浅色模式还是浅底)。
for candidate in &CANDIDATES {
let light = scheme(candidate, SchemeFlavor::Light);
let surface = light.get(Role::Surface);
let chroma = Hct::from_color(surface).chroma;
assert!(
chroma <= 1.2 * candidate.neutral_chroma,
"{} 的 surface 彩度 {chroma} 超过中性彩度入参的两倍",
candidate.tag
);
assert!(surface.relative_luminance() > 0.85);
}
}
#[test]
fn dump_candidates_for_the_color_card() {
// 色卡脚本读的就是这两行格式(`SEED|` / `ROLE|`)。跑法:
// cargo test -p unlucky-design --test skyseed_candidates -- --nocapture
for candidate in &CANDIDATES {
let hct = seed_hct(candidate);
println!(
"SEED|{}|{}|{}|{}|hue={:.2}|chroma={:.2}",
candidate.tag,
candidate.title,
candidate.seed_hex,
candidate.neutral_chroma,
hct.hue,
hct.chroma
);
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let s = scheme(candidate, flavor);
for role in Role::ALL {
println!(
"ROLE|{}|{}|{}|{}",
candidate.tag,
flavor.name(),
role.name(),
s.get(role).to_hex().trim_start_matches('#')
);
}
}
}
}
+246
View File
@@ -0,0 +1,246 @@
//! 令牌契约测试:从**消费方视角**(只用公开 API)钉住 M61 承诺的那些性质。
//!
//! 这份测试的作用是「冻结」:`IconKind`/`Role` 的次序与个数、type scale 的取值、形状/高程阶梯、
//! 描边的 SVG 语义换算。后续 iced 页面线靠它保证不会因为一次「顺手改动」而悄悄换观感。
use unlucky_design::color::{Role, Scheme, SchemeFlavor, TonalPalette};
use unlucky_design::component_map::Component;
use unlucky_design::elevation::{Elevation, MaterialLayer};
use unlucky_design::icons::{
stroke_width_for, visual_stroke_px, IconKind, IconPaint, IconSize, VIEWBOX,
};
use unlucky_design::motion::{
DurationToken, Easing, MotionScale, SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
};
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::{LayoutMetrics, Space};
use unlucky_design::typography::{FontFamily, Platform, TypeRole, Weight};
use unlucky_design::DesignTokens;
#[test]
fn color_role_table_is_frozen() {
assert_eq!(Role::ALL.len(), 49, "M3 基础角色集共 49 个");
assert_eq!(Role::ALL[0], Role::Primary);
assert_eq!(Role::ALL[16], Role::Surface);
assert_eq!(Role::ALL[48], Role::OnTertiaryFixedVariant);
// 每个角色都能从两套 scheme 里取到值,且前景角色都带 on- 语义。
let light = Scheme::baseline(SchemeFlavor::Light);
let dark = Scheme::baseline(SchemeFlavor::Dark);
for role in Role::ALL {
assert!(
light.get(role).is_opaque(),
"{} 应当是不透明色",
role.name()
);
assert!(dark.get(role).is_opaque());
if role.name().starts_with("on-") {
assert!(role.is_foreground(), "{} 应被判为前景色", role.name());
}
}
assert!(light.get(Role::Primary).is_opaque());
}
#[test]
fn tone_mapping_is_the_m3_table() {
// 抽查几行关键映射(light / dark),防止有人改 tone 表时静默换观感。
assert_eq!(Scheme::tone_of(Role::Primary, SchemeFlavor::Light), 40.0);
assert_eq!(Scheme::tone_of(Role::Surface, SchemeFlavor::Light), 98.0);
assert_eq!(Scheme::tone_of(Role::Surface, SchemeFlavor::Dark), 6.0);
assert_eq!(
Scheme::tone_of(Role::OnPrimaryContainer, SchemeFlavor::Dark),
90.0
);
assert_eq!(Scheme::tone_of(Role::Outline, SchemeFlavor::Light), 50.0);
}
#[test]
fn type_scale_values_are_the_m3_baseline() {
assert_eq!(TypeRole::ALL.len(), 15);
let title = TypeRole::TitleLarge.style();
assert_eq!((title.size_px, title.line_height_px), (22.0, 28.0));
let body = TypeRole::BodyMedium.style();
assert_eq!((body.size_px, body.line_height_px), (14.0, 20.0));
assert_eq!(body.weight, Weight::Regular);
let label = TypeRole::LabelLarge.style();
assert_eq!(label.weight, Weight::Medium);
assert_eq!(TypeRole::BodySmall.style().size_px, 12.0);
// 强调档只动字重。
assert_eq!(TypeRole::TitleLarge.emphasized().size_px, 22.0);
assert!(TypeRole::TitleMedium.emphasized().weight.value() > Weight::Medium.value());
}
#[test]
fn font_stacks_never_embed_font_files() {
for platform in [Platform::Windows, Platform::MacOs, Platform::Linux] {
for family in [FontFamily::Display, FontFamily::Text, FontFamily::Mono] {
let stack = platform.font_stack(family);
assert!(!stack.is_empty());
for face in stack {
let lower = face.family.to_ascii_lowercase();
for banned in [".ttf", ".otf", ".ttc", "woff", "file"] {
assert!(
!lower.contains(banned),
"{} 的字体栈出现文件引用 {}(只允许族名,禁止内嵌受限字体)",
platform.name(),
face.family
);
}
}
}
}
}
#[test]
fn cjk_weight_gap_is_registered() {
// 中文 700(Expressive 强调档要用的)在三平台都要能找到原生落点,否则就是合成加粗降级。
use unlucky_design::typography::{Weight, WeightMatch};
// Windows:Segoe UI Variable 覆盖 700(原生);CJK 落点是雅黑 700(原生,雅黑有 Bold)。
let win = Platform::Windows.resolve_weight(FontFamily::Text, Weight::Bold);
assert_eq!(win.quality, WeightMatch::Native);
assert!(Platform::Windows.cjk_face().supports(Weight::Bold));
// macOS:苹方只到 600 → CJK 的 700 只能合成/换族,这条 gap 已在文档登记。
assert!(!Platform::MacOs.cjk_face().supports(Weight::Bold));
// Linux:Noto Sans CJK 可变字体全覆盖。
for weight in [Weight::Regular, Weight::Medium, Weight::Bold] {
assert!(Platform::Linux.cjk_face().supports(weight));
}
}
#[test]
fn icon_kind_is_frozen_and_append_only() {
assert_eq!(IconKind::ALL.len(), 28, "M61 冻结 28 个图标");
// 迁移来的 24 个必须保持原次序(序号即持久化 id)。
assert_eq!(IconKind::ALL[0], IconKind::Instances);
assert_eq!(IconKind::ALL[4], IconKind::Ai);
assert_eq!(IconKind::ALL[20], IconKind::More);
assert_eq!(IconKind::ALL[23], IconKind::User);
// 新增的 4 个追加在末尾。
assert_eq!(IconKind::ALL[24], IconKind::Refresh);
assert_eq!(IconKind::ALL[27], IconKind::Trash);
for kind in IconKind::ALL {
assert!(kind.icon().path.starts_with('M'));
assert_eq!(kind.icon().kind, kind);
}
}
#[test]
fn icon_stroke_follows_svg_semantics_not_slint() {
// 派生式(**这条测试钉的就是它**):`stroke = 1.5 × 24 / render_px`,1.5 是视觉线宽、24 是 viewBox。
//
// 两个锚点,一个不随本次收敛变化、一个随令牌取值:
// - 20px(`IconSize::Nav`,本轮没动)→ 1.5 × 24 / 20 = **1.8**
// - 18px(`IconSize::Button`,M86 由 16 收敛到官方 18)→ 1.5 × 24 / 18 = **2.0**
//
// ⚠️ **这条只保证「烘焙路径」**(`crates/app-iced/src/icons.rs::view_at`:按盒尺寸烘焙 SVG,
// 于是一个 viewBox 单位的 stroke 落到盒子后正好是 1.5 视觉像素)。
// 它**不**保证「24 烘焙 + 布局缩到 18」那条路径——那条会得到 `1.5 × 18/24 = 1.125px`,
// 正是复审 r1 P2-2 实测到的 1.04~1.25px 缺陷。所以组件侧凡是盒 ≠ `RENDER_PX`(24) 的调用点
// 都必须走 `view_at`(见 `icons.rs::tests::view_at_bakes_the_requested_size` 与
// `crates/app-iced/src/components/gate.rs` 的 Q9 扫描)。
//
// 之所以写死字面量而不写成 `stroke_width_for(IconSize::Nav.px(), 1.5)`:后者等于拿实现算实现,
// 派生式一旦被改坏(例如把 ×24 写成 ×20)它照样绿。写死才会红。
// Slint 口径(绝对 1.5px,不乘 viewBox)在这两档都会得到 1.5 —— 那正是这条要防的错。
let nav_width = IconKind::Settings.icon().stroke_width(IconSize::Nav.px());
assert!(
(nav_width - 1.8).abs() < 1.0e-6,
"20px 渲染应取 1.8 个 viewBox 单位(锚点,不随 IconSize 收敛变化)"
);
let button_width = IconKind::Settings
.icon()
.stroke_width(IconSize::Button.px());
assert!(
(button_width - 2.0).abs() < 1.0e-6,
"18px 渲染应取 2.0 个 viewBox 单位(M86:Button 由 16→18,旧期望 2.25 是 16px 的派生值)"
);
// 视觉宽度的往返一致性(这条是「别照抄 Slint 的 1.5」的守门测试)。
for size in IconSize::ALL {
let w = stroke_width_for(size.px(), 1.5);
assert!((visual_stroke_px(size.px(), w) - 1.5).abs() < 1.0e-6);
}
assert!((VIEWBOX - 24.0).abs() < f32::EPSILON);
// 实心图标不描边。
assert_eq!(IconKind::Play.icon().paint, IconPaint::Fill);
assert_eq!(IconKind::Play.icon().stroke_width(24.0), 0.0);
}
#[test]
fn shape_and_elevation_ladders() {
assert_eq!(ShapeScale::ExtraSmall.dp(), 4.0);
assert_eq!(ShapeScale::Medium.dp(), 12.0);
assert_eq!(ShapeScale::ExtraLarge.dp(), 28.0);
assert_eq!(ShapeScale::Full.radius_px(40.0), 20.0);
assert!(ShapeScale::ExtraLargeIncreased.is_expressive());
let dp: Vec<f32> = Elevation::ALL.iter().map(|e| e.dp()).collect();
assert_eq!(dp, vec![0.0, 1.0, 3.0, 6.0, 8.0, 12.0]);
assert_eq!(
MaterialLayer::Card.surface_role(),
Role::SurfaceContainerLow
);
}
#[test]
fn motion_tokens_are_usable_as_data() {
assert_eq!(DurationToken::Medium2.ms(), 300);
assert!((Easing::Standard.sample(0.0)).abs() < 1.0e-6);
assert!((Easing::Standard.sample(1.0) - 1.0).abs() < 1.0e-6);
let settle = SPRING_EXPRESSIVE_SPATIAL_DEFAULT.settle_ms(0.001);
assert!(settle > 0.0 && settle < 2000.0);
assert_eq!(MotionScale::NONE.ms(DurationToken::Long4), 0.0);
}
#[test]
fn spacing_and_layout_metrics_are_shared() {
assert_eq!(Space::X4.dp(), 16.0);
let metrics = LayoutMetrics::DESKTOP;
assert_eq!(metrics.header_height(false), 56.0);
assert_eq!(metrics.header_height(true), 84.0);
}
#[test]
fn component_map_covers_the_page_skeleton() {
// M69(G9)在末尾追加 Checkbox/Radio/Search/NavigationDrawer/Divider:20 → 25。
assert_eq!(Component::ALL.len(), 25);
for component in Component::ALL {
let spec = component.spec();
assert_eq!(spec.component, component);
assert!(spec.text.style().size_px >= 11.0);
}
assert_eq!(Component::PrimaryButton.spec().shape, ShapeScale::Full);
// 浮层必须高于卡片层。
assert!(Component::Dialog.spec().elevation > Component::OutlinedCard.spec().elevation);
// 末尾追加的 5 个不许改动既有次序。
assert_eq!(Component::ALL[0], Component::TopAppBar);
assert_eq!(Component::ALL[19], Component::EmptyState);
}
#[test]
fn design_tokens_entry_point() {
let light = DesignTokens::baseline(SchemeFlavor::Light);
let dark = DesignTokens::baseline(SchemeFlavor::Dark);
assert_eq!(light.flavor, SchemeFlavor::Light);
assert!(light.color(Role::Surface).relative_luminance() > 0.8);
assert!(dark.color(Role::Surface).relative_luminance() < 0.05);
assert!(!light.motion_scale.is_off());
let accent = DesignTokens::with_accent(
SchemeFlavor::Light,
unlucky_design::Color::from_hex("#006A5C").unwrap(),
);
assert_ne!(accent.color(Role::Primary), light.color(Role::Primary));
assert_eq!(accent.color(Role::Surface), light.color(Role::Surface));
}
#[test]
fn tonal_palette_hits_requested_tones() {
let palette = TonalPalette::new(150.0, 36.0);
for tone in [20.0_f64, 40.0, 60.0, 80.0] {
let color = palette.tone(tone);
assert!(
(f64::from(color.lstar()) - tone).abs() <= 1.0,
"tone {tone} 实际 {}",
color.lstar()
);
}
}
+252
View File
@@ -0,0 +1,252 @@
//! 官方数值钉死(二):**色彩 tone 表 49 条**、7 条 easing 曲线、16 档时长、28 个图标下标、
//! 形状阶梯,以及「同族容器不撞色」的回归(reviewer P3-2)。
//!
//! 期望值全部独立于实现重写;实现改一个数、表不改,这里就红。
use unlucky_design::color::{Role, Scheme, SchemeFlavor};
use unlucky_design::icons::IconKind;
use unlucky_design::motion::{DurationToken, Easing};
use unlucky_design::shape::ShapeScale;
fn expect_tone(role: Role, light: f64, dark: f64) {
assert_eq!(
Scheme::tone_of(role, SchemeFlavor::Light),
light,
"{} 的 light tone 被改动了",
role.name()
);
assert_eq!(
Scheme::tone_of(role, SchemeFlavor::Dark),
dark,
"{} 的 dark tone 被改动了",
role.name()
);
}
#[test]
fn all_forty_nine_tones_are_pinned() {
expect_tone(Role::Primary, 40.0, 80.0);
expect_tone(Role::OnPrimary, 100.0, 20.0);
expect_tone(Role::PrimaryContainer, 90.0, 30.0);
expect_tone(Role::OnPrimaryContainer, 30.0, 90.0);
expect_tone(Role::Secondary, 40.0, 80.0);
expect_tone(Role::OnSecondary, 100.0, 20.0);
expect_tone(Role::SecondaryContainer, 90.0, 30.0);
expect_tone(Role::OnSecondaryContainer, 30.0, 90.0);
expect_tone(Role::Tertiary, 40.0, 80.0);
expect_tone(Role::OnTertiary, 100.0, 20.0);
expect_tone(Role::TertiaryContainer, 90.0, 30.0);
expect_tone(Role::OnTertiaryContainer, 30.0, 90.0);
expect_tone(Role::Error, 40.0, 80.0);
expect_tone(Role::OnError, 100.0, 20.0);
expect_tone(Role::ErrorContainer, 90.0, 30.0);
expect_tone(Role::OnErrorContainer, 30.0, 90.0);
expect_tone(Role::Surface, 98.0, 6.0);
expect_tone(Role::OnSurface, 10.0, 90.0);
expect_tone(Role::SurfaceVariant, 90.0, 30.0);
expect_tone(Role::OnSurfaceVariant, 30.0, 80.0);
expect_tone(Role::Background, 98.0, 6.0);
expect_tone(Role::OnBackground, 10.0, 90.0);
expect_tone(Role::SurfaceDim, 87.0, 6.0);
expect_tone(Role::SurfaceBright, 98.0, 24.0);
expect_tone(Role::SurfaceContainerLowest, 100.0, 4.0);
expect_tone(Role::SurfaceContainerLow, 96.0, 10.0);
expect_tone(Role::SurfaceContainer, 94.0, 12.0);
expect_tone(Role::SurfaceContainerHigh, 92.0, 17.0);
expect_tone(Role::SurfaceContainerHighest, 90.0, 22.0);
expect_tone(Role::Outline, 50.0, 60.0);
expect_tone(Role::OutlineVariant, 80.0, 30.0);
expect_tone(Role::InverseSurface, 20.0, 90.0);
expect_tone(Role::InverseOnSurface, 95.0, 20.0);
expect_tone(Role::InversePrimary, 80.0, 40.0);
expect_tone(Role::Shadow, 0.0, 0.0);
expect_tone(Role::Scrim, 0.0, 0.0);
expect_tone(Role::SurfaceTint, 40.0, 80.0);
expect_tone(Role::PrimaryFixed, 90.0, 90.0);
expect_tone(Role::PrimaryFixedDim, 80.0, 80.0);
expect_tone(Role::OnPrimaryFixed, 10.0, 10.0);
expect_tone(Role::OnPrimaryFixedVariant, 30.0, 30.0);
expect_tone(Role::SecondaryFixed, 90.0, 90.0);
expect_tone(Role::SecondaryFixedDim, 80.0, 80.0);
expect_tone(Role::OnSecondaryFixed, 10.0, 10.0);
expect_tone(Role::OnSecondaryFixedVariant, 30.0, 30.0);
expect_tone(Role::TertiaryFixed, 90.0, 90.0);
expect_tone(Role::TertiaryFixedDim, 80.0, 80.0);
expect_tone(Role::OnTertiaryFixed, 10.0, 10.0);
expect_tone(Role::OnTertiaryFixedVariant, 30.0, 30.0);
}
#[test]
fn easing_curves_are_pinned() {
let cases: [(Easing, f32, f32, f32, f32); 7] = [
(Easing::Linear, 0.0, 0.0, 1.0, 1.0),
(Easing::Standard, 0.2, 0.0, 0.0, 1.0),
(Easing::StandardDecelerate, 0.0, 0.0, 0.0, 1.0),
(Easing::StandardAccelerate, 0.3, 0.0, 1.0, 1.0),
(Easing::Emphasized, 0.2, 0.0, 0.0, 1.0),
(Easing::EmphasizedDecelerate, 0.05, 0.7, 0.1, 1.0),
(Easing::EmphasizedAccelerate, 0.3, 0.0, 0.8, 0.15),
];
for (easing, x1, y1, x2, y2) in cases {
let curve = easing.curve();
assert_eq!(
(curve.x1, curve.y1, curve.x2, curve.y2),
(x1, y1, x2, y2),
"{easing:?} 的曲线参数被改动了"
);
}
}
#[test]
fn durations_are_pinned() {
let cases: [(DurationToken, u32); 16] = [
(DurationToken::Short1, 50),
(DurationToken::Short2, 100),
(DurationToken::Short3, 150),
(DurationToken::Short4, 200),
(DurationToken::Medium1, 250),
(DurationToken::Medium2, 300),
(DurationToken::Medium3, 350),
(DurationToken::Medium4, 400),
(DurationToken::Long1, 450),
(DurationToken::Long2, 500),
(DurationToken::Long3, 550),
(DurationToken::Long4, 600),
(DurationToken::ExtraLong1, 700),
(DurationToken::ExtraLong2, 800),
(DurationToken::ExtraLong3, 900),
(DurationToken::ExtraLong4, 1000),
];
for (token, ms) in cases {
assert_eq!(token.ms(), ms, "{} 的毫秒值被改动了", token.name());
}
}
#[test]
fn spring_presets_are_pinned() {
use unlucky_design::motion::{
spring, MotionSchemeKind, Spring, SpringCategory, SpringSpeed, SPRING_EFFECTS_DEFAULT,
SPRING_EFFECTS_FAST, SPRING_EFFECTS_SLOW, SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
SPRING_EXPRESSIVE_SPATIAL_FAST, SPRING_EXPRESSIVE_SPATIAL_SLOW,
SPRING_STANDARD_SPATIAL_DEFAULT, SPRING_STANDARD_SPATIAL_FAST,
SPRING_STANDARD_SPATIAL_SLOW,
};
// A 级:StandardMotionTokens / ExpressiveMotionTokens(damping = 阻尼比 ζ,stiffness = k)。
let cases: [(Spring, f32, f32); 9] = [
// standard 方案 spatial:ζ=0.9(保守)
(SPRING_STANDARD_SPATIAL_FAST, 1400.0, 0.9),
(SPRING_STANDARD_SPATIAL_DEFAULT, 700.0, 0.9),
(SPRING_STANDARD_SPATIAL_SLOW, 300.0, 0.9),
// expressive 方案 spatial:ζ=0.6/0.8(回弹明显)
(SPRING_EXPRESSIVE_SPATIAL_FAST, 800.0, 0.6),
(SPRING_EXPRESSIVE_SPATIAL_DEFAULT, 380.0, 0.8),
(SPRING_EXPRESSIVE_SPATIAL_SLOW, 200.0, 0.8),
// effects 两套同值:ζ=1.0(不过冲)
(SPRING_EFFECTS_FAST, 3800.0, 1.0),
(SPRING_EFFECTS_DEFAULT, 1600.0, 1.0),
(SPRING_EFFECTS_SLOW, 800.0, 1.0),
];
for (entry, stiffness, damping_ratio) in cases {
assert_eq!(entry.stiffness, stiffness, "弹簧刚度被改动");
assert_eq!(entry.damping_ratio, damping_ratio, "弹簧阻尼比被改动");
}
// 二维表查询与常量必须一致(改了表结构也别想绕过 pin)。
assert_eq!(
spring(
MotionSchemeKind::Expressive,
SpringCategory::Spatial,
SpringSpeed::Default
),
SPRING_EXPRESSIVE_SPATIAL_DEFAULT
);
assert_eq!(
spring(
MotionSchemeKind::Standard,
SpringCategory::Spatial,
SpringSpeed::Fast
),
SPRING_STANDARD_SPATIAL_FAST
);
}
#[test]
fn icon_indices_are_pinned() {
let names = [
"instances",
"download",
"logs",
"settings",
"ai",
"play",
"stop",
"search",
"plus",
"close",
"minimize",
"maximize",
"chevron-left",
"chevron-right",
"chevron-down",
"expand",
"send",
"check",
"warn",
"error",
"more",
"sidebar",
"restore",
"user",
"refresh",
"filter",
"folder",
"trash",
];
assert_eq!(IconKind::ALL.len(), names.len());
for (i, kind) in IconKind::ALL.iter().enumerate() {
assert_eq!(
kind.name(),
names[i],
"第 {i} 个图标被换位/改名了(序号即持久化 id)"
);
}
}
#[test]
fn shape_ladder_is_pinned() {
let cases: [(ShapeScale, f32); 9] = [
(ShapeScale::None, 0.0),
(ShapeScale::ExtraSmall, 4.0),
(ShapeScale::Small, 8.0),
(ShapeScale::Medium, 12.0),
(ShapeScale::Large, 16.0),
(ShapeScale::LargeIncreased, 20.0),
(ShapeScale::ExtraLarge, 28.0),
(ShapeScale::ExtraLargeIncreased, 32.0),
(ShapeScale::ExtraExtraLarge, 48.0),
];
for (scale, dp) in cases {
assert_eq!(scale.dp(), dp, "{scale:?} 的半径档位被改动了");
}
assert_eq!(ShapeScale::Full.radius_px(64.0), 32.0);
}
#[test]
fn same_family_containers_do_not_collide() {
// P3-2 回归:light 模式下 primary-container / secondary-container 曾经被 gamut 裁剪成同色。
let light = Scheme::baseline(SchemeFlavor::Light);
let primary = light.get(Role::PrimaryContainer);
let secondary = light.get(Role::SecondaryContainer);
assert_ne!(
primary, secondary,
"同族容器撞色(彩度压缩没生效):{primary:?}"
);
// secondary-container 必须比 primary-container 更灰(M3 的「次级」语义)。
let c_primary = unlucky_design::color::Hct::from_color(primary).chroma;
let c_secondary = unlucky_design::color::Hct::from_color(secondary).chroma;
assert!(
c_secondary < c_primary,
"secondary-container 彩度 {c_secondary} 应低于 primary-container {c_primary}"
);
// tertiary 与 primary 也该可区分(不同色相)。
assert_ne!(light.get(Role::TertiaryContainer), primary);
}
+297
View File
@@ -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));
}
+106
View File
@@ -0,0 +1,106 @@
//! 官方数值钉死(一):**排版两张表**(baseline 15 档 + Expressive 强调 15 档)。
//!
//! 表的来源是 A 级 `androidx.compose.material3.tokens.TypeScaleTokens`(v0_103)。这里的期望值是
//! **独立于实现重写的一份**:任何人改动 `TypeRole::style()` / `emphasized()` 的数值,都必须同时改
//! 这张表才会同时绿——这就是「冻结」的含义(reviewer P2-1 的变异测试就是打这里)。
use unlucky_design::typography::{FontFamily, TypeRole, Weight};
/// baseline 单档对拍。
fn expect_baseline(
role: TypeRole,
size: f32,
line_height: f32,
weight: Weight,
tracking: f32,
family: FontFamily,
) {
let got = role.style();
assert_eq!(
(
got.size_px,
got.line_height_px,
got.weight,
got.letter_spacing_px,
got.family
),
(size, line_height, weight, tracking, family),
"{} 的 baseline 值被改动了(官方 TypeScaleTokens)",
role.name()
);
}
/// Expressive 强调档单档对拍。
fn expect_emphasized(role: TypeRole, size: f32, line_height: f32, weight: Weight, tracking: f32) {
let got = role.emphasized();
assert_eq!(
(
got.size_px,
got.line_height_px,
got.weight,
got.letter_spacing_px
),
(size, line_height, weight, tracking),
"{} 的 Expressive 强调档值被改动了(官方 TypeScaleTokens *Emphasized*)",
role.name()
);
}
#[test]
fn baseline_type_scale_matches_official_tokens() {
use FontFamily::{Display, Text};
use Weight::{Bold, Medium, Regular};
expect_baseline(TypeRole::DisplayLarge, 57.0, 64.0, Regular, -0.2, Display);
expect_baseline(TypeRole::DisplayMedium, 45.0, 52.0, Regular, 0.0, Display);
expect_baseline(TypeRole::DisplaySmall, 36.0, 44.0, Regular, 0.0, Display);
expect_baseline(TypeRole::HeadlineLarge, 32.0, 40.0, Regular, 0.0, Display);
expect_baseline(TypeRole::HeadlineMedium, 28.0, 36.0, Regular, 0.0, Display);
expect_baseline(TypeRole::HeadlineSmall, 24.0, 32.0, Regular, 0.0, Display);
expect_baseline(TypeRole::TitleLarge, 22.0, 28.0, Regular, 0.0, Display);
expect_baseline(TypeRole::TitleMedium, 16.0, 24.0, Medium, 0.2, Text);
expect_baseline(TypeRole::TitleSmall, 14.0, 20.0, Medium, 0.1, Text);
expect_baseline(TypeRole::BodyLarge, 16.0, 24.0, Regular, 0.5, Text);
expect_baseline(TypeRole::BodyMedium, 14.0, 20.0, Regular, 0.2, Text);
expect_baseline(TypeRole::BodySmall, 12.0, 16.0, Regular, 0.4, Text);
expect_baseline(TypeRole::LabelLarge, 14.0, 20.0, Medium, 0.1, Text);
expect_baseline(TypeRole::LabelMedium, 12.0, 16.0, Medium, 0.5, Text);
expect_baseline(TypeRole::LabelSmall, 11.0, 16.0, Medium, 0.5, Text);
let _ = Bold;
}
#[test]
fn expressive_emphasis_matches_official_tokens() {
use Weight::{Bold, Medium};
// 展示/正文类强调 → Medium(500);label 与小标题 → Bold(700)。
expect_emphasized(TypeRole::DisplayLarge, 57.0, 64.0, Medium, 0.0);
expect_emphasized(TypeRole::DisplayMedium, 45.0, 52.0, Medium, 0.0);
expect_emphasized(TypeRole::DisplaySmall, 36.0, 44.0, Medium, 0.0);
expect_emphasized(TypeRole::HeadlineLarge, 32.0, 40.0, Medium, 0.0);
expect_emphasized(TypeRole::HeadlineMedium, 28.0, 36.0, Medium, 0.0);
expect_emphasized(TypeRole::HeadlineSmall, 24.0, 32.0, Medium, 0.0);
expect_emphasized(TypeRole::TitleLarge, 22.0, 28.0, Medium, 0.0);
expect_emphasized(TypeRole::TitleMedium, 16.0, 24.0, Bold, 0.15);
expect_emphasized(TypeRole::TitleSmall, 14.0, 20.0, Bold, 0.1);
expect_emphasized(TypeRole::BodyLarge, 16.0, 24.0, Medium, 0.15);
expect_emphasized(TypeRole::BodyMedium, 14.0, 20.0, Medium, 0.25);
expect_emphasized(TypeRole::BodySmall, 12.0, 16.0, Medium, 0.4);
expect_emphasized(TypeRole::LabelLarge, 14.0, 20.0, Bold, 0.1);
expect_emphasized(TypeRole::LabelMedium, 12.0, 16.0, Bold, 0.5);
expect_emphasized(TypeRole::LabelSmall, 11.0, 16.0, Bold, 0.5);
}
#[test]
fn both_tables_cover_all_fifteen_roles() {
// 防止「表里少写一档」:15 个角色都必须被上面两张表摸到。
let mut names = std::collections::BTreeSet::new();
for role in TypeRole::ALL {
names.insert(role.name());
assert_ne!(
role.style(),
role.emphasized(),
"{} 强调档等同 baseline",
role.name()
);
}
assert_eq!(names.len(), 15);
}