//! 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 帧余量最稳。 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; use crate::fonts; use crate::probe; use crate::shell::{self, NavItem}; use crate::shot; use crate::theme::{Families, Ui}; /// 验收窗口尺寸(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, /// 截图模式的「主题已定」闸门:`system::theme()` 是异步的,早拍会拍到错的深浅色。 theme_resolved: bool, } /// 消息(单一大枚举;页面接进来时按页拆子枚举)。 #[derive(Debug, Clone)] pub enum Message { /// 启动即发一次(`iced::application` 的 boot 闭包)。 Boot, /// `window::latest()` 的结果。 WindowReady(Option), /// 系统主题探测结果。 SystemTheme(iced::theme::Mode), /// 一帧画完(只有截图模式才订阅)。 Frame, /// 导航项切换。 Select(NavItem), /// 按下标题栏 → 拖窗。 DragWindow, /// 按下边缘热区 → 缩放窗口。 ResizeStart(Direction), /// 最小化。 Minimize, /// 最大化/还原。 ToggleMaximize, /// 关闭。 Close, /// 截图回来了。 Captured(window::Screenshot), } 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, options, nav: NavItem::Instances, window: None, frames: 0, // 显式给了主题就算已定;否则等 `SystemTheme` 到达 theme_resolved: options_theme_given, } } 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)]; // 主题:参数里没指定就探系统(深浅两套都要能出图) if self.options.shot_flavor().is_none() { 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:?} -> {}", slug(flavor)); self.set_flavor(flavor); self.theme_resolved = true; 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), } } /// 切主题:只重建 [`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; // 主题没定就不出图:等 `SystemTheme`(否则暗色系统上会拍到浅色图) if !self.has_screenshot() || !self.theme_resolved || 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] theme={} renderer={}", shot::flavor_slug(&self.ui), renderer_note(self.options.backend) ); } Err(error) => eprintln!("[shot] 失败:{error}"), } iced::exit() } fn subscription(&self) -> Subscription { // 只有截图模式才订阅帧事件:否则「每帧一条消息 → 每帧重绘」会白烧 CPU。 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 ); } } 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, ) } } 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()); 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` > 浅色(后续系统探测会覆盖) let flavor = options.shot_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)); 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"); } #[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 帧出图" ); } }