新增 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(不入库)。
264 lines
8.8 KiB
Rust
264 lines
8.8 KiB
Rust
//! 字体基座(D28 前半):把令牌的字体栈求值成「本机真的有的族名」,并打一行启动日志。
|
||
//!
|
||
//! ## 做什么
|
||
//!
|
||
//! 1. 取 `Platform::current()` 的三条栈(Display / Text / Mono,见 `unlucky_design::typography`);
|
||
//! 2. 用 iced 的**全局字体系统**(`iced_graphics::text::font_system()`,即 cosmic-text 的
|
||
//! `FontSystem`,首次调用会扫系统字体)核对每个族名是否装机 → 取栈里第一个装机可用的族;
|
||
//! 3. 给出 400/500/700 三档字重的落点(原生 / 最近档 / 合成加粗):
|
||
//! **先查系统里这个族真实提供的字面(fontdb 的 `FaceInfo.weight`)**,查不到才回落到令牌的
|
||
//! 纯函数 `Platform::resolve_weight`。缺档按降级记录。
|
||
//!
|
||
//! ## 为什么要真查系统
|
||
//!
|
||
//! 令牌栈写的是「Win11 有 Segoe UI Variable Text」,但 Win10 / Server 只有 `Segoe UI`(静态
|
||
//! 300/400/600/700,**没有 500**),干净安装的 Linux 可能一个都没有。只按栈头写死族名会把
|
||
//! 「实际使用哪个族、哪一档字重」变成猜测——本 mission 的开发机就是 Windows Server 2025,
|
||
//! 实测就回退到了经典 `Segoe UI`(见交接文档的启动日志)。
|
||
|
||
//! ## 不做什么
|
||
//!
|
||
//! **中文字体 CDN 下载不在这里**(后续 mission)。本模块只做「系统字体优先」这一半,
|
||
//! 也没有能力判断 CJK 字形覆盖(那是 `ttf-parser` 查 cmap 的活,M60 Q3 的 spike 做过)。
|
||
|
||
use unlucky_design::typography::{FontFamily, Platform, Weight, WeightMatch};
|
||
|
||
use crate::theme::Families;
|
||
|
||
/// 一条栈的求值结果。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct FamilyReport {
|
||
/// 栈的用途(`text` / `display` / `mono`)。
|
||
pub stack: &'static str,
|
||
/// 实际选中的族名(栈里第一个装机可用的;都没装就是栈头)。
|
||
pub family: &'static str,
|
||
/// 选中的族是否真的在系统字体库里。
|
||
pub installed: bool,
|
||
}
|
||
|
||
/// 一档字重的求值结果。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct WeightReport {
|
||
/// 请求的字重(400/500/700)。
|
||
pub requested: u16,
|
||
/// 实际可用的字重(降级后)。
|
||
pub available: u16,
|
||
/// 匹配质量(原生 / 最近档 / 合成加粗)。
|
||
pub quality: WeightMatch,
|
||
}
|
||
|
||
/// 字体求值的全部结论(启动日志与测试都用它)。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct Resolved {
|
||
/// 平台(决定栈与字重覆盖表)。
|
||
pub platform: Platform,
|
||
/// 三条栈的落点。
|
||
pub families: [FamilyReport; 3],
|
||
/// 三档字重的落点(对**正文**栈求值)。
|
||
pub weights: [WeightReport; 3],
|
||
}
|
||
|
||
impl Resolved {
|
||
/// 把结论塞回主题适配层要的形状。
|
||
pub fn families(&self) -> Families {
|
||
Families {
|
||
text: self.families[0].family,
|
||
display: self.families[1].family,
|
||
mono: self.families[2].family,
|
||
}
|
||
}
|
||
|
||
/// 是否有任何一档走了「合成加粗」(= 观感降级,要记日志)。
|
||
pub fn has_synthetic(&self) -> bool {
|
||
self.weights
|
||
.iter()
|
||
.any(|weight| weight.quality == WeightMatch::Synthetic)
|
||
}
|
||
|
||
/// 启动日志(两行:一行族,一行字重)。
|
||
pub fn describe(&self) -> String {
|
||
let families = self
|
||
.families
|
||
.iter()
|
||
.map(|report| {
|
||
format!(
|
||
"{}={:?}{}",
|
||
report.stack,
|
||
report.family,
|
||
if report.installed {
|
||
""
|
||
} else {
|
||
"(未装机→回落)"
|
||
}
|
||
)
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join(" ");
|
||
|
||
let weights = self
|
||
.weights
|
||
.iter()
|
||
.map(|report| {
|
||
let quality = match report.quality {
|
||
WeightMatch::Native => "native",
|
||
WeightMatch::Nearest => "nearest",
|
||
WeightMatch::Synthetic => "synthetic",
|
||
};
|
||
format!("w{}={}({quality})", report.requested, report.available)
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join(" ");
|
||
|
||
format!(
|
||
"[font] platform={} {families} | weights {weights}",
|
||
self.platform.name()
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 求值本机字体(会触发 iced 全局字体系统的首次初始化,约 0.1~0.8s)。
|
||
pub fn resolve() -> Resolved {
|
||
let platform = Platform::current();
|
||
let faces = system_faces();
|
||
|
||
let is_installed = |family: &str| -> bool {
|
||
faces
|
||
.as_ref()
|
||
.is_none_or(|faces: &Vec<Face>| faces.iter().any(|face| face.family == family))
|
||
};
|
||
|
||
let families = [
|
||
FamilyReport {
|
||
stack: "text",
|
||
family: pick(platform, FontFamily::Text, &is_installed),
|
||
installed: is_installed(pick(platform, FontFamily::Text, &is_installed)),
|
||
},
|
||
FamilyReport {
|
||
stack: "display",
|
||
family: pick(platform, FontFamily::Display, &is_installed),
|
||
installed: is_installed(pick(platform, FontFamily::Display, &is_installed)),
|
||
},
|
||
FamilyReport {
|
||
stack: "mono",
|
||
family: pick(platform, FontFamily::Mono, &is_installed),
|
||
installed: is_installed(pick(platform, FontFamily::Mono, &is_installed)),
|
||
},
|
||
];
|
||
|
||
// 字重:**优先用系统里这个族真实提供的档位**(令牌表的 `WeightCoverage` 是「这个族通常有哪几档」,
|
||
// 装机版可能是静态子集:Win Server / 精简版只有经典 Segoe UI,没有 500)。查不到才回落到令牌纯函数。
|
||
let weights = [Weight::Regular, Weight::Medium, Weight::Bold]
|
||
.map(|weight| weight_report(platform, families[0].family, weight, faces.as_deref()));
|
||
|
||
Resolved {
|
||
platform,
|
||
families,
|
||
weights,
|
||
}
|
||
}
|
||
|
||
/// 一条系统字面(族名 + 字重)。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
struct Face {
|
||
family: String,
|
||
weight: u16,
|
||
}
|
||
|
||
/// 某档字重的落点:先问系统,再问令牌。
|
||
fn weight_report(
|
||
platform: Platform,
|
||
family: &str,
|
||
weight: Weight,
|
||
faces: Option<&[Face]>,
|
||
) -> WeightReport {
|
||
let requested = weight.value();
|
||
|
||
let available = faces.and_then(|faces| {
|
||
let mut weights = faces
|
||
.iter()
|
||
.filter(|face| face.family == family)
|
||
.map(|face| face.weight)
|
||
.collect::<Vec<_>>();
|
||
weights.sort_unstable();
|
||
weights.dedup();
|
||
if weights.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
Some(
|
||
weights
|
||
.iter()
|
||
.copied()
|
||
.min_by_key(|candidate| candidate.abs_diff(requested))
|
||
.unwrap_or(requested),
|
||
)
|
||
});
|
||
|
||
match available {
|
||
Some(available) => WeightReport {
|
||
requested,
|
||
available,
|
||
quality: quality_of(requested, available),
|
||
},
|
||
None => {
|
||
let choice = platform.resolve_weight(FontFamily::Text, weight);
|
||
WeightReport {
|
||
requested: choice.requested,
|
||
available: choice.available,
|
||
quality: choice.quality,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 与令牌层同一判据:原生 / 最近档(差 <200)/ 合成加粗(差 ≥200)。
|
||
fn quality_of(requested: u16, available: u16) -> WeightMatch {
|
||
match available.abs_diff(requested) {
|
||
0 => WeightMatch::Native,
|
||
delta if delta >= 200 => WeightMatch::Synthetic,
|
||
_ => WeightMatch::Nearest,
|
||
}
|
||
}
|
||
|
||
/// 在栈里选第一个**装机可用**的族(跳过 `sans-serif` 这类通用族名)。
|
||
///
|
||
/// 一个都没装上时返回栈头:让 cosmic-text 自己去回退,比我们猜一个更靠谱。
|
||
pub fn pick(
|
||
platform: Platform,
|
||
stack: FontFamily,
|
||
is_installed: &dyn Fn(&str) -> bool,
|
||
) -> &'static str {
|
||
let faces = platform.font_stack(stack);
|
||
|
||
faces
|
||
.iter()
|
||
.find(|face| !is_generic(face.family) && is_installed(face.family))
|
||
.map_or(faces[0].family, |face| face.family)
|
||
}
|
||
|
||
/// 通用族名(CSS 关键字,不是真族名)。
|
||
fn is_generic(family: &str) -> bool {
|
||
matches!(family, "sans-serif" | "serif" | "monospace" | "cursive")
|
||
}
|
||
|
||
/// 系统字体库里的全部字面(`None` = 拿不到,视为「不知道」,全部当装机)。
|
||
fn system_faces() -> Option<Vec<Face>> {
|
||
let lock = iced::advanced::graphics::text::font_system();
|
||
let mut font_system = lock.write().ok()?;
|
||
|
||
let mut faces = Vec::new();
|
||
for face in font_system.raw().db().faces() {
|
||
for (family, _) in &face.families {
|
||
faces.push(Face {
|
||
family: family.clone(),
|
||
weight: face.weight.0,
|
||
});
|
||
}
|
||
}
|
||
|
||
Some(faces)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|