Files
Unlucky-Launcher/crates/app-iced/src/pages/harness.rs
T
AskaEth 2c442585b1 feat(app-iced): M84 页面骨架与布局系统(断点 / 行宽上限 / 三种版面)
视觉复审在 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;`。
2026-11-01 23:27:28 +08:00

400 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 截图 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());
}
}