feat(app-iced): M68 —— iced 0.14 骨架(后端探测/字体基座/自绘标题栏/导航轨/深浅主题/截图通道)
新增 crates/app-iced(package `unlucky-app-iced`,bin `unlucky-launcher-iced`),与 Slint 版并存, 为 D26「UI 从头写」打地基。本 mission 只做骨架:能开窗、颜色/字体对、层级在,不做业务页面。 - 后端(D27):启动探测 wgpu 适配器(只看 device_type,避开 WARP 误判)→ 写 ICED_BACKEND; auto/gpu/cpu 三条路径都保留 tiny-skia 兜底,绝不硬失败;`--renderer=cpu` 跳过探测(省 ~0.85s) - 字体(D28 前半):令牌三平台字体栈 → 用 iced 全局 FontSystem 查装机情况 → 族名与 400/500/700 字重落点(缺档按 Nearest/Synthetic 记录)打一行启动日志 - 令牌适配层 theme::Ui:唯一把 unlucky-design 的值翻成 iced Color/Font/Border/Radius/Theme 的地方; 49 个 M3 角色留在自己的 Scheme 里,iced 的 6 色 Palette 只给 devtools - 骨架:1180×760 无边框窗口、48 高自绘标题栏(drag + 东/南 drag_resize + 最小化/最大化/关闭)、 88 宽导航轨(56×32 SecondaryContainer 药丸,由令牌推导)、页头 56 + 一张 OutlinedCard - 主题:系统主题探测 + --theme,切主题只重建 Ui(Theme::name 变化触发重算),不动 UI 状态 - 截图通道:`--screenshot <png>` + `--screenshot-theme`(真开窗第 3 帧出图),以及 `--diff <a> <b>` 自带逐像素对比(CI 不依赖 Python/ImageMagick) - 图标:令牌 SVG path → iced svg 控件(颜色走 alpha 蒙版,深浅共用一份位图缓存) - 门禁:tests/no_hardcoded_tokens.rs 扫源码挡色值/尺寸字面量 + 挡 slint 依赖(自检也算过) 实测(Windows Server 2025 / 96 DPI / RX 6600 XT):浅/深 × GPU/CPU 四张真开窗截图;GPU vs CPU 差异 0.72% 像素、全部落在字形/图标抗锯齿与 1px 圆角描边、最佳对齐 (0,0)、平坦区颜色逐字节相同; release 体积双后端 6.44 MiB / 仅 CPU 3.75 MiB;首帧 GPU 1.18~1.31s、CPU 33~37ms。 未做/未验证:macOS 与 Wayland 全链路、西/北边缩放热区、letter spacing(iced 0.14 无此属性)、 中文 CDN 字体、玻璃;翻牌步骤写在 DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md(不入库)。
This commit is contained in:
1 parent
3fcdc3d3e1
commit
df4ff373b7
16 files changed
+4244
-97
No files matched your search
@@ -0,0 +1,118 @@
|
||||
//! 令牌图标(`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()
|
||||
});
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user