已落地:
- 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 并排与数值表。
401 lines
14 KiB
Rust
401 lines
14 KiB
Rust
//! Elm 三件套:`State` / `Message` / `update` + `view` 接线。
|
||
//!
|
||
//! 启动序列:①探测渲染后端并写 `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;
|
||
use std::time::Instant;
|
||
|
||
use iced::{window, window::Direction, Element, Subscription, Task};
|
||
|
||
use unlucky_design::color::SchemeFlavor;
|
||
|
||
use crate::cli::{Options, Screen};
|
||
use crate::fonts;
|
||
use crate::probe;
|
||
use crate::shell::{self, NavItem};
|
||
use crate::shot;
|
||
use crate::theme::{Families, Ui};
|
||
|
||
// 组件库(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 借用)。
|
||
pub const APP_TITLE: &str = "Unlucky Launcher";
|
||
|
||
/// 窗口最小尺寸(再小骨架会挤坏:88 导航 + 页面留白 + 卡片)。
|
||
const MIN_SIZE: iced::Size = iced::Size {
|
||
width: 880.0,
|
||
height: 560.0,
|
||
};
|
||
|
||
/// 截图落盘的帧号(见模块文档)。
|
||
const CAPTURE_AT_FRAME: u32 = 3;
|
||
|
||
/// 进程内「run() 开始」的时刻(首帧耗时基线)。
|
||
static STARTED: OnceLock<Instant> = OnceLock::new();
|
||
/// 首帧日志只打一次。
|
||
static FIRST_FRAME_LOGGED: AtomicBool = AtomicBool::new(false);
|
||
|
||
/// 应用状态(**只有 UI 状态**;业务状态等页面 mission 接 `host`/`state` 时再进)。
|
||
pub struct App {
|
||
options: Options,
|
||
families: Families,
|
||
ui: Ui,
|
||
nav: NavItem,
|
||
window: Option<window::Id>,
|
||
frames: u32,
|
||
}
|
||
|
||
/// 消息(单一大枚举;页面接进来时按页拆子枚举)。
|
||
#[derive(Debug, Clone)]
|
||
pub enum Message {
|
||
/// 启动即发一次(`iced::application` 的 boot 闭包)。
|
||
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 {
|
||
Self {
|
||
ui: Ui::new(flavor, families),
|
||
families,
|
||
options,
|
||
nav: NavItem::Instances,
|
||
window: None,
|
||
frames: 0,
|
||
}
|
||
}
|
||
|
||
fn has_screenshot(&self) -> bool {
|
||
self.options.screenshot.is_some()
|
||
}
|
||
|
||
fn update(&mut self, message: Message) -> Task<Message> {
|
||
match message {
|
||
Message::Boot => {
|
||
let mut tasks = vec![window::latest().map(Message::WindowReady)];
|
||
|
||
// 交互模式且没显式指定主题 → 探系统;截图模式永不等(见 cli 模块文档)
|
||
if self.options.wants_system_theme() {
|
||
tasks.push(iced::system::theme().map(Message::SystemTheme));
|
||
}
|
||
|
||
Task::batch(tasks)
|
||
}
|
||
Message::WindowReady(id) => {
|
||
self.window = id;
|
||
Task::none()
|
||
}
|
||
Message::SystemTheme(mode) => {
|
||
let flavor = match mode {
|
||
iced::theme::Mode::Dark => SchemeFlavor::Dark,
|
||
iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light,
|
||
};
|
||
eprintln!("[theme] 系统主题 = {mode:?}");
|
||
self.set_flavor(flavor);
|
||
Task::none()
|
||
}
|
||
Message::Frame => self.on_frame(),
|
||
Message::Select(nav) => {
|
||
self.nav = nav;
|
||
Task::none()
|
||
}
|
||
Message::DragWindow => match self.window {
|
||
Some(id) => window::drag(id),
|
||
None => Task::none(),
|
||
},
|
||
Message::ResizeStart(direction) => match self.window {
|
||
Some(id) => window::drag_resize(id, direction),
|
||
None => Task::none(),
|
||
},
|
||
Message::Minimize => match self.window {
|
||
Some(id) => window::minimize(id, true),
|
||
None => Task::none(),
|
||
},
|
||
Message::ToggleMaximize => match self.window {
|
||
Some(id) => window::toggle_maximize(id),
|
||
None => Task::none(),
|
||
},
|
||
Message::Close => iced::exit(),
|
||
Message::Captured(screenshot) => self.on_captured(&screenshot),
|
||
Message::Demo(message) => {
|
||
// gallery 只是 harness:按下只打日志,不接业务(组件库要能脱离业务验收)
|
||
eprintln!("[gallery] {} pressed", message.id());
|
||
Task::none()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 切主题:只重建 [`Ui`](= 重建 Style),**不动任何 UI 状态**。
|
||
fn set_flavor(&mut self, flavor: SchemeFlavor) {
|
||
if self.ui.flavor() == flavor {
|
||
return;
|
||
}
|
||
|
||
let next = Ui::new(flavor, self.families);
|
||
eprintln!(
|
||
"[theme] {} -> {} (Theme::name 变化触发缓存重算)",
|
||
self.ui.name(),
|
||
next.name()
|
||
);
|
||
self.ui = next;
|
||
}
|
||
|
||
fn on_frame(&mut self) -> Task<Message> {
|
||
self.frames += 1;
|
||
|
||
// 主题在启动时就已确定(截图模式必有主题,见 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(),
|
||
}
|
||
}
|
||
|
||
fn on_captured(&mut self, screenshot: &window::Screenshot) -> Task<Message> {
|
||
let Some(path) = self.options.screenshot.clone() else {
|
||
return Task::none();
|
||
};
|
||
match shot::save(screenshot, &path) {
|
||
Ok(summary) => {
|
||
eprintln!("{}", summary.describe());
|
||
eprintln!(
|
||
"[shot] screen={} theme={} renderer={}",
|
||
self.options.screen.name(),
|
||
shot::flavor_slug(&self.ui),
|
||
self.options.backend.note()
|
||
);
|
||
}
|
||
Err(error) => eprintln!("[shot] 失败:{error}"),
|
||
}
|
||
|
||
iced::exit()
|
||
}
|
||
|
||
/// 只有截图模式才订阅帧事件:否则「每帧一条消息 → 每帧重绘」会白烧 CPU。
|
||
fn subscription(&self) -> Subscription<Message> {
|
||
if self.wants_frames() {
|
||
window::frames().map(|_| Message::Frame)
|
||
} else {
|
||
Subscription::none()
|
||
}
|
||
}
|
||
|
||
/// 是否订阅帧事件(= 截图模式)。
|
||
fn wants_frames(&self) -> bool {
|
||
self.has_screenshot()
|
||
}
|
||
|
||
fn theme(&self) -> iced::Theme {
|
||
self.ui.theme()
|
||
}
|
||
|
||
fn title(&self) -> String {
|
||
APP_TITLE.to_owned()
|
||
}
|
||
|
||
fn view(&self) -> Element<'_, Message> {
|
||
if !FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) {
|
||
if let Some(started) = STARTED.get() {
|
||
eprintln!(
|
||
"[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)",
|
||
started.elapsed().as_secs_f64() * 1000.0
|
||
);
|
||
}
|
||
}
|
||
|
||
// `--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,
|
||
APP_TITLE,
|
||
self.nav.label(),
|
||
"Skeleton only: titlebar, navigation rail and one card. \
|
||
Business pages, the component library and the glass shader are follow-up missions.",
|
||
self.options.force_state,
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 启动 iced(阻塞到退出)。`--diff` 不走这里,见 `main.rs`。
|
||
pub(crate) fn run(options: Options) -> iced::Result {
|
||
let _ = STARTED.set(Instant::now());
|
||
|
||
let probe_started = Instant::now();
|
||
// 只有 `auto` 需要探测:`cpu` 与 `gpu` 的决策与「有没有硬件 GPU」无关,
|
||
// 而枚举 13 个适配器在本机要 0.65~1.2 s(实测),纯属白花。
|
||
let needs_probe = options.backend == crate::Backend::Auto;
|
||
let probe = if needs_probe {
|
||
probe::Probe::run()
|
||
} else {
|
||
probe::Probe::default()
|
||
};
|
||
let probe_ms = probe_started.elapsed().as_secs_f64() * 1000.0;
|
||
probe::apply(options.backend, &probe);
|
||
if cfg!(feature = "gpu") {
|
||
if needs_probe {
|
||
eprintln!("[probe] 探测耗时 {probe_ms:.0} ms(wgpu 实例 + 全后端适配器枚举)");
|
||
} else {
|
||
eprintln!(
|
||
"[probe] 探测耗时 {probe_ms:.0} ms(--renderer={} 与硬件无关,跳过枚举)",
|
||
options.backend.name()
|
||
);
|
||
}
|
||
}
|
||
|
||
let font_started = Instant::now();
|
||
let resolved = fonts::resolve();
|
||
eprintln!(
|
||
"[font] 求值耗时 {:.0} ms",
|
||
font_started.elapsed().as_secs_f64() * 1000.0
|
||
);
|
||
eprintln!("{}", resolved.describe());
|
||
if resolved.has_synthetic() {
|
||
eprintln!(
|
||
"[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换族)要处理"
|
||
);
|
||
}
|
||
let families = resolved.families();
|
||
|
||
// 截图模式:`--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(
|
||
move || {
|
||
(
|
||
App::new(boot_options.clone(), families, flavor),
|
||
Task::done(Message::Boot),
|
||
)
|
||
},
|
||
App::update,
|
||
App::view,
|
||
)
|
||
.title(App::title)
|
||
.theme(App::theme)
|
||
.subscription(App::subscription)
|
||
.default_font(iced::Font {
|
||
family: iced::font::Family::Name(families.text),
|
||
..iced::Font::DEFAULT
|
||
})
|
||
.window(window::Settings {
|
||
size: WINDOW_SIZE,
|
||
min_size: Some(MIN_SIZE),
|
||
decorations: false,
|
||
resizable: true,
|
||
..window::Settings::default()
|
||
});
|
||
|
||
match options.scale_factor {
|
||
Some(scale) => application.scale_factor(move |_| scale).run(),
|
||
None => application.run(),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
const FAMILIES: Families = Families {
|
||
text: "Segoe UI",
|
||
display: "Segoe UI",
|
||
mono: "Consolas",
|
||
};
|
||
|
||
#[test]
|
||
fn switching_flavor_rebuilds_styles_but_keeps_ui_state() {
|
||
let mut app = App::new(Options::default(), FAMILIES, SchemeFlavor::Light);
|
||
app.nav = NavItem::Logs;
|
||
assert_eq!(app.ui.name(), "ull-m3-light");
|
||
|
||
app.set_flavor(SchemeFlavor::Dark);
|
||
|
||
assert_eq!(app.ui.name(), "ull-m3-dark", "切主题要换主题名");
|
||
assert_eq!(app.nav, NavItem::Logs, "切主题不许重置 UI 状态");
|
||
assert!(app.options.screenshot.is_none(), "切主题不许改启动参数");
|
||
}
|
||
|
||
#[test]
|
||
fn window_size_is_the_acceptance_size() {
|
||
assert_eq!((WINDOW_SIZE.width, WINDOW_SIZE.height), (1180.0, 760.0));
|
||
}
|
||
|
||
#[test]
|
||
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]
|
||
fn frames_are_only_requested_in_screenshot_mode() {
|
||
let mut app = App::new(Options::default(), FAMILIES, SchemeFlavor::Light);
|
||
assert!(!app.wants_frames(), "普通运行不订阅帧,避免每帧重绘");
|
||
|
||
app.options.screenshot = Some(std::path::PathBuf::from("x.png"));
|
||
assert!(
|
||
app.wants_frames(),
|
||
"截图模式必须订阅帧事件才能在第 3 帧出图"
|
||
);
|
||
}
|
||
}
|