代码复审 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 定案后重拍
127 lines
4.8 KiB
Rust
127 lines
4.8 KiB
Rust
//! 令牌图标(`unlucky_design::icons` 的 24 网格 SVG path)→ iced `svg` 控件。
|
||
//!
|
||
//! ## 为什么走 SVG 而不是自绘 canvas
|
||
//!
|
||
//! 令牌里存的是 **SVG path 字符串**(含 `A` 弧线命令),自绘要自己写 path parser + 弧线细分,
|
||
//! 那是一份将来只有自己在维护的代码;iced 的 `svg` 控件已经带 usvg 解析与按 id 缓存的位图。
|
||
//! 代价是二进制多一份 resvg/usvg(体积基线里会单列出来)。
|
||
//!
|
||
//! ## 描边语义(`design-令牌约定.md` §4)
|
||
//!
|
||
//! 令牌的 `Icon::stroke_width(render_px)` 已经把「视觉 1.5px」折算成 **viewBox 单位**
|
||
//! (`1.5 × 24 / render_px`),本模块**不重算也不手抄常数**。
|
||
//! 本骨架所有图标都按 24px 渲染(12b 2.2 标题栏 / 5.5 导航),所以每个图标只有一份位图缓存。
|
||
|
||
use std::sync::OnceLock;
|
||
|
||
use iced::widget::svg;
|
||
use iced::Color;
|
||
|
||
use unlucky_design::icons::{IconKind, IconPaint, IconSize};
|
||
|
||
/// 骨架期图标的统一渲染尺寸(12b:标题栏与导航轨都是 24)。
|
||
pub const RENDER_PX: f32 = IconSize::Toolbar.px();
|
||
|
||
/// 生成一个图标的 SVG 源码(`viewBox` 24×24,颜色用 `currentColor` 交给 iced 的色滤镜)。
|
||
///
|
||
/// 之所以用 `currentColor` 而不是把令牌色写进 SVG:色滤镜(`svg::Style::color`)会按
|
||
/// **alpha 蒙版**重涂 RGB,这样一份 SVG 位图能被浅/深两套主题复用,缓存不会翻倍。
|
||
pub fn source(kind: IconKind) -> String {
|
||
let icon = kind.icon();
|
||
let paint = match icon.paint {
|
||
IconPaint::Stroke => format!(
|
||
"fill=\"none\" stroke=\"currentColor\" stroke-width=\"{}\" \
|
||
stroke-linecap=\"round\" stroke-linejoin=\"round\"",
|
||
icon.stroke_width(RENDER_PX)
|
||
),
|
||
IconPaint::Fill => "fill=\"currentColor\" stroke=\"none\"".to_owned(),
|
||
};
|
||
|
||
format!(
|
||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{0}\" height=\"{0}\" \
|
||
viewBox=\"0 0 24 24\"><path d=\"{1}\" {paint}/></svg>",
|
||
RENDER_PX, icon.path
|
||
)
|
||
}
|
||
|
||
/// 图标的 SVG 句柄(按 `IconKind` 缓存;句柄 id 是内容哈希,所以 iced 侧也能命中缓存)。
|
||
fn handle(kind: IconKind) -> svg::Handle {
|
||
static HANDLES: OnceLock<Vec<svg::Handle>> = OnceLock::new();
|
||
|
||
let handles = HANDLES.get_or_init(|| {
|
||
IconKind::ALL
|
||
.iter()
|
||
.map(|kind| svg::Handle::from_memory(source(*kind).into_bytes()))
|
||
.collect()
|
||
});
|
||
|
||
// 依赖「枚举下标 == IconKind::ALL 下标」(令牌层用 `GLYPHS[self as usize]` 同一约定)。
|
||
// 这条断言把约定钉在测试与 debug 构建里,`IconKind` 一旦乱序插入就会立刻炸。
|
||
debug_assert_eq!(
|
||
IconKind::ALL[kind as usize],
|
||
kind,
|
||
"IconKind::ALL 必须与枚举声明顺序一致"
|
||
);
|
||
|
||
handles[kind as usize].clone()
|
||
}
|
||
|
||
/// 一个图标控件(尺寸走令牌,颜色走令牌角色)。
|
||
pub fn view<'a, Message: 'a>(kind: IconKind, color: Color) -> iced::Element<'a, Message> {
|
||
svg(handle(kind))
|
||
.width(RENDER_PX)
|
||
.height(RENDER_PX)
|
||
.style(move |_theme, _status| svg::Style { color: Some(color) })
|
||
.into()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn every_icon_produces_wellformed_svg() {
|
||
for kind in IconKind::ALL {
|
||
let source = source(kind);
|
||
assert!(source.starts_with("<svg"), "{} 的 SVG 头不对", kind.name());
|
||
assert!(source.ends_with("</svg>"), "{} 的 SVG 尾不对", kind.name());
|
||
assert!(source.contains("viewBox=\"0 0 24 24\""), "{}", kind.name());
|
||
assert!(
|
||
source.contains(kind.icon().path),
|
||
"{} 的 path 没写进去",
|
||
kind.name()
|
||
);
|
||
assert!(!source.contains('#'), "{} 里出现了色值字面量", kind.name());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn stroke_icons_carry_the_token_stroke_width() {
|
||
let stroke = source(IconKind::Settings);
|
||
let expected = IconKind::Settings.icon().stroke_width(RENDER_PX);
|
||
assert!(
|
||
stroke.contains(&format!("stroke-width=\"{expected}\"")),
|
||
"{stroke}"
|
||
);
|
||
assert!(stroke.contains("stroke-linecap=\"round\""));
|
||
// 24px 渲染时的视觉线宽必须是 1.5(M54 结论)
|
||
assert!((expected - 1.5).abs() < f32::EPSILON);
|
||
}
|
||
|
||
#[test]
|
||
fn filled_icons_do_not_get_a_stroke() {
|
||
// `more` 是三个填充圆点
|
||
let filled = source(IconKind::More);
|
||
assert!(filled.contains("fill=\"currentColor\""));
|
||
assert!(filled.contains("stroke=\"none\""));
|
||
}
|
||
|
||
#[test]
|
||
fn handles_are_cached_per_kind() {
|
||
let first = handle(IconKind::Close);
|
||
let second = handle(IconKind::Close);
|
||
assert_eq!(first.id(), second.id());
|
||
assert_ne!(first.id(), handle(IconKind::Minimize).id());
|
||
}
|
||
}
|