//! 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 = 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, frames: u32, } /// 消息(单一大枚举;页面接进来时按页拆子枚举)。 #[derive(Debug, Clone)] pub enum Message { /// 启动即发一次(`iced::application` 的 boot 闭包)。 Boot, /// `window::latest()` 的结果。 WindowReady(Option), /// 系统主题探测结果(只可能在**交互模式**出现,截图模式必有主题)。 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 { 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 { 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 { 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 { 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 帧出图" ); } }