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:
AskaEth committed 2026-11-01 17:11:24 +08:00
1 parent 3fcdc3d3e1
commit df4ff373b7
16 files changed
+4244 -97

No files matched your search

+118
View File
@@ -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());
}
}