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;`。
This commit is contained in:
1 parent
352dd7937e
commit
2c442585b1
15 files changed
+2979
No files matched your search
@@ -0,0 +1,399 @@
|
||||
//! 截图 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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user