//! 截图 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, /// 渲染后端请求。 pub backend: Backend, /// 强制缩放因子(截图要 100% DPI)。 pub scale_factor: Option, } 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) -> Result { let mut request = Self::default(); let mut theme: Option = None; let mut shot_theme: Option = 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) } /// 出图文件名片段(`--`,归档用)。 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, inline: Option, flag: &str, ) -> Result { inline .or_else(|| args.next()) .ok_or_else(|| format!("{flag} 需要一个值")) } fn number(raw: &str, flag: &str) -> Result { raw.parse::() .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 { 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, frames: u32, } #[derive(Debug, Clone)] enum Message { Boot, WindowReady(Option), Frame, Captured(window::Screenshot), /// 页面里的动作(骨架期只打印,接线后由 app 层处理)。 Page(super::Message), } impl Harness { fn update(&mut self, message: Message) -> Task { 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 { 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::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()); } }