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:
AskaEth committed 2026-11-01 23:27:28 +08:00
1 parent 352dd7937e
commit 2c442585b1
15 files changed
+2979

No files matched your search

+399
View File
@@ -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());
}
}