代码复审 p2-2items / 视觉复审 p2-2items 的修复轮(配色重拍等 M70 定案)。
M69 迁移(2 行)
- nav 轨宽:nav_rail_width(metrics) 函数体改用 metrics.nav_rail_width
- theme::STROKE_PX 改为 pub use unlucky_design::stroke::HAIRLINE as STROKE_PX
P2-1 零硬编码守门补洞(reviewer 造样 5 个漏网样本全部补上,并进自检正向断言)
- COLOR 调用表补 from_rgb8 / rgb8 / rgba(`from_rgb(` 子串匹配不到 `from_rgb8(` 是根因)
- 新增 `Color { r: .., g: .. }` 结构体字面量检查
- 尺寸规则遇到 Length::Fixed( / Padding::from( / Size::new( 往里剥一层再判数字
- 新增渲染层回归守门 pill_never_overrides_its_size_with_fill(禁 nav.rs 出现 center_*( ):
视觉复审 P2-1 的病根就是 center_*(Length::Fill) 把容器尺寸覆盖成 Fill
视觉 P2-1 药丸 88×32 → 56×32(实测 x=16..71)
- 去掉 center_x/center_y(Fill);居中改由父 column 的 align_x + 显式 width(轨宽) 完成
(只去掉 center_* 还不够:column 会 shrink 到 56,药丸贴左缘 x=0..55,是同一个坑的第二层)
视觉 P2-2 / 用户点名 caption 图标 46 → 24(按钮容器保持 46×48)
- button 内包 container(...).width(24).height(24).center(Length::Fill):
只设 width/height 会让容器缩到 24 并贴 button 内容左上角;center(Fill) 才既撑满又居中
视觉 P3-1 东侧/南侧缩放热区改成叠层
- 原来是 row 的兄弟列,吃掉 4px 布局宽(右留白 28、关闭键离窗角 4px);
现在用 stack! + 对齐容器叠在工作区上,布局宽回到 1180(卡片右缘 x=1155 = 右留白 24)
P3 清单
1. --data-dir 接受并忽略(翻牌前 CI 兼容;现役 smoke 命令行可原样跑,exit 0)+ 单测
2. --diff 内建 ±N px 平移扫描(--diff-scan),CI 不再依赖 numpy
3. 截图模式等 SystemTheme 到达才出图(否则暗色系统上会拍到浅色)
4. (Auto, 未编入 wgpu) 返回 tiny-skia,不再把 wgpu 写进候选列表
5. --renderer=gpu 也跳过适配器枚举(决策与探测无关,省 ~0.6~1.2s)
6. icons::handle 加 debug_assert_eq!(IconKind::ALL[kind as usize], kind)
7. 轨顶留白改 container padding-top,实测 24(原来 spacer + column spacing = 28)
顺带:shot.rs 拆成 shot/mod.rs + shot/diff.rs(≤400 行门禁)
门禁:fmt OK / clippy -D warnings 干净 / 46+5 测试全过 / cargo check --workspace --all-targets OK
体积:双后端 6,769,664 B(6.46 MiB)、仅 CPU 3,955,712 B(3.77 MiB)
截图:r1 代码验证图(r1-verify / hover / pressed / compare-nav-rail)已出;4 张交付主图待 M70 定案后重拍
335 lines
12 KiB
Rust
335 lines
12 KiB
Rust
//! 令牌 → iced 类型(`Color` / `Font` / `Border` / `Radius` / `Theme`)。
|
||
//!
|
||
//! ## 边界(M61 `design-令牌约定.md` §0 的落地)
|
||
//!
|
||
//! 本模块是**唯一**允许把 `unlucky-design` 的值翻成 iced 类型的地方。组件层(`shell`)
|
||
//! 只调 [`Ui`] 的方法,绝不出现色值 / 字号 / 半径 / 间距字面量——`tests/no_hardcoded_tokens.rs`
|
||
//! 会扫 `src/**` 做守门。
|
||
//!
|
||
//! ## 为什么不用 `iced::theme::Base` 自定义主题承载 49 个角色
|
||
//!
|
||
//! `iced` 的 `Palette` 只有 6 个颜色(`background/text/primary/success/warning/danger`),
|
||
//! M3 有 49 个角色,塞不进去(M60 Q5 + M61 §9-0 的同一结论)。所以:
|
||
//!
|
||
//! - **真源**是本模块持有的 [`Scheme`](49 角色);
|
||
//! - iced 的 `Theme` 只用来「换名字」——`Theme::name()` 变了 iced 才会重算受主题影响的缓存
|
||
//! (M61 §9 的最后一条)。`palette()` 只给 devtools 看,控件外观一律由各自的 `Style` 注入。
|
||
//!
|
||
//! `success` / `warning` 在 M3 里**没有对应角色**(`design-令牌约定.md` §2 明写),
|
||
//! 这里按「语义最近的现成角色」映射:`success → Secondary`(不是「成功」的承诺)、
|
||
//! `warning → Tertiary`(M3 把「提醒」放 Tertiary)。任何控件都不要去读这两个槽位。
|
||
|
||
use iced::border::Radius;
|
||
use iced::theme::Palette;
|
||
use iced::{Border, Color, Font, Theme};
|
||
|
||
use unlucky_design::color::{Role, Scheme, SchemeFlavor, StateLayer};
|
||
use unlucky_design::elevation::MaterialLayer;
|
||
use unlucky_design::shape::ShapeScale;
|
||
use unlucky_design::spacing::{LayoutMetrics, Space};
|
||
use unlucky_design::typography::{FontFamily, Platform, TypeRole, TypeStyle, Weight};
|
||
|
||
/// 全站描边宽度(逻辑 px @100% DPI)。rubric G7 要求全站 1px —— 令牌层的「一像素线单一真源」。
|
||
///
|
||
/// M69 起令牌层提供 `unlucky_design::stroke::HAIRLINE`(容器描边/分隔线/聚焦环共用),
|
||
/// 这里**再导出**而不是自己写 `1.0`:全仓只有令牌层一个定义点。
|
||
pub use unlucky_design::stroke::HAIRLINE as STROKE_PX;
|
||
|
||
/// 已经解析到系统的字体族名(`&'static str` 才能塞进 `iced::Font`)。
|
||
///
|
||
/// 由 `fonts` 模块按令牌栈 + 系统字体库求值,本模块只消费。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct Families {
|
||
/// 正文族(`FontFamily::Text` 栈里第一个装机可用的)。
|
||
pub text: &'static str,
|
||
/// 展示族(`FontFamily::Display`)。
|
||
pub display: &'static str,
|
||
/// 等宽族(`FontFamily::Mono`,日志/版本号用)。
|
||
pub mono: &'static str,
|
||
}
|
||
|
||
/// 令牌求值器:一个 `Ui` 对应一套主题(浅或深)。
|
||
///
|
||
/// 深浅热切 = 重新 `Ui::new(flavor, families)` 并整树重绘;控件里不要缓存颜色。
|
||
#[derive(Debug, Clone)]
|
||
pub struct Ui {
|
||
scheme: Scheme,
|
||
platform: Platform,
|
||
metrics: LayoutMetrics,
|
||
families: Families,
|
||
}
|
||
|
||
impl Ui {
|
||
/// 按平台(由 `cfg!` 决定)与令牌基线方案求值。
|
||
pub fn new(flavor: SchemeFlavor, families: Families) -> Self {
|
||
Self::with_platform(flavor, Platform::current(), families)
|
||
}
|
||
|
||
/// 显式指定平台(跨平台核对与测试用)。
|
||
pub fn with_platform(flavor: SchemeFlavor, platform: Platform, families: Families) -> Self {
|
||
Self {
|
||
scheme: Scheme::baseline(flavor),
|
||
platform,
|
||
metrics: LayoutMetrics::DESKTOP,
|
||
families,
|
||
}
|
||
}
|
||
|
||
/// 当前深浅模式。
|
||
pub fn flavor(&self) -> SchemeFlavor {
|
||
self.scheme.flavor()
|
||
}
|
||
|
||
/// 当前平台(决定字体族与字重降级)。
|
||
pub fn platform(&self) -> Platform {
|
||
self.platform
|
||
}
|
||
|
||
/// 骨架尺寸(标题栏/导航/行高…)。
|
||
pub fn metrics(&self) -> LayoutMetrics {
|
||
self.metrics
|
||
}
|
||
|
||
/// 已解析的字体族名。
|
||
pub fn families(&self) -> Families {
|
||
self.families
|
||
}
|
||
|
||
/// 间距档位(4px 栅格)。
|
||
pub fn space(&self, space: Space) -> f32 {
|
||
space.dp()
|
||
}
|
||
|
||
/// M3 角色 → iced 颜色。
|
||
pub fn color(&self, role: Role) -> Color {
|
||
to_iced(self.scheme.get(role))
|
||
}
|
||
|
||
/// 材质层(Canvas/Card/Overlay)→ 容器色。
|
||
pub fn layer(&self, layer: MaterialLayer) -> Color {
|
||
self.color(layer.surface_role())
|
||
}
|
||
|
||
/// 状态层叠在容器色上,返回**不透明**结果。
|
||
///
|
||
/// 为什么不用带 alpha 的叠加:D27 要求 CPU 档不出现半透明,而「不透明结果」让 GPU/CPU
|
||
/// 两档的颜色也逐像素一致(只有材质/玻璃才允许两档不同)。
|
||
pub fn state_over(&self, state: StateLayer, content: Role, container: Role) -> Color {
|
||
to_iced(state.over(self.scheme.get(content), self.scheme.get(container)))
|
||
}
|
||
|
||
/// 圆角(令牌刻度);`side` 是控件在该轴上的边长(`ShapeScale::Full` 要按边长折算)。
|
||
pub fn radius(&self, shape: ShapeScale, side: f32) -> Radius {
|
||
Radius::new(shape.radius_px(side))
|
||
}
|
||
|
||
/// 固定档圆角(不需要边长)。`ShapeScale::Full` 必须走 [`Ui::radius`](它没有绝对值)。
|
||
pub fn radius_dp(&self, shape: ShapeScale) -> Radius {
|
||
debug_assert!(
|
||
!matches!(shape, ShapeScale::Full),
|
||
"Full 需要边长,请用 Ui::radius(shape, side)"
|
||
);
|
||
Radius::new(if matches!(shape, ShapeScale::Full) {
|
||
0.0
|
||
} else {
|
||
shape.dp()
|
||
})
|
||
}
|
||
|
||
/// 描边:`OutlineVariant` + [`STROKE_PX`],圆角走刻度(固定档)。
|
||
pub fn outlined(&self, shape: ShapeScale) -> Border {
|
||
Border {
|
||
color: self.color(Role::OutlineVariant),
|
||
width: STROKE_PX,
|
||
radius: self.radius_dp(shape),
|
||
}
|
||
}
|
||
|
||
/// baseline 排版档。
|
||
pub fn style(&self, role: TypeRole) -> TypeStyle {
|
||
role.style()
|
||
}
|
||
|
||
/// Expressive 强调档(官方值:字重升到 500/700)。
|
||
pub fn emphasized(&self, role: TypeRole) -> TypeStyle {
|
||
role.emphasized()
|
||
}
|
||
|
||
/// 某个排版档对应的 iced 字体(族 + 字重)。
|
||
pub fn font_of(&self, style: TypeStyle) -> Font {
|
||
let family = match style.family {
|
||
FontFamily::Display => self.families.display,
|
||
FontFamily::Text => self.families.text,
|
||
FontFamily::Mono => self.families.mono,
|
||
};
|
||
|
||
Font {
|
||
family: iced::font::Family::Name(family),
|
||
weight: weight_of(style.weight),
|
||
..Font::DEFAULT
|
||
}
|
||
}
|
||
|
||
/// baseline 档的字体。
|
||
pub fn font(&self, role: TypeRole) -> Font {
|
||
self.font_of(self.style(role))
|
||
}
|
||
|
||
/// 强调档的字体。
|
||
pub fn emphasized_font(&self, role: TypeRole) -> Font {
|
||
self.font_of(self.emphasized(role))
|
||
}
|
||
|
||
/// iced 的 6 色 `Palette`(只给 devtools / 兜底控件用;M3 的 49 角色在 [`Ui::color`])。
|
||
pub fn palette(&self) -> Palette {
|
||
Palette {
|
||
background: self.color(Role::Surface),
|
||
text: self.color(Role::OnSurface),
|
||
primary: self.color(Role::Primary),
|
||
success: self.color(Role::Secondary),
|
||
warning: self.color(Role::Tertiary),
|
||
danger: self.color(Role::Error),
|
||
}
|
||
}
|
||
|
||
/// iced 主题对象:**名字带深浅**,切主题时 `Theme::name()` 变化会触发重算。
|
||
pub fn theme(&self) -> Theme {
|
||
Theme::custom(self.name(), self.palette())
|
||
}
|
||
|
||
/// `Theme::name()` 的取值(稳定字符串,改名要先在协作文档登记)。
|
||
pub fn name(&self) -> &'static str {
|
||
match self.flavor() {
|
||
SchemeFlavor::Light => "ull-m3-light",
|
||
SchemeFlavor::Dark => "ull-m3-dark",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 令牌色(8 bit sRGB)→ iced 的 sRGB 分量。
|
||
pub fn to_iced(color: unlucky_design::color::Color) -> Color {
|
||
Color::from_rgba8(
|
||
color.r,
|
||
color.g,
|
||
color.b,
|
||
f32::from(color.a) / f32::from(u8::MAX),
|
||
)
|
||
}
|
||
|
||
/// 令牌字重 → iced 字重。
|
||
pub fn weight_of(weight: Weight) -> iced::font::Weight {
|
||
match weight {
|
||
Weight::Regular => iced::font::Weight::Normal,
|
||
Weight::Medium => iced::font::Weight::Medium,
|
||
Weight::SemiBold => iced::font::Weight::Semibold,
|
||
Weight::Bold => iced::font::Weight::Bold,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
const FAMILIES: Families = Families {
|
||
text: "Segoe UI Variable Text",
|
||
display: "Segoe UI Variable Display",
|
||
mono: "Cascadia Mono",
|
||
};
|
||
|
||
fn ui(flavor: SchemeFlavor) -> Ui {
|
||
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
|
||
}
|
||
|
||
#[test]
|
||
fn theme_name_tracks_the_flavor() {
|
||
assert_eq!(ui(SchemeFlavor::Light).name(), "ull-m3-light");
|
||
assert_eq!(ui(SchemeFlavor::Dark).name(), "ull-m3-dark");
|
||
assert_ne!(
|
||
ui(SchemeFlavor::Light).theme().to_string(),
|
||
ui(SchemeFlavor::Dark).theme().to_string()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn canvas_card_overlay_follow_surface_containers() {
|
||
let ui = ui(SchemeFlavor::Light);
|
||
assert_eq!(ui.layer(MaterialLayer::Canvas), ui.color(Role::Surface));
|
||
assert_eq!(
|
||
ui.layer(MaterialLayer::Card),
|
||
ui.color(Role::SurfaceContainerLow)
|
||
);
|
||
assert_eq!(
|
||
ui.layer(MaterialLayer::Overlay),
|
||
ui.color(Role::SurfaceContainerHigh)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn radii_only_come_from_the_scale() {
|
||
let ui = ui(SchemeFlavor::Light);
|
||
// 卡片 Medium(12):边长够大时就是 12
|
||
assert_eq!(ui.radius_dp(ShapeScale::Medium).top_left, 12.0);
|
||
assert_eq!(ui.radius_dp(ShapeScale::ExtraSmall).top_left, 4.0);
|
||
// 药丸 Full:按边长一半折算
|
||
assert_eq!(ui.radius(ShapeScale::Full, 32.0).top_left, 16.0);
|
||
// 不许超过边长一半
|
||
assert_eq!(ui.radius(ShapeScale::ExtraLarge, 20.0).top_left, 10.0);
|
||
}
|
||
|
||
#[test]
|
||
fn outlines_are_one_pixel_and_use_outline_variant() {
|
||
let ui = ui(SchemeFlavor::Light);
|
||
let border = ui.outlined(ShapeScale::Medium);
|
||
assert_eq!(border.width, STROKE_PX);
|
||
assert_eq!(border.width, 1.0, "rubric G7:全站 1px");
|
||
assert_eq!(border.color, ui.color(Role::OutlineVariant));
|
||
assert_eq!(border.radius.top_left, 12.0);
|
||
}
|
||
|
||
#[test]
|
||
fn emphasized_weights_are_the_official_ones() {
|
||
let ui = ui(SchemeFlavor::Light);
|
||
// title-small 的强调档是 700(官方 Emphasized)
|
||
assert_eq!(
|
||
ui.emphasized_font(TypeRole::TitleSmall).weight,
|
||
iced::font::Weight::Bold
|
||
);
|
||
// baseline 正文是 400
|
||
assert_eq!(
|
||
ui.font(TypeRole::BodyMedium).weight,
|
||
iced::font::Weight::Normal
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn body_text_keeps_wcag_contrast_in_both_flavors() {
|
||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||
let scheme = Scheme::baseline(flavor);
|
||
assert!(
|
||
scheme.contrast(Role::OnSurface, Role::Surface) >= 4.5,
|
||
"{flavor:?} 的正文对比度不够"
|
||
);
|
||
assert!(
|
||
scheme.contrast(Role::OnSecondaryContainer, Role::SecondaryContainer) >= 4.5,
|
||
"{flavor:?} 的选中态药丸对比度不够"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn every_role_maps_to_an_iced_color() {
|
||
for role in Role::ALL {
|
||
let color = ui(SchemeFlavor::Dark).color(role);
|
||
assert!((0.0..=1.0).contains(&color.a), "{role:?} 的 alpha 越界");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn state_layers_are_opaque_so_cpu_and_gpu_match() {
|
||
let ui = ui(SchemeFlavor::Light);
|
||
let hovered = ui.state_over(StateLayer::Hover, Role::OnSurface, Role::Surface);
|
||
assert_eq!(hovered.a, 1.0, "状态层必须给出不透明结果");
|
||
assert_ne!(hovered, ui.color(Role::Surface));
|
||
}
|
||
}
|