已落地:
- components/{mod,interaction,layout,divider,gate}.rs + buttons/{mod,geometry,assemble,tests}.rs
- gallery/{mod,buttons,probe,scan}.rs + 四个家族桩(selection/inputs/feedback/navigation)
- cli.rs: --screen=shell|gallery + capture_flavor(截图默认 Light,删掉等系统主题的闸门)
- app.rs: gallery 路由 + DemoMessage 接线(400 行)
待办(下一个 commit):编译/测试全绿、浅深矩阵出图、1:1 并排与数值表。
395 lines
15 KiB
Rust
395 lines
15 KiB
Rust
//! 命令行参数(骨架期 + 组件库 harness 需要的那些;业务开关等页面 mission 再加)。
|
||
//!
|
||
//! 取值语法与现役 Slint 版一致:`--key value` 与 `--key=value` 两种都认
|
||
//! (`crates/app/src/screenshot.rs` 的 `value_of` 是同一口径),这样将来 CI 换 bin 不用改脚本。
|
||
//!
|
||
//! ## 参数总表
|
||
//!
|
||
//! | 参数 | 取值 | 语义 |
|
||
//! |---|---|---|
|
||
//! | `--software-renderer` | — | 等价 `--renderer=cpu`(D27 的 CPU 档) |
|
||
//! | `--renderer` | `auto`/`gpu`/`cpu` | 渲染后端请求 |
|
||
//! | `--theme` | `light`/`dark` | 主题;不给则跟随系统(仅交互模式) |
|
||
//! | `--screenshot` / `--screenshot-theme` | 路径 / `light`·`dark` | 截图通道(出图后退出)与截图专用主题 |
|
||
//! | `--screen` | `shell`/`gallery` | 启动画面;**`gallery` = 组件库视觉验收 harness**(D39 §5) |
|
||
//! | `--scale-factor` | `>0` | 强制缩放因子(验收要 100%,即 `1`) |
|
||
//! | `--diff` / `--diff-scan` | 两个路径 / 非负整数 | 逐像素对比(不开窗)与 ±n px 平移扫描 |
|
||
//! | `--force-state` | `hover`/`pressed` | 把整屏可交互控件强制成某态(取证) |
|
||
//! | `--data-dir` | 路径 | 翻牌兼容:**接受并忽略**(CI 冒烟在用) |
|
||
//!
|
||
//! **截图主题默认 Light(门禁依赖,不许改回 None)**,理由与不变量见
|
||
//! [`Options::capture_flavor`] 与 `app.rs` 的截图路径。
|
||
|
||
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",
|
||
}
|
||
}
|
||
|
||
/// 截图日志用的后端说明(`cfg!(feature = "gpu")` 决定有没有 wgpu 这一档)。
|
||
pub fn note(self) -> &'static str {
|
||
if !cfg!(feature = "gpu") {
|
||
return "cpu(build without wgpu)";
|
||
}
|
||
match self {
|
||
Backend::Cpu => "cpu(forced)",
|
||
Backend::Gpu => "gpu(preferred)",
|
||
Backend::Auto => "auto",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 启动画面(D39 §5:`gallery` 是视觉验收 harness,不是产品页面)。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub enum Screen {
|
||
/// 产品骨架:自绘标题栏 + 导航轨 + 内容区。
|
||
#[default]
|
||
Shell,
|
||
/// 组件库 harness:整窗只放状态矩阵。
|
||
Gallery,
|
||
}
|
||
|
||
impl Screen {
|
||
/// 稳定名字(日志用)。
|
||
pub const fn name(self) -> &'static str {
|
||
match self {
|
||
Screen::Shell => "shell",
|
||
Screen::Gallery => "gallery",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 解析后的启动参数。
|
||
#[derive(Debug, Clone, PartialEq, Default)]
|
||
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,即 `1`;`None` = 用系统 DPI)。
|
||
pub scale_factor: Option<f32>,
|
||
/// `--diff <left> <right>` 与 `--diff-scan <n>`:逐像素对比(不开窗)与 ±n px 平移扫描。
|
||
pub diff: Option<(PathBuf, PathBuf)>,
|
||
/// 见上(默认 0 = 不扫)。
|
||
pub diff_scan: u32,
|
||
/// `--force-state=hover|pressed`:把按钮强制成某个状态便于截图取证(D30 的状态图)。
|
||
pub force_state: Option<ForcedState>,
|
||
/// `--screen=shell|gallery`:启动画面(`gallery` = 组件库视觉验收 harness)。
|
||
pub screen: Screen,
|
||
/// `--data-dir <dir>`:当前**接受并忽略**(现役 Slint 版 CI 冒烟在用;
|
||
/// 真正的消费由后续页面 mission 接 `host`/`state` 时做)。
|
||
pub data_dir: Option<PathBuf>,
|
||
}
|
||
|
||
/// 截图取证用的强制交互状态(只影响观感,不改变任何状态机)。
|
||
///
|
||
/// 五态完整矩阵由 `--screen=gallery` 的 `Button::state(..)` 出,本开关只给整屏取证。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum ForcedState {
|
||
/// 所有可交互控件按 hover 画。
|
||
Hover,
|
||
/// 所有可交互控件按 pressed 画。
|
||
Pressed,
|
||
}
|
||
|
||
impl Options {
|
||
/// 从进程参数解析(错误信息是人话,不 panic、不 `exit`)。
|
||
///
|
||
/// # Errors
|
||
/// 取值不合法(未知 `--renderer`/`--theme`/`--screen`、非法 `--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:?}"
|
||
));
|
||
}
|
||
});
|
||
}
|
||
"--screen" => {
|
||
let raw = take(&mut args, inline, "--screen")?;
|
||
options.screen = match raw.as_str() {
|
||
"shell" => Screen::Shell,
|
||
"gallery" => Screen::Gallery,
|
||
other => {
|
||
return Err(format!("--screen 只认 shell|gallery,收到 {other:?}"));
|
||
}
|
||
};
|
||
}
|
||
// 翻牌前的兼容参数:接受并忽略(见字段文档)
|
||
"--data-dir" => {
|
||
options.data_dir = Some(PathBuf::from(take(&mut args, inline, "--data-dir")?));
|
||
}
|
||
other => return Err(format!("未知参数 {other:?}")),
|
||
}
|
||
}
|
||
|
||
Ok(options)
|
||
}
|
||
|
||
/// 截图时实际使用的主题(**只表达用户显式给了什么**;默认值见 [`Options::capture_flavor`])。
|
||
pub fn shot_flavor(&self) -> Option<SchemeFlavor> {
|
||
self.screenshot_theme.or(self.theme)
|
||
}
|
||
|
||
/// 本次进程真正要画的主题(**唯一取值口**)。
|
||
///
|
||
/// 截图模式(给了 `--screenshot`)**永远返回 `Some`**:`--screenshot-theme` > `--theme` > Light。
|
||
/// 交互模式只有显式指定才有值(`None` = 等系统主题)。
|
||
///
|
||
/// ⚠️ **门禁依赖,不许把截图分支改回 `None`**:CI 的 runtime-smoke 打
|
||
/// `--software-renderer --screenshot <png> --data-dir <dir>`(不带主题),xvfb 里没有桌面主题
|
||
/// 会话,M68 的「等系统主题到达再出图」闸门在这条路径上会把 runner 挂到超时(不是红,更难查)。
|
||
pub fn capture_flavor(&self) -> Option<SchemeFlavor> {
|
||
match self.screenshot {
|
||
Some(_) => Some(self.shot_flavor().unwrap_or(SchemeFlavor::Light)),
|
||
None => self.shot_flavor(),
|
||
}
|
||
}
|
||
|
||
/// 是否要问系统主题。截图模式**永远不问**——这就是「无主题环境不会卡住」的不变量。
|
||
pub fn wants_system_theme(&self) -> bool {
|
||
self.capture_flavor().is_none()
|
||
}
|
||
}
|
||
|
||
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_system_theme_and_shell() {
|
||
let options = parse(&[]).expect("空参数要能解析");
|
||
assert_eq!(options.backend, Backend::Auto);
|
||
assert_eq!((options.theme, options.screenshot), (None, None));
|
||
assert_eq!(options.screen, Screen::Shell);
|
||
}
|
||
|
||
#[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 both_argv_forms_are_accepted() {
|
||
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);
|
||
assert_eq!(
|
||
parse(&["--diff", "a.png", "b.png"]).unwrap().diff,
|
||
Some((PathBuf::from("a.png"), PathBuf::from("b.png")))
|
||
);
|
||
assert_eq!(
|
||
parse(&["--screen", "gallery"]).unwrap().screen,
|
||
Screen::Gallery
|
||
);
|
||
assert_eq!(parse(&["--diff-scan=2"]).unwrap().diff_scan, 2);
|
||
assert_eq!(
|
||
parse(&["--force-state", "hover"]).unwrap().force_state,
|
||
Some(ForcedState::Hover)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn screenshot_theme_wins_over_theme() {
|
||
let o = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap();
|
||
assert_eq!(
|
||
(o.theme, o.shot_flavor()),
|
||
(Some(SchemeFlavor::Dark), Some(SchemeFlavor::Light))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn bad_values_are_reported_not_panicked() {
|
||
for args in [
|
||
&["--renderer=metal"][..],
|
||
&["--theme=blue"],
|
||
&["--scale-factor=0"],
|
||
&["--scale-factor=abc"],
|
||
&["--screenshot"],
|
||
&["--diff", "only-one.png"],
|
||
&["--screen=settings"],
|
||
&["--force-state=focus"],
|
||
&["--diff-scan=-1"],
|
||
&["--nope"],
|
||
] {
|
||
assert!(parse(args).is_err(), "{args:?} 应该报错");
|
||
}
|
||
}
|
||
|
||
#[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")));
|
||
}
|
||
|
||
/// **门禁依赖**:CI 的 runtime-smoke 不带主题,截图模式必须有确定主题。
|
||
/// 退化成 `None` 的话,xvfb 里的 runner 会挂到超时(不是红,更难查)。
|
||
#[test]
|
||
fn screenshot_without_theme_defaults_to_light() {
|
||
let options = parse(&["--screenshot", "out.png"]).unwrap();
|
||
assert_eq!(options.shot_flavor(), None, "用户没给主题");
|
||
assert_eq!(
|
||
options.capture_flavor(),
|
||
Some(SchemeFlavor::Light),
|
||
"截图模式必须给确定主题(CI 冒烟依赖这条)"
|
||
);
|
||
assert!(!options.wants_system_theme(), "截图模式不许等系统主题");
|
||
}
|
||
|
||
#[test]
|
||
fn explicit_themes_still_win_in_screenshot_mode() {
|
||
let dark = parse(&["--screenshot", "a.png", "--theme=dark"]).unwrap();
|
||
assert_eq!(dark.capture_flavor(), Some(SchemeFlavor::Dark));
|
||
|
||
let both = parse(&[
|
||
"--screenshot",
|
||
"a.png",
|
||
"--theme=dark",
|
||
"--screenshot-theme=light",
|
||
]);
|
||
assert_eq!(both.unwrap().capture_flavor(), Some(SchemeFlavor::Light));
|
||
}
|
||
|
||
#[test]
|
||
fn interactive_mode_still_follows_the_system_theme() {
|
||
let interactive = parse(&[]).unwrap();
|
||
assert_eq!(interactive.capture_flavor(), None);
|
||
assert!(interactive.wants_system_theme(), "交互模式等系统主题");
|
||
|
||
let pinned = parse(&["--theme=dark"]).unwrap();
|
||
assert_eq!(pinned.capture_flavor(), Some(SchemeFlavor::Dark));
|
||
assert!(!pinned.wants_system_theme());
|
||
}
|
||
}
|