WIP(app-iced): M75 组件基座 checkpoint —— 组件骨架/按钮五式/分隔线/gallery harness
已落地:
- components/{mod,interaction,layout,divider,gate}.rs + buttons/{mod,geometry,assemble,tests}.rs
- gallery/{mod,buttons,probe,scan}.rs + 四个家族桩(selection/inputs/feedback/navigation)
- cli.rs: --screen=shell|gallery + capture_flavor(截图默认 Light,删掉等系统主题的闸门)
- app.rs: gallery 路由 + DemoMessage 接线(400 行)
待办(下一个 commit):编译/测试全绿、浅深矩阵出图、1:1 并排与数值表。
This commit is contained in:
1 parent
22ddc74784
commit
4774e7b788
23 files changed
+3136
-133
No files matched your search
+67
-67
@@ -1,16 +1,8 @@
|
||||
//! Elm 三件套:`State` / `Message` / `update` + `view` 接线。
|
||||
//!
|
||||
//! ## 启动序列(顺序有讲究)
|
||||
//!
|
||||
//! 1. **探测渲染后端** → 写 `ICED_BACKEND`(必须在 `run()` 之前,M60 Q1);
|
||||
//! 2. **求值字体栈**(D28 前半)→ 一行启动日志;
|
||||
//! 3. `iced::application(..).run()`:窗口 1180×760、无边框(自绘标题栏);
|
||||
//! 4. 首个 `view()` 打「到首帧」耗时;截图模式下第 3 帧 `window::screenshot` 落盘后退出。
|
||||
//!
|
||||
//! ## 为什么截图要等 3 帧
|
||||
//!
|
||||
//! 无边框窗口启动时会先收到一次「显示器工作区尺寸」的 `Resized`,再回到设定尺寸(M60 坑 #5),
|
||||
//! 第一帧可能是旧尺寸;再叠上字体系统首次上屏的排版抖动,留 2 帧余量最稳。
|
||||
//! 启动序列:①探测渲染后端并写 `ICED_BACKEND`(必须在 `run()` 之前,M60 Q1)→ ②求值字体栈
|
||||
//! (D28 前半)→ ③`iced::application(..).run()`(1180×760、无边框自绘标题栏)→ ④首个 `view()`
|
||||
//! 打耗时;截图模式第 3 帧落盘后退出(无边框窗首帧可能是旧尺寸,M60 坑 #5)。
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
@@ -20,23 +12,28 @@ use iced::{window, window::Direction, Element, Subscription, Task};
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
|
||||
use crate::cli::Options;
|
||||
use crate::cli::{Options, Screen};
|
||||
use crate::fonts;
|
||||
use crate::probe;
|
||||
use crate::shell::{self, NavItem};
|
||||
use crate::shot;
|
||||
use crate::theme::{Families, Ui};
|
||||
|
||||
/// 验收窗口尺寸(1180×760,100% DPI —— D30 的硬要求)。
|
||||
// 组件库(D39)在本文件声明:`src/lib.rs` 是五条组件线共用的文件,改它必然冲突;
|
||||
// `#[path]` 保证物理路径仍是 `src/components/`(组件目录自己的归属规则不变)。
|
||||
#[path = "components/mod.rs"]
|
||||
mod components;
|
||||
|
||||
/// 验收窗口尺寸(1180×760 @100% DPI —— D30 的硬要求)。
|
||||
pub const WINDOW_SIZE: iced::Size = iced::Size {
|
||||
width: 1180.0,
|
||||
height: 760.0,
|
||||
};
|
||||
|
||||
/// 产品名(自绘标题栏的标题;也是 `'static`,好让 view 借用)。
|
||||
/// 产品名(自绘标题栏的标题;`'static` 好让 view 借用)。
|
||||
pub const APP_TITLE: &str = "Unlucky Launcher";
|
||||
|
||||
/// 窗口最小尺寸(低于这个值骨架会挤坏:88 导航 + 页面留白 + 卡片)。
|
||||
/// 窗口最小尺寸(再小骨架会挤坏:88 导航 + 页面留白 + 卡片)。
|
||||
const MIN_SIZE: iced::Size = iced::Size {
|
||||
width: 880.0,
|
||||
height: 560.0,
|
||||
@@ -58,8 +55,6 @@ pub struct App {
|
||||
nav: NavItem,
|
||||
window: Option<window::Id>,
|
||||
frames: u32,
|
||||
/// 截图模式的「主题已定」闸门:`system::theme()` 是异步的,早拍会拍到错的深浅色。
|
||||
theme_resolved: bool,
|
||||
}
|
||||
|
||||
/// 消息(单一大枚举;页面接进来时按页拆子枚举)。
|
||||
@@ -69,29 +64,27 @@ pub enum Message {
|
||||
Boot,
|
||||
/// `window::latest()` 的结果。
|
||||
WindowReady(Option<window::Id>),
|
||||
/// 系统主题探测结果。
|
||||
/// 系统主题探测结果(只可能在**交互模式**出现,截图模式必有主题)。
|
||||
SystemTheme(iced::theme::Mode),
|
||||
/// 一帧画完(只有截图模式才订阅)。
|
||||
Frame,
|
||||
/// 导航项切换。
|
||||
Select(NavItem),
|
||||
/// 按下标题栏 → 拖窗。
|
||||
/// 按下标题栏 → 拖窗 / 边缘热区 → 缩放窗口。
|
||||
DragWindow,
|
||||
/// 按下边缘热区 → 缩放窗口。
|
||||
ResizeStart(Direction),
|
||||
/// 最小化。
|
||||
/// 最小化 / 最大化还原 / 关闭(三个 caption 按钮)。
|
||||
Minimize,
|
||||
/// 最大化/还原。
|
||||
ToggleMaximize,
|
||||
/// 关闭。
|
||||
Close,
|
||||
/// 截图回来了。
|
||||
Captured(window::Screenshot),
|
||||
/// gallery(组件库 harness)里的演示交互。
|
||||
Demo(components::gallery::DemoMessage),
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new(options: Options, families: Families, flavor: SchemeFlavor) -> Self {
|
||||
let options_theme_given = options.shot_flavor().is_some();
|
||||
Self {
|
||||
ui: Ui::new(flavor, families),
|
||||
families,
|
||||
@@ -99,8 +92,6 @@ impl App {
|
||||
nav: NavItem::Instances,
|
||||
window: None,
|
||||
frames: 0,
|
||||
// 显式给了主题就算已定;否则等 `SystemTheme` 到达
|
||||
theme_resolved: options_theme_given,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,8 +104,8 @@ impl App {
|
||||
Message::Boot => {
|
||||
let mut tasks = vec![window::latest().map(Message::WindowReady)];
|
||||
|
||||
// 主题:参数里没指定就探系统(深浅两套都要能出图)
|
||||
if self.options.shot_flavor().is_none() {
|
||||
// 交互模式且没显式指定主题 → 探系统;截图模式永不等(见 cli 模块文档)
|
||||
if self.options.wants_system_theme() {
|
||||
tasks.push(iced::system::theme().map(Message::SystemTheme));
|
||||
}
|
||||
|
||||
@@ -129,9 +120,8 @@ impl App {
|
||||
iced::theme::Mode::Dark => SchemeFlavor::Dark,
|
||||
iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light,
|
||||
};
|
||||
eprintln!("[theme] 系统主题 = {mode:?} -> {}", slug(flavor));
|
||||
eprintln!("[theme] 系统主题 = {mode:?}");
|
||||
self.set_flavor(flavor);
|
||||
self.theme_resolved = true;
|
||||
Task::none()
|
||||
}
|
||||
Message::Frame => self.on_frame(),
|
||||
@@ -157,6 +147,11 @@ impl App {
|
||||
},
|
||||
Message::Close => iced::exit(),
|
||||
Message::Captured(screenshot) => self.on_captured(&screenshot),
|
||||
Message::Demo(message) => {
|
||||
// gallery 只是 harness:按下只打日志,不接业务(组件库要能脱离业务验收)
|
||||
eprintln!("[gallery] {} pressed", message.id());
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,14 +169,14 @@ impl App {
|
||||
);
|
||||
self.ui = next;
|
||||
}
|
||||
|
||||
fn on_frame(&mut self) -> Task<Message> {
|
||||
self.frames += 1;
|
||||
|
||||
// 主题没定就不出图:等 `SystemTheme`(否则暗色系统上会拍到浅色图)
|
||||
if !self.has_screenshot() || !self.theme_resolved || self.frames < CAPTURE_AT_FRAME {
|
||||
// 主题在启动时就已确定(截图模式必有主题,见 cli 模块文档),这里只剩帧数闸门
|
||||
if !self.has_screenshot() || self.frames < CAPTURE_AT_FRAME {
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
match self.window {
|
||||
Some(id) => window::screenshot(id).map(Message::Captured),
|
||||
None => Task::none(),
|
||||
@@ -192,14 +187,14 @@ impl App {
|
||||
let Some(path) = self.options.screenshot.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
match shot::save(screenshot, &path) {
|
||||
Ok(summary) => {
|
||||
eprintln!("{}", summary.describe());
|
||||
eprintln!(
|
||||
"[shot] theme={} renderer={}",
|
||||
"[shot] screen={} theme={} renderer={}",
|
||||
self.options.screen.name(),
|
||||
shot::flavor_slug(&self.ui),
|
||||
renderer_note(self.options.backend)
|
||||
self.options.backend.note()
|
||||
);
|
||||
}
|
||||
Err(error) => eprintln!("[shot] 失败:{error}"),
|
||||
@@ -208,8 +203,8 @@ impl App {
|
||||
iced::exit()
|
||||
}
|
||||
|
||||
/// 只有截图模式才订阅帧事件:否则「每帧一条消息 → 每帧重绘」会白烧 CPU。
|
||||
fn subscription(&self) -> Subscription<Message> {
|
||||
// 只有截图模式才订阅帧事件:否则「每帧一条消息 → 每帧重绘」会白烧 CPU。
|
||||
if self.wants_frames() {
|
||||
window::frames().map(|_| Message::Frame)
|
||||
} else {
|
||||
@@ -240,6 +235,12 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
// `--screen=gallery`:组件库 harness(D39 §5)。它用自己的 DemoMessage,
|
||||
// 这里只做一次 map,组件库与业务 Message 依然解耦。
|
||||
if self.options.screen == Screen::Gallery {
|
||||
return components::gallery::view(&self.ui).map(Message::Demo);
|
||||
}
|
||||
|
||||
shell::view(
|
||||
&self.ui,
|
||||
self.nav,
|
||||
@@ -252,27 +253,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn renderer_note(backend: crate::Backend) -> &'static str {
|
||||
if cfg!(feature = "gpu") {
|
||||
match backend {
|
||||
crate::Backend::Cpu => "cpu(forced)",
|
||||
crate::Backend::Gpu => "gpu(preferred)",
|
||||
crate::Backend::Auto => "auto",
|
||||
}
|
||||
} else {
|
||||
"cpu(build without wgpu)"
|
||||
}
|
||||
}
|
||||
|
||||
/// 主题名(日志用,和 `Ui::name()` 同一口径)。
|
||||
fn slug(flavor: SchemeFlavor) -> &'static str {
|
||||
if flavor.is_dark() {
|
||||
"ull-m3-dark"
|
||||
} else {
|
||||
"ull-m3-light"
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动 iced(阻塞到退出)。`--diff` 不走这里,见 `main.rs`。
|
||||
pub(crate) fn run(options: Options) -> iced::Result {
|
||||
let _ = STARTED.set(Instant::now());
|
||||
@@ -313,8 +293,12 @@ pub(crate) fn run(options: Options) -> iced::Result {
|
||||
}
|
||||
let families = resolved.families();
|
||||
|
||||
// 截图模式:`--screenshot-theme` > `--theme` > 浅色(后续系统探测会覆盖)
|
||||
let flavor = options.shot_flavor().unwrap_or(SchemeFlavor::Light);
|
||||
// 截图模式:`--screenshot-theme` > `--theme` > **Light**(唯一取值口在 `Options::capture_flavor`)。
|
||||
// 这里**不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。
|
||||
if options.screenshot.is_some() && options.shot_flavor().is_none() {
|
||||
eprintln!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)");
|
||||
}
|
||||
let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light);
|
||||
let boot_options = options.clone();
|
||||
|
||||
let application = iced::application(
|
||||
@@ -374,16 +358,32 @@ mod tests {
|
||||
#[test]
|
||||
fn window_size_is_the_acceptance_size() {
|
||||
assert_eq!((WINDOW_SIZE.width, WINDOW_SIZE.height), (1180.0, 760.0));
|
||||
const {
|
||||
assert!(MIN_SIZE.width < WINDOW_SIZE.width);
|
||||
assert!(MIN_SIZE.height < WINDOW_SIZE.height);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_matches_the_theme_name() {
|
||||
assert_eq!(slug(SchemeFlavor::Light), "ull-m3-light");
|
||||
assert_eq!(slug(SchemeFlavor::Dark), "ull-m3-dark");
|
||||
fn screenshot_mode_never_waits_for_the_system_theme() {
|
||||
// 门禁依赖:CI 的 runtime-smoke 不带主题,xvfb 里也没有主题会话。
|
||||
let options = Options {
|
||||
screenshot: Some(std::path::PathBuf::from("x.png")),
|
||||
..Options::default()
|
||||
};
|
||||
assert!(!options.wants_system_theme(), "截图模式不许等系统主题");
|
||||
assert_eq!(options.capture_flavor(), Some(SchemeFlavor::Light));
|
||||
|
||||
let app = App::new(options, FAMILIES, SchemeFlavor::Light);
|
||||
assert!(app.has_screenshot() && app.ui.name() == "ull-m3-light");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gallery_screen_builds_a_tree() {
|
||||
assert_eq!(Options::default().screen, Screen::Shell, "默认仍是产品骨架");
|
||||
let options = Options {
|
||||
screen: Screen::Gallery,
|
||||
..Options::default()
|
||||
};
|
||||
let app = App::new(options, FAMILIES, SchemeFlavor::Light);
|
||||
// 真实像素由 `--screen=gallery --screenshot` 出图 + `gallery::probe` 量
|
||||
let _ = app.view();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user