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,263 @@
|
||||
//! 字体基座(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;
|
||||
Reference in new issue
Block a user