Compare commits
4
Commits
3fcdc3d3e1
...
c20c742b60
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c20c742b60 | ||
|
|
a1adf024ba | ||
|
|
14da7b3dec | ||
|
|
df4ff373b7 |
No files matched your search
Generated
+1272
-97
File diff suppressed because it is too large.
Load diff
@@ -6,6 +6,9 @@ members = [
|
|||||||
"crates/app",
|
"crates/app",
|
||||||
"crates/cli",
|
"crates/cli",
|
||||||
"crates/design",
|
"crates/design",
|
||||||
|
# P4(D26 iced 重写)第一步:与 Slint 版 `crates/app` 并存的新 UI crate。
|
||||||
|
# bin 名 `unlucky-launcher-iced`,翻牌时才改名(见 DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md)。
|
||||||
|
"crates/app-iced",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
[package]
|
||||||
|
name = "unlucky-app-iced"
|
||||||
|
description = "Unlucky-Launcher 桌面壳(iced 0.14 重写版):M3 Expressive 骨架 / 令牌适配 / 截图通道"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
# 并存期(D26):Slint 版仍是 `crates/app` 的 `unlucky-launcher`,本 crate 出
|
||||||
|
# `unlucky-launcher-iced`。两个 package 在同一 target 目录产出同名 bin 会冲突,
|
||||||
|
# 所以**翻牌前不许改回 `unlucky-launcher`**(步骤见 SubAgentsTalk/app-iced-骨架与翻牌.md)。
|
||||||
|
[[bin]]
|
||||||
|
name = "unlucky-launcher-iced"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "unlucky_app_iced"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
|
||||||
|
unlucky-design = { path = "../design", version = "0.1" }
|
||||||
|
|
||||||
|
# UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。
|
||||||
|
# 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`),
|
||||||
|
# 这里给 `thread-pool`(iced 自己的默认执行器,不额外引 tokio 运行时)。
|
||||||
|
# `wgpu` 由本 crate 的 `gpu` feature 打开,于是「仅 CPU」体积基线用 `--no-default-features` 就能量到。
|
||||||
|
iced = { version = "=0.14.0", default-features = false, features = [
|
||||||
|
"tiny-skia",
|
||||||
|
"crisp",
|
||||||
|
"advanced",
|
||||||
|
"svg",
|
||||||
|
"thread-pool",
|
||||||
|
"x11",
|
||||||
|
"wayland",
|
||||||
|
"linux-theme-detection",
|
||||||
|
] }
|
||||||
|
|
||||||
|
# 截图落 PNG。只开 png 编码器,不要默认全套 codec(体积)。
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["gpu"]
|
||||||
|
# 硬件渲染后端(wgpu→Vulkan/DX12)。关闭后只剩 tiny-skia(CPU),用于体积基线与无 GPU 机器。
|
||||||
|
gpu = ["iced/wgpu"]
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
//! Elm 三件套:`State` / `Message` / `update` + `view` 接线。
|
||||||
|
//!
|
||||||
|
//! ## 启动序列(顺序有讲究)
|
||||||
|
//!
|
||||||
|
//! 1. **探测渲染后端** → 写 `ICED_BACKEND`(必须在 `run()` 之前,M60 Q1);
|
||||||
|
//! 2. **求值字体栈**(D28 前半)→ 一行启动日志;
|
||||||
|
//! 3. `iced::application(..).run()`:窗口 1180×760、无边框(自绘标题栏);
|
||||||
|
//! 4. 首个 `view()` 打「到首帧」耗时;截图模式下第 3 帧 `window::screenshot` 落盘后退出。
|
||||||
|
//!
|
||||||
|
//! ## 为什么截图要等 3 帧
|
||||||
|
//!
|
||||||
|
//! 无边框窗口启动时会先收到一次「显示器工作区尺寸」的 `Resized`,再回到设定尺寸(M60 坑 #5),
|
||||||
|
//! 第一帧可能是旧尺寸;再叠上字体系统首次上屏的排版抖动,留 2 帧余量最稳。
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use iced::{window, window::Direction, Element, Subscription, Task};
|
||||||
|
|
||||||
|
use unlucky_design::color::SchemeFlavor;
|
||||||
|
|
||||||
|
use crate::cli::Options;
|
||||||
|
use crate::fonts;
|
||||||
|
use crate::probe;
|
||||||
|
use crate::shell::{self, NavItem};
|
||||||
|
use crate::shot;
|
||||||
|
use crate::theme::{Families, Ui};
|
||||||
|
|
||||||
|
/// 验收窗口尺寸(1180×760,100% DPI —— D30 的硬要求)。
|
||||||
|
pub const WINDOW_SIZE: iced::Size = iced::Size {
|
||||||
|
width: 1180.0,
|
||||||
|
height: 760.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 产品名(自绘标题栏的标题;也是 `'static`,好让 view 借用)。
|
||||||
|
pub const APP_TITLE: &str = "Unlucky Launcher";
|
||||||
|
|
||||||
|
/// 窗口最小尺寸(低于这个值骨架会挤坏:88 导航 + 页面留白 + 卡片)。
|
||||||
|
const MIN_SIZE: iced::Size = iced::Size {
|
||||||
|
width: 880.0,
|
||||||
|
height: 560.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 截图落盘的帧号(见模块文档)。
|
||||||
|
const CAPTURE_AT_FRAME: u32 = 3;
|
||||||
|
|
||||||
|
/// 进程内「run() 开始」的时刻(首帧耗时基线)。
|
||||||
|
static STARTED: OnceLock<Instant> = OnceLock::new();
|
||||||
|
/// 首帧日志只打一次。
|
||||||
|
static FIRST_FRAME_LOGGED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// 应用状态(**只有 UI 状态**;业务状态等页面 mission 接 `host`/`state` 时再进)。
|
||||||
|
pub struct App {
|
||||||
|
options: Options,
|
||||||
|
families: Families,
|
||||||
|
ui: Ui,
|
||||||
|
nav: NavItem,
|
||||||
|
window: Option<window::Id>,
|
||||||
|
frames: u32,
|
||||||
|
/// 截图模式的「主题已定」闸门:`system::theme()` 是异步的,早拍会拍到错的深浅色。
|
||||||
|
theme_resolved: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 消息(单一大枚举;页面接进来时按页拆子枚举)。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Message {
|
||||||
|
/// 启动即发一次(`iced::application` 的 boot 闭包)。
|
||||||
|
Boot,
|
||||||
|
/// `window::latest()` 的结果。
|
||||||
|
WindowReady(Option<window::Id>),
|
||||||
|
/// 系统主题探测结果。
|
||||||
|
SystemTheme(iced::theme::Mode),
|
||||||
|
/// 一帧画完(只有截图模式才订阅)。
|
||||||
|
Frame,
|
||||||
|
/// 导航项切换。
|
||||||
|
Select(NavItem),
|
||||||
|
/// 按下标题栏 → 拖窗。
|
||||||
|
DragWindow,
|
||||||
|
/// 按下边缘热区 → 缩放窗口。
|
||||||
|
ResizeStart(Direction),
|
||||||
|
/// 最小化。
|
||||||
|
Minimize,
|
||||||
|
/// 最大化/还原。
|
||||||
|
ToggleMaximize,
|
||||||
|
/// 关闭。
|
||||||
|
Close,
|
||||||
|
/// 截图回来了。
|
||||||
|
Captured(window::Screenshot),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
fn new(options: Options, families: Families, flavor: SchemeFlavor) -> Self {
|
||||||
|
let options_theme_given = options.shot_flavor().is_some();
|
||||||
|
Self {
|
||||||
|
ui: Ui::new(flavor, families),
|
||||||
|
families,
|
||||||
|
options,
|
||||||
|
nav: NavItem::Instances,
|
||||||
|
window: None,
|
||||||
|
frames: 0,
|
||||||
|
// 显式给了主题就算已定;否则等 `SystemTheme` 到达
|
||||||
|
theme_resolved: options_theme_given,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_screenshot(&self) -> bool {
|
||||||
|
self.options.screenshot.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, message: Message) -> Task<Message> {
|
||||||
|
match message {
|
||||||
|
Message::Boot => {
|
||||||
|
let mut tasks = vec![window::latest().map(Message::WindowReady)];
|
||||||
|
|
||||||
|
// 主题:参数里没指定就探系统(深浅两套都要能出图)
|
||||||
|
if self.options.shot_flavor().is_none() {
|
||||||
|
tasks.push(iced::system::theme().map(Message::SystemTheme));
|
||||||
|
}
|
||||||
|
|
||||||
|
Task::batch(tasks)
|
||||||
|
}
|
||||||
|
Message::WindowReady(id) => {
|
||||||
|
self.window = id;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::SystemTheme(mode) => {
|
||||||
|
let flavor = match mode {
|
||||||
|
iced::theme::Mode::Dark => SchemeFlavor::Dark,
|
||||||
|
iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light,
|
||||||
|
};
|
||||||
|
eprintln!("[theme] 系统主题 = {mode:?} -> {}", slug(flavor));
|
||||||
|
self.set_flavor(flavor);
|
||||||
|
self.theme_resolved = true;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::Frame => self.on_frame(),
|
||||||
|
Message::Select(nav) => {
|
||||||
|
self.nav = nav;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::DragWindow => match self.window {
|
||||||
|
Some(id) => window::drag(id),
|
||||||
|
None => Task::none(),
|
||||||
|
},
|
||||||
|
Message::ResizeStart(direction) => match self.window {
|
||||||
|
Some(id) => window::drag_resize(id, direction),
|
||||||
|
None => Task::none(),
|
||||||
|
},
|
||||||
|
Message::Minimize => match self.window {
|
||||||
|
Some(id) => window::minimize(id, true),
|
||||||
|
None => Task::none(),
|
||||||
|
},
|
||||||
|
Message::ToggleMaximize => match self.window {
|
||||||
|
Some(id) => window::toggle_maximize(id),
|
||||||
|
None => Task::none(),
|
||||||
|
},
|
||||||
|
Message::Close => iced::exit(),
|
||||||
|
Message::Captured(screenshot) => self.on_captured(&screenshot),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切主题:只重建 [`Ui`](= 重建 Style),**不动任何 UI 状态**。
|
||||||
|
fn set_flavor(&mut self, flavor: SchemeFlavor) {
|
||||||
|
if self.ui.flavor() == flavor {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let next = Ui::new(flavor, self.families);
|
||||||
|
eprintln!(
|
||||||
|
"[theme] {} -> {} (Theme::name 变化触发缓存重算)",
|
||||||
|
self.ui.name(),
|
||||||
|
next.name()
|
||||||
|
);
|
||||||
|
self.ui = next;
|
||||||
|
}
|
||||||
|
fn on_frame(&mut self) -> Task<Message> {
|
||||||
|
self.frames += 1;
|
||||||
|
|
||||||
|
// 主题没定就不出图:等 `SystemTheme`(否则暗色系统上会拍到浅色图)
|
||||||
|
if !self.has_screenshot() || !self.theme_resolved || self.frames < CAPTURE_AT_FRAME {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.window {
|
||||||
|
Some(id) => window::screenshot(id).map(Message::Captured),
|
||||||
|
None => Task::none(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_captured(&mut self, screenshot: &window::Screenshot) -> Task<Message> {
|
||||||
|
let Some(path) = self.options.screenshot.clone() else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
|
||||||
|
match shot::save(screenshot, &path) {
|
||||||
|
Ok(summary) => {
|
||||||
|
eprintln!("{}", summary.describe());
|
||||||
|
eprintln!(
|
||||||
|
"[shot] theme={} renderer={}",
|
||||||
|
shot::flavor_slug(&self.ui),
|
||||||
|
renderer_note(self.options.backend)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => eprintln!("[shot] 失败:{error}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
iced::exit()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscription(&self) -> Subscription<Message> {
|
||||||
|
// 只有截图模式才订阅帧事件:否则「每帧一条消息 → 每帧重绘」会白烧 CPU。
|
||||||
|
if self.wants_frames() {
|
||||||
|
window::frames().map(|_| Message::Frame)
|
||||||
|
} else {
|
||||||
|
Subscription::none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否订阅帧事件(= 截图模式)。
|
||||||
|
fn wants_frames(&self) -> bool {
|
||||||
|
self.has_screenshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn theme(&self) -> iced::Theme {
|
||||||
|
self.ui.theme()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(&self) -> String {
|
||||||
|
APP_TITLE.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self) -> Element<'_, Message> {
|
||||||
|
if !FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) {
|
||||||
|
if let Some(started) = STARTED.get() {
|
||||||
|
eprintln!(
|
||||||
|
"[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)",
|
||||||
|
started.elapsed().as_secs_f64() * 1000.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shell::view(
|
||||||
|
&self.ui,
|
||||||
|
self.nav,
|
||||||
|
APP_TITLE,
|
||||||
|
self.nav.label(),
|
||||||
|
"Skeleton only: titlebar, navigation rail and one card. \
|
||||||
|
Business pages, the component library and the glass shader are follow-up missions.",
|
||||||
|
self.options.force_state,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn renderer_note(backend: crate::Backend) -> &'static str {
|
||||||
|
if cfg!(feature = "gpu") {
|
||||||
|
match backend {
|
||||||
|
crate::Backend::Cpu => "cpu(forced)",
|
||||||
|
crate::Backend::Gpu => "gpu(preferred)",
|
||||||
|
crate::Backend::Auto => "auto",
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"cpu(build without wgpu)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 主题名(日志用,和 `Ui::name()` 同一口径)。
|
||||||
|
fn slug(flavor: SchemeFlavor) -> &'static str {
|
||||||
|
if flavor.is_dark() {
|
||||||
|
"ull-m3-dark"
|
||||||
|
} else {
|
||||||
|
"ull-m3-light"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动 iced(阻塞到退出)。`--diff` 不走这里,见 `main.rs`。
|
||||||
|
pub(crate) fn run(options: Options) -> iced::Result {
|
||||||
|
let _ = STARTED.set(Instant::now());
|
||||||
|
|
||||||
|
let probe_started = Instant::now();
|
||||||
|
// 只有 `auto` 需要探测:`cpu` 与 `gpu` 的决策与「有没有硬件 GPU」无关,
|
||||||
|
// 而枚举 13 个适配器在本机要 0.65~1.2 s(实测),纯属白花。
|
||||||
|
let needs_probe = options.backend == crate::Backend::Auto;
|
||||||
|
let probe = if needs_probe {
|
||||||
|
probe::Probe::run()
|
||||||
|
} else {
|
||||||
|
probe::Probe::default()
|
||||||
|
};
|
||||||
|
let probe_ms = probe_started.elapsed().as_secs_f64() * 1000.0;
|
||||||
|
probe::apply(options.backend, &probe);
|
||||||
|
if cfg!(feature = "gpu") {
|
||||||
|
if needs_probe {
|
||||||
|
eprintln!("[probe] 探测耗时 {probe_ms:.0} ms(wgpu 实例 + 全后端适配器枚举)");
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"[probe] 探测耗时 {probe_ms:.0} ms(--renderer={} 与硬件无关,跳过枚举)",
|
||||||
|
options.backend.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let font_started = Instant::now();
|
||||||
|
let resolved = fonts::resolve();
|
||||||
|
eprintln!(
|
||||||
|
"[font] 求值耗时 {:.0} ms",
|
||||||
|
font_started.elapsed().as_secs_f64() * 1000.0
|
||||||
|
);
|
||||||
|
eprintln!("{}", resolved.describe());
|
||||||
|
if resolved.has_synthetic() {
|
||||||
|
eprintln!(
|
||||||
|
"[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换族)要处理"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let families = resolved.families();
|
||||||
|
|
||||||
|
// 截图模式:`--screenshot-theme` > `--theme` > 浅色(后续系统探测会覆盖)
|
||||||
|
let flavor = options.shot_flavor().unwrap_or(SchemeFlavor::Light);
|
||||||
|
let boot_options = options.clone();
|
||||||
|
|
||||||
|
let application = iced::application(
|
||||||
|
move || {
|
||||||
|
(
|
||||||
|
App::new(boot_options.clone(), families, flavor),
|
||||||
|
Task::done(Message::Boot),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
App::update,
|
||||||
|
App::view,
|
||||||
|
)
|
||||||
|
.title(App::title)
|
||||||
|
.theme(App::theme)
|
||||||
|
.subscription(App::subscription)
|
||||||
|
.default_font(iced::Font {
|
||||||
|
family: iced::font::Family::Name(families.text),
|
||||||
|
..iced::Font::DEFAULT
|
||||||
|
})
|
||||||
|
.window(window::Settings {
|
||||||
|
size: WINDOW_SIZE,
|
||||||
|
min_size: Some(MIN_SIZE),
|
||||||
|
decorations: false,
|
||||||
|
resizable: true,
|
||||||
|
..window::Settings::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
match options.scale_factor {
|
||||||
|
Some(scale) => application.scale_factor(move |_| scale).run(),
|
||||||
|
None => application.run(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const FAMILIES: Families = Families {
|
||||||
|
text: "Segoe UI",
|
||||||
|
display: "Segoe UI",
|
||||||
|
mono: "Consolas",
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switching_flavor_rebuilds_styles_but_keeps_ui_state() {
|
||||||
|
let mut app = App::new(Options::default(), FAMILIES, SchemeFlavor::Light);
|
||||||
|
app.nav = NavItem::Logs;
|
||||||
|
assert_eq!(app.ui.name(), "ull-m3-light");
|
||||||
|
|
||||||
|
app.set_flavor(SchemeFlavor::Dark);
|
||||||
|
|
||||||
|
assert_eq!(app.ui.name(), "ull-m3-dark", "切主题要换主题名");
|
||||||
|
assert_eq!(app.nav, NavItem::Logs, "切主题不许重置 UI 状态");
|
||||||
|
assert!(app.options.screenshot.is_none(), "切主题不许改启动参数");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_size_is_the_acceptance_size() {
|
||||||
|
assert_eq!((WINDOW_SIZE.width, WINDOW_SIZE.height), (1180.0, 760.0));
|
||||||
|
const {
|
||||||
|
assert!(MIN_SIZE.width < WINDOW_SIZE.width);
|
||||||
|
assert!(MIN_SIZE.height < WINDOW_SIZE.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slug_matches_the_theme_name() {
|
||||||
|
assert_eq!(slug(SchemeFlavor::Light), "ull-m3-light");
|
||||||
|
assert_eq!(slug(SchemeFlavor::Dark), "ull-m3-dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frames_are_only_requested_in_screenshot_mode() {
|
||||||
|
let mut app = App::new(Options::default(), FAMILIES, SchemeFlavor::Light);
|
||||||
|
assert!(!app.wants_frames(), "普通运行不订阅帧,避免每帧重绘");
|
||||||
|
|
||||||
|
app.options.screenshot = Some(std::path::PathBuf::from("x.png"));
|
||||||
|
assert!(
|
||||||
|
app.wants_frames(),
|
||||||
|
"截图模式必须订阅帧事件才能在第 3 帧出图"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
//! 命令行参数(只做骨架期需要的那些;业务开关等页面 mission 再加)。
|
||||||
|
//!
|
||||||
|
//! 取值语法与现役 Slint 版一致:`--key value` 与 `--key=value` 两种都认
|
||||||
|
//! (`crates/app/src/screenshot.rs` 的 `value_of` 是同一口径),这样将来 CI 换 bin 不用改脚本。
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use unlucky_design::color::SchemeFlavor;
|
||||||
|
|
||||||
|
/// 渲染后端请求(D27)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum Backend {
|
||||||
|
/// 探测后决定:有硬件 GPU 就让 iced 自己走 wgpu→tiny-skia 回退;没有就强制 CPU 起步。
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
/// 优先 GPU(写 `ICED_BACKEND=wgpu,tiny-skia`:拿不到适配器仍会降级,不硬失败)。
|
||||||
|
Gpu,
|
||||||
|
/// 强制 CPU(`--software-renderer` 的等价物,写 `ICED_BACKEND=tiny-skia`)。
|
||||||
|
Cpu,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Backend {
|
||||||
|
/// 稳定名字(日志用)。
|
||||||
|
pub const fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Backend::Auto => "auto",
|
||||||
|
Backend::Gpu => "gpu",
|
||||||
|
Backend::Cpu => "cpu",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析后的启动参数。
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Options {
|
||||||
|
/// 渲染后端请求。
|
||||||
|
pub backend: Backend,
|
||||||
|
/// 主题:`None` = 探测系统主题。
|
||||||
|
pub theme: Option<SchemeFlavor>,
|
||||||
|
/// 截图通道:给了路径就出图后退出。
|
||||||
|
pub screenshot: Option<PathBuf>,
|
||||||
|
/// 截图时用的主题(`--screenshot-theme`,优先级高于 `--theme`)。
|
||||||
|
pub screenshot_theme: Option<SchemeFlavor>,
|
||||||
|
/// 强制窗口缩放因子(截图验收要 100% DPI;`None` = 用系统 DPI)。
|
||||||
|
pub scale_factor: Option<f32>,
|
||||||
|
/// `--diff <left> <right>`:只做两图逐像素对比,不开窗。
|
||||||
|
pub diff: Option<(PathBuf, PathBuf)>,
|
||||||
|
/// `--diff-scan <n>`:对比时额外做 ±n px 的平移扫描(找最佳对齐,默认 0 = 不扫)。
|
||||||
|
pub diff_scan: u32,
|
||||||
|
/// `--force-state=hover|pressed`:把按钮强制成某个状态便于截图取证(D30 的状态图)。
|
||||||
|
pub force_state: Option<ForcedState>,
|
||||||
|
/// `--data-dir <dir>`:当前**接受并忽略**。
|
||||||
|
///
|
||||||
|
/// 现役 Slint 版的 CI 冒烟用 `--software-renderer --screenshot <png> --data-dir <dir>`,
|
||||||
|
/// 翻牌后同一套脚本要能直接跑 iced 版;真正的数据目录消费由后续页面 mission 接 `host`/`state` 时做。
|
||||||
|
pub data_dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 截图取证用的强制交互状态(只影响观感,不改变任何状态机)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ForcedState {
|
||||||
|
/// 所有可交互控件按 hover 画。
|
||||||
|
Hover,
|
||||||
|
/// 所有可交互控件按 pressed 画。
|
||||||
|
Pressed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ForcedState {
|
||||||
|
/// 稳定名字(日志用)。
|
||||||
|
pub const fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ForcedState::Hover => "hover",
|
||||||
|
ForcedState::Pressed => "pressed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Options {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
backend: Backend::Auto,
|
||||||
|
theme: None,
|
||||||
|
screenshot: None,
|
||||||
|
screenshot_theme: None,
|
||||||
|
scale_factor: None,
|
||||||
|
diff: None,
|
||||||
|
diff_scan: 0,
|
||||||
|
force_state: None,
|
||||||
|
data_dir: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Options {
|
||||||
|
/// 从进程参数解析。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// 参数值不合法(未知的 `--renderer` / `--theme` / 非法 `--scale-factor`)时返回人话。
|
||||||
|
pub fn from_env() -> Result<Self, String> {
|
||||||
|
Self::parse(std::env::args().skip(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从任意参数序列解析(`argv[1..]`,测试直接喂数组)。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// 同 [`Options::from_env`]。
|
||||||
|
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
|
||||||
|
let mut options = Self::default();
|
||||||
|
let mut args = args.into_iter();
|
||||||
|
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
let (key, inline) = match arg.split_once('=') {
|
||||||
|
Some((key, value)) => (key.to_owned(), Some(value.to_owned())),
|
||||||
|
None => (arg.clone(), None),
|
||||||
|
};
|
||||||
|
|
||||||
|
match key.as_str() {
|
||||||
|
"--software-renderer" => options.backend = Backend::Cpu,
|
||||||
|
"--renderer" => {
|
||||||
|
let value = take(&mut args, inline, "--renderer")?;
|
||||||
|
options.backend = match value.as_str() {
|
||||||
|
"auto" => Backend::Auto,
|
||||||
|
"gpu" => Backend::Gpu,
|
||||||
|
"cpu" => Backend::Cpu,
|
||||||
|
other => {
|
||||||
|
return Err(format!("--renderer 只认 auto|gpu|cpu,收到 {other:?}"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
"--theme" => {
|
||||||
|
options.theme = Some(flavor(&take(&mut args, inline, "--theme")?)?);
|
||||||
|
}
|
||||||
|
"--screenshot-theme" => {
|
||||||
|
options.screenshot_theme =
|
||||||
|
Some(flavor(&take(&mut args, inline, "--screenshot-theme")?)?);
|
||||||
|
}
|
||||||
|
"--screenshot" => {
|
||||||
|
options.screenshot =
|
||||||
|
Some(PathBuf::from(take(&mut args, inline, "--screenshot")?));
|
||||||
|
}
|
||||||
|
"--scale-factor" => {
|
||||||
|
let raw = take(&mut args, inline, "--scale-factor")?;
|
||||||
|
let value: f32 = raw
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| format!("--scale-factor 需要数字,收到 {raw:?}"))?;
|
||||||
|
if !(value.is_finite() && value > 0.0) {
|
||||||
|
return Err(format!("--scale-factor 必须 > 0,收到 {raw:?}"));
|
||||||
|
}
|
||||||
|
options.scale_factor = Some(value);
|
||||||
|
}
|
||||||
|
"--diff" => {
|
||||||
|
let left = take(&mut args, inline, "--diff")?;
|
||||||
|
let right = args
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "--diff 需要两张图的路径".to_owned())?;
|
||||||
|
options.diff = Some((PathBuf::from(left), PathBuf::from(right)));
|
||||||
|
}
|
||||||
|
"--diff-scan" => {
|
||||||
|
let raw = take(&mut args, inline, "--diff-scan")?;
|
||||||
|
options.diff_scan = raw
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| format!("--diff-scan 需要非负整数,收到 {raw:?}"))?;
|
||||||
|
}
|
||||||
|
"--force-state" => {
|
||||||
|
let raw = take(&mut args, inline, "--force-state")?;
|
||||||
|
options.force_state = Some(match raw.as_str() {
|
||||||
|
"hover" => ForcedState::Hover,
|
||||||
|
"pressed" => ForcedState::Pressed,
|
||||||
|
other => {
|
||||||
|
return Err(format!(
|
||||||
|
"--force-state 只认 hover|pressed,收到 {other:?}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 翻牌前的兼容参数:接受并忽略(见字段文档)
|
||||||
|
"--data-dir" => {
|
||||||
|
options.data_dir = Some(PathBuf::from(take(&mut args, inline, "--data-dir")?));
|
||||||
|
}
|
||||||
|
other => return Err(format!("未知参数 {other:?}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 截图时实际使用的主题(截图专用参数优先)。
|
||||||
|
pub fn shot_flavor(&self) -> Option<SchemeFlavor> {
|
||||||
|
self.screenshot_theme.or(self.theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take(
|
||||||
|
args: &mut impl Iterator<Item = String>,
|
||||||
|
inline: Option<String>,
|
||||||
|
flag: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
inline
|
||||||
|
.or_else(|| args.next())
|
||||||
|
.ok_or_else(|| format!("{flag} 需要一个值"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flavor(raw: &str) -> Result<SchemeFlavor, String> {
|
||||||
|
match raw {
|
||||||
|
"light" => Ok(SchemeFlavor::Light),
|
||||||
|
"dark" => Ok(SchemeFlavor::Dark),
|
||||||
|
other => Err(format!("--theme 只认 light|dark,收到 {other:?}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn parse(args: &[&str]) -> Result<Options, String> {
|
||||||
|
Options::parse(args.iter().map(|arg| (*arg).to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_are_auto_and_system_theme() {
|
||||||
|
let options = parse(&[]).expect("空参数要能解析");
|
||||||
|
assert_eq!(options.backend, Backend::Auto);
|
||||||
|
assert_eq!(options.theme, None);
|
||||||
|
assert_eq!(options.screenshot, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn software_renderer_is_an_alias_of_cpu() {
|
||||||
|
assert_eq!(
|
||||||
|
parse(&["--software-renderer"]).unwrap().backend,
|
||||||
|
Backend::Cpu
|
||||||
|
);
|
||||||
|
assert_eq!(parse(&["--renderer=cpu"]).unwrap().backend, Backend::Cpu);
|
||||||
|
assert_eq!(parse(&["--renderer", "gpu"]).unwrap().backend, Backend::Gpu);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn screenshot_accepts_both_argv_forms() {
|
||||||
|
let spaced = parse(&["--screenshot", "out.png"]).unwrap();
|
||||||
|
let inline = parse(&["--screenshot=out.png"]).unwrap();
|
||||||
|
assert_eq!(spaced.screenshot, Some(PathBuf::from("out.png")));
|
||||||
|
assert_eq!(spaced.screenshot, inline.screenshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn screenshot_theme_wins_over_theme() {
|
||||||
|
let options = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap();
|
||||||
|
assert_eq!(options.theme, Some(SchemeFlavor::Dark));
|
||||||
|
assert_eq!(options.shot_flavor(), Some(SchemeFlavor::Light));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bad_values_are_reported_not_panicked() {
|
||||||
|
assert!(parse(&["--renderer=metal"]).is_err());
|
||||||
|
assert!(parse(&["--theme=blue"]).is_err());
|
||||||
|
assert!(parse(&["--scale-factor=0"]).is_err());
|
||||||
|
assert!(parse(&["--scale-factor=abc"]).is_err());
|
||||||
|
assert!(parse(&["--screenshot"]).is_err());
|
||||||
|
assert!(parse(&["--diff", "only-one.png"]).is_err());
|
||||||
|
assert!(parse(&["--nope"]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn data_dir_is_accepted_and_ignored_for_the_flip() {
|
||||||
|
// 现役 CI 冒烟:--software-renderer --screenshot <png> --data-dir <dir>
|
||||||
|
let options = parse(&[
|
||||||
|
"--software-renderer",
|
||||||
|
"--screenshot",
|
||||||
|
"shot.png",
|
||||||
|
"--data-dir",
|
||||||
|
"/d/tmp/x",
|
||||||
|
])
|
||||||
|
.expect("翻牌前必须兼容 --data-dir");
|
||||||
|
assert_eq!(options.backend, Backend::Cpu);
|
||||||
|
assert_eq!(options.screenshot, Some(PathBuf::from("shot.png")));
|
||||||
|
assert_eq!(options.data_dir, Some(PathBuf::from("/d/tmp/x")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diff_scan_and_force_state_parse() {
|
||||||
|
assert_eq!(parse(&["--diff-scan=2"]).unwrap().diff_scan, 2);
|
||||||
|
assert_eq!(
|
||||||
|
parse(&["--force-state", "hover"]).unwrap().force_state,
|
||||||
|
Some(ForcedState::Hover)
|
||||||
|
);
|
||||||
|
assert!(parse(&["--force-state=focus"]).is_err());
|
||||||
|
assert!(parse(&["--diff-scan=-1"]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diff_takes_two_paths() {
|
||||||
|
let options = parse(&["--diff", "a.png", "b.png"]).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
options.diff,
|
||||||
|
Some((PathBuf::from("a.png"), PathBuf::from("b.png")))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
//! `fonts` 的单元测试(拆出来只为「src 文件 ≤400 行」的门禁)。
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use unlucky_design::typography::WeightCoverage;
|
||||||
|
|
||||||
|
fn only(installed: &'static [&'static str]) -> impl Fn(&str) -> bool {
|
||||||
|
move |family| installed.contains(&family)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn windows_stack_falls_back_to_classic_segoe_when_variable_is_absent() {
|
||||||
|
// Win10 没有 Segoe UI Variable Text
|
||||||
|
let family = pick(
|
||||||
|
Platform::Windows,
|
||||||
|
FontFamily::Text,
|
||||||
|
&only(&["Segoe UI", "Microsoft YaHei UI"]),
|
||||||
|
);
|
||||||
|
assert_eq!(family, "Segoe UI");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn windows_stack_prefers_the_variable_face_on_win11() {
|
||||||
|
let family = pick(
|
||||||
|
Platform::Windows,
|
||||||
|
FontFamily::Text,
|
||||||
|
&only(&["Segoe UI Variable Text", "Segoe UI"]),
|
||||||
|
);
|
||||||
|
assert_eq!(family, "Segoe UI Variable Text");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generic_family_names_are_never_reported_as_the_resolved_family() {
|
||||||
|
// 一个都没装:返回栈头,且栈头不是通用族名
|
||||||
|
let family = pick(Platform::Linux, FontFamily::Text, &only(&[]));
|
||||||
|
assert_eq!(family, "Roboto");
|
||||||
|
assert!(!is_generic(family));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_cjk_fonts_do_not_change_the_latin_pick() {
|
||||||
|
// 装了苹方但没装 SF:macOS 栈应该选 Helvetica Neue 而不是中文族
|
||||||
|
let family = pick(
|
||||||
|
Platform::MacOs,
|
||||||
|
FontFamily::Text,
|
||||||
|
&only(&["PingFang SC", "Helvetica Neue"]),
|
||||||
|
);
|
||||||
|
assert_eq!(family, "Helvetica Neue");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn weight_reports_use_the_token_degradation_path() {
|
||||||
|
// 没有系统字面表时回落到令牌纯函数:Windows 栈里 Segoe UI Variable Text 是可变 300..700
|
||||||
|
let medium = weight_report(
|
||||||
|
Platform::Windows,
|
||||||
|
"Segoe UI Variable Text",
|
||||||
|
Weight::Medium,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(medium.requested, 500);
|
||||||
|
assert_eq!(medium.quality, WeightMatch::Native);
|
||||||
|
|
||||||
|
// 手工构造一个「只有 400/700」的族(= 经典 Segoe UI),500 应该落到最近档
|
||||||
|
let faces = [
|
||||||
|
Face {
|
||||||
|
family: "Segoe UI".to_owned(),
|
||||||
|
weight: 400,
|
||||||
|
},
|
||||||
|
Face {
|
||||||
|
family: "Segoe UI".to_owned(),
|
||||||
|
weight: 700,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let medium = weight_report(Platform::Windows, "Segoe UI", Weight::Medium, Some(&faces));
|
||||||
|
assert_eq!(medium.available, 400, "400 比 700 更近");
|
||||||
|
assert_eq!(medium.quality, WeightMatch::Nearest);
|
||||||
|
|
||||||
|
// 只有 400 却要 700 → 合成加粗
|
||||||
|
let only_regular = [Face {
|
||||||
|
family: "Narrow".to_owned(),
|
||||||
|
weight: 400,
|
||||||
|
}];
|
||||||
|
let bold = weight_report(
|
||||||
|
Platform::Windows,
|
||||||
|
"Narrow",
|
||||||
|
Weight::Bold,
|
||||||
|
Some(&only_regular),
|
||||||
|
);
|
||||||
|
assert_eq!(bold.quality, WeightMatch::Synthetic);
|
||||||
|
|
||||||
|
// 原生有 500 就用 500
|
||||||
|
let has_medium = [Face {
|
||||||
|
family: "Noto".to_owned(),
|
||||||
|
weight: 500,
|
||||||
|
}];
|
||||||
|
assert_eq!(
|
||||||
|
weight_report(Platform::Linux, "Noto", Weight::Medium, Some(&has_medium)).quality,
|
||||||
|
WeightMatch::Native
|
||||||
|
);
|
||||||
|
|
||||||
|
let narrow = WeightCoverage::Static(&[400]);
|
||||||
|
assert_eq!(narrow.nearest(700), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn describe_is_one_line_and_mentions_all_three_stacks() {
|
||||||
|
let resolved = Resolved {
|
||||||
|
platform: Platform::Windows,
|
||||||
|
families: [
|
||||||
|
FamilyReport {
|
||||||
|
stack: "text",
|
||||||
|
family: "Segoe UI",
|
||||||
|
installed: true,
|
||||||
|
},
|
||||||
|
FamilyReport {
|
||||||
|
stack: "display",
|
||||||
|
family: "Segoe UI Variable Display",
|
||||||
|
installed: false,
|
||||||
|
},
|
||||||
|
FamilyReport {
|
||||||
|
stack: "mono",
|
||||||
|
family: "Cascadia Mono",
|
||||||
|
installed: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
weights: [
|
||||||
|
WeightReport {
|
||||||
|
requested: 400,
|
||||||
|
available: 400,
|
||||||
|
quality: WeightMatch::Native,
|
||||||
|
},
|
||||||
|
WeightReport {
|
||||||
|
requested: 500,
|
||||||
|
available: 400,
|
||||||
|
quality: WeightMatch::Nearest,
|
||||||
|
},
|
||||||
|
WeightReport {
|
||||||
|
requested: 700,
|
||||||
|
available: 400,
|
||||||
|
quality: WeightMatch::Synthetic,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let line = resolved.describe();
|
||||||
|
assert_eq!(line.lines().count(), 1);
|
||||||
|
assert!(line.contains("text=\"Segoe UI\""), "{line}");
|
||||||
|
assert!(line.contains("未装机"), "{line}");
|
||||||
|
assert!(line.contains("w700=400(synthetic)"), "{line}");
|
||||||
|
assert!(resolved.has_synthetic());
|
||||||
|
assert_eq!(resolved.families().text, "Segoe UI");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn type_roles_pick_the_display_stack_for_big_text() {
|
||||||
|
use unlucky_design::typography::TypeRole;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
TypeRole::TitleLarge.style().family,
|
||||||
|
FontFamily::Display,
|
||||||
|
"22px 标题走 Display 栈"
|
||||||
|
);
|
||||||
|
assert_eq!(TypeRole::BodyMedium.style().family, FontFamily::Text);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
//! 令牌图标(`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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
//! # unlucky-app-iced · iced 版桌面壳(P4 / D26)
|
||||||
|
//!
|
||||||
|
//! 本 crate 是「UI 从头写」的第一步:**只做骨架**(能开窗、颜色/字体对、层级在),
|
||||||
|
//! 业务页面、组件库、i18n 全文案、玻璃 shader 都是后续 mission。
|
||||||
|
//!
|
||||||
|
//! | 模块 | 职责 | 不负责 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | [`theme`] | 令牌(`unlucky-design`)→ iced 类型:`Color` / `Font` / `Radius` / `Theme` | 发明令牌值 |
|
||||||
|
//! | `cli` | `--renderer` / `--software-renderer` / `--theme` / `--screenshot*` 参数 | 业务开关 |
|
||||||
|
//! | `probe` | 渲染后端探测(D27)+ 写 `ICED_BACKEND`,失败自动降级 CPU | 绘制 |
|
||||||
|
//! | `fonts` | 字体栈求值(D28 前半):平台族名 → 系统字体库校验 → 启动日志 | 中文字体下载 |
|
||||||
|
//! | `app` / `shell` | Elm 三件套 + 自绘标题栏 / 导航轨 / 内容骨架 | 业务逻辑 |
|
||||||
|
//! | `shot` | `window::screenshot` → PNG(验收通道) | 图像比对 |
|
||||||
|
//! | `icons` | 令牌里的 SVG path → iced `svg` 控件 | 画新图标 |
|
||||||
|
//!
|
||||||
|
//! ## 消费纪律
|
||||||
|
//!
|
||||||
|
//! **组件层零硬编码色值/字号/半径/间距**:一律走 [`theme::Ui`]。守门测试在
|
||||||
|
//! `tests/no_hardcoded_tokens.rs`(扫 `src/**`,出现 `#RRGGBB` / `Color::from_rgb(` 即红)。
|
||||||
|
//!
|
||||||
|
//! ## 与 Slint 版的关系
|
||||||
|
//!
|
||||||
|
//! 并存期:`crates/app` 仍出 `unlucky-launcher`,本 crate 出 `unlucky-launcher-iced`。
|
||||||
|
//! 等价验收后翻牌(bin 改名 + 旧 crate 降级),步骤写在
|
||||||
|
//! `DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md`。
|
||||||
|
|
||||||
|
pub mod shot;
|
||||||
|
pub mod theme;
|
||||||
|
|
||||||
|
mod app;
|
||||||
|
mod cli;
|
||||||
|
mod fonts;
|
||||||
|
mod icons;
|
||||||
|
mod probe;
|
||||||
|
mod shell;
|
||||||
|
|
||||||
|
pub use cli::{Backend, Options};
|
||||||
|
pub use probe::Probe;
|
||||||
|
|
||||||
|
/// 启动 UI(阻塞到窗口关闭)。渲染后端由 `probe` 在 `run()` 之前写进 `ICED_BACKEND`。
|
||||||
|
pub fn run(options: Options) -> iced::Result {
|
||||||
|
app::run(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 与 [`run`] 同义,但把命令行做成最终结论:`Options::from_env()` + 启动日志。
|
||||||
|
///
|
||||||
|
/// 二进制入口用 [`Options::from_env`] + [`run`] 自己拼(要额外处理 `--diff`),这个函数留给
|
||||||
|
/// 集成测试与将来的 CLI 复用。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// 参数不合法时返回人话(不 panic、不 `exit`)。
|
||||||
|
pub fn run_from_env() -> Result<iced::Result, String> {
|
||||||
|
let options = Options::from_env()?;
|
||||||
|
Ok(run(options))
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
//! 入口。所有实际逻辑在 lib(`unlucky_app_iced`)里:
|
||||||
|
//!
|
||||||
|
//! - `--diff <a> <b>`:只读两张 PNG 做逐像素对比(D30 的 GPU/CPU 几何一致性检查),不开窗;
|
||||||
|
//! - 其余参数:开窗跑骨架,`--screenshot` 出图后退出。
|
||||||
|
//!
|
||||||
|
//! 退出码:`0` 成功 / `1` 截图有差异(diff 模式)/ `2` 参数或运行错误。
|
||||||
|
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
use unlucky_app_iced::shot;
|
||||||
|
use unlucky_app_iced::Options;
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
let options = match Options::from_env() {
|
||||||
|
Ok(options) => options,
|
||||||
|
Err(message) => {
|
||||||
|
eprintln!("[cli] {message}");
|
||||||
|
eprintln!(
|
||||||
|
"用法:unlucky-launcher-iced [--renderer=auto|gpu|cpu] [--software-renderer] \
|
||||||
|
[--theme=light|dark] [--screenshot <png>] [--screenshot-theme=light|dark] \
|
||||||
|
[--scale-factor <f32>] [--diff <a.png> <b.png>]"
|
||||||
|
);
|
||||||
|
return ExitCode::from(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((left, right)) = options.diff.clone() {
|
||||||
|
let scan = i32::try_from(options.diff_scan).unwrap_or(i32::MAX);
|
||||||
|
return match shot::diff_files_with_scan(&left, &right, scan) {
|
||||||
|
Ok(diff) => {
|
||||||
|
eprintln!("{}", diff.describe());
|
||||||
|
if diff.is_identical() {
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
} else {
|
||||||
|
ExitCode::from(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(message) => {
|
||||||
|
eprintln!("[diff] {message}");
|
||||||
|
ExitCode::from(2)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
match unlucky_app_iced::run(options) {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("[run] {error}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
//! 渲染后端探测 + 自动降级(D27)。
|
||||||
|
//!
|
||||||
|
//! ## 为什么需要探测
|
||||||
|
//!
|
||||||
|
//! iced 0.14 的 `Renderer` 本身就是「wgpu → tiny-skia」的运行时回退枚举(M60 Q1):
|
||||||
|
//! **不设** `ICED_BACKEND` 时,拿不到 GPU 适配器会自动降级到 CPU,零代码。
|
||||||
|
//! 需要自研的只有**判据**:
|
||||||
|
//!
|
||||||
|
//! - 本机会枚举出 `Microsoft Basic Render Driver [Cpu]`(= WARP 软光栅),
|
||||||
|
//! 所以「有没有适配器」不能当「有没有 GPU」(M60 实测),必须看 `device_type`。
|
||||||
|
//! - 没有硬件 GPU 时要**主动**把 CPU 放到候选首位(`tiny-skia,wgpu`),
|
||||||
|
//! 否则 wgpu 会先花时间尝试 WARP,观感与行为都难料。
|
||||||
|
//!
|
||||||
|
//! ## 语义订正(与 M60 备忘录的差异,以源码 + 实测为准)
|
||||||
|
//!
|
||||||
|
//! M60 记「`ICED_BACKEND=wgpu` 会**关闭**回退」。读 `iced_renderer-0.14.0/src/fallback.rs`
|
||||||
|
//! 得到的是:**候选列表**语义——列表里没有 `tiny-skia` 时才真的关闭回退。
|
||||||
|
//! 所以本 crate 的「优先 GPU」写 `wgpu,tiny-skia`(首选 GPU,拿不到仍降级,**绝不硬失败**),
|
||||||
|
//! 而不是 `wgpu`。
|
||||||
|
|
||||||
|
use crate::Backend;
|
||||||
|
|
||||||
|
/// 单个适配器的摘要(只留日志要用的字段)。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct AdapterSummary {
|
||||||
|
/// 适配器名(如 `AMD Radeon RX 6600 XT`)。
|
||||||
|
pub name: String,
|
||||||
|
/// `device_type` 的字符串形式(`DiscreteGpu` / `IntegratedGpu` / `Cpu` / …)。
|
||||||
|
pub device_type: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 探测结论。
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct Probe {
|
||||||
|
/// 枚举到的全部适配器(含 WARP 这类软光栅)。
|
||||||
|
pub adapters: Vec<AdapterSummary>,
|
||||||
|
/// 是否存在**真**硬件适配器(`device_type` 既不是 `Cpu` 也不是 `Other`)。
|
||||||
|
pub hardware: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Probe {
|
||||||
|
/// 探测本机渲染能力(编入 `wgpu` 时真的枚举适配器)。
|
||||||
|
#[cfg(feature = "gpu")]
|
||||||
|
pub fn run() -> Self {
|
||||||
|
run_wgpu()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 探测本机渲染能力(`--no-default-features` 的纯 CPU 构建:没有 wgpu,直接判定无硬件)。
|
||||||
|
#[cfg(not(feature = "gpu"))]
|
||||||
|
pub fn run() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一行日志:适配器清单 + 判定。
|
||||||
|
pub fn describe(&self) -> String {
|
||||||
|
if self.adapters.is_empty() {
|
||||||
|
return format!("adapters=0 hardware={}", self.hardware);
|
||||||
|
}
|
||||||
|
|
||||||
|
let list = self
|
||||||
|
.adapters
|
||||||
|
.iter()
|
||||||
|
.map(|adapter| format!("{} [{}]", adapter.name, adapter.device_type))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" | ");
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"adapters={} [{list}] hardware={}",
|
||||||
|
self.adapters.len(),
|
||||||
|
self.hardware
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按请求与探测结果决定 `ICED_BACKEND` 的值(`None` = 不干预,交给 iced 自己回退)。
|
||||||
|
///
|
||||||
|
/// 纯函数,好让「降级矩阵」被测试钉死。
|
||||||
|
pub fn backend_env(request: Backend, probe: &Probe, gpu_compiled: bool) -> Option<&'static str> {
|
||||||
|
match (request, gpu_compiled) {
|
||||||
|
// 命令行 --software-renderer / --renderer=cpu:只留 CPU 候选。
|
||||||
|
(Backend::Cpu, _) => Some("tiny-skia"),
|
||||||
|
// 用户点名要 GPU,但没编进 wgpu:只能 CPU,日志里说明。
|
||||||
|
(Backend::Gpu, false) => Some("tiny-skia"),
|
||||||
|
// 优先 GPU,但保留 tiny-skia 兜底(候选列表语义:不会硬失败)。
|
||||||
|
(Backend::Gpu, true) => Some("wgpu,tiny-skia"),
|
||||||
|
// 自动:有硬件就让 iced 走默认顺序(wgpu 优先,自动回退)。
|
||||||
|
(Backend::Auto, true) if probe.hardware => None,
|
||||||
|
// 自动 + 没编进 wgpu:只有 CPU 候选,别把 wgpu 写进列表(会与「未编入 wgpu」的日志自相矛盾)。
|
||||||
|
(Backend::Auto, false) => Some("tiny-skia"),
|
||||||
|
// 自动 + 有 wgpu 但没硬件(含只有 WARP 的机器):CPU 起步,wgpu 留作兜底。
|
||||||
|
(Backend::Auto, true) => Some("tiny-skia,wgpu"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把决策落到进程环境变量,并把探测结果 + 生效值打进日志。
|
||||||
|
///
|
||||||
|
/// 返回**实际生效**的后端描述(`"auto (iced: wgpu -> tiny-skia)"` 之类)。
|
||||||
|
pub fn apply(request: Backend, probe: &Probe) -> String {
|
||||||
|
let gpu_compiled = cfg!(feature = "gpu");
|
||||||
|
let env = backend_env(request, probe, gpu_compiled);
|
||||||
|
|
||||||
|
// ICED_BACKEND 是进程级环境变量,必须在 `iced::application(..).run()` 之前写。
|
||||||
|
// 仓库是 edition 2021 → `set_var` 仍是安全 API(升 2024 后要改成子进程 exec 自身)。
|
||||||
|
if let Some(value) = env {
|
||||||
|
std::env::set_var("ICED_BACKEND", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let effective = if gpu_compiled {
|
||||||
|
match (request, env) {
|
||||||
|
(Backend::Cpu, _) => "tiny-skia (CPU,强制)".to_owned(),
|
||||||
|
(_, None) => "auto (wgpu -> tiny-skia)".to_owned(),
|
||||||
|
(_, Some("wgpu,tiny-skia")) => "wgpu.then(tiny-skia) (优先 GPU)".to_owned(),
|
||||||
|
(_, Some(value)) => format!("{value} (CPU 起步)"),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"tiny-skia (本构建未编入 wgpu,只有 CPU)".to_owned()
|
||||||
|
};
|
||||||
|
|
||||||
|
let note = if !gpu_compiled && request != Backend::Cpu {
|
||||||
|
";本构建未编入 wgpu(--no-default-features),只能 CPU"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"[probe] {} :: request={} -> ICED_BACKEND={}{note}",
|
||||||
|
probe.describe(),
|
||||||
|
request.name(),
|
||||||
|
env.unwrap_or("<unset>")
|
||||||
|
);
|
||||||
|
eprintln!("[probe] renderer = {effective}");
|
||||||
|
|
||||||
|
effective
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "gpu")]
|
||||||
|
fn run_wgpu() -> Probe {
|
||||||
|
// `iced::wgpu` 就是 wgpu crate 本身(`pub use iced_renderer::wgpu::wgpu` 再导出),
|
||||||
|
// 所以不需要单独依赖 wgpu,版本也不会和 iced 打架。
|
||||||
|
use iced::wgpu::{Backends, DeviceType, Instance, InstanceDescriptor};
|
||||||
|
|
||||||
|
let instance = Instance::new(&InstanceDescriptor {
|
||||||
|
backends: Backends::all(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let adapters = instance
|
||||||
|
.enumerate_adapters(Backends::all())
|
||||||
|
.into_iter()
|
||||||
|
.map(|adapter| {
|
||||||
|
let info = adapter.get_info();
|
||||||
|
let device_type = match info.device_type {
|
||||||
|
DeviceType::DiscreteGpu => "DiscreteGpu",
|
||||||
|
DeviceType::IntegratedGpu => "IntegratedGpu",
|
||||||
|
DeviceType::VirtualGpu => "VirtualGpu",
|
||||||
|
DeviceType::Cpu => "Cpu",
|
||||||
|
DeviceType::Other => "Other",
|
||||||
|
};
|
||||||
|
AdapterSummary {
|
||||||
|
name: info.name,
|
||||||
|
device_type,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let hardware = adapters
|
||||||
|
.iter()
|
||||||
|
.any(|adapter| !matches!(adapter.device_type, "Cpu" | "Other"));
|
||||||
|
|
||||||
|
Probe { adapters, hardware }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn probe(types: &[&'static str]) -> Probe {
|
||||||
|
let adapters = types
|
||||||
|
.iter()
|
||||||
|
.map(|device_type| AdapterSummary {
|
||||||
|
name: "adapter".to_owned(),
|
||||||
|
device_type,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Probe {
|
||||||
|
adapters,
|
||||||
|
hardware: types
|
||||||
|
.iter()
|
||||||
|
.any(|device_type| !matches!(*device_type, "Cpu" | "Other")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn warp_only_machine_counts_as_no_gpu() {
|
||||||
|
// 本机实测会枚举出 WARP:`Microsoft Basic Render Driver [Cpu]`
|
||||||
|
let warp = probe(&["Cpu"]);
|
||||||
|
assert!(!warp.hardware, "只有 WARP 不能当「有 GPU」");
|
||||||
|
assert_eq!(
|
||||||
|
backend_env(Backend::Auto, &warp, true),
|
||||||
|
Some("tiny-skia,wgpu")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auto_with_real_gpu_leaves_iced_alone() {
|
||||||
|
let gpu = probe(&["DiscreteGpu", "Cpu"]);
|
||||||
|
assert!(gpu.hardware);
|
||||||
|
assert_eq!(backend_env(Backend::Auto, &gpu, true), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_choices_always_keep_a_fallback() {
|
||||||
|
let gpu = probe(&["DiscreteGpu"]);
|
||||||
|
let none = probe(&[]);
|
||||||
|
// 优先 GPU 但候选列表里留 tiny-skia:拿不到适配器时降级而不是报错
|
||||||
|
assert_eq!(
|
||||||
|
backend_env(Backend::Gpu, &gpu, true),
|
||||||
|
Some("wgpu,tiny-skia")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
backend_env(Backend::Gpu, &none, true),
|
||||||
|
Some("wgpu,tiny-skia")
|
||||||
|
);
|
||||||
|
assert_eq!(backend_env(Backend::Cpu, &gpu, true), Some("tiny-skia"));
|
||||||
|
// 没编进 wgpu 的构建绝不写 wgpu(连候选列表也不许出现,reviewer P3-4)
|
||||||
|
assert_eq!(backend_env(Backend::Gpu, &gpu, false), Some("tiny-skia"));
|
||||||
|
assert_eq!(backend_env(Backend::Auto, &gpu, false), Some("tiny-skia"));
|
||||||
|
assert_eq!(backend_env(Backend::Auto, &none, false), Some("tiny-skia"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn describe_lists_every_adapter() {
|
||||||
|
let text = probe(&["DiscreteGpu", "Cpu"]).describe();
|
||||||
|
assert!(text.contains("DiscreteGpu"), "{text}");
|
||||||
|
assert!(text.contains("Cpu"), "{text}");
|
||||||
|
assert!(text.contains("hardware=true"), "{text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
//! 首屏骨架(**不做业务**):自绘标题栏 + 左侧导航轨 + 内容区(页头 + 一张卡片)。
|
||||||
|
//!
|
||||||
|
//! ## 与参考判据的对应(`research/12b-component-fidelity.md`)
|
||||||
|
//!
|
||||||
|
//! | 部件 | 参考 | 令牌落点 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | 标题栏高度 | 16.2 桌面标题栏 **40**(登记偏离) | `LM.titlebar_height = 48`(M63 §1.4/R13 桌面档,令牌优先) |
|
||||||
|
//! | 标题栏按钮 | 16.5 图标 **24** | `IconSize::Toolbar` + `LM.titlebar_button_width = 46` |
|
||||||
|
//! | 页头 | 16.1 官方 AppBar **64**;本项目 **56** | `LM.page_header_height = 56`(已有偏离登记) |
|
||||||
|
//! | 卡片 | 3.x Outlined:**Medium(12)**、L0、`OutlineVariant` 1px、`SurfaceContainerLow` | `Component::OutlinedCard.spec()` + `STROKE_PX` |
|
||||||
|
//! | 字级 | 16.4 标题 `TitleLarge`;卡片 `TitleMedium` / `BodySmall` | `Ui::style` / `Ui::emphasized` |
|
||||||
|
//!
|
||||||
|
//! 导航轨在 [`nav`] 子模块里(12b §5 的判据表也在那边)。
|
||||||
|
//!
|
||||||
|
//! 一屏只出现 4 档字号(22 / 16 / 14 / 12),且**没有实心 accent**(rubric K2:一屏最多一个实心
|
||||||
|
//! Primary,骨架期一个都不要)。阴影只给浮层,骨架里没有浮层,所以**全屏零阴影**(rubric K3)。
|
||||||
|
|
||||||
|
mod nav;
|
||||||
|
|
||||||
|
pub use nav::NavItem;
|
||||||
|
|
||||||
|
use iced::widget::text::LineHeight;
|
||||||
|
use iced::widget::{button, column, container, mouse_area, row, space, stack, text};
|
||||||
|
use iced::{mouse, Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow};
|
||||||
|
use iced::{window::Direction, Theme as IcedTheme};
|
||||||
|
|
||||||
|
use unlucky_design::color::{Role, StateLayer};
|
||||||
|
use unlucky_design::component_map::Component;
|
||||||
|
use unlucky_design::elevation::MaterialLayer;
|
||||||
|
use unlucky_design::icons::IconKind;
|
||||||
|
use unlucky_design::spacing::Space;
|
||||||
|
use unlucky_design::typography::TypeRole;
|
||||||
|
|
||||||
|
use crate::app::Message;
|
||||||
|
use crate::cli::ForcedState;
|
||||||
|
use crate::icons;
|
||||||
|
use crate::theme::Ui;
|
||||||
|
|
||||||
|
/// 整棵树。
|
||||||
|
pub fn view<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
selected: NavItem,
|
||||||
|
app_title: &'a str,
|
||||||
|
page_title: &'a str,
|
||||||
|
body: &'a str,
|
||||||
|
forced: Option<ForcedState>,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let work_area = column![
|
||||||
|
titlebar(ui, app_title, forced),
|
||||||
|
row![
|
||||||
|
nav::rail(ui, selected, forced),
|
||||||
|
content(ui, page_title, body)
|
||||||
|
]
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill),
|
||||||
|
]
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill);
|
||||||
|
|
||||||
|
// 无边框窗口没有系统缩放边框 → 自己给两条热区(东/南)。
|
||||||
|
// **必须叠层而不是兄弟列**:当兄弟列会吃掉 4px 布局宽(右留白变 28、caption 按钮离窗角 4px,
|
||||||
|
// 视觉复审 P3-1)。叠层容器自己铺满,再用 align 把 4px 热区贴到边上,布局宽保持 1180。
|
||||||
|
// 东侧热区从标题栏下方开始,避免吃掉三个 caption 按钮最右 4px 的点击区。
|
||||||
|
let thickness = ui.space(Space::X1);
|
||||||
|
|
||||||
|
let east = container(
|
||||||
|
column![
|
||||||
|
space::vertical().height(ui.metrics().titlebar_height),
|
||||||
|
resize_edge(
|
||||||
|
Direction::East,
|
||||||
|
(Length::Fixed(thickness), Length::Fill),
|
||||||
|
mouse::Interaction::ResizingHorizontally,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.height(Length::Fill),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.align_x(Alignment::End);
|
||||||
|
|
||||||
|
let south = container(resize_edge(
|
||||||
|
Direction::South,
|
||||||
|
(Length::Fill, Length::Fixed(thickness)),
|
||||||
|
mouse::Interaction::ResizingVertically,
|
||||||
|
))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.align_y(Alignment::End);
|
||||||
|
|
||||||
|
let shell = stack![work_area, east, south];
|
||||||
|
|
||||||
|
container(shell)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.style(|_theme| filled(ui.layer(MaterialLayer::Canvas)))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 窗口缩放热区(透明;玻璃/底色由根容器提供)。
|
||||||
|
///
|
||||||
|
/// 拖动时调 `window::drag_resize(id, direction)`——Windows/X11 走 winit 原生实现,
|
||||||
|
/// **macOS 返回 `NotSupported`**(M60 Q4),如实登记在交接文档里。
|
||||||
|
fn resize_edge<'a>(
|
||||||
|
direction: Direction,
|
||||||
|
size: (Length, Length),
|
||||||
|
interaction: mouse::Interaction,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let (width, height) = size;
|
||||||
|
|
||||||
|
mouse_area(container(space::horizontal()).width(width).height(height))
|
||||||
|
.interaction(interaction)
|
||||||
|
.on_press(Message::ResizeStart(direction))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 标题栏
|
||||||
|
|
||||||
|
fn titlebar<'a>(ui: &'a Ui, title: &'a str, forced: Option<ForcedState>) -> Element<'a, Message> {
|
||||||
|
let metrics = ui.metrics();
|
||||||
|
|
||||||
|
// 拖动热区:从窗口左缘到第一个按钮(按钮自己不能被拖区吞掉,所以只包左侧一段)
|
||||||
|
let drag_area = mouse_area(
|
||||||
|
container(label(
|
||||||
|
ui,
|
||||||
|
TypeRole::LabelLarge,
|
||||||
|
true,
|
||||||
|
title,
|
||||||
|
ui.color(Role::OnSurface),
|
||||||
|
))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.padding(Padding::from([0.0, ui.space(Space::X4)])),
|
||||||
|
)
|
||||||
|
.on_press(Message::DragWindow);
|
||||||
|
|
||||||
|
let bar = row![
|
||||||
|
drag_area,
|
||||||
|
caption(ui, IconKind::Minimize, Message::Minimize, forced),
|
||||||
|
caption(ui, IconKind::Maximize, Message::ToggleMaximize, forced),
|
||||||
|
caption(ui, IconKind::Close, Message::Close, forced),
|
||||||
|
]
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(metrics.titlebar_height)
|
||||||
|
.align_y(Alignment::Center);
|
||||||
|
|
||||||
|
container(bar)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(metrics.titlebar_height)
|
||||||
|
.style(|_theme| filled(ui.layer(MaterialLayer::Canvas)))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标题栏按钮(自绘最小化/最大化/关闭)。
|
||||||
|
///
|
||||||
|
/// 图标必须**再包一层固定 24×24 的容器**:iced 的 `button` 在 layout 里给内容 Fill 约束,
|
||||||
|
/// 直接把 `svg(...).width(24)` 塞进 button,usvg 会按 Contain 撑满内区(实测渲染盒 46 → 视觉线宽
|
||||||
|
/// 从 1.5 变 2.9,视觉复审 P2-2)。Container 尊重子元素的 Length,所以这层是「锁尺寸」用的。
|
||||||
|
fn caption<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
kind: IconKind,
|
||||||
|
message: Message,
|
||||||
|
forced: Option<ForcedState>,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let metrics = ui.metrics();
|
||||||
|
// 用 `center(Length::Fill)` 而不是只设 `width(24).height(24)`:后者会让容器缩到 24×24 并贴到
|
||||||
|
// button 内容的左上角(button 不居中「收缩后」的内容,r1 实测图标跑到 cell 左上 (left+11.5, 11.5));
|
||||||
|
// `center(Fill)` = 撑满按钮内容区 + 内容居中,字形仍是 24 盒、命中区仍是 46×48。
|
||||||
|
let icon = container(icons::view::<Message>(
|
||||||
|
kind,
|
||||||
|
ui.color(Role::OnSurfaceVariant),
|
||||||
|
))
|
||||||
|
.width(icons::RENDER_PX)
|
||||||
|
.height(icons::RENDER_PX)
|
||||||
|
.center(Length::Fill);
|
||||||
|
|
||||||
|
button(icon)
|
||||||
|
.width(metrics.titlebar_button_width)
|
||||||
|
.height(metrics.titlebar_height)
|
||||||
|
.padding(0)
|
||||||
|
.style(move |_theme: &IcedTheme, status| {
|
||||||
|
let background = match overridden(forced, status) {
|
||||||
|
button::Status::Hovered => Some(Background::Color(ui.state_over(
|
||||||
|
StateLayer::Hover,
|
||||||
|
Role::OnSurface,
|
||||||
|
Role::Surface,
|
||||||
|
))),
|
||||||
|
button::Status::Pressed => Some(Background::Color(ui.state_over(
|
||||||
|
StateLayer::Pressed,
|
||||||
|
Role::OnSurface,
|
||||||
|
Role::Surface,
|
||||||
|
))),
|
||||||
|
button::Status::Active | button::Status::Disabled => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
button::Style {
|
||||||
|
background,
|
||||||
|
text_color: ui.color(Role::OnSurfaceVariant),
|
||||||
|
border: Border::default(),
|
||||||
|
shadow: Shadow::default(),
|
||||||
|
snap: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_press(message)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 内容区
|
||||||
|
|
||||||
|
fn content<'a>(ui: &'a Ui, title: &'a str, body: &'a str) -> Element<'a, Message> {
|
||||||
|
let metrics = ui.metrics();
|
||||||
|
// 卡片档位由组件表决定(圆角/字级),组件层不自己挑
|
||||||
|
let card_spec = Component::OutlinedCard.spec();
|
||||||
|
let body_role = card_spec.support.unwrap_or(card_spec.text);
|
||||||
|
|
||||||
|
let header = container(label(
|
||||||
|
ui,
|
||||||
|
TypeRole::TitleLarge,
|
||||||
|
true,
|
||||||
|
title,
|
||||||
|
ui.color(Role::OnSurface),
|
||||||
|
))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(metrics.page_header_height)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.padding(Padding::from([0.0, ui.space(Space::X6)]));
|
||||||
|
|
||||||
|
let card = container(
|
||||||
|
column![
|
||||||
|
label(
|
||||||
|
ui,
|
||||||
|
card_spec.text,
|
||||||
|
true,
|
||||||
|
"Shell ready",
|
||||||
|
ui.color(Role::OnSurface),
|
||||||
|
),
|
||||||
|
label(ui, body_role, false, body, ui.color(Role::OnSurfaceVariant)),
|
||||||
|
]
|
||||||
|
.spacing(ui.space(Space::X2)),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.padding(metrics.card_padding)
|
||||||
|
.style(move |_theme| container::Style {
|
||||||
|
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
|
||||||
|
border: ui.outlined(card_spec.shape),
|
||||||
|
..container::Style::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let body_area = container(card)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.padding(ui.space(Space::X6))
|
||||||
|
.align_y(Alignment::Start);
|
||||||
|
|
||||||
|
column![header, body_area]
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 小工具
|
||||||
|
|
||||||
|
/// 一个文字元素:字号/行高/字重全部来自令牌排版档。
|
||||||
|
pub(super) fn label<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
role: TypeRole,
|
||||||
|
emphasized: bool,
|
||||||
|
content: &'a str,
|
||||||
|
color: Color,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let style = if emphasized {
|
||||||
|
ui.emphasized(role)
|
||||||
|
} else {
|
||||||
|
ui.style(role)
|
||||||
|
};
|
||||||
|
|
||||||
|
text(content)
|
||||||
|
.size(style.size_px)
|
||||||
|
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
|
||||||
|
.font(ui.font_of(style))
|
||||||
|
.color(color)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取证用的状态覆盖(`--force-state`):只改画出来的状态,不动任何状态机。
|
||||||
|
pub(super) fn overridden(forced: Option<ForcedState>, status: button::Status) -> button::Status {
|
||||||
|
match forced {
|
||||||
|
Some(ForcedState::Hover) => button::Status::Hovered,
|
||||||
|
Some(ForcedState::Pressed) => button::Status::Pressed,
|
||||||
|
None => status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn filled(color: Color) -> container::Style {
|
||||||
|
container::Style {
|
||||||
|
background: Some(Background::Color(color)),
|
||||||
|
..container::Style::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use unlucky_design::color::SchemeFlavor;
|
||||||
|
use unlucky_design::typography::Platform;
|
||||||
|
|
||||||
|
use crate::theme::Families;
|
||||||
|
|
||||||
|
fn ui(flavor: SchemeFlavor) -> Ui {
|
||||||
|
Ui::with_platform(
|
||||||
|
flavor,
|
||||||
|
Platform::Windows,
|
||||||
|
Families {
|
||||||
|
text: "Segoe UI",
|
||||||
|
display: "Segoe UI",
|
||||||
|
mono: "Consolas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_screen_only_uses_four_text_sizes_at_most() {
|
||||||
|
let ui = ui(SchemeFlavor::Dark);
|
||||||
|
let card = Component::OutlinedCard.spec();
|
||||||
|
let sizes = [
|
||||||
|
ui.emphasized(TypeRole::TitleLarge).size_px,
|
||||||
|
ui.emphasized(TypeRole::LabelLarge).size_px,
|
||||||
|
ui.emphasized(card.text).size_px,
|
||||||
|
ui.style(card.support.unwrap_or(card.text)).size_px,
|
||||||
|
ui.style(TypeRole::LabelMedium).size_px,
|
||||||
|
];
|
||||||
|
let mut unique = sizes.to_vec();
|
||||||
|
unique.sort_by(f32::total_cmp);
|
||||||
|
unique.dedup();
|
||||||
|
assert!(
|
||||||
|
unique.len() <= 4,
|
||||||
|
"rubric D4:一屏字号档 ≤4(实际 {} 档 {unique:?})",
|
||||||
|
unique.len()
|
||||||
|
);
|
||||||
|
assert!(unique[0] >= 12.0, "最小档不下 12px(caption ≥11)");
|
||||||
|
assert!(unique[unique.len() - 1] <= 28.0, "列表页标题不得 >28px");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flavor_slug_is_stable_for_filenames() {
|
||||||
|
assert_eq!(crate::shot::flavor_slug(&ui(SchemeFlavor::Light)), "light");
|
||||||
|
assert_eq!(crate::shot::flavor_slug(&ui(SchemeFlavor::Dark)), "dark");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
//! 左侧导航轨(M3 `NavigationRail`)。
|
||||||
|
//!
|
||||||
|
//! 判据来源 `research/12b-component-fidelity.md` §5:
|
||||||
|
//!
|
||||||
|
//! | 项 | 参考值 | 令牌落点 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | 轨宽 | 5.1 官方 96(narrow 80);本项目 **88** | [`nav_rail_width`](`LayoutMetrics` 骨架值,详见该函数文档的 M69 迁移说明) |
|
||||||
|
//! | 轨容器 | 5.3 形状 None / L0 / `Surface` | `MaterialLayer::Canvas` |
|
||||||
|
//! | 选中指示器 | 5.4 **56×32**、圆角 full、`SecondaryContainer` | [`indicator_size`](令牌推导) |
|
||||||
|
//! | 图标 | 5.5 24px | `IconSize::Toolbar` |
|
||||||
|
//! | 标签 | 5.5 `LabelMedium 12/16` | `TypeRole::LabelMedium` |
|
||||||
|
//! | 颜色 | 5.6 选中图标 `OnSecondaryContainer` / 选中标签 `Secondary` / 未选中 `OnSurfaceVariant` | `Ui::color` |
|
||||||
|
//! | 项间距 | 5.7 **4** | `LayoutMetrics::nav_item_gap` |
|
||||||
|
|
||||||
|
use iced::widget::{button, column, container};
|
||||||
|
use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow, Theme as IcedTheme};
|
||||||
|
|
||||||
|
use unlucky_design::color::{Role, StateLayer};
|
||||||
|
use unlucky_design::elevation::MaterialLayer;
|
||||||
|
use unlucky_design::icons::IconKind;
|
||||||
|
use unlucky_design::shape::ShapeScale;
|
||||||
|
use unlucky_design::spacing::{LayoutMetrics, Space};
|
||||||
|
use unlucky_design::typography::TypeRole;
|
||||||
|
|
||||||
|
use crate::app::Message;
|
||||||
|
use crate::cli::ForcedState;
|
||||||
|
use crate::icons;
|
||||||
|
use crate::shell::{filled, label, overridden};
|
||||||
|
use crate::theme::Ui;
|
||||||
|
|
||||||
|
/// 导航占位项(任务要求 4~6 个;真正的页面由后续 mission 接管)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum NavItem {
|
||||||
|
/// 实例列表。
|
||||||
|
Instances,
|
||||||
|
/// 下载/版本。
|
||||||
|
Download,
|
||||||
|
/// 日志。
|
||||||
|
Logs,
|
||||||
|
/// 设置。
|
||||||
|
Settings,
|
||||||
|
/// AI 助手。
|
||||||
|
Ai,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NavItem {
|
||||||
|
/// 全部占位项(顺序 = 导航栏自上而下)。
|
||||||
|
pub const ALL: [NavItem; 5] = [
|
||||||
|
NavItem::Instances,
|
||||||
|
NavItem::Download,
|
||||||
|
NavItem::Logs,
|
||||||
|
NavItem::Settings,
|
||||||
|
NavItem::Ai,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 显示名(i18n 是后续 mission;本 mission 用英文基线文案,避免干净 CI 上缺中文变豆腐块)。
|
||||||
|
pub const fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
NavItem::Instances => "Instances",
|
||||||
|
NavItem::Download => "Download",
|
||||||
|
NavItem::Logs => "Logs",
|
||||||
|
NavItem::Settings => "Settings",
|
||||||
|
NavItem::Ai => "Assistant",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 令牌图标。
|
||||||
|
pub const fn icon(self) -> IconKind {
|
||||||
|
match self {
|
||||||
|
NavItem::Instances => IconKind::Instances,
|
||||||
|
NavItem::Download => IconKind::Download,
|
||||||
|
NavItem::Logs => IconKind::Logs,
|
||||||
|
NavItem::Settings => IconKind::Settings,
|
||||||
|
NavItem::Ai => IconKind::Ai,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 整条导航轨。
|
||||||
|
pub(super) fn rail<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
selected: NavItem,
|
||||||
|
forced: Option<ForcedState>,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let metrics = ui.metrics();
|
||||||
|
let indicator = indicator_size(metrics);
|
||||||
|
|
||||||
|
let items = NavItem::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|item| rail_item(ui, *item, *item == selected, indicator, forced))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
// 轨顶留白走 container padding,而不是「先塞一个 spacer 元素」:后者会被 column 的
|
||||||
|
// spacing(4)再加一档,实测变 28(视觉复审 P3-5)。
|
||||||
|
container(column(items).spacing(metrics.nav_item_gap))
|
||||||
|
.width(nav_rail_width(metrics))
|
||||||
|
.height(Length::Fill)
|
||||||
|
.padding(Padding::from([ui.space(Space::X6), 0.0]))
|
||||||
|
.style(|_theme| filled(ui.layer(MaterialLayer::Canvas)))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rail_item<'a>(
|
||||||
|
ui: &'a Ui,
|
||||||
|
item: NavItem,
|
||||||
|
selected: bool,
|
||||||
|
indicator: (f32, f32),
|
||||||
|
forced: Option<ForcedState>,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let (indicator_width, indicator_height) = indicator;
|
||||||
|
|
||||||
|
let icon_color = if selected {
|
||||||
|
ui.color(Role::OnSecondaryContainer)
|
||||||
|
} else {
|
||||||
|
ui.color(Role::OnSurfaceVariant)
|
||||||
|
};
|
||||||
|
// 12b 5.6:选中标签用 `Secondary`,未选中 `OnSurfaceVariant`
|
||||||
|
let label_color = if selected {
|
||||||
|
ui.color(Role::Secondary)
|
||||||
|
} else {
|
||||||
|
ui.color(Role::OnSurfaceVariant)
|
||||||
|
};
|
||||||
|
|
||||||
|
// **不要用 `center_x/center_y(Fill)`**:那会把容器自己的 width/height 改成 Fill,
|
||||||
|
// 于是 56 被父级(button 宽 88)覆盖成 88、32 靠布局凑巧(视觉复审 P2-1)。
|
||||||
|
// 尺寸只认令牌推导值,居中交给父 column 的 `align_x`。
|
||||||
|
let pill = container(icons::view::<Message>(item.icon(), icon_color))
|
||||||
|
.width(indicator_width)
|
||||||
|
.height(indicator_height)
|
||||||
|
.align_x(Alignment::Center)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.style(move |_theme| container::Style {
|
||||||
|
background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))),
|
||||||
|
border: Border {
|
||||||
|
radius: ui.radius(ShapeScale::Full, indicator_height),
|
||||||
|
..Border::default()
|
||||||
|
},
|
||||||
|
..container::Style::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let item_height = nav_item_height(ui, indicator_height);
|
||||||
|
// 列必须显式占满轨宽,否则它会 shrink 到 56,`align_x(Center)` 就变成空操作,
|
||||||
|
// 药丸会贴左缘(x=0..55 而不是 16..71)——r1 实测踩到的第二个坑。
|
||||||
|
let stack = column![
|
||||||
|
pill,
|
||||||
|
label(ui, TypeRole::LabelMedium, false, item.label(), label_color),
|
||||||
|
]
|
||||||
|
.spacing(ui.space(Space::X1))
|
||||||
|
.width(nav_rail_width(ui.metrics()))
|
||||||
|
.align_x(Alignment::Center)
|
||||||
|
.height(item_height);
|
||||||
|
|
||||||
|
button(stack)
|
||||||
|
.width(nav_rail_width(ui.metrics()))
|
||||||
|
.height(item_height)
|
||||||
|
.padding(0)
|
||||||
|
.style(move |_theme: &IcedTheme, status| {
|
||||||
|
let background = match overridden(forced, status) {
|
||||||
|
button::Status::Hovered if !selected => Some(Background::Color(ui.state_over(
|
||||||
|
StateLayer::Hover,
|
||||||
|
Role::OnSurface,
|
||||||
|
Role::Surface,
|
||||||
|
))),
|
||||||
|
button::Status::Pressed if !selected => Some(Background::Color(ui.state_over(
|
||||||
|
StateLayer::Pressed,
|
||||||
|
Role::OnSurface,
|
||||||
|
Role::Surface,
|
||||||
|
))),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
button::Style {
|
||||||
|
background,
|
||||||
|
text_color: label_color,
|
||||||
|
border: Border::default(),
|
||||||
|
shadow: Shadow::default(),
|
||||||
|
snap: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_press(Message::Select(item))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 导航轨宽度(M69 起是令牌层的 `nav_rail_width`)。
|
||||||
|
///
|
||||||
|
/// M69(`141e9b1`)把 `LayoutMetrics` 的 nav 字段做了语义纠正:
|
||||||
|
///
|
||||||
|
/// | 旧 | 新 |
|
||||||
|
/// |---|---|
|
||||||
|
/// | `nav_expanded_width = 88` | **`nav_rail_width = 88`**(值不变,名字从「展开」改成「轨」) |
|
||||||
|
/// | `nav_collapsed_width = 48` | 删除(48 连 56 宽的选中指示器都放不下) |
|
||||||
|
/// | —(缺) | `nav_drawer_width = 240`(D33 的展开态抽屉,本 crate 未用) |
|
||||||
|
///
|
||||||
|
/// 三处调用点都走这里:将来令牌再改名,仍然只改这一行。
|
||||||
|
fn nav_rail_width(metrics: LayoutMetrics) -> f32 {
|
||||||
|
metrics.nav_rail_width
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选中指示器尺寸(12b 5.4 参考 56×32),全部由令牌推导、不写死:
|
||||||
|
///
|
||||||
|
/// - 宽 = [`nav_rail_width`] − 2 × `Space::X4` = 88 − 32 = **56**(轨的左右内边距 16)
|
||||||
|
/// - 高 = `control_height − Space::X2` = 40 − 8 = **32**(可见高度上下各让 4)
|
||||||
|
pub fn indicator_size(metrics: LayoutMetrics) -> (f32, f32) {
|
||||||
|
(
|
||||||
|
nav_rail_width(metrics) - Space::X4.dp() * 2.0,
|
||||||
|
metrics.control_height - Space::X2.dp(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 导航项高度 = 指示器 + 4 + 标签行高 = 32 + 4 + 16 = 52(`LM.nav_item_height` 是列表行节奏,
|
||||||
|
/// 药丸 + 标签的轨项放不进 44,偏差登记在交接文档里)。
|
||||||
|
pub fn nav_item_height(ui: &Ui, indicator_height: f32) -> f32 {
|
||||||
|
indicator_height + ui.space(Space::X1) + ui.style(TypeRole::LabelMedium).line_height_px
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use unlucky_design::color::SchemeFlavor;
|
||||||
|
use unlucky_design::typography::Platform;
|
||||||
|
|
||||||
|
use crate::theme::Families;
|
||||||
|
|
||||||
|
fn ui() -> Ui {
|
||||||
|
Ui::with_platform(
|
||||||
|
SchemeFlavor::Light,
|
||||||
|
Platform::Windows,
|
||||||
|
Families {
|
||||||
|
text: "Segoe UI",
|
||||||
|
display: "Segoe UI",
|
||||||
|
mono: "Consolas",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn indicator_matches_the_reference_56x32() {
|
||||||
|
let (width, height) = indicator_size(LayoutMetrics::DESKTOP);
|
||||||
|
assert_eq!((width, height), (56.0, 32.0), "12b 5.4 的参考值");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nav_item_height_is_indicator_plus_gap_plus_label() {
|
||||||
|
let ui = ui();
|
||||||
|
let (_, indicator_height) = indicator_size(ui.metrics());
|
||||||
|
// 32 + 4 + 16
|
||||||
|
assert_eq!(nav_item_height(&ui, indicator_height), 52.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_nav_item_has_a_label_and_an_icon() {
|
||||||
|
assert!((4..=6).contains(&NavItem::ALL.len()));
|
||||||
|
for item in NavItem::ALL {
|
||||||
|
assert!(!item.label().is_empty(), "{item:?} 没有文案");
|
||||||
|
assert!(!item.icon().icon().path.is_empty(), "{item:?} 没有图标");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
//! 两图逐像素对比(GPU/CPU 几何一致性)与 ±N px 平移扫描。
|
||||||
|
//!
|
||||||
|
//! 拆成子模块只为「src 文件 ≤400 行」的门禁;对外通过 `shot::{compare, diff_files, Diff, Image}`
|
||||||
|
//! 使用,调用方不用感知这层。
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// 一张已解码的 RGBA 图。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Image {
|
||||||
|
/// RGBA8 字节(逐像素 4 字节)。
|
||||||
|
pub rgba: Vec<u8>,
|
||||||
|
/// 宽(px)。
|
||||||
|
pub width: u32,
|
||||||
|
/// 高(px)。
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Image {
|
||||||
|
/// 读 PNG(只支持 PNG:本 crate 的 `image` 只开了 png 编码/解码)。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// 读文件失败或解码失败时返回人话。
|
||||||
|
pub fn load(path: &Path) -> Result<Self, String> {
|
||||||
|
let reader = image::ImageReader::open(path)
|
||||||
|
.map_err(|error| format!("读图 {} 失败:{error}", path.display()))?
|
||||||
|
.with_guessed_format()
|
||||||
|
.map_err(|error| format!("判定 {} 的格式失败:{error}", path.display()))?;
|
||||||
|
|
||||||
|
let decoded = reader
|
||||||
|
.decode()
|
||||||
|
.map_err(|error| format!("解码 {} 失败:{error}", path.display()))?
|
||||||
|
.to_rgba8();
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
width: decoded.width(),
|
||||||
|
height: decoded.height(),
|
||||||
|
rgba: decoded.into_raw(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 两图对比结果(GPU/CPU 一致性靠它给数字,不靠肉眼)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct Diff {
|
||||||
|
/// 宽(px)。
|
||||||
|
pub width: u32,
|
||||||
|
/// 高(px)。
|
||||||
|
pub height: u32,
|
||||||
|
/// 有差异的像素数。
|
||||||
|
pub differing_pixels: usize,
|
||||||
|
/// 单通道最大差(0~255)。
|
||||||
|
pub max_channel_delta: u8,
|
||||||
|
/// 单通道平均差(0~255)。
|
||||||
|
pub mean_channel_delta: f64,
|
||||||
|
/// 平移扫描的最佳对齐(dx, dy);不扫描时为 (0, 0)。
|
||||||
|
pub best_shift: (i32, i32),
|
||||||
|
/// 最佳对齐下的差异像素数(不扫描时等于 `differing_pixels`)。
|
||||||
|
pub best_differing_pixels: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 逐像素对比两张尺寸相同的图。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// 尺寸不同直接报错——**几何不一致不许静默通过**。
|
||||||
|
pub fn compare(left: &Image, right: &Image) -> Result<Diff, String> {
|
||||||
|
if (left.width, left.height) != (right.width, right.height) {
|
||||||
|
return Err(format!(
|
||||||
|
"尺寸不同:{}x{} vs {}x{}",
|
||||||
|
left.width, left.height, right.width, right.height
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pixel_count = left.rgba.len() / 4;
|
||||||
|
let mut differing = 0_usize;
|
||||||
|
let mut max_delta = 0_u8;
|
||||||
|
let mut sum_delta = 0_u64;
|
||||||
|
|
||||||
|
for index in 0..pixel_count {
|
||||||
|
let offset = index * 4;
|
||||||
|
let mut pixel_differs = false;
|
||||||
|
|
||||||
|
for channel in 0..4 {
|
||||||
|
let delta = left.rgba[offset + channel].abs_diff(right.rgba[offset + channel]);
|
||||||
|
if delta > 0 {
|
||||||
|
pixel_differs = true;
|
||||||
|
max_delta = max_delta.max(delta);
|
||||||
|
sum_delta += u64::from(delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pixel_differs {
|
||||||
|
differing += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Diff {
|
||||||
|
width: left.width,
|
||||||
|
height: left.height,
|
||||||
|
differing_pixels: differing,
|
||||||
|
max_channel_delta: max_delta,
|
||||||
|
mean_channel_delta: if pixel_count == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
sum_delta as f64 / (pixel_count * 4) as f64
|
||||||
|
},
|
||||||
|
best_shift: (0, 0),
|
||||||
|
best_differing_pixels: differing,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ±`max_shift` px 的平移扫描:把 `right` 平移后再比,返回差异像素数最小的那一档。
|
||||||
|
///
|
||||||
|
/// 用途:D30 的「几何逐像素一致」除了看差异占比,还要证明**没有整体错位**——
|
||||||
|
/// 如果 (0,0) 就是最优,说明差异只来自抗锯齿而不是位移/缩放。CI 不必再依赖 numpy。
|
||||||
|
///
|
||||||
|
/// 平移后越界的像素按「无法比较」处理,不计入差异(否则边缘会造成假阳性)。
|
||||||
|
pub fn shift_scan(left: &Image, right: &Image, max_shift: i32) -> Result<Diff, String> {
|
||||||
|
let mut best = compare(left, right)?;
|
||||||
|
let max_shift = max_shift.unsigned_abs();
|
||||||
|
|
||||||
|
if (left.width, left.height) != (right.width, right.height) {
|
||||||
|
return Err("尺寸不同,无法做平移扫描".to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
for dy in -(max_shift as i32)..=(max_shift as i32) {
|
||||||
|
for dx in -(max_shift as i32)..=(max_shift as i32) {
|
||||||
|
if (dx, dy) == (0, 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = shifted_differing_pixels(left, right, dx, dy);
|
||||||
|
if count < best.best_differing_pixels {
|
||||||
|
best.best_differing_pixels = count;
|
||||||
|
best.best_shift = (dx, dy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(best)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把 `right` 平移 `(dx, dy)` 后与 `left` 比较,返回差异像素数(越界不算)。
|
||||||
|
fn shifted_differing_pixels(left: &Image, right: &Image, dx: i32, dy: i32) -> usize {
|
||||||
|
let width = left.width as i32;
|
||||||
|
let height = left.height as i32;
|
||||||
|
let mut differing = 0;
|
||||||
|
|
||||||
|
for y in 0..height {
|
||||||
|
let source_y = y - dy;
|
||||||
|
if !(0..height).contains(&source_y) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for x in 0..width {
|
||||||
|
let source_x = x - dx;
|
||||||
|
if !(0..width).contains(&source_x) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let left_offset = ((y * width + x) * 4) as usize;
|
||||||
|
let right_offset = ((source_y * width + source_x) * 4) as usize;
|
||||||
|
|
||||||
|
if left.rgba[left_offset..left_offset + 4] != right.rgba[right_offset..right_offset + 4]
|
||||||
|
{
|
||||||
|
differing += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
differing
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Diff {
|
||||||
|
/// 完全一致(几何 + 颜色都一致)。
|
||||||
|
pub fn is_identical(&self) -> bool {
|
||||||
|
self.differing_pixels == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一行日志。
|
||||||
|
pub fn describe(&self) -> String {
|
||||||
|
let total = (self.width as usize) * (self.height as usize);
|
||||||
|
let percent = if total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
self.differing_pixels as f64 * 100.0 / total as f64
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"[diff] {}x{} differing={}/{} ({percent:.2}%) max_delta={} mean_delta={:.4} best_shift=({},{}) best_differing={}",
|
||||||
|
self.width,
|
||||||
|
self.height,
|
||||||
|
self.differing_pixels,
|
||||||
|
total,
|
||||||
|
self.max_channel_delta,
|
||||||
|
self.mean_channel_delta,
|
||||||
|
self.best_shift.0,
|
||||||
|
self.best_shift.1,
|
||||||
|
self.best_differing_pixels,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--diff <left> <right>` 的实现:读两张 PNG、对比、打日志。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// 任一图读不出来,或尺寸不同。
|
||||||
|
pub fn diff_files(left: &Path, right: &Path) -> Result<Diff, String> {
|
||||||
|
diff_files_with_scan(left, right, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同 [`diff_files`],但额外做 ±`max_shift` px 的平移扫描(`--diff-scan`)。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// 任一图读不出来,或尺寸不同。
|
||||||
|
pub fn diff_files_with_scan(left: &Path, right: &Path, max_shift: i32) -> Result<Diff, String> {
|
||||||
|
let left = Image::load(left)?;
|
||||||
|
let right = Image::load(right)?;
|
||||||
|
|
||||||
|
if max_shift == 0 {
|
||||||
|
compare(&left, &right)
|
||||||
|
} else {
|
||||||
|
shift_scan(&left, &right, max_shift)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use iced::window;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::shot::{save, write_rgba};
|
||||||
|
|
||||||
|
fn temp_dir() -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join("ull-app-iced-shot-tests");
|
||||||
|
std::fs::create_dir_all(&dir).expect("建临时目录");
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identical_images_have_zero_diff() {
|
||||||
|
let image = Image {
|
||||||
|
rgba: vec![7_u8; 4 * 4],
|
||||||
|
width: 2,
|
||||||
|
height: 2,
|
||||||
|
};
|
||||||
|
let diff = compare(&image, &image).unwrap();
|
||||||
|
assert!(diff.is_identical());
|
||||||
|
assert_eq!(diff.max_channel_delta, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn geometry_mismatch_is_an_error_not_a_silent_pass() {
|
||||||
|
let square = Image {
|
||||||
|
rgba: vec![0; 16],
|
||||||
|
width: 2,
|
||||||
|
height: 2,
|
||||||
|
};
|
||||||
|
let strip = Image {
|
||||||
|
rgba: vec![0; 16],
|
||||||
|
width: 4,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
assert!(compare(&square, &strip).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn color_only_differences_are_counted_per_pixel() {
|
||||||
|
let left = Image {
|
||||||
|
rgba: vec![0, 0, 0, 255, 0, 0, 0, 255],
|
||||||
|
width: 2,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
let right = Image {
|
||||||
|
rgba: vec![10, 0, 0, 255, 0, 0, 0, 255],
|
||||||
|
width: 2,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
let diff = compare(&left, &right).unwrap();
|
||||||
|
assert_eq!(diff.differing_pixels, 1);
|
||||||
|
assert_eq!(diff.max_channel_delta, 10);
|
||||||
|
assert!(diff.describe().contains("differing=1/2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn png_roundtrip_through_disk() {
|
||||||
|
let path = temp_dir().join("roundtrip.png");
|
||||||
|
let bytes = vec![200_u8; 4 * 3 * 2];
|
||||||
|
write_rgba(&bytes, 3, 2, &path).unwrap();
|
||||||
|
|
||||||
|
let loaded = Image::load(&path).unwrap();
|
||||||
|
assert_eq!((loaded.width, loaded.height), (3, 2));
|
||||||
|
assert_eq!(loaded.rgba, bytes);
|
||||||
|
|
||||||
|
let diff = diff_files(&path, &path).unwrap();
|
||||||
|
assert!(diff.is_identical());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_scan_finds_the_best_alignment() {
|
||||||
|
// 5x5 图,R 通道存唯一值;构造 right[y][x] = left[y+1][x+1](即把左图向左上挪 1px),
|
||||||
|
// 于是「left 与 right 在 dy=1,dx=1 时逐像素相等」——扫描应该把 (1,1) 找出来。
|
||||||
|
let side = 5_i32;
|
||||||
|
let mut rgba = Vec::new();
|
||||||
|
for y in 0..side {
|
||||||
|
for x in 0..side {
|
||||||
|
rgba.extend([(y * side + x) as u8, 0, 0, 255]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let left = Image {
|
||||||
|
rgba,
|
||||||
|
width: side as u32,
|
||||||
|
height: side as u32,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut moved = vec![255_u8; (side * side * 4) as usize];
|
||||||
|
for y in 0..side - 1 {
|
||||||
|
for x in 0..side - 1 {
|
||||||
|
let source = ((y + 1) * side + (x + 1)) as usize * 4;
|
||||||
|
let target = (y * side + x) as usize * 4;
|
||||||
|
moved[target..target + 4].copy_from_slice(&left.rgba[source..source + 4]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let right = Image {
|
||||||
|
rgba: moved,
|
||||||
|
width: side as u32,
|
||||||
|
height: side as u32,
|
||||||
|
};
|
||||||
|
|
||||||
|
let diff = shift_scan(&left, &right, 2).unwrap();
|
||||||
|
assert_eq!(diff.best_shift, (1, 1), "应扫出 1px 的位移");
|
||||||
|
assert_eq!(diff.best_differing_pixels, 0, "对齐后应逐像素相等");
|
||||||
|
assert!(
|
||||||
|
diff.differing_pixels > 0,
|
||||||
|
"不对齐时应该有差异(否则这条测试没有信息量)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 同一张图:最佳对齐就是 (0,0)
|
||||||
|
let same = shift_scan(&left, &left, 2).unwrap();
|
||||||
|
assert_eq!(same.best_shift, (0, 0));
|
||||||
|
assert_eq!(same.best_differing_pixels, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_byte_count_is_reported() {
|
||||||
|
let screenshot = window::Screenshot::new(vec![0_u8; 3], iced::Size::new(1, 1), 1.0);
|
||||||
|
let error = save(&screenshot, &temp_dir().join("unused.png")).unwrap_err();
|
||||||
|
assert!(error.contains("字节数对不上"), "{error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
//! 截图通道(D30 验收用):`iced::window::screenshot` 的真窗口出图 → PNG,以及两图逐像素对比。
|
||||||
|
//!
|
||||||
|
//! ## 为什么用真窗口而不是 `iced_test::Emulator`
|
||||||
|
//!
|
||||||
|
//! 任务书首选路径①:真开窗截图覆盖了标题栏/DPI/窗口管理的真实行为,离屏通道不覆盖
|
||||||
|
//! (M60 §Q6 的边界说明)。本机与 Windows CI 都有交互会话,所以走路径①。
|
||||||
|
//!
|
||||||
|
//! ## 关键事实(M60 踩坑 Q3)
|
||||||
|
//!
|
||||||
|
//! `Screenshot` 的字节字段在 0.14 叫 **`rgba`**(0.13 是 `bytes`),`size` 是**物理像素**,
|
||||||
|
//! 另带 `scale_factor`(换算逻辑坐标用)。落盘用 `image::save_buffer` + `ExtendedColorType::Rgba8`。
|
||||||
|
//!
|
||||||
|
//! ## 为什么自己带一个 diff
|
||||||
|
//!
|
||||||
|
//! D30 的硬项是「GPU 档与 CPU 档**几何逐像素一致**,只有颜色/材质不同」。CI 里不该假设有
|
||||||
|
//! Python/PIL/ImageMagick,所以把「读两张 PNG → 数差异像素 → 给最大/平均通道差」做进本程序
|
||||||
|
//! (`--diff a.png b.png`),复用同一个二进制。
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use iced::window;
|
||||||
|
|
||||||
|
use crate::theme;
|
||||||
|
|
||||||
|
/// 把一帧截图写到 `path`(自动建父目录)。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// 目录建不出来、PNG 编码失败、字节数与尺寸对不上时返回人话(截图通道要能一眼看出哪一步断)。
|
||||||
|
pub fn save(screenshot: &window::Screenshot, path: &Path) -> Result<Summary, String> {
|
||||||
|
let width = screenshot.size.width;
|
||||||
|
let height = screenshot.size.height;
|
||||||
|
let bytes = screenshot.rgba.as_ref();
|
||||||
|
|
||||||
|
let expected = width as usize * height as usize * 4;
|
||||||
|
if bytes.len() != expected {
|
||||||
|
return Err(format!(
|
||||||
|
"截图字节数对不上:{width}x{height} 应该 {expected} B,实际 {} B",
|
||||||
|
bytes.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
write_rgba(bytes, width, height, path)?;
|
||||||
|
|
||||||
|
Ok(Summary {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
scale_factor: screenshot.scale_factor,
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_rgba(bytes: &[u8], width: u32, height: u32, path: &Path) -> Result<(), String> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
if !parent.as_os_str().is_empty() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|error| format!("建目录 {} 失败:{error}", parent.display()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
image::save_buffer(path, bytes, width, height, image::ExtendedColorType::Rgba8)
|
||||||
|
.map_err(|error| format!("写 PNG {} 失败:{error}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次截图的结果摘要(打日志与报告用)。
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Summary {
|
||||||
|
/// 物理宽(px)。
|
||||||
|
pub width: u32,
|
||||||
|
/// 物理高(px)。
|
||||||
|
pub height: u32,
|
||||||
|
/// 窗口缩放因子(100% DPI = 1.0)。
|
||||||
|
pub scale_factor: f32,
|
||||||
|
/// 落盘路径。
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Summary {
|
||||||
|
/// 一行日志。
|
||||||
|
pub fn describe(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"[shot] {}x{} scale={} -> {}",
|
||||||
|
self.width,
|
||||||
|
self.height,
|
||||||
|
self.scale_factor,
|
||||||
|
self.path.display()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod diff;
|
||||||
|
|
||||||
|
pub use diff::{compare, diff_files, diff_files_with_scan, shift_scan, Diff, Image};
|
||||||
|
|
||||||
|
/// 主题名 → 归档文件名片段(`visual-acceptance-protocol.md` §7 的命名)。
|
||||||
|
pub fn flavor_slug(ui: &theme::Ui) -> &'static str {
|
||||||
|
match ui.flavor() {
|
||||||
|
unlucky_design::color::SchemeFlavor::Light => "light",
|
||||||
|
unlucky_design::color::SchemeFlavor::Dark => "dark",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
//! 令牌 → iced 类型(`Color` / `Font` / `Border` / `Radius` / `Theme`)。
|
||||||
|
//!
|
||||||
|
//! ## 边界(M61 `design-令牌约定.md` §0 的落地)
|
||||||
|
//!
|
||||||
|
//! 本模块是**唯一**允许把 `unlucky-design` 的值翻成 iced 类型的地方。组件层(`shell`)
|
||||||
|
//! 只调 [`Ui`] 的方法,绝不出现色值 / 字号 / 半径 / 间距字面量——`tests/no_hardcoded_tokens.rs`
|
||||||
|
//! 会扫 `src/**` 做守门。
|
||||||
|
//!
|
||||||
|
//! ## 为什么不用 `iced::theme::Base` 自定义主题承载 49 个角色
|
||||||
|
//!
|
||||||
|
//! `iced` 的 `Palette` 只有 6 个颜色(`background/text/primary/success/warning/danger`),
|
||||||
|
//! M3 有 49 个角色,塞不进去(M60 Q5 + M61 §9-0 的同一结论)。所以:
|
||||||
|
//!
|
||||||
|
//! - **真源**是本模块持有的 [`Scheme`](49 角色);
|
||||||
|
//! - iced 的 `Theme` 只用来「换名字」——`Theme::name()` 变了 iced 才会重算受主题影响的缓存
|
||||||
|
//! (M61 §9 的最后一条)。`palette()` 只给 devtools 看,控件外观一律由各自的 `Style` 注入。
|
||||||
|
//!
|
||||||
|
//! `success` / `warning` 在 M3 里**没有对应角色**(`design-令牌约定.md` §2 明写),
|
||||||
|
//! 这里按「语义最近的现成角色」映射:`success → Secondary`(不是「成功」的承诺)、
|
||||||
|
//! `warning → Tertiary`(M3 把「提醒」放 Tertiary)。任何控件都不要去读这两个槽位。
|
||||||
|
|
||||||
|
use iced::border::Radius;
|
||||||
|
use iced::theme::Palette;
|
||||||
|
use iced::{Border, Color, Font, Theme};
|
||||||
|
|
||||||
|
use unlucky_design::color::{Role, Scheme, SchemeFlavor, StateLayer};
|
||||||
|
use unlucky_design::elevation::MaterialLayer;
|
||||||
|
use unlucky_design::shape::ShapeScale;
|
||||||
|
use unlucky_design::spacing::{LayoutMetrics, Space};
|
||||||
|
use unlucky_design::typography::{FontFamily, Platform, TypeRole, TypeStyle, Weight};
|
||||||
|
|
||||||
|
/// 全站描边宽度(逻辑 px @100% DPI)。rubric G7 要求全站 1px —— 令牌层的「一像素线单一真源」。
|
||||||
|
///
|
||||||
|
/// M69 起令牌层提供 `unlucky_design::stroke::HAIRLINE`(容器描边/分隔线/聚焦环共用),
|
||||||
|
/// 这里**再导出**而不是自己写 `1.0`:全仓只有令牌层一个定义点。
|
||||||
|
pub use unlucky_design::stroke::HAIRLINE as STROKE_PX;
|
||||||
|
|
||||||
|
/// 已经解析到系统的字体族名(`&'static str` 才能塞进 `iced::Font`)。
|
||||||
|
///
|
||||||
|
/// 由 `fonts` 模块按令牌栈 + 系统字体库求值,本模块只消费。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Families {
|
||||||
|
/// 正文族(`FontFamily::Text` 栈里第一个装机可用的)。
|
||||||
|
pub text: &'static str,
|
||||||
|
/// 展示族(`FontFamily::Display`)。
|
||||||
|
pub display: &'static str,
|
||||||
|
/// 等宽族(`FontFamily::Mono`,日志/版本号用)。
|
||||||
|
pub mono: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 令牌求值器:一个 `Ui` 对应一套主题(浅或深)。
|
||||||
|
///
|
||||||
|
/// 深浅热切 = 重新 `Ui::new(flavor, families)` 并整树重绘;控件里不要缓存颜色。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Ui {
|
||||||
|
scheme: Scheme,
|
||||||
|
platform: Platform,
|
||||||
|
metrics: LayoutMetrics,
|
||||||
|
families: Families,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ui {
|
||||||
|
/// 按平台(由 `cfg!` 决定)与令牌基线方案求值。
|
||||||
|
pub fn new(flavor: SchemeFlavor, families: Families) -> Self {
|
||||||
|
Self::with_platform(flavor, Platform::current(), families)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 显式指定平台(跨平台核对与测试用)。
|
||||||
|
pub fn with_platform(flavor: SchemeFlavor, platform: Platform, families: Families) -> Self {
|
||||||
|
Self {
|
||||||
|
scheme: Scheme::baseline(flavor),
|
||||||
|
platform,
|
||||||
|
metrics: LayoutMetrics::DESKTOP,
|
||||||
|
families,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前深浅模式。
|
||||||
|
pub fn flavor(&self) -> SchemeFlavor {
|
||||||
|
self.scheme.flavor()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前平台(决定字体族与字重降级)。
|
||||||
|
pub fn platform(&self) -> Platform {
|
||||||
|
self.platform
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 骨架尺寸(标题栏/导航/行高…)。
|
||||||
|
pub fn metrics(&self) -> LayoutMetrics {
|
||||||
|
self.metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已解析的字体族名。
|
||||||
|
pub fn families(&self) -> Families {
|
||||||
|
self.families
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 间距档位(4px 栅格)。
|
||||||
|
pub fn space(&self, space: Space) -> f32 {
|
||||||
|
space.dp()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// M3 角色 → iced 颜色。
|
||||||
|
pub fn color(&self, role: Role) -> Color {
|
||||||
|
to_iced(self.scheme.get(role))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 材质层(Canvas/Card/Overlay)→ 容器色。
|
||||||
|
pub fn layer(&self, layer: MaterialLayer) -> Color {
|
||||||
|
self.color(layer.surface_role())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 状态层叠在容器色上,返回**不透明**结果。
|
||||||
|
///
|
||||||
|
/// 为什么不用带 alpha 的叠加:D27 要求 CPU 档不出现半透明,而「不透明结果」让 GPU/CPU
|
||||||
|
/// 两档的颜色也逐像素一致(只有材质/玻璃才允许两档不同)。
|
||||||
|
pub fn state_over(&self, state: StateLayer, content: Role, container: Role) -> Color {
|
||||||
|
to_iced(state.over(self.scheme.get(content), self.scheme.get(container)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 圆角(令牌刻度);`side` 是控件在该轴上的边长(`ShapeScale::Full` 要按边长折算)。
|
||||||
|
pub fn radius(&self, shape: ShapeScale, side: f32) -> Radius {
|
||||||
|
Radius::new(shape.radius_px(side))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 固定档圆角(不需要边长)。`ShapeScale::Full` 必须走 [`Ui::radius`](它没有绝对值)。
|
||||||
|
pub fn radius_dp(&self, shape: ShapeScale) -> Radius {
|
||||||
|
debug_assert!(
|
||||||
|
!matches!(shape, ShapeScale::Full),
|
||||||
|
"Full 需要边长,请用 Ui::radius(shape, side)"
|
||||||
|
);
|
||||||
|
Radius::new(if matches!(shape, ShapeScale::Full) {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
shape.dp()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 描边:`OutlineVariant` + [`STROKE_PX`],圆角走刻度(固定档)。
|
||||||
|
pub fn outlined(&self, shape: ShapeScale) -> Border {
|
||||||
|
Border {
|
||||||
|
color: self.color(Role::OutlineVariant),
|
||||||
|
width: STROKE_PX,
|
||||||
|
radius: self.radius_dp(shape),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// baseline 排版档。
|
||||||
|
pub fn style(&self, role: TypeRole) -> TypeStyle {
|
||||||
|
role.style()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expressive 强调档(官方值:字重升到 500/700)。
|
||||||
|
pub fn emphasized(&self, role: TypeRole) -> TypeStyle {
|
||||||
|
role.emphasized()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 某个排版档对应的 iced 字体(族 + 字重)。
|
||||||
|
pub fn font_of(&self, style: TypeStyle) -> Font {
|
||||||
|
let family = match style.family {
|
||||||
|
FontFamily::Display => self.families.display,
|
||||||
|
FontFamily::Text => self.families.text,
|
||||||
|
FontFamily::Mono => self.families.mono,
|
||||||
|
};
|
||||||
|
|
||||||
|
Font {
|
||||||
|
family: iced::font::Family::Name(family),
|
||||||
|
weight: weight_of(style.weight),
|
||||||
|
..Font::DEFAULT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// baseline 档的字体。
|
||||||
|
pub fn font(&self, role: TypeRole) -> Font {
|
||||||
|
self.font_of(self.style(role))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 强调档的字体。
|
||||||
|
pub fn emphasized_font(&self, role: TypeRole) -> Font {
|
||||||
|
self.font_of(self.emphasized(role))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iced 的 6 色 `Palette`(只给 devtools / 兜底控件用;M3 的 49 角色在 [`Ui::color`])。
|
||||||
|
pub fn palette(&self) -> Palette {
|
||||||
|
Palette {
|
||||||
|
background: self.color(Role::Surface),
|
||||||
|
text: self.color(Role::OnSurface),
|
||||||
|
primary: self.color(Role::Primary),
|
||||||
|
success: self.color(Role::Secondary),
|
||||||
|
warning: self.color(Role::Tertiary),
|
||||||
|
danger: self.color(Role::Error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iced 主题对象:**名字带深浅**,切主题时 `Theme::name()` 变化会触发重算。
|
||||||
|
pub fn theme(&self) -> Theme {
|
||||||
|
Theme::custom(self.name(), self.palette())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Theme::name()` 的取值(稳定字符串,改名要先在协作文档登记)。
|
||||||
|
pub fn name(&self) -> &'static str {
|
||||||
|
match self.flavor() {
|
||||||
|
SchemeFlavor::Light => "ull-m3-light",
|
||||||
|
SchemeFlavor::Dark => "ull-m3-dark",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 令牌色(8 bit sRGB)→ iced 的 sRGB 分量。
|
||||||
|
pub fn to_iced(color: unlucky_design::color::Color) -> Color {
|
||||||
|
Color::from_rgba8(
|
||||||
|
color.r,
|
||||||
|
color.g,
|
||||||
|
color.b,
|
||||||
|
f32::from(color.a) / f32::from(u8::MAX),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 令牌字重 → iced 字重。
|
||||||
|
pub fn weight_of(weight: Weight) -> iced::font::Weight {
|
||||||
|
match weight {
|
||||||
|
Weight::Regular => iced::font::Weight::Normal,
|
||||||
|
Weight::Medium => iced::font::Weight::Medium,
|
||||||
|
Weight::SemiBold => iced::font::Weight::Semibold,
|
||||||
|
Weight::Bold => iced::font::Weight::Bold,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const FAMILIES: Families = Families {
|
||||||
|
text: "Segoe UI Variable Text",
|
||||||
|
display: "Segoe UI Variable Display",
|
||||||
|
mono: "Cascadia Mono",
|
||||||
|
};
|
||||||
|
|
||||||
|
fn ui(flavor: SchemeFlavor) -> Ui {
|
||||||
|
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn theme_name_tracks_the_flavor() {
|
||||||
|
assert_eq!(ui(SchemeFlavor::Light).name(), "ull-m3-light");
|
||||||
|
assert_eq!(ui(SchemeFlavor::Dark).name(), "ull-m3-dark");
|
||||||
|
assert_ne!(
|
||||||
|
ui(SchemeFlavor::Light).theme().to_string(),
|
||||||
|
ui(SchemeFlavor::Dark).theme().to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canvas_card_overlay_follow_surface_containers() {
|
||||||
|
let ui = ui(SchemeFlavor::Light);
|
||||||
|
assert_eq!(ui.layer(MaterialLayer::Canvas), ui.color(Role::Surface));
|
||||||
|
assert_eq!(
|
||||||
|
ui.layer(MaterialLayer::Card),
|
||||||
|
ui.color(Role::SurfaceContainerLow)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ui.layer(MaterialLayer::Overlay),
|
||||||
|
ui.color(Role::SurfaceContainerHigh)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn radii_only_come_from_the_scale() {
|
||||||
|
let ui = ui(SchemeFlavor::Light);
|
||||||
|
// 卡片 Medium(12):边长够大时就是 12
|
||||||
|
assert_eq!(ui.radius_dp(ShapeScale::Medium).top_left, 12.0);
|
||||||
|
assert_eq!(ui.radius_dp(ShapeScale::ExtraSmall).top_left, 4.0);
|
||||||
|
// 药丸 Full:按边长一半折算
|
||||||
|
assert_eq!(ui.radius(ShapeScale::Full, 32.0).top_left, 16.0);
|
||||||
|
// 不许超过边长一半
|
||||||
|
assert_eq!(ui.radius(ShapeScale::ExtraLarge, 20.0).top_left, 10.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn outlines_are_one_pixel_and_use_outline_variant() {
|
||||||
|
let ui = ui(SchemeFlavor::Light);
|
||||||
|
let border = ui.outlined(ShapeScale::Medium);
|
||||||
|
assert_eq!(border.width, STROKE_PX);
|
||||||
|
assert_eq!(border.width, 1.0, "rubric G7:全站 1px");
|
||||||
|
assert_eq!(border.color, ui.color(Role::OutlineVariant));
|
||||||
|
assert_eq!(border.radius.top_left, 12.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emphasized_weights_are_the_official_ones() {
|
||||||
|
let ui = ui(SchemeFlavor::Light);
|
||||||
|
// title-small 的强调档是 700(官方 Emphasized)
|
||||||
|
assert_eq!(
|
||||||
|
ui.emphasized_font(TypeRole::TitleSmall).weight,
|
||||||
|
iced::font::Weight::Bold
|
||||||
|
);
|
||||||
|
// baseline 正文是 400
|
||||||
|
assert_eq!(
|
||||||
|
ui.font(TypeRole::BodyMedium).weight,
|
||||||
|
iced::font::Weight::Normal
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn body_text_keeps_wcag_contrast_in_both_flavors() {
|
||||||
|
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||||
|
let scheme = Scheme::baseline(flavor);
|
||||||
|
assert!(
|
||||||
|
scheme.contrast(Role::OnSurface, Role::Surface) >= 4.5,
|
||||||
|
"{flavor:?} 的正文对比度不够"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
scheme.contrast(Role::OnSecondaryContainer, Role::SecondaryContainer) >= 4.5,
|
||||||
|
"{flavor:?} 的选中态药丸对比度不够"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_role_maps_to_an_iced_color() {
|
||||||
|
for role in Role::ALL {
|
||||||
|
let color = ui(SchemeFlavor::Dark).color(role);
|
||||||
|
assert!((0.0..=1.0).contains(&color.a), "{role:?} 的 alpha 越界");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_layers_are_opaque_so_cpu_and_gpu_match() {
|
||||||
|
let ui = ui(SchemeFlavor::Light);
|
||||||
|
let hovered = ui.state_over(StateLayer::Hover, Role::OnSurface, Role::Surface);
|
||||||
|
assert_eq!(hovered.a, 1.0, "状态层必须给出不透明结果");
|
||||||
|
assert_ne!(hovered, ui.color(Role::Surface));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
//! 门禁:**组件层零硬编码**(色值 / 尺寸)与「不依赖 slint」。
|
||||||
|
//!
|
||||||
|
//! 这三条测试会扫本 crate 的 `src/**` 与 `Cargo.toml`,故意写得「宁可错杀」:
|
||||||
|
//! 将来 reviewer 复跑 `cargo test -p unlucky-app-iced` 就能挡住「顺手写个 #RRGGBB」的提交。
|
||||||
|
//!
|
||||||
|
//! 判定口径(与 `component-fidelity-rubric.md` T1/T2、`visual-acceptance-protocol.md` §1 对齐):
|
||||||
|
//!
|
||||||
|
//! - **色值字面量**:`#` + 6 位十六进制(且**至少含一个数字**,这样文档里的 `#RRGGBB` 占位不会被误杀)
|
||||||
|
//! 或 `Color::from_rgb(0.1, …)` / `color!(0x…)` 这类带数值参数的构造。
|
||||||
|
//! - **令牌优先**:唯一允许的是 `theme.rs`(适配层),且它也只做 `token → iced` 的搬运。
|
||||||
|
//! - **尺寸字面量**:`shell.rs` 里 `.width(12.0)` / `.height(34)` / `.size(22)` 一律不许出现
|
||||||
|
//! (必须走 `LayoutMetrics` / `TypeStyle` / `Space`)。
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// 本 crate 根目录(编译期注入,换机器也成立)。
|
||||||
|
fn manifest_dir() -> PathBuf {
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 递归收集 `dir` 下的 `.rs` 文件。
|
||||||
|
fn rust_files(dir: &Path, collected: &mut Vec<PathBuf>) {
|
||||||
|
let entries =
|
||||||
|
std::fs::read_dir(dir).unwrap_or_else(|error| panic!("读不动 {}:{error}", dir.display()));
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let path = entry.expect("目录项读失败").path();
|
||||||
|
if path.is_dir() {
|
||||||
|
rust_files(&path, collected);
|
||||||
|
} else if path.extension().is_some_and(|ext| ext == "rs") {
|
||||||
|
collected.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sources() -> Vec<(PathBuf, String)> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
rust_files(&manifest_dir().join("src"), &mut files);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
files.len() >= 8,
|
||||||
|
"只扫到 {} 个源文件:扫描逻辑坏了,这条门禁等于没跑",
|
||||||
|
files.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
files
|
||||||
|
.into_iter()
|
||||||
|
.map(|path| {
|
||||||
|
let text = std::fs::read_to_string(&path)
|
||||||
|
.unwrap_or_else(|error| panic!("读不动 {}:{error}", path.display()));
|
||||||
|
(path, text)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `#RRGGBB` / `#AARRGGBB` 形式的色值字面量(至少含一个数字,避开 `#RRGGBB` 这种占位写法)。
|
||||||
|
fn hex_color_at(bytes: &[u8], start: usize) -> Option<&str> {
|
||||||
|
let digits = bytes.get(start + 1..start + 7)?;
|
||||||
|
let all_hex = digits
|
||||||
|
.iter()
|
||||||
|
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_whitespace());
|
||||||
|
let has_number = digits.iter().any(u8::is_ascii_digit);
|
||||||
|
|
||||||
|
if all_hex && has_number {
|
||||||
|
// 后面不能再跟着十六进制字符(否则是更长的 token,不是 6 位色值)
|
||||||
|
let next = bytes.get(start + 7).copied();
|
||||||
|
if next.is_none_or(|byte| !byte.is_ascii_hexdigit()) {
|
||||||
|
return Some("hex 色值字面量(#RRGGBB)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 带数值参数的取色构造(`Color::from_rgb(0.1, …)`、`Color::from_rgb8(103, 80, 164)`、`color!(0x12, …)`)。
|
||||||
|
///
|
||||||
|
/// **子串匹配要注意前后缀**:`from_rgb(` 匹配不到 `from_rgb8(`,所以 8 bit 那个必须单独列出来
|
||||||
|
/// (reviewer 造样就是这么绕过去的)。
|
||||||
|
const NUMERIC_COLOR_CALLS: [&str; 8] = [
|
||||||
|
"Color::from_rgb(",
|
||||||
|
"Color::from_rgb8(",
|
||||||
|
"Color::from_rgba(",
|
||||||
|
"Color::from_rgba8(",
|
||||||
|
"Color::from_hex(",
|
||||||
|
"Color::rgba(",
|
||||||
|
"Color::rgb8(",
|
||||||
|
"color!(",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 取色构造后面是不是跟着数值字面量(允许 `Color::from_rgba8(color.r, …)` 这类搬运)。
|
||||||
|
fn numeric_color_call(text: &str) -> Option<&'static str> {
|
||||||
|
for pattern in NUMERIC_COLOR_CALLS {
|
||||||
|
let mut from = 0;
|
||||||
|
while let Some(found) = text[from..].find(pattern) {
|
||||||
|
let after = from + found + pattern.len();
|
||||||
|
let next = text[after..].trim_start().chars().next();
|
||||||
|
|
||||||
|
// 参数以数字或 `0x`/`#` 开头才算字面量;`Color::from_rgba8(color.r, …)` 是搬运,放行。
|
||||||
|
if next.is_some_and(|ch| ch.is_ascii_digit() || ch == '#') {
|
||||||
|
return Some(pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
from = after;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 结构体字面量取色:`Color { r: 0.4, g: 0.3, b: 0.6, a: 1.0 }`。
|
||||||
|
///
|
||||||
|
/// 判据:`Color {` 之后 64 个字符内同时出现 `r:` 与 `g:` 且有数字(`Color { a: 0.0, .. }` 这种
|
||||||
|
/// 只改 alpha 的写法不会命中,因为它没有 `r:`)。
|
||||||
|
fn color_struct_literal(text: &str) -> bool {
|
||||||
|
let mut from = 0;
|
||||||
|
while let Some(found) = text[from..].find("Color {") {
|
||||||
|
let after = from + found + "Color {".len();
|
||||||
|
let window: String = text[after..].chars().take(64).collect();
|
||||||
|
let has_channels = window.contains("r:") && window.contains("g:");
|
||||||
|
let has_number = window.chars().any(|ch| ch.is_ascii_digit());
|
||||||
|
|
||||||
|
if has_channels && has_number {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
from = after;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_hardcoded_colors_in_sources() {
|
||||||
|
let mut offenders = Vec::new();
|
||||||
|
|
||||||
|
for (path, text) in sources() {
|
||||||
|
for (index, line) in text.lines().enumerate() {
|
||||||
|
let bytes = line.as_bytes();
|
||||||
|
let mut position = 0;
|
||||||
|
|
||||||
|
while let Some(found) = line[position..].find('#') {
|
||||||
|
let at = position + found;
|
||||||
|
if let Some(kind) = hex_color_at(bytes, at) {
|
||||||
|
offenders.push(format!(
|
||||||
|
"{}:{} {line:?} -> {kind}",
|
||||||
|
path.display(),
|
||||||
|
index + 1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
position = at + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(pattern) = numeric_color_call(line) {
|
||||||
|
offenders.push(format!(
|
||||||
|
"{}:{} {line:?} -> {pattern}",
|
||||||
|
path.display(),
|
||||||
|
index + 1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if color_struct_literal(line) {
|
||||||
|
offenders.push(format!(
|
||||||
|
"{}:{} {line:?} -> Color {{ r: … }} 结构体字面量",
|
||||||
|
path.display(),
|
||||||
|
index + 1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
offenders.is_empty(),
|
||||||
|
"组件层不许出现色值字面量(一律走 unlucky_design 的 Role):\n{}",
|
||||||
|
offenders.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断某个布局调用后面是不是直接跟了尺寸字面量;`0`(= 无内边距)放行。
|
||||||
|
///
|
||||||
|
/// iced 里尺寸经常被包一层:`.width(Length::Fixed(123.0))`、`.padding(Padding::from([12.0, 8.0]))`、
|
||||||
|
/// `.size(Size::new(10.0, 10.0))` —— 所以遇到这些包装要**往里剥一层**再判。
|
||||||
|
fn size_literal(argument: &str) -> Option<String> {
|
||||||
|
let mut rest = argument.trim_start();
|
||||||
|
|
||||||
|
for wrapper in ["Length::Fixed(", "Padding::from(", "Size::new("] {
|
||||||
|
if let Some(after) = rest.strip_prefix(wrapper) {
|
||||||
|
rest = after.trim_start().trim_start_matches(['[', '(']);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let number: String = rest
|
||||||
|
.trim_start()
|
||||||
|
.chars()
|
||||||
|
.take_while(|ch| ch.is_ascii_digit() || *ch == '.')
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if number.is_empty() || number.trim_end_matches('.').parse::<f64>() == Ok(0.0) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(number)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_takes_every_size_from_tokens() {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
rust_files(&manifest_dir().join("src").join("shell"), &mut files);
|
||||||
|
assert!(
|
||||||
|
files.len() >= 2,
|
||||||
|
"shell 模块至少有两个文件(mod.rs + nav.rs),实际扫到 {}",
|
||||||
|
files.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut offenders = Vec::new();
|
||||||
|
|
||||||
|
for path in files {
|
||||||
|
let text = std::fs::read_to_string(&path).expect("读 shell 源文件");
|
||||||
|
|
||||||
|
for (index, line) in text.lines().enumerate() {
|
||||||
|
// 文档注释是散文不是代码:里面写 `.width(24)` 是在解释坑,不该判违规
|
||||||
|
if line.trim_start().starts_with("//") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for pattern in [".width(", ".height(", ".size(", ".spacing(", ".padding("] {
|
||||||
|
let mut from = 0;
|
||||||
|
while let Some(found) = line[from..].find(pattern) {
|
||||||
|
let after = from + found + pattern.len();
|
||||||
|
if let Some(number) = size_literal(&line[after..]) {
|
||||||
|
offenders.push(format!(
|
||||||
|
"{}:{} {line:?} -> {pattern} 后面直接写了数字 {number}",
|
||||||
|
path.display(),
|
||||||
|
index + 1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
from = after;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
offenders.is_empty(),
|
||||||
|
"骨架尺寸必须来自 LayoutMetrics / TypeStyle / Space:\n{}",
|
||||||
|
offenders.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 依赖行里出现 `slint` 就算违规(注释行放过:说明并存期要提到 slint)。
|
||||||
|
fn manifest_has_slint_dependency(manifest: &str) -> Vec<String> {
|
||||||
|
manifest
|
||||||
|
.lines()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, line)| {
|
||||||
|
let trimmed = line.trim_start();
|
||||||
|
!trimmed.starts_with('#') && trimmed.contains("slint")
|
||||||
|
})
|
||||||
|
.map(|(index, line)| format!("Cargo.toml:{} {line}", index + 1))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn this_crate_never_depends_on_slint() {
|
||||||
|
let manifest =
|
||||||
|
std::fs::read_to_string(manifest_dir().join("Cargo.toml")).expect("读 Cargo.toml");
|
||||||
|
|
||||||
|
let offenders = manifest_has_slint_dependency(&manifest);
|
||||||
|
assert!(
|
||||||
|
offenders.is_empty(),
|
||||||
|
"P4 的 iced 版 crate 不许依赖 slint(D26):\n{}",
|
||||||
|
offenders.join("\n")
|
||||||
|
);
|
||||||
|
|
||||||
|
for (path, text) in sources() {
|
||||||
|
for (index, line) in text.lines().enumerate() {
|
||||||
|
// 只查真代码里的 use/路径,文档里提 slint 是允许的(要解释并存期)
|
||||||
|
let code = line.trim_start();
|
||||||
|
let is_import = code.starts_with("use ") || code.starts_with("extern crate");
|
||||||
|
if is_import && code.contains("slint") {
|
||||||
|
panic!("{}:{} 引入了 slint:{line}", path.display(), index + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_gate_itself_catches_a_planted_violation() {
|
||||||
|
// 自检:把违规样本喂给同一套判定,必须被抓住(否则门禁是空转)。
|
||||||
|
// **reviewer r1 造样验证过的 5 个漏网样本逐条钉在这里**,退化成「放行」立刻红。
|
||||||
|
|
||||||
|
// 1. `Color::from_rgb8(103, 80, 164)` —— 8bit 版曾因 `from_rgb(` 匹配不到而漏
|
||||||
|
assert!(numeric_color_call("Color::from_rgb8(103, 80, 164)").is_some());
|
||||||
|
// 2. `iced::Color::from_rgb8(...)` —— 带路径前缀的写法同样要拦
|
||||||
|
assert!(numeric_color_call("iced::Color::from_rgb8(103, 80, 164)").is_some());
|
||||||
|
// 3. `Color { r: 0.4, g: 0.3, b: 0.6, a: 1.0 }` —— 结构体字面量
|
||||||
|
assert!(color_struct_literal(
|
||||||
|
"Color { r: 0.4, g: 0.3, b: 0.6, a: 1.0 }"
|
||||||
|
));
|
||||||
|
// 4. `.width(Length::Fixed(123.0))` —— 尺寸被 iced 包装一层
|
||||||
|
assert_eq!(
|
||||||
|
size_literal("Length::Fixed(123.0))").as_deref(),
|
||||||
|
Some("123.0")
|
||||||
|
);
|
||||||
|
// 5. `.padding(Padding::from([12.0, 8.0]))` —— 数组字面量
|
||||||
|
assert_eq!(
|
||||||
|
size_literal("Padding::from([12.0, 8.0]))").as_deref(),
|
||||||
|
Some("12.0")
|
||||||
|
);
|
||||||
|
|
||||||
|
// 原有规则不能退化
|
||||||
|
assert!(hex_color_at(b" color: #6750A4,", 11).is_some());
|
||||||
|
assert!(hex_color_at(b"// placeholder #RRGGBB here", 15).is_none());
|
||||||
|
assert!(numeric_color_call("Color::from_rgb(0.1, 0.2, 0.3)").is_some());
|
||||||
|
assert!(numeric_color_call("Color::from_rgba8(color.r, color.g, color.b, color.a)").is_none());
|
||||||
|
assert!(numeric_color_call("color!(0x5865F2)").is_some());
|
||||||
|
assert!(size_literal("40.0)").is_some());
|
||||||
|
assert!(size_literal("0)").is_none());
|
||||||
|
assert!(size_literal("metrics.titlebar_height)").is_none());
|
||||||
|
assert!(size_literal("Length::Fixed(thickness))").is_none());
|
||||||
|
assert!(size_literal("Padding::from([0.0, ui.space(Space::X4)]))").is_none());
|
||||||
|
assert!(!color_struct_literal(
|
||||||
|
"Color::from_rgba8(color.r, color.g, color.b, 1.0)"
|
||||||
|
));
|
||||||
|
assert!(!color_struct_literal("let color = Color { a: 0.5 };"));
|
||||||
|
assert!(!manifest_has_slint_dependency("slint = \"1.18\"").is_empty());
|
||||||
|
assert!(manifest_has_slint_dependency("# 不得依赖 slint").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 渲染层回归守门(视觉复审 P2-1 的教训):`indicator_matches_the_reference_56x32` 只测纯函数,
|
||||||
|
/// 门禁全绿但图上的药丸实际是 88×32 —— 因为 `.center_x(Length::Fill)` 会把容器 width 覆盖成 Fill。
|
||||||
|
/// 这里直接扫 `nav.rs`:药丸容器**不许**出现 `center_x(` / `center_y(`。
|
||||||
|
#[test]
|
||||||
|
fn pill_never_overrides_its_size_with_fill() {
|
||||||
|
let nav = manifest_dir().join("src").join("shell").join("nav.rs");
|
||||||
|
let text = std::fs::read_to_string(&nav).expect("读 nav.rs");
|
||||||
|
|
||||||
|
for (index, line) in text.lines().enumerate() {
|
||||||
|
let code = line.trim_start();
|
||||||
|
if code.starts_with("//") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for pattern in ["center_x(", "center_y("] {
|
||||||
|
assert!(
|
||||||
|
!code.contains(pattern),
|
||||||
|
"nav.rs:{} 用了 {pattern}——`center_*(Length::Fill)` 会把容器尺寸改成 Fill, 药丸会被父级宽度覆盖(视觉复审 P2-1)。居中请用父级 `align_x`:{line}",
|
||||||
|
index + 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user