视觉复审在 M68 的判词是不能把「全宽单卡」当页面模板(1180 宽下正文单行 712px ≈ 118 字符)。 本 mission 把它落成一套**布局系统**,四个页面只是它的用例: - `pages/layout.rs`:官方 600 / 840 两个档位 → 三种版面(单列 / supporting-pane / list-detail), 阈值由列宽和算出(设置 240+24+320 → 720;实例 336+24+320 → 816 ≤ 840),行宽硬上限 14 × 0.5em × 72 = **504px**,全部是纯函数,`pages/layout/tests.rs` 用绝对值和边界表钉死。 - `pages/frame.rs`:页头(56 / 两行 84)+ 内容列(≤504)+ 两列骨架 + 空态位; `pages/frame/pieces.rs` 是占位控件(卡片 / 分隔线 / 文本按钮 / 图标),令牌驱动、无完整状态机, 待 M75 的 components 家族合入后替换。 - 四页:实例(list-detail,列表列 336 独立滚动,窄窗退化成整页切换)、 设置(supporting-pane,240 分组导航 + ≤504 表单列)、下载 / 日志(单列,两行页头)。 - `pages/harness.rs` + `examples/pages_demo.rs`:自带 `--screen/--width/--screenshot-theme`, 不碰 `cli.rs` / `app.rs`(M75 在改)。 偏离与说明: - 布局用「页边距 16/24」而非 16/24/32,官方 expanded 也是 24(少一条偏离登记); - 实例空详情态一屏会有 5 档字号(22/16/24/12 + 14 的操作按钮),未截图的状态,已知并登记; - `lib.rs` 只加一行 `pub mod pages;`。
400 lines
13 KiB
Rust
400 lines
13 KiB
Rust
//! 截图 harness:把**一页**按给定窗口宽度 / 主题单独渲染并出图。
|
||
//!
|
||
//! 它是 `examples/pages_demo.rs` 的入口(该 example 只有三行)。**刻意不接 `cli.rs`**:
|
||
//! `cli.rs` 归 M75 改(它正在加 `--screen`),本 mission 不碰;等 `cli.rs` 具备 `--screen` /
|
||
//! `--window-width` 之后,这个 harness 应当删掉,改由正式启动参数出图。
|
||
//!
|
||
//! ## 用法
|
||
//!
|
||
//! ```text
|
||
//! cargo run -p unlucky-app-iced --example pages_demo -- \
|
||
//! --screen=instances --width=1180 --height=760 \
|
||
//! --screenshot=out.png --screenshot-theme=light --software-renderer
|
||
//! ```
|
||
//!
|
||
//! 参数:`--screen=instances|download|settings|logs`、`--width`、`--height`、
|
||
//! `--theme` / `--screenshot-theme`(后者优先)、`--screenshot`、`--renderer`、
|
||
//! `--software-renderer`、`--scale-factor`。没给 `--screenshot` 就只是开窗看。
|
||
//!
|
||
//! ## 为什么还要等 3 帧
|
||
//!
|
||
//! 与 `app.rs` 同一口径(M60 坑 #5):第一帧可能是启动时的旧尺寸,且字体首次上屏会抖动。
|
||
|
||
use std::path::PathBuf;
|
||
use std::time::Instant;
|
||
|
||
use iced::{window, Element, Subscription, Task};
|
||
|
||
use unlucky_design::color::SchemeFlavor;
|
||
|
||
use crate::cli::Backend;
|
||
use crate::fonts;
|
||
use crate::probe::{self, Probe};
|
||
use crate::shot;
|
||
use crate::theme::Ui;
|
||
|
||
use super::{demo, Screen};
|
||
|
||
/// 截图落盘的帧号(见模块文档)。
|
||
const CAPTURE_AT_FRAME: u32 = 3;
|
||
/// 验收窗口尺寸(D30:1180×760、100% DPI)。
|
||
const DEFAULT_SIZE: (f32, f32) = (1180.0, 760.0);
|
||
|
||
/// 一次渲染请求。
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct Request {
|
||
/// 渲染哪一页。
|
||
pub screen: Screen,
|
||
/// 窗口宽度(**含外壳**:布局档位就是按它判的)。
|
||
pub width: f32,
|
||
/// 窗口高度。
|
||
pub height: f32,
|
||
/// 主题(`--screenshot-theme` > `--theme` > 浅色)。
|
||
pub flavor: SchemeFlavor,
|
||
/// 出图路径(`None` = 只开窗)。
|
||
pub screenshot: Option<PathBuf>,
|
||
/// 渲染后端请求。
|
||
pub backend: Backend,
|
||
/// 强制缩放因子(截图要 100% DPI)。
|
||
pub scale_factor: Option<f32>,
|
||
}
|
||
|
||
impl Default for Request {
|
||
fn default() -> Self {
|
||
Self {
|
||
screen: Screen::Instances,
|
||
width: DEFAULT_SIZE.0,
|
||
height: DEFAULT_SIZE.1,
|
||
flavor: SchemeFlavor::Light,
|
||
screenshot: None,
|
||
backend: Backend::Auto,
|
||
scale_factor: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Request {
|
||
/// 从进程参数解析(`argv[1..]`)。
|
||
///
|
||
/// # Errors
|
||
/// 未知参数、缺值、数字非法时返回人话。
|
||
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
|
||
let mut request = Self::default();
|
||
let mut theme: Option<SchemeFlavor> = None;
|
||
let mut shot_theme: Option<SchemeFlavor> = None;
|
||
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() {
|
||
"--screen" => {
|
||
let raw = take(&mut args, inline, "--screen")?;
|
||
request.screen = Screen::from_slug(&raw).ok_or_else(|| {
|
||
format!("--screen 只认 instances|download|settings|logs,收到 {raw:?}")
|
||
})?;
|
||
}
|
||
"--width" => {
|
||
request.width = number(&take(&mut args, inline, "--width")?, "--width")?
|
||
}
|
||
"--height" => {
|
||
request.height = number(&take(&mut args, inline, "--height")?, "--height")?;
|
||
}
|
||
"--theme" => theme = Some(flavor(&take(&mut args, inline, "--theme")?)?),
|
||
"--screenshot-theme" => {
|
||
shot_theme = Some(flavor(&take(&mut args, inline, "--screenshot-theme")?)?);
|
||
}
|
||
"--screenshot" => {
|
||
request.screenshot =
|
||
Some(PathBuf::from(take(&mut args, inline, "--screenshot")?));
|
||
}
|
||
"--software-renderer" => request.backend = Backend::Cpu,
|
||
"--renderer" => {
|
||
request.backend = match take(&mut args, inline, "--renderer")?.as_str() {
|
||
"auto" => Backend::Auto,
|
||
"gpu" => Backend::Gpu,
|
||
"cpu" => Backend::Cpu,
|
||
other => {
|
||
return Err(format!("--renderer 只认 auto|gpu|cpu,收到 {other:?}"))
|
||
}
|
||
};
|
||
}
|
||
"--scale-factor" => {
|
||
let raw = take(&mut args, inline, "--scale-factor")?;
|
||
let value = number(&raw, "--scale-factor")?;
|
||
if value <= 0.0 {
|
||
return Err(format!("--scale-factor 必须 > 0,收到 {raw:?}"));
|
||
}
|
||
request.scale_factor = Some(value);
|
||
}
|
||
other => return Err(format!("未知参数 {other:?}")),
|
||
}
|
||
}
|
||
|
||
request.flavor = shot_theme.or(theme).unwrap_or(SchemeFlavor::Light);
|
||
|
||
if request.width < 320.0 || request.height < 320.0 {
|
||
return Err(format!(
|
||
"窗口太小:{}×{}(至少 320×320)",
|
||
request.width, request.height
|
||
));
|
||
}
|
||
|
||
Ok(request)
|
||
}
|
||
|
||
/// 出图文件名片段(`<screen>-<class>-<flavor>`,归档用)。
|
||
pub fn slug(&self) -> String {
|
||
let class = super::layout::WidthClass::of(self.width);
|
||
let flavor = if self.flavor.is_dark() {
|
||
"dark"
|
||
} else {
|
||
"light"
|
||
};
|
||
format!(
|
||
"{}-{}-{flavor}-w{}",
|
||
self.screen.slug(),
|
||
class.name(),
|
||
self.width as u32
|
||
)
|
||
}
|
||
}
|
||
|
||
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 number(raw: &str, flag: &str) -> Result<f32, String> {
|
||
raw.parse::<f32>()
|
||
.map_err(|_| format!("{flag} 需要数字,收到 {raw:?}"))
|
||
.and_then(|value| {
|
||
if value.is_finite() && value > 0.0 {
|
||
Ok(value)
|
||
} else {
|
||
Err(format!("{flag} 必须是正数,收到 {raw:?}"))
|
||
}
|
||
})
|
||
}
|
||
|
||
fn flavor(raw: &str) -> Result<SchemeFlavor, String> {
|
||
match raw {
|
||
"light" => Ok(SchemeFlavor::Light),
|
||
"dark" => Ok(SchemeFlavor::Dark),
|
||
other => Err(format!("主题只认 light|dark,收到 {other:?}")),
|
||
}
|
||
}
|
||
|
||
/// 启动(阻塞到出图或关窗)。
|
||
///
|
||
/// # Errors
|
||
/// 后端探测/字体求值失败、截图落盘失败时返回人话。
|
||
pub fn run(request: Request) -> Result<(), String> {
|
||
let started = Instant::now();
|
||
|
||
let needs_probe = request.backend == Backend::Auto;
|
||
let probe = if needs_probe {
|
||
Probe::run()
|
||
} else {
|
||
Probe::default()
|
||
};
|
||
probe::apply(request.backend, &probe);
|
||
|
||
let resolved = fonts::resolve();
|
||
eprintln!("{}", resolved.describe());
|
||
let families = resolved.families();
|
||
|
||
let boot = request.clone();
|
||
let application = iced::application(
|
||
move || {
|
||
(
|
||
Harness {
|
||
ui: Ui::new(boot.flavor, families),
|
||
request: boot.clone(),
|
||
window: None,
|
||
frames: 0,
|
||
},
|
||
Task::done(Message::Boot),
|
||
)
|
||
},
|
||
Harness::update,
|
||
Harness::view,
|
||
)
|
||
.theme(Harness::theme)
|
||
.subscription(Harness::subscription)
|
||
.default_font(iced::Font {
|
||
family: iced::font::Family::Name(families.text),
|
||
..iced::Font::DEFAULT
|
||
})
|
||
.window(window::Settings {
|
||
size: iced::Size::new(request.width, request.height),
|
||
decorations: false,
|
||
resizable: true,
|
||
..window::Settings::default()
|
||
});
|
||
|
||
eprintln!(
|
||
"[harness] screen={} width={} height={} slug={}(布局档位 {})",
|
||
request.screen.slug(),
|
||
request.width,
|
||
request.height,
|
||
request.slug(),
|
||
super::layout::WidthClass::of(request.width).name()
|
||
);
|
||
|
||
match request.scale_factor {
|
||
Some(scale) => application.scale_factor(move |_| scale).run(),
|
||
None => application.run(),
|
||
}
|
||
.map_err(|error| format!("iced 启动失败:{error}"))?;
|
||
|
||
eprintln!(
|
||
"[harness] 用时 {:.0} ms",
|
||
started.elapsed().as_secs_f64() * 1000.0
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
struct Harness {
|
||
request: Request,
|
||
ui: Ui,
|
||
window: Option<window::Id>,
|
||
frames: u32,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
enum Message {
|
||
Boot,
|
||
WindowReady(Option<window::Id>),
|
||
Frame,
|
||
Captured(window::Screenshot),
|
||
/// 页面里的动作(骨架期只打印,接线后由 app 层处理)。
|
||
Page(super::Message),
|
||
}
|
||
|
||
impl Harness {
|
||
fn update(&mut self, message: Message) -> Task<Message> {
|
||
match message {
|
||
Message::Boot => window::latest().map(Message::WindowReady),
|
||
Message::WindowReady(id) => {
|
||
self.window = id;
|
||
Task::none()
|
||
}
|
||
Message::Frame => {
|
||
self.frames += 1;
|
||
if self.frames < CAPTURE_AT_FRAME || self.request.screenshot.is_none() {
|
||
return Task::none();
|
||
}
|
||
match self.window {
|
||
Some(id) => window::screenshot(id).map(Message::Captured),
|
||
None => Task::none(),
|
||
}
|
||
}
|
||
Message::Captured(screenshot) => {
|
||
let Some(path) = self.request.screenshot.clone() else {
|
||
return Task::none();
|
||
};
|
||
match shot::save(&screenshot, &path) {
|
||
Ok(summary) => {
|
||
eprintln!("{}", summary.describe());
|
||
eprintln!("[harness] slug={}", self.request.slug());
|
||
}
|
||
Err(error) => eprintln!("[harness] 截图失败:{error}"),
|
||
}
|
||
iced::exit()
|
||
}
|
||
Message::Page(message) => {
|
||
eprintln!("[harness] 页面动作:{message:?}(骨架期不做业务)");
|
||
Task::none()
|
||
}
|
||
}
|
||
}
|
||
|
||
fn subscription(&self) -> Subscription<Message> {
|
||
if self.request.screenshot.is_some() {
|
||
window::frames().map(|_| Message::Frame)
|
||
} else {
|
||
Subscription::none()
|
||
}
|
||
}
|
||
|
||
fn theme(&self) -> iced::Theme {
|
||
self.ui.theme()
|
||
}
|
||
|
||
fn view(&self) -> Element<'_, Message> {
|
||
demo(&self.ui, self.request.screen, self.request.width).map(Message::Page)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn parse(args: &[&str]) -> Result<Request, String> {
|
||
Request::parse(args.iter().map(|arg| (*arg).to_owned()))
|
||
}
|
||
|
||
#[test]
|
||
fn defaults_are_the_acceptance_frame() {
|
||
let request = parse(&[]).expect("空参数要能解析");
|
||
assert_eq!(request.screen, Screen::Instances);
|
||
assert_eq!((request.width, request.height), (1180.0, 760.0));
|
||
assert_eq!(request.flavor, SchemeFlavor::Light);
|
||
assert!(request.screenshot.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn screen_and_width_parse_in_both_argv_forms() {
|
||
let spaced = parse(&["--screen", "settings", "--width", "900"]).unwrap();
|
||
let inline = parse(&["--screen=settings", "--width=900"]).unwrap();
|
||
assert_eq!(spaced, inline);
|
||
assert_eq!(spaced.screen, Screen::Settings);
|
||
assert_eq!(spaced.width, 900.0);
|
||
assert!(parse(&["--screen=gallery"]).is_err());
|
||
assert!(parse(&["--width=abc"]).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn screenshot_theme_wins_over_theme() {
|
||
let request = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap();
|
||
assert_eq!(request.flavor, SchemeFlavor::Light);
|
||
assert_eq!(parse(&["--theme=dark"]).unwrap().flavor, SchemeFlavor::Dark);
|
||
}
|
||
|
||
#[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 slug_carries_screen_class_and_flavor() {
|
||
let request = parse(&[
|
||
"--screen=instances",
|
||
"--width=700",
|
||
"--screenshot-theme=dark",
|
||
])
|
||
.unwrap();
|
||
assert_eq!(request.slug(), "instances-medium-dark-w700");
|
||
}
|
||
|
||
#[test]
|
||
fn tiny_windows_are_rejected() {
|
||
assert!(parse(&["--width=200"]).is_err());
|
||
assert!(parse(&["--height=100"]).is_err());
|
||
}
|
||
}
|