diff --git a/crates/app-iced/src/app.rs b/crates/app-iced/src/app.rs deleted file mode 100644 index 26f2ea6..0000000 --- a/crates/app-iced/src/app.rs +++ /dev/null @@ -1,400 +0,0 @@ -//! 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 帧出图" - ); - } -} diff --git a/crates/app-iced/src/app/boot.rs b/crates/app-iced/src/app/boot.rs new file mode 100644 index 0000000..164a4d6 --- /dev/null +++ b/crates/app-iced/src/app/boot.rs @@ -0,0 +1,105 @@ +//! 启动:后端探测 → 字体求值 → `iced::application(..).run()`。 + +use std::time::Instant; + +use iced::{window, Task}; + +use unlucky_design::color::SchemeFlavor; + +use crate::cli::Options; +use crate::fonts; +use crate::probe; +use crate::theme::Families; + +use super::message::Message; +use super::state::{App, STARTED}; +use super::{MIN_SIZE, WINDOW_SIZE}; + +/// 启动 iced(阻塞到退出)。`--diff` 不走这里,见 `main.rs`。 +pub(crate) fn run(options: Options) -> iced::Result { + let _ = STARTED.set(Instant::now()); + + boot_probe(&options); + + 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(default_font(families)) + .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(), + } +} + +/// 渲染后端探测 + 写 `ICED_BACKEND`。 +fn boot_probe(options: &Options) { + 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() + ); + } + } +} + +/// 默认字体(正文档解析出来的族)。 +fn default_font(families: Families) -> iced::Font { + iced::Font { + family: iced::font::Family::Name(families.text), + ..iced::Font::DEFAULT + } +} diff --git a/crates/app-iced/src/app/message.rs b/crates/app-iced/src/app/message.rs new file mode 100644 index 0000000..24a885b --- /dev/null +++ b/crates/app-iced/src/app/message.rs @@ -0,0 +1,71 @@ +//! 应用消息:单一大枚举。 +//! +//! 纪律:本枚举是**唯一**的消息类型,`view` 产出的所有回调都必须落到这里;组件库与页面层 +//! 各自用自己的 `Message`(组件契约 §2 的泛型纪律),由 `view` 做一次 `.map` 接上。 +//! +//! ## 三类输入(M90) +//! +//! | 来源 | 变体 | 落点 | +//! |---|---|---| +//! | 窗口/框架 | `Boot` / `WindowReady` / `Window` / `Frame` / `Captured` / 三个 caption | [`super::update`] 直接处理 | +//! | **时间** | `Frame` + 33 ms 时间闸门 | presenter 的 TICK 节拍:`backend.tick` + `drain_events` + `apply` | +//! | **页面** | `Page(pages::Message)` | 导航 → `AppState::navigate`;启动/停止 → `AppCommand` | + +use iced::{window, window::Direction}; + +use unlucky_presenter::router::Route; + +use crate::pages; +use crate::shell::NavItem; + +use super::components; + +/// 消息(单一大枚举)。 +#[derive(Debug, Clone)] +pub enum Message { + /// 启动即发一次(`iced::application` 的 boot 闭包)。 + Boot, + /// `window::latest()` 的结果。 + WindowReady(Option), + /// 系统主题探测结果(只可能在**交互模式**出现,截图模式必有主题)。 + SystemTheme(iced::theme::Mode), + /// 一帧画完(只有截图模式才订阅)。 + Frame, + /// 窗口事件(M90:`Resized` 决定布局档位,600 / 840 两条断点靠它生效)。 + Window(window::Event), + /// 页面消息(导航 / 启动 / 停止 / 占位动作)。 + Page(pages::Message), + /// 外壳导航轨点选(`src/shell/nav.rs` 的回调仍然发这一条;收到就翻成同一个路由跳转)。 + /// + /// M90 起产品界面用 `pages` 里的真 `NavigationRail`,这条变体是给**外壳回退路径** + /// (`Route::Agent`)留的——`shell` 不在本 mission 的 scope 里,不能删它的消息。 + Select(NavItem), + /// 按下标题栏 → 拖窗。 + DragWindow, + /// 边缘热区:开始缩放(方向由坐标决定)。 + ResizeStart(Direction), + /// 最小化。 + Minimize, + /// 最大化/还原。 + ToggleMaximize, + /// 关窗退出。 + Close, + /// 截图回来了。 + Captured(window::Screenshot), + /// gallery(组件库 harness)里的演示交互。 + Demo(components::gallery::DemoMessage), +} + +/// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。 +/// +/// 这是 `NavItem → Route` 的**唯一**转换点:外壳(`src/shell/nav.rs`)与页面导航轨 +/// (`pages::NAV`)最终都落到同一个 `Route`,所以两条入口不会各走一套页面语义。 +pub(super) fn route_of(nav: NavItem) -> Route { + match nav { + NavItem::Instances => Route::Instances, + NavItem::Download => Route::Download, + NavItem::Logs => Route::Logs, + NavItem::Settings => Route::Settings, + NavItem::Ai => Route::Agent, + } +} diff --git a/crates/app-iced/src/app/mod.rs b/crates/app-iced/src/app/mod.rs new file mode 100644 index 0000000..9a2a80f --- /dev/null +++ b/crates/app-iced/src/app/mod.rs @@ -0,0 +1,68 @@ +//! Elm 三件套:`State` / `Message` / `update` + `view` 接线,以及启动序列。 +//! +//! 启动序列:①探测渲染后端并写 `ICED_BACKEND`(必须在 `run()` 之前,M60 Q1)→ ②求值字体栈 +//! (D28 前半)→ ③`iced::application(..).run()`(1180×760、无边框自绘标题栏)→ ④首个 `view()` +//! 打耗时;截图模式第 3 帧落盘后退出(无边框窗首帧可能是旧尺寸,M60 坑 #5)。 +//! +//! ## 为什么拆成目录(M90) +//! +//! 本文件原来是**单个 400 行的 `app.rs`**:正好卡在「单文件 ≤400 行、动它前必须先拆」的硬门槛上 +//! (AGENTS 的文件上限 + 本 mission 的第一条任务)。拆法与职责: +//! +//! | 文件 | 职责(**只做这一件事**) | +//! |---|---| +//! | [`state`] | [`App`] 字段 + 构造(presenter 三件在此持有)+ 主题切换 | +//! | [`message`] | [`Message`] 枚举与「导航项 → 路由」的翻译 | +//! | [`update`] | 全部 `Message` 分支 + 「用户动作 → `AppCommand`」的派发与事件回流 | +//! | [`subscription`] | 帧事件(截图)与 presenter 的 TICK 节拍 | +//! | [`view`] | `view` / `theme` / `title` | +//! | [`boot`] | [`run`]:后端探测 → 字体 → `iced::application(..)` | +//! +//! 拆分的每一步都保持**可观察行为不变**(截图四件套 `--screenshot` / `--screenshot-theme` / +//! `--screen=gallery` / `--diff` 出图逐字节相同),presenter 接线是在拆分**之后**另一步做的。 +//! +//! ## 组件库为什么还在本模块下声明 +//! +//! `src/lib.rs` 是五条组件线共用的文件,改它必然冲突;`#[path]` 保证物理路径仍是 +//! `src/components/`(组件目录自己的归属规则不变),逻辑路径仍是 `crate::app::components` +//! (组件源码里的文档与测试都按这个路径写)。 + +mod boot; +mod message; +mod state; +mod subscription; +mod update; +mod view; + +#[cfg(test)] +mod tests; + +pub(crate) use boot::run; +pub use message::Message; + +// 组件库(D39)在本文件声明:`src/lib.rs` 是五条组件线共用的文件,改它必然冲突; +// `#[path]` 保证物理路径仍是 `src/components/`(组件目录自己的归属规则不变)。 +// +// M90:可见性从 `mod` 放到 `pub(crate) mod` —— 页面层(`src/pages/**`)要**用**真组件 +// (`components::buttons` / `navigation` / `divider`),而 `app` 不是它的父模块。 +// 只放开 crate 内可见,组件目录的归属规则一字不改。 +#[path = "../components/mod.rs"] +pub(crate) 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; diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs new file mode 100644 index 0000000..6093b5f --- /dev/null +++ b/crates/app-iced/src/app/state.rs @@ -0,0 +1,189 @@ +//! 应用状态:**只放字段与构造**,不放消息分支(那是 [`super::update`] 的事)。 +//! +//! ## presenter 三件(`crates/presenter/src/lib.rs` crate 文档 §4) +//! +//! | 字段 | 谁给 | 干什么 | +//! |---|---|---| +//! | `backend: Box` | [`build_backend`](unlucky_presenter::backend::build_backend) | 派发 [`AppCommand`](unlucky_presenter::dto::AppCommand)、收 [`AppEvent`](unlucky_presenter::dto::AppEvent) | +//! | `bridge: SettingsBridge` | [`SettingsBridge::open`](unlucky_presenter::settings_bridge::SettingsBridge::open) | 读一次外观/主题,之后每 N 拍问「文件变了吗」 | +//! | `state: AppState` | [`AppState::new`](unlucky_presenter::state::AppState::new) | **唯一**的状态写入口(`apply` / `navigate`) | +//! +//! `view: pages::Model` 是**展示转换的缓存**:presenter 快照 → 页面要画的东西,每拍重建一次 +//! (presenter 文档的原话:「`self.view = ViewModel::from(&self.state)`」)。缓存成字段是为了让 +//! `view(&self)` 能只借 `&self`——iced 的 `view` 签名不允许返回一棵借用局部变量的树。 + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; +use std::time::Instant; + +use iced::window; + +use unlucky_design::color::SchemeFlavor; +use unlucky_presenter::backend::{build_backend, AppOptions}; +use unlucky_presenter::dto::Backend; +use unlucky_presenter::prefs::ThemeMode; +use unlucky_presenter::settings_bridge::SettingsBridge; +use unlucky_presenter::state::AppState; + +use crate::cli::Options; +use crate::pages; +use crate::theme::{Families, Ui}; + +use super::WINDOW_SIZE; + +/// presenter 的 TICK 节拍(33 ms ≈ 30 fps)。 +/// +/// 与 Slint 版的 `TICK_MS` 同值:`backend.tick(..)` 的时间推进、toast 过期、 +/// `theme::config_poll_due` 的计数都按这个节拍走,两个 UI 版的时间语义因此一致。 +pub(super) const TICK: std::time::Duration = std::time::Duration::from_millis(33); + +/// 进程内「run() 开始」的时刻(首帧耗时基线)。 +pub(super) static STARTED: OnceLock = OnceLock::new(); +/// 首帧日志只打一次。 +pub(super) static FIRST_FRAME_LOGGED: AtomicBool = AtomicBool::new(false); + +/// 应用状态:UI 状态 + presenter 三件 + 展示转换缓存。 +pub struct App { + /// 启动参数(截图/主题/画面开关)。 + pub(super) options: Options, + /// 字体族(切主题时重建 [`Ui`] 要用)。 + pub(super) families: Families, + /// 令牌求值器(浅/深两套)。 + pub(super) ui: Ui, + /// 后端通道(真 host 或 mock)。 + pub(super) backend: Box, + /// 设置持久化桥。 + pub(super) bridge: SettingsBridge, + /// presenter 的全局状态(唯一写入口是 `apply`)。 + pub(super) state: AppState, + /// 已推进的拍数(`theme::config_poll_due` 的输入)。 + pub(super) ticks: u64, + /// 上一次推进节拍的时刻(节拍由帧事件驱动,按真实流逝时间累加)。 + pub(super) last_tick: Instant, + /// 窗口宽度(布局档位的判据;交互模式跟着 resize 事件走)。 + pub(super) width: f32, + /// 展示转换缓存(presenter 快照 → 页面视图模型)。 + pub(super) view: pages::Model, + /// 窗口句柄(`window::latest()` 之后才有)。 + pub(super) window: Option, + /// 已画的帧数(截图闸门用)。 + pub(super) frames: u32, + /// 首屏快照到位后是否已经自动选中过第一行(list-detail 的桌面惯例,见 `update::auto_select`)。 + pub(super) auto_selected: bool, +} + +impl App { + /// 生产构造:按启动参数选后端、打开设置桥。 + /// + /// **截图通道强制 mock**:出图要可复现(D30),真 host 会按本机盘上的实例清单画面, + /// 同一份代码在两台机器上出不了同一张图。交互模式走真 host(数据根解析不出来时 + /// [`build_backend`] 自己退回 mock)。 + pub(super) fn new(options: Options, families: Families, flavor: SchemeFlavor) -> Self { + let backend = build_backend(&app_options(&options)); + let bridge = SettingsBridge::open(); + Self::with_presenter(options, families, flavor, backend, bridge) + } + + /// 注入式构造(单测用:后端与桥都由调用方给,**不碰磁盘**)。 + pub(super) fn with_presenter( + options: Options, + families: Families, + flavor: SchemeFlavor, + backend: Box, + bridge: SettingsBridge, + ) -> Self { + let width = WINDOW_SIZE.width; + let view = pages::Model::new( + pages::Screen::Instances, + width, + Some(pages::Screen::Instances.nav()), + ); + + Self { + ui: Ui::new(flavor, families), + families, + options, + backend, + bridge, + state: AppState::new(), + ticks: 0, + last_tick: Instant::now(), + width, + view, + window: None, + frames: 0, + auto_selected: false, + } + } + + /// 本次进程是否走截图通道。 + pub(super) fn has_screenshot(&self) -> bool { + self.options.screenshot.is_some() + } + + /// 切主题:只重建 [`Ui`](= 重建 Style),**不动任何 UI 状态**。 + pub(super) 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; + } + + /// 把 presenter 的快照转换成页面视图模型(**展示转换在 UI 侧做**)。 + pub(super) fn refresh_view(&mut self) { + self.view = super::view::model(&self.state, self.width); + } + + /// 按设置文件里的主题模式切一次外观;`--theme` 显式给了就不动。 + /// + /// **截图通道直接跳过**:`--screenshot` 的主题唯一取值口是 `Options::capture_flavor` + /// (D30 可复现),读本机 `launcher.json` 会让同一份代码在两台机器上出不同的图。 + /// + /// `ThemeMode::Auto` 不在这里处理:那要看系统主题(`iced::system::theme()` 的结果在 + /// `Message::SystemTheme` 里落地)。 + pub(super) fn apply_config_theme(&mut self) { + if self.has_screenshot() || self.options.theme.is_some() { + return; + } + match self.bridge.load().1 { + ThemeMode::Light => self.set_flavor(SchemeFlavor::Light), + ThemeMode::Dark => self.set_flavor(SchemeFlavor::Dark), + ThemeMode::Auto => {} + } + } + + /// 当前令牌求值器(测试与截图日志用)。 + pub(super) fn ui(&self) -> &Ui { + &self.ui + } +} + +/// 启动参数 → presenter 的后端选项。 +fn app_options(options: &Options) -> AppOptions { + AppOptions { + // `--data-dir`:M90 起**真的消费**它(此前是「接受并忽略」的翻牌兼容参数)。 + // 截图通道强制 mock,所以 CI 冒烟那条 `--data-dir ` 不会往那个目录写东西。 + data_root: options.data_dir.clone(), + mock: options.screenshot.is_some(), + } +} + +/// 首帧耗时日志(只在第一次 `view()` 打一行)。 +pub(super) fn log_first_frame() { + if FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) { + return; + } + if let Some(started) = STARTED.get() { + eprintln!( + "[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)", + started.elapsed().as_secs_f64() * 1000.0 + ); + } +} diff --git a/crates/app-iced/src/app/subscription.rs b/crates/app-iced/src/app/subscription.rs new file mode 100644 index 0000000..aa67f39 --- /dev/null +++ b/crates/app-iced/src/app/subscription.rs @@ -0,0 +1,38 @@ +//! 订阅:帧事件(截图闸门 + presenter 的 TICK 节拍)与窗口尺寸事件。 +//! +//! ## 为什么节拍挂在帧事件上,而不是 `iced::time::every` +//! +//! `iced::time::every` 只有 `tokio` / `smol` 两个异步后端提供;本 crate 关掉了 iced 的默认 features, +//! 执行器是 `thread-pool`(M60 踩坑 Q2),拿不到那个函数。想用它就得改 `Cargo.toml` 开一个异步 +//! 运行时(不在本 mission 的 scope 内,已报 finding)。 +//! +//! 所以节拍走**帧事件 + 时间闸门**:`window::frames()` 每帧给一条 `Frame`,app 侧按真实流逝时间 +//! 判断「够不够 33 ms」,够了才推进一拍 presenter(`backend.tick(elapsed)` / `drain_events`)。 +//! 好处是**按真实时间推进**(不是「数了多少帧 × 33 ms」,掉帧也不会把游戏启动进度拖慢), +//! 代价是交互模式下窗口会持续重绘(30 帧的活干在 vsync 的节拍上)。 +//! +//! 截图模式下这条订阅原本就在(第 3 帧落盘),合并之后两条路径共用同一个计时器。 + +use iced::{window, Subscription}; + +use super::message::Message; +use super::state::App; + +impl App { + /// 本次进程的订阅表。 + pub(super) fn subscription(&self) -> Subscription { + let mut subscriptions = vec![window::frames().map(|_| Message::Frame)]; + + // 断点 600 / 840 要跟着真窗口宽度走;截图模式的窗口尺寸由参数钉死,不吃 resize。 + if !self.has_screenshot() { + subscriptions.push(window::events().map(|(_, event)| Message::Window(event))); + } + + Subscription::batch(subscriptions) + } + + /// 是否订阅帧事件(= 截图模式要帧数闸门)。 + pub(super) fn wants_frames(&self) -> bool { + self.has_screenshot() + } +} diff --git a/crates/app-iced/src/app/tests.rs b/crates/app-iced/src/app/tests.rs new file mode 100644 index 0000000..c84414b --- /dev/null +++ b/crates/app-iced/src/app/tests.rs @@ -0,0 +1,263 @@ +//! `app` 的单测:主题切换、窗口尺寸、截图闸门、gallery 能构图,以及**presenter 接线的闭环**。 +//! +//! 闭环测试用的是 `MockBackend`(presenter 自带的假后端):它走的是与真 host 完全相同的 +//! `dispatch → drain_events → AppState::apply → 视图模型` 通道,所以「点 Play → 状态真的变」 +//! 这件事在这里就能判死,不必等图形环境。 + +use unlucky_design::color::SchemeFlavor; +use unlucky_presenter::mock::MockBackend; +use unlucky_presenter::router::Route; +use unlucky_presenter::settings_bridge::SettingsBridge; + +use super::message::Message; +use super::state::App; +use super::{APP_TITLE, WINDOW_SIZE}; +use crate::cli::{Options, Screen}; +use crate::pages; +use crate::theme::Families; + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +/// 不碰磁盘的 App(mock 后端 + 内存设置桥)。 +fn headless(options: Options, flavor: SchemeFlavor) -> App { + App::with_presenter( + options, + FAMILIES, + flavor, + Box::new(MockBackend::new()), + SettingsBridge::memory_only(), + ) +} + +fn screenshot_options() -> Options { + Options { + screenshot: Some(std::path::PathBuf::from("x.png")), + ..Options::default() + } +} + +#[test] +fn switching_flavor_rebuilds_styles_but_keeps_ui_state() { + let mut app = headless(Options::default(), SchemeFlavor::Light); + app.state.navigate(Route::Logs); + app.refresh_view(); + assert_eq!(app.ui().name(), "ull-m3-light"); + + app.set_flavor(SchemeFlavor::Dark); + + assert_eq!(app.ui().name(), "ull-m3-dark", "切主题要换主题名"); + assert_eq!(app.state.route(), &Route::Logs, "切主题不许重置 UI 状态"); + assert_eq!(app.view.screen, pages::Screen::Logs, "视图模型跟着路由"); + 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)); + assert_eq!(APP_TITLE, "Unlucky Launcher"); +} + +#[test] +fn screenshot_mode_never_waits_for_the_system_theme() { + // 门禁依赖:CI 的 runtime-smoke 不带主题,xvfb 里也没有主题会话。 + let options = screenshot_options(); + assert!(!options.wants_system_theme(), "截图模式不许等系统主题"); + assert_eq!(options.capture_flavor(), Some(SchemeFlavor::Light)); + + let app = headless(options, 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 = headless(options, SchemeFlavor::Light); + // 真实像素由 `--screen=gallery --screenshot` 出图 + `gallery::probe` 量 + let _ = app.view(); +} + +#[test] +fn frames_are_only_requested_in_screenshot_mode() { + let mut app = headless(Options::default(), SchemeFlavor::Light); + assert!(!app.wants_frames(), "普通运行不订阅帧,避免每帧重绘"); + + app.options.screenshot = Some(std::path::PathBuf::from("x.png")); + assert!( + app.wants_frames(), + "截图模式必须订阅帧事件才能在第 3 帧出图" + ); +} + +// --------------------------------------------------------------- presenter 闭环 + +/// **首屏快照在 Boot 就到位**(截图在第 3 帧落盘,等 30 fps 的 tick 会拍进空页面)。 +#[test] +fn boot_drains_the_first_snapshot_before_the_first_frame() { + let mut app = headless(screenshot_options(), SchemeFlavor::Light); + assert!(app.view.instances.rows.is_empty(), "还没 Boot"); + + let _ = app.update(Message::Boot); + + assert_eq!( + app.view.instances.rows.len(), + 4, + "mock 的四个实例已进视图模型" + ); + assert_eq!(app.view.screen, pages::Screen::Instances); +} + +/// 首屏自动选中第一行(右栏不该开局空白),且**只做一次**:返回列表后再刷新不弹回详情。 +#[test] +fn the_first_snapshot_selects_the_first_instance_once() { + let mut app = headless(screenshot_options(), SchemeFlavor::Light); + let _ = app.update(Message::Boot); + assert_eq!( + app.state.route(), + &Route::Detail("survival".to_string()), + "首屏快照到位后自动选中第一行" + ); + + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances))); + assert_eq!(app.state.route(), &Route::Instances); + + // 再推进一拍(会 drain_events + auto_select):不该把他弹回详情页 + app.advance(super::state::TICK); + assert_eq!(app.state.route(), &Route::Instances, "自动选中只做一次"); +} + +/// **点 Play → 命令真的派发出去 → 事件回流 → 页面真的变**(不做乐观回显)。 +#[test] +fn launching_goes_through_the_backend_and_comes_back_as_events() { + let mut app = headless(screenshot_options(), SchemeFlavor::Light); + let _ = app.update(Message::Boot); + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( + "survival".to_string(), + )))); + + let detail = app.view.instances.detail.as_ref().expect("有选中项"); + assert!( + detail.can_launch && !detail.can_stop, + "空闲:可启动不可停止" + ); + + let _ = app.update(Message::Page(pages::Message::Launch( + "survival".to_string(), + ))); + + let detail = app.view.instances.detail.as_ref().expect("有选中项"); + assert!( + !detail.can_launch && detail.can_stop, + "派发 LaunchInstance 后后端发回了 Busy 事件:按钮组合必须已经翻转" + ); + assert_eq!( + detail.status.as_deref(), + Some("Downloading · 0%"), + "状态行来自事件里的进度,不是本地假造" + ); +} + +/// 时间推进真的把状态往前推(后端的脚本按 tick 的累计时长走)。 +#[test] +fn ticks_advance_the_backend_script() { + let mut app = headless(screenshot_options(), SchemeFlavor::Light); + let _ = app.update(Message::Boot); + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( + "survival".to_string(), + )))); + let _ = app.update(Message::Page(pages::Message::Launch( + "survival".to_string(), + ))); + + // 33 ms × 50 ≈ 1.65 s:mock 的脚本在 1.5 s 从 Download 切到 Verify + for _ in 0..50 { + app.advance(super::state::TICK); + } + + let detail = app.view.instances.detail.as_ref().expect("有选中项"); + let status = detail.status.as_deref().unwrap_or_default(); + assert!( + status.starts_with("Verifying") || status.starts_with("Starting"), + "50 拍之后应已过下载阶段,实际 {status:?}" + ); +} + +/// 停止也是真命令:`StopInstance` 让 mock 直接回一条 Idle 事件。 +#[test] +fn stopping_returns_to_idle_through_an_event() { + let mut app = headless(screenshot_options(), SchemeFlavor::Light); + let _ = app.update(Message::Boot); + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( + "survival".to_string(), + )))); + let _ = app.update(Message::Page(pages::Message::Launch( + "survival".to_string(), + ))); + let _ = app.update(Message::Page(pages::Message::Stop("survival".to_string()))); + + let detail = app.view.instances.detail.as_ref().expect("有选中项"); + assert!(detail.can_launch && !detail.can_stop, "停下来回到空闲"); + assert!(detail.status.is_none(), "空闲没有状态行"); +} + +/// 导航:一级页替换、详情压栈、返回出栈——页面换的是 `Route`,不是页面自己的选中项。 +#[test] +fn navigation_follows_the_presenter_router() { + let mut app = headless(screenshot_options(), SchemeFlavor::Light); + let _ = app.update(Message::Boot); + + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( + "survival".to_string(), + )))); + assert_eq!(app.view.screen, pages::Screen::Instances, "详情仍是实例页"); + assert!(app.state.router.can_go_back()); + + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Settings))); + assert_eq!(app.view.screen, pages::Screen::Settings); + assert_eq!( + app.view.active, + Some(pages::Screen::Settings.nav()), + "导航轨高亮跟着路由走" + ); + assert!( + !app.state.router.can_go_back(), + "一级页互相替换,不进栈(presenter::router 的语义)" + ); + + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances))); + assert_eq!(app.view.screen, pages::Screen::Instances); +} + +/// 视图模型跟着窗口宽度走(交互模式下 600 / 840 两条断点靠 resize 事件生效)。 +#[test] +fn resizing_the_window_rebuilds_the_view_model() { + let mut app = headless(Options::default(), SchemeFlavor::Light); + let _ = app.update(Message::Boot); + + let _ = app.update(Message::Window(iced::window::Event::Resized( + iced::Size::new(1180.0, 760.0), + ))); + assert!((app.view.window_width - 1180.0).abs() < f32::EPSILON); + + let _ = app.update(Message::Window(iced::window::Event::Resized( + iced::Size::new(700.0, 600.0), + ))); + assert!((app.view.window_width - 700.0).abs() < f32::EPSILON); + // 页面照旧能构图(窄窗退化到单列,不 panic) + let _ = app.view(); +} + +/// 截图模式不读设置文件里的主题(D30 可复现:两台机器出同一张图)。 +#[test] +fn screenshot_mode_ignores_the_settings_file_theme() { + let mut app = headless(screenshot_options(), SchemeFlavor::Light); + app.apply_config_theme(); + assert_eq!(app.ui().name(), "ull-m3-light", "截图模式的主题只认命令行"); +} diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs new file mode 100644 index 0000000..833c343 --- /dev/null +++ b/crates/app-iced/src/app/update.rs @@ -0,0 +1,245 @@ +//! `update`:消息分支的**唯一**落点(含「用户动作 → 后端指令」的派发与事件回流)。 +//! +//! ## 数据流的三个入口(presenter crate 文档 §2) +//! +//! ```text +//! 用户动作 ──dispatch(AppCommand)──► backend ──► drain_events() ──► AppState::apply ──► 视图模型 +//! ▲ ▲ +//! └── pages::Message(导航/启动/停止)──────────────────┘ Frame + 33 ms 时间闸门 +//! ``` +//! +//! 三条纪律: +//! +//! 1. **不做乐观回显**:点了「Play」不改本地状态,等 `InstanceRuntimeChanged` 事件回来才变 +//! (否则事件与本地状态打架时,磁贴与进度条会各说一套); +//! 2. **状态只有一个写入口**:`AppState::apply`(事件)与 `AppState::navigate`(路由); +//! 3. **每拍重建视图模型**:`refresh_view()` 是快照 → 页面的唯一转换点。 + +use std::time::{Duration, Instant}; + +use iced::{window, Task}; + +use unlucky_design::color::SchemeFlavor; +use unlucky_presenter::dto::AppCommand; +use unlucky_presenter::router::Route; +use unlucky_presenter::theme::config_poll_due; + +use crate::pages; + +use super::message::{route_of, Message}; +use super::state::{App, TICK}; +use super::CAPTURE_AT_FRAME; + +impl App { + /// 一条消息进来,改状态并返回后续任务。 + pub(super) fn update(&mut self, message: Message) -> Task { + match message { + Message::Boot => self.on_boot(), + 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::Window(event) => { + if let window::Event::Resized(size) = event { + self.width = size.width; + self.refresh_view(); + } + Task::none() + } + Message::Page(message) => { + self.on_page(message); + Task::none() + } + // 外壳导航轨(`src/shell/nav.rs`)发来的点选:翻成同一次路由跳转 + Message::Select(nav) => { + self.state.navigate(route_of(nav)); + self.refresh_view(); + 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() + } + } + } + + /// 启动:问窗口句柄 → 让后端发布首屏快照 → 决定主题。 + /// + /// **首屏快照在 `Boot` 就抽干**(不是等第一拍):截图通道在第 3 帧落盘,33 ms 的节拍 + /// 未必来得及到达,等节拍会把空页面拍进图里。 + fn on_boot(&mut self) -> Task { + let mut tasks = vec![window::latest().map(Message::WindowReady)]; + + // 主题优先级:显式 `--theme` / `--screenshot-theme` > 设置文件 > 系统。 + // 截图通道**不读设置文件**(D30 可复现:同一份代码在两台机器上要出同一张图)。 + self.apply_config_theme(); + + // 交互模式且没显式指定主题 → 探系统;截图模式永不等(见 cli 模块文档) + if self.options.wants_system_theme() { + tasks.push(iced::system::theme().map(Message::SystemTheme)); + } + + // 「重新扫描实例目录 + 全部页面快照」:真 host 在这里发首批事件,mock 在构造时已预置 + self.dispatch(AppCommand::RefreshAll); + self.auto_select(); + self.refresh_view(); + + Task::batch(tasks) + } + + /// 一帧画完:①够 33 ms 就推进一拍 presenter ②截图模式在第 [`CAPTURE_AT_FRAME`] 帧出图。 + /// + /// 时间闸门按**真实流逝时间**判(不是数帧):掉帧不会把游戏启动进度拖慢, + /// 也不会在慢机器上把 1.5 s 的下载阶段拉成 4 s。 + fn on_frame(&mut self) -> Task { + self.frames += 1; + + let now = Instant::now(); + let elapsed = now.saturating_duration_since(self.last_tick); + if elapsed >= TICK { + self.last_tick = now; + self.advance(elapsed); + } + + // 主题在启动时就已确定(截图模式必有主题,见 cli 模块文档),这里只剩帧数闸门 + if !self.wants_frames() || self.frames < CAPTURE_AT_FRAME { + return Task::none(); + } + match self.window { + Some(id) => window::screenshot(id).map(Message::Captured), + None => Task::none(), + } + } + + /// 推进一拍:后端消化时间 → 抽干事件 → 过期 toast → 偶发重读配置文件。 + /// + /// `elapsed` 是真实流逝时间(毫秒级抖动是正常的:`MockBackend` 的脚本按累计时长走)。 + pub(super) fn advance(&mut self, elapsed: Duration) { + self.backend.tick(elapsed); + self.pump(); + + self.state + .toasts + .expire(u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)); + self.ticks += 1; + + // 外部(AI / CLI)改了配置文件:主题跟着换,其余外观项等玻璃/背景 mission。 + // 截图通道不轮询(D30 可复现),所以整条判断短路在 `has_screenshot` 上。 + let reloaded = !self.has_screenshot() + && config_poll_due(self.ticks) + && self.bridge.reload_if_changed().is_some(); + if reloaded { + eprintln!("[settings] 配置文件变了,重读主题"); + self.apply_config_theme(); + } + + self.refresh_view(); + } + + /// 页面消息:导航走路由栈,动作翻成 `AppCommand`。 + fn on_page(&mut self, message: pages::Message) { + match message { + pages::Message::Navigate(route) => { + self.state.navigate(route); + self.refresh_view(); + } + pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)), + pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)), + // 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记) + pages::Message::Action(action) => { + eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)"); + } + } + } + + /// 派发一条指令并**立刻**抽干它带出来的事件(不本地回显,等价于 Slint 版的 `dispatch`)。 + fn dispatch(&mut self, command: AppCommand) { + self.backend.dispatch(command); + self.pump(); + } + + /// 抽干事件队列并逐条 `apply`(**唯一**的状态写入口)。 + pub(super) fn pump(&mut self) { + let events = self.backend.drain_events(); + if events.is_empty() { + return; + } + for event in events { + self.state.apply(event); + } + self.auto_select(); + self.refresh_view(); + } + + /// 首屏快照到位后**自动选中第一行**(桌面 list-detail 的惯例:右栏不该开局就是空的)。 + /// + /// 只做一次:用户点「返回」回到列表后,再刷新实例列表也不会把他弹回详情页。 + /// 选中这件事走的是 `AppState::navigate`(路由栈),不是本地选中字段——于是「当前选中」 + /// 永远等于「路由栈顶」,用户点返回时栈语义自然成立。 + fn auto_select(&mut self) { + if self.auto_selected || self.state.route() != &Route::Instances { + return; + } + let Some(first) = self + .state + .tiles() + .first() + .map(|cell| cell.instance.id.clone()) + else { + return; + }; + self.auto_selected = true; + self.state.navigate(Route::Detail(first)); + } + + /// 截图回来了:落盘 + 打一行取证日志,然后退出。 + fn on_captured(&mut self, screenshot: &window::Screenshot) -> Task { + let Some(path) = self.options.screenshot.clone() else { + return Task::none(); + }; + match crate::shot::save(screenshot, &path) { + Ok(summary) => { + eprintln!("{}", summary.describe()); + eprintln!( + "[shot] screen={} theme={} renderer={} route={:?}", + self.options.screen.name(), + crate::shot::flavor_slug(&self.ui), + self.options.backend.note(), + self.state.route() + ); + } + Err(error) => eprintln!("[shot] 失败:{error}"), + } + + iced::exit() + } +} diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs new file mode 100644 index 0000000..1a23618 --- /dev/null +++ b/crates/app-iced/src/app/view.rs @@ -0,0 +1,295 @@ +//! `view`:presenter 快照 → 页面视图模型 → 控件树。 +//! +//! 展示转换(快照字段 → 显示文本/网格)**全部在这里**,页面层只认 [`pages::Model`]。 + +use iced::{Element, Theme}; + +use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage, LoaderKind}; +use unlucky_presenter::router::Route; +use unlucky_presenter::state::AppState; + +use crate::cli::Screen; +use crate::pages::{self, Detail, Meta, Row}; +use crate::shell::{self, NavItem}; + +use super::message::Message; +use super::state::{log_first_frame, App}; +use super::APP_TITLE; + +impl App { + /// 主题名(iced 只在 `Theme::name()` 变化时重算受主题影响的缓存)。 + pub(super) fn theme(&self) -> Theme { + self.ui().theme() + } + + /// 窗口标题(自绘标题栏另画一份,这里是给系统/截图工具看的)。 + pub(super) fn title(&self) -> String { + APP_TITLE.to_owned() + } + + /// 整棵树。 + pub(super) fn view(&self) -> Element<'_, Message> { + log_first_frame(); + + // `--screen=gallery`:组件库 harness(D39 §5)。它用自己的 DemoMessage, + // 这里只做一次 map,组件库与业务 Message 依然解耦。 + if self.options.screen == Screen::Gallery { + return super::components::gallery::view(&self.ui).map(Message::Demo); + } + + match pages::Screen::of(self.state.route()) { + Some(_) => pages::app(&self.ui, &self.view).map(Message::Page), + // `Route::Agent`:AI 独立页还没有页面实现(P1),暂时回退到 M68 的外壳卡。 + // 这条回退也是 `src/shell/` 目前唯一的调用点——见报告里的「谁该补」。 + None => shell::view( + &self.ui, + NavItem::Ai, + APP_TITLE, + NavItem::Ai.label(), + "The assistant page is not built yet (P1). Chat lives in the docked panel once \ + the AI mission lands; this screen is the M68 skeleton fallback.", + self.options.force_state, + ), + } + } +} + +// --------------------------------------------------------------- 快照 → 视图模型 + +/// 把 presenter 的快照转成页面视图模型(**唯一**的展示转换点)。 +pub(super) fn model(state: &AppState, width: f32) -> pages::Model { + let route = state.route().clone(); + let screen = pages::Screen::of(&route).unwrap_or(pages::Screen::Instances); + + let rows: Vec = state + .tiles() + .into_iter() + .map(|cell| Row { + id: cell.instance.id.clone(), + name: cell.instance.name.clone(), + supporting: supporting_line(cell.instance), + status: runtime_status(&cell.instance.runtime), + }) + .collect(); + + let detail = match &route { + Route::Detail(id) => state + .instances + .iter() + .find(|instance| &instance.id == id) + .map(detail_of), + _ => None, + }; + + pages::Model { + screen, + window_width: width, + active: route.nav(), + instances: pages::Instances { rows, detail }, + } +} + +/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版」,见 `LoaderInfo::badge`)。 +fn supporting_line(instance: &InstanceSummary) -> String { + format!("{} · {}", instance.mc_version, instance.loader.badge()) +} + +/// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。 +fn runtime_status(runtime: &InstanceRuntime) -> Option { + match runtime { + InstanceRuntime::Idle => None, + InstanceRuntime::Busy { stage, progress } => Some(format!( + "{} · {:.0}%", + stage_label(*stage), + progress.ratio() * 100.0 + )), + InstanceRuntime::Running { pid } => Some(format!("Running · pid {pid}")), + InstanceRuntime::Failed { reason } => Some(format!("Launch failed · {reason}")), + } +} + +/// 阶段文案(英文基线;i18n mission 接手后统一走词表)。 +/// +/// 不复用 `LaunchStage::label()`:那是中文,而本 crate 的界面文案在 i18n 之前是英文基线 +/// (见 `pages/instances/data.rs` 的同一条理由:干净 CI 上没有中文字体)。 +fn stage_label(stage: LaunchStage) -> &'static str { + match stage { + LaunchStage::Download => "Downloading", + LaunchStage::Verify => "Verifying", + LaunchStage::Launch => "Starting", + LaunchStage::Window => "Waiting for window", + } +} + +/// 详情列的内容(字段全部来自 [`InstanceSummary`])。 +fn detail_of(instance: &InstanceSummary) -> Detail { + let loader = if instance.loader.kind == LoaderKind::Vanilla { + "Vanilla".to_string() + } else { + instance.loader.badge() + }; + + Detail { + id: instance.id.clone(), + name: instance.name.clone(), + subtitle: supporting_line(instance), + meta: vec![ + meta("VERSION", &instance.mc_version), + meta("LOADER", &loader), + meta("ISOLATION", &instance.isolation), + meta("VERSION ID", &instance.version_id), + meta( + "FILES", + if instance.version_present { + "Present on disk" + } else { + "Missing" + }, + ), + meta("LAST PLAYED", &last_played(instance.last_played.as_deref())), + ], + // 真快照没有「正文描述」这种字段(偏离 #9):演示数据才有正文段 + note: None, + status: runtime_status(&instance.runtime), + can_launch: matches!( + instance.runtime, + InstanceRuntime::Idle | InstanceRuntime::Failed { .. } + ), + can_stop: matches!( + instance.runtime, + InstanceRuntime::Busy { .. } | InstanceRuntime::Running { .. } + ), + } +} + +fn meta(label: &str, value: &str) -> Meta { + Meta { + label: label.to_string(), + value: value.to_string(), + } +} + +/// ISO-8601(`2026-10-29T19:22:00Z`)→ `2026-10-29 19:22 UTC`;`None` = 从未启动。 +/// +/// 只做切片不做时区换算:core 给的是 UTC,`Z` 就是它的时区;本地时区换算等 i18n/时间格式 mission。 +/// 非 ASCII 或长度不足时原样返回(不 panic、不猜)。 +pub(super) fn last_played(raw: Option<&str>) -> String { + let Some(text) = raw else { + return "Never".to_string(); + }; + match (text.get(0..10), text.get(11..16)) { + (Some(date), Some(time)) if date.starts_with(|c: char| c.is_ascii_digit()) => { + format!("{date} {time} UTC") + } + _ => text.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use unlucky_presenter::dto::{InstanceSummary, LoaderInfo, LoaderKind, Progress}; + use unlucky_presenter::mock_data::mock_instances; + + fn instance(runtime: InstanceRuntime) -> InstanceSummary { + InstanceSummary { + id: "survival".to_string(), + name: "Survival".to_string(), + mc_version: "1.20.4".to_string(), + loader: LoaderInfo { + kind: LoaderKind::Fabric, + version: "0.16.5".to_string(), + }, + last_played: Some("2026-10-29T19:22:00Z".to_string()), + runtime, + icon: None, + isolation: "isolated".to_string(), + version_id: "1.20.4".to_string(), + version_present: true, + } + } + + #[test] + fn the_snapshot_becomes_the_page_model() { + let mut state = AppState::new(); + state.apply(unlucky_presenter::dto::AppEvent::Core( + unlucky_presenter::dto::CoreEvent::InstancesChanged(mock_instances()), + )); + state.navigate(Route::Detail("survival".to_string())); + + let model = model(&state, 1180.0); + assert_eq!(model.screen, pages::Screen::Instances); + assert_eq!(model.active, Some(pages::Screen::Instances.nav())); + assert_eq!(model.window_width, 1180.0); + assert_eq!(model.instances.rows.len(), 4, "mock 的四个实例"); + assert_eq!(model.instances.rows[0].id, "survival"); + + let detail = model.instances.detail.expect("路由是 Detail,要有详情"); + assert_eq!(detail.id, "survival"); + assert_eq!(detail.meta.len(), 6); + assert!(detail.can_launch, "空闲实例可启动"); + assert!(!detail.can_stop); + assert!(detail.note.is_none(), "真快照没有正文段"); + } + + #[test] + fn a_busy_instance_offers_stop_instead_of_play() { + let mut state = AppState::new(); + state.apply(unlucky_presenter::dto::AppEvent::Core( + unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![instance( + InstanceRuntime::Busy { + stage: LaunchStage::Download, + progress: Progress { + done: 750, + total: 1_500, + }, + }, + )]), + )); + state.navigate(Route::Detail("survival".to_string())); + + let model = model(&state, 1180.0); + assert_eq!( + model.instances.rows[0].status.as_deref(), + Some("Downloading · 50%") + ); + let detail = model.instances.detail.expect("有详情"); + assert!(!detail.can_launch, "启动中不许再点 Play(不做乐观回显)"); + assert!(detail.can_stop, "启动中要能 Stop"); + } + + #[test] + fn failed_instances_can_be_retried() { + let failed = instance(InstanceRuntime::Failed { + reason: "exit 1".to_string(), + }); + assert_eq!( + runtime_status(&failed.runtime).as_deref(), + Some("Launch failed · exit 1") + ); + assert!(detail_of(&failed).can_launch, "失败后要能重试"); + } + + #[test] + fn timestamps_are_sliced_not_guessed() { + assert_eq!( + last_played(Some("2026-10-29T19:22:00Z")), + "2026-10-29 19:22 UTC" + ); + assert_eq!(last_played(None), "Never"); + // 短串 / 非 ASCII 原样返回(不 panic) + assert_eq!(last_played(Some("2026-10")), "2026-10"); + assert_eq!(last_played(Some("昨天")), "昨天"); + } + + #[test] + fn the_agent_route_has_no_page() { + let mut state = AppState::new(); + state.navigate(Route::Agent); + let model = model(&state, 1180.0); + // Agent 没有页面:模型退回实例页的空壳,view 会走外壳回退(`None` 分支) + assert_eq!(pages::Screen::of(state.route()), None); + assert!(model.instances.detail.is_none()); + assert_eq!(model.active, None, "AI 页不高亮任何导航项"); + } +} diff --git a/crates/app-iced/src/pages/frame.rs b/crates/app-iced/src/pages/frame.rs index fc912f4..2348d1f 100644 --- a/crates/app-iced/src/pages/frame.rs +++ b/crates/app-iced/src/pages/frame.rs @@ -23,7 +23,8 @@ mod tests; pub use pieces::{ card, divider, empty_state, filled, gap, glyph, glyph_box, scroll_style, text_action, - text_role, text_role_centered, ICON_PX, ICON_SMALL_PX, SCROLLBAR_MARGIN, SCROLLBAR_WIDTH, + text_role, text_role_centered, text_role_owned, ICON_PX, ICON_SMALL_PX, SCROLLBAR_MARGIN, + SCROLLBAR_WIDTH, }; use iced::widget::{column, container, row}; diff --git a/crates/app-iced/src/pages/frame/pieces.rs b/crates/app-iced/src/pages/frame/pieces.rs index aac04a8..7f08cd6 100644 --- a/crates/app-iced/src/pages/frame/pieces.rs +++ b/crates/app-iced/src/pages/frame/pieces.rs @@ -176,6 +176,32 @@ pub fn text_role<'a>( text_widget(ui, role, emphasized, content, color).into() } +/// 同 [`text_role`],但文案是**拥有的**字符串(计数、时间戳这类运行时格式化出来的短串)。 +/// +/// 为什么需要它:文字控件**借用**它的内容(`&'a str`),所以 `format!` 出来的临时串不能直接进树; +/// iced 的 `text()` 接受 `String`(`IntoFragment` 为 `String` 实现了 owned 分支),这里把 +/// 「令牌化 + owned 内容」两件事合成一处,页面就不必为了一个计数往视图模型里塞字段。 +pub fn text_role_owned<'a>( + ui: &Ui, + role: TypeRole, + emphasized: bool, + content: String, + color: Color, +) -> Element<'a, Message> { + let style: TypeStyle = if emphasized { + ui.emphasized(role) + } else { + ui.style(role) + }; + + iced::widget::text(content) + .size(style.size_px) + .line_height(LineHeight::Absolute(Pixels(style.line_height_px))) + .font(ui.font_of(style)) + .color(color) + .into() +} + /// 同 [`text_role`],但**文字自身居中**(空态的标题与副文案:图标块是居中锚点)。 pub fn text_role_centered<'a>( ui: &'a Ui, diff --git a/crates/app-iced/src/pages/harness.rs b/crates/app-iced/src/pages/harness.rs index 868478a..b74d20c 100644 --- a/crates/app-iced/src/pages/harness.rs +++ b/crates/app-iced/src/pages/harness.rs @@ -33,7 +33,7 @@ use crate::probe::{self, Probe}; use crate::shot; use crate::theme::Ui; -use super::{demo, Screen}; +use super::{app, demo_model, Model, Screen}; /// 截图落盘的帧号(见模块文档)。 const CAPTURE_AT_FRAME: u32 = 3; @@ -218,6 +218,7 @@ pub fn run(request: Request) -> Result<(), String> { ( Harness { ui: Ui::new(boot.flavor, families), + model: demo_model(boot.screen, boot.width), request: boot.clone(), window: None, frames: 0, @@ -266,6 +267,8 @@ pub fn run(request: Request) -> Result<(), String> { struct Harness { request: Request, ui: Ui, + /// 视图模型(演示数据;harness 不接 presenter,只出**单页**图) + model: Model, window: Option, frames: u32, } @@ -331,7 +334,7 @@ impl Harness { } fn view(&self) -> Element<'_, Message> { - demo(&self.ui, self.request.screen, self.request.width).map(Message::Page) + app(&self.ui, &self.model).map(Message::Page) } } diff --git a/crates/app-iced/src/pages/instances.rs b/crates/app-iced/src/pages/instances.rs index 1da057f..3d8ebce 100644 --- a/crates/app-iced/src/pages/instances.rs +++ b/crates/app-iced/src/pages/instances.rs @@ -1,4 +1,4 @@ -//! 实例页:**list-detail** 的唯一用例。 +//! 实例页:**list-detail** 的唯一用例,也是 M90 唯一打通到「真组件 + 真数据 + 真命令」的页。 //! //! ## 版式(由 [`super::layout`] 决议,页面不自己算宽度) //! @@ -10,57 +10,82 @@ //! 为什么窄窗不是「上下堆」:官方 compact 下 list-detail 的口径是一次只显示一个 pane(列表 → 详情, //! 整页切换)。上下堆会把详情挤到折叠线以下,等于详情不存在。 //! -//! ## 判据 +//! ## M90:占位控件 → 真组件(逐项对照) //! -//! 列表行 `Component::ListItem`(BodyLarge / BodySmall,圆角 `Small`),行高 = `row_height + Space::X3` -//! = 56(两行行文;`row_height` 44 是单行列表的节奏)。选中行 `SecondaryContainer` + full 圆角(M3 列表)。 +//! | 位置 | M84(占位) | M90(真组件) | +//! |---|---|---| +//! | 列表行 | 手搓 `container` + `text_role`(行高 56) | `navigation::list_item`(一/两/三行 44/60/76,五态状态层) | +//! | 页头动作 | `frame::text_action`(纯 `text` + 手搓 hover) | `buttons::text`(`Button` 五式 + 状态层) | +//! | 「Play」 | `frame::text_action` | `buttons::filled`(**全屏唯一实心 accent**,rubric K2) | +//! | 分隔线 | `frame::divider` | `divider::full` | +//! | 数据 | `DEMO` 常量 | [`super::model::Instances`](真页面由 presenter 快照转,演示由 `DEMO` 转) | +//! +//! 判据没有因此放松:列宽仍由 [`super::layout`] 给(见 `pages/tests.rs` 的 424 断言)。 +//! +//! ## 行高为什么从 56 变成 60(M90 登记) +//! +//! M84 的行高是「`row_height` 44 + `Space::X3` 12 = 56」的手工值;真 `ListItem` 的两行档是 +//! **60**(令牌 `row_height` 44 + 每多一行 16,A1 是 72)。差异来自组件自己的几何真源, +//! 页面不许覆盖——这正是「接真组件」的应有结果。列表仍高于可视区,独立滚动条照样可见。 mod data; +mod demo; #[cfg(test)] mod tests; -pub(super) use data::SELECTED; -use data::{Entry, DEMO, NOTE}; +#[cfg(test)] +pub(crate) use data::{DEMO, NOTE, SELECTED}; +pub(super) use demo::demo; use iced::widget::{column, container, row, scrollable}; use iced::{Alignment, Background, Border, Element, Length, Padding}; use unlucky_design::color::Role; -use unlucky_design::component_map::Component; use unlucky_design::elevation::MaterialLayer; use unlucky_design::icons::IconKind; use unlucky_design::shape::ShapeScale; use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; +use unlucky_presenter::router::Route; +use crate::app::components::buttons::{self, Button}; +use crate::app::components::divider; +use crate::app::components::navigation::list_item::{list_item, ListItem}; use crate::theme::Ui; use super::frame::{self, Header}; use super::layout::{self, FrameMetrics, PageLayout}; +use super::model::{Detail, Instances, Row}; use super::{Action, Message}; -/// 整页(`selected = None` 时详情区显示空态)。 -pub fn view<'a>( - ui: &'a Ui, - metrics: FrameMetrics, - selected: Option, -) -> Element<'a, Message> { +/// 列表行的间距(`Space::X1`;与 M84 同值,行高差异全部来自 `ListItem` 自己的几何)。 +const ROW_GAP: Space = Space::X1; + +/// 整页(`block.detail = None` 时详情区显示空态)。 +pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, block: &'a Instances) -> Element<'a, Message> { let header = Header::new("Instances") .subtitle("Local versions and their mod loaders") - .action(frame::text_action(ui, "New instance", Action::NewInstance)) - .action(frame::text_action(ui, "Import", Action::Import)); + .action(buttons::text( + ui, + Button::new("New instance", Message::Action(Action::NewInstance)), + )) + .action(buttons::text( + ui, + Button::new("Import", Message::Action(Action::Import)), + )); let body = match metrics.layout { - PageLayout::ListDetail => { - let detail = match selected { - Some(_) => detail(ui, metrics), + PageLayout::ListDetail => frame::panes( + metrics, + list_column(ui, block), + match &block.detail { + Some(detail) => detail_column(ui, metrics, detail), None => detail_empty(ui), - }; - frame::panes(metrics, list_column(ui, selected), detail) - } - _ => match selected { - Some(_) => narrow_detail(ui, metrics), + }, + ), + _ => match &block.detail { + Some(detail) => narrow_detail(ui, metrics, detail), None => frame::single_column(metrics, detail_empty(ui)), }, }; @@ -70,7 +95,7 @@ pub fn view<'a>( // --------------------------------------------------------------- 列表列 -fn list_column<'a>(ui: &'a Ui, selected: Option) -> Element<'a, Message> { +fn list_column<'a>(ui: &'a Ui, block: &'a Instances) -> Element<'a, Message> { let head = container( row![ container(frame::text_role( @@ -82,11 +107,11 @@ fn list_column<'a>(ui: &'a Ui, selected: Option) -> Element<'a, Message> )) .width(Length::Fill) .align_y(Alignment::Center), - frame::text_role( + frame::text_role_owned( ui, TypeRole::LabelMedium, false, - "12", + block.rows.len().to_string(), ui.color(Role::OnSurfaceVariant), ), ] @@ -102,12 +127,13 @@ fn list_column<'a>(ui: &'a Ui, selected: Option) -> Element<'a, Message> }); let rows = column( - DEMO.iter() - .enumerate() - .map(|(index, entry)| list_row(ui, entry, selected == Some(index))) + block + .rows + .iter() + .map(|entry| list_row(ui, entry, is_selected(block, entry))) .collect::>(), ) - .spacing(ui.space(Space::X1)) + .spacing(ui.space(ROW_GAP)) .padding(Padding::from([0.0, ui.space(Space::X2)])) .width(Length::Fill); @@ -136,182 +162,190 @@ fn list_column<'a>(ui: &'a Ui, selected: Option) -> Element<'a, Message> .into() } -fn list_row<'a>(ui: &'a Ui, entry: &'a Entry, selected: bool) -> Element<'a, Message> { - let spec = Component::ListItem.spec(); - let height = ui.metrics().row_height + Space::X3.dp(); +fn is_selected(block: &Instances, entry: &Row) -> bool { + block + .detail + .as_ref() + .is_some_and(|detail| detail.id == entry.id) +} - let name_color = if selected { - ui.color(Role::OnSecondaryContainer) - } else { - ui.color(Role::OnSurface) - }; - let support_color = if selected { - ui.color(Role::OnSecondaryContainer) - } else { - ui.color(Role::OnSurfaceVariant) - }; +/// 一行(`ListItem` 的两/三行档:overline = 运行状态,headline = 名字,supporting = 版本 + 加载器)。 +/// +/// 点一行 = **压一层路由栈**(`Route::Detail(id)`),页面自己不留「选中项」副本—— +/// 于是「当前选中」与「返回栈」永远是同一份真相(`presenter::router`)。 +fn list_row<'a>(ui: &'a Ui, entry: &'a Row, selected: bool) -> Element<'a, Message> { + let mut item = ListItem::new( + entry.name.as_str(), + Message::Navigate(Route::Detail(entry.id.clone())), + ) + .supporting(entry.supporting.as_str()) + .leading(IconKind::Instances) + .trailing(IconKind::ChevronRight); - let texts = column![ - frame::text_role(ui, spec.text, false, entry.name, name_color), - frame::text_role( - ui, - spec.support.unwrap_or(TypeRole::BodySmall), - false, - entry.loader, - support_color, - ), - ] - .spacing(0.0) - .width(Length::Fill); + if let Some(status) = &entry.status { + item = item.overline(status.as_str()); + } + if selected { + item = item.selected(); + } - let inner = row![ - frame::glyph_box(IconKind::Instances, support_color, frame::ICON_PX), - texts, - frame::glyph(IconKind::ChevronRight, support_color, frame::ICON_SMALL_PX), - ] - .spacing(ui.space(Space::X3)) - .width(Length::Fill) - .align_y(Alignment::Center); - - container(inner) - .width(Length::Fill) - .height(height) - .padding(Padding::from([0.0, ui.space(Space::X2)])) - .align_y(Alignment::Center) - .style(move |_theme| container::Style { - background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))), - border: Border { - radius: ui.radius(ShapeScale::Full, height), - ..Border::default() - }, - ..container::Style::default() - }) - .into() + list_item(ui, item) } // --------------------------------------------------------------- 详情列 -fn detail<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> { - let entry = &DEMO[SELECTED]; +fn detail_column<'a>( + ui: &'a Ui, + metrics: FrameMetrics, + detail: &'a Detail, +) -> Element<'a, Message> { + let mut body = column![ + detail_head(ui, detail), + meta_grid(ui, metrics, detail), + divider::full(ui), + ] + .spacing(ui.space(Space::X4)) + .width(Length::Fill); - frame::left_aligned( - metrics.column, - column![ - detail_head(ui, entry), - meta_grid(ui, metrics, entry), - frame::divider(ui), - body_paragraph(ui), - frame::divider(ui), - action_row(ui), - ] - .spacing(ui.space(Space::X4)) - .width(Length::Fill), - ) + body = push_text(ui, body, detail); + + // 页头 + 元信息网格 + **一条**分隔线以下才是正文与操作区(M84 那两条线是占位时期的排版残留) + frame::left_aligned(metrics.column, body.push(action_row(ui))) } -fn narrow_detail<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> { +fn narrow_detail<'a>( + ui: &'a Ui, + metrics: FrameMetrics, + detail: &'a Detail, +) -> Element<'a, Message> { // 窄窗:列表退成上一屏,本屏只放详情(官方 compact 的 list-detail 口径)。 - let entry = &DEMO[SELECTED]; + let mut body = column![ + row![buttons::text( + ui, + Button::new("Back to instances", Message::Navigate(Route::Instances)), + )] + .width(Length::Fill) + .align_y(Alignment::Center), + detail_head(ui, detail), + meta_grid(ui, metrics, detail), + divider::full(ui), + ] + .spacing(ui.space(Space::X4)) + .width(Length::Fill); - frame::single_column( - metrics, - column![ - row![frame::text_action(ui, "Back to instances", Action::Back)] - .width(Length::Fill) - .align_y(Alignment::Center), - detail_head(ui, entry), - meta_grid(ui, metrics, entry), - frame::divider(ui), - body_paragraph(ui), - frame::divider(ui), - frame::text_role( - ui, - TypeRole::BodySmall, - false, - "Below 840 px the list and the detail share one column, so opening an instance replaces \ - this screen (full-page switch). Widen the window to see both panes again.", - ui.color(Role::OnSurfaceVariant), - ), - action_row(ui), - ] - .spacing(ui.space(Space::X4)) - .width(Length::Fill), - ) + body = push_text(ui, body, detail); + + body = body.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + "Below 840 px the list and the detail share one column, so opening an instance replaces \ + this screen (full-page switch). Widen the window to see both panes again.", + ui.color(Role::OnSurfaceVariant), + )); + + frame::single_column(metrics, body.push(action_row(ui))) } -fn detail_head<'a>(ui: &'a Ui, entry: &'a Entry) -> Element<'a, Message> { +/// 正文段(演示数据才有)与运行状态行(真数据才有)——两者都是可选的,顺序固定。 +fn push_text<'a>( + ui: &'a Ui, + mut body: iced::widget::Column<'a, Message>, + detail: &'a Detail, +) -> iced::widget::Column<'a, Message> { + if let Some(note) = &detail.note { + body = body.push(frame::text_role( + ui, + TypeRole::BodyMedium, + false, + note.as_str(), + ui.color(Role::OnSurfaceVariant), + )); + } + if let Some(status) = &detail.status { + body = body.push(frame::text_role( + ui, + TypeRole::BodyMedium, + false, + status.as_str(), + ui.color(Role::OnSurface), + )); + } + body +} + +fn detail_head<'a>(ui: &'a Ui, detail: &'a Detail) -> Element<'a, Message> { let titles = column![ frame::text_role( ui, TypeRole::TitleLarge, true, - entry.name, + detail.name.as_str(), ui.color(Role::OnSurface) ), frame::text_role( ui, TypeRole::BodySmall, false, - entry.version, + detail.subtitle.as_str(), ui.color(Role::OnSurfaceVariant), ), ] .spacing(ui.space(Space::X1)) .width(Length::Fill); - row![ - titles, - frame::text_action(ui, "Play", Action::Play), - frame::text_action(ui, "More", Action::More), - ] - .spacing(ui.space(Space::X2)) - .width(Length::Fill) - .align_y(Alignment::Center) - .into() + // 主操作:**全屏唯一**实心 accent(rubric K2)。不可用时退化成禁用态(只清消息,不改身份)。 + let launch = if detail.can_launch { + buttons::filled(ui, Button::new("Play", Message::Launch(detail.id.clone()))) + } else { + buttons::filled(ui, Button::disabled("Play")) + }; + let stop = if detail.can_stop { + buttons::text(ui, Button::new("Stop", Message::Stop(detail.id.clone()))) + } else { + buttons::text(ui, Button::disabled("Stop")) + }; + + row![titles, launch, stop] + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .align_y(Alignment::Center) + .into() } /// 元信息网格:列数由 [`layout::meta_columns`](按正文列宽)决定 —— **1180 与 900 都是 2 列**。 /// /// 3 列需要 `3 × META_CARD_MIN_WIDTH + 2 × META_GAP` = 504px,而行宽上限是 424,**数学上不可行**; /// 硬塞回 3 列会让 `NeoForge 20.4.237`(118px)这类值按词折行、同排卡底不齐。 -fn meta_grid<'a>(ui: &'a Ui, metrics: FrameMetrics, entry: &'a Entry) -> Element<'a, Message> { - let cells = [ - ("VERSION", entry.version), - ("LOADER", entry.loader), - ("MEMORY", entry.memory), - ("JAVA", entry.java), - ("MODS", entry.mods), - ("LAST PLAYED", entry.last_played), - ]; - +fn meta_grid<'a>(ui: &'a Ui, metrics: FrameMetrics, detail: &'a Detail) -> Element<'a, Message> { let mut grid = column![].spacing(layout::META_GAP).width(Length::Fill); - let columns = metrics.meta_columns.clamp(1, cells.len()); + let columns = metrics.meta_columns.clamp(1, detail.meta.len().max(1)); - for chunk in cells.chunks(columns) { + for chunk in detail.meta.chunks(columns) { let mut cells_row = row![].spacing(layout::META_GAP).width(Length::Fill); - for (label, value) in chunk { - let cell = frame::card( + for cell in chunk { + let card = frame::card( ui, column![ frame::text_role( ui, TypeRole::LabelMedium, false, - label, + cell.label.as_str(), ui.color(Role::OnSurfaceVariant), ), frame::text_role( ui, TypeRole::BodyMedium, false, - value, + cell.value.as_str(), ui.color(Role::OnSurface) ), ] .spacing(ui.space(Space::X1)) .width(Length::Fill), ); - cells_row = cells_row.push(container(cell).width(Length::Fill)); + cells_row = cells_row.push(container(card).width(Length::Fill)); } grid = grid.push(cells_row); } @@ -319,21 +353,17 @@ fn meta_grid<'a>(ui: &'a Ui, metrics: FrameMetrics, entry: &'a Entry) -> Element grid.into() } -fn body_paragraph<'a>(ui: &'a Ui) -> Element<'a, Message> { - frame::text_role( - ui, - TypeRole::BodyMedium, - false, - NOTE, - ui.color(Role::OnSurfaceVariant), - ) -} - fn action_row<'a>(ui: &'a Ui) -> Element<'a, Message> { row![ - frame::text_action(ui, "Duplicate", Action::Duplicate), - frame::text_action(ui, "Open folder", Action::OpenFolder), - frame::text_action(ui, "Delete", Action::Delete), + buttons::text( + ui, + Button::new("Duplicate", Message::Action(Action::Duplicate)) + ), + buttons::text( + ui, + Button::new("Open folder", Message::Action(Action::OpenFolder)) + ), + buttons::text(ui, Button::new("Delete", Message::Action(Action::Delete))), ] .spacing(ui.space(Space::X2)) .align_y(Alignment::Center) diff --git a/crates/app-iced/src/pages/instances/demo.rs b/crates/app-iced/src/pages/instances/demo.rs new file mode 100644 index 0000000..f473897 --- /dev/null +++ b/crates/app-iced/src/pages/instances/demo.rs @@ -0,0 +1,51 @@ +//! 实例页的**演示数据 → 视图模型**转换(M84 的出图基线靠这份数据)。 +//! +//! 只被 [`crate::pages::demo_model`] 用:产品路径的转换在 `app/view.rs`(presenter 快照 → 模型)。 +//! 两份数据源喂同一个渲染函数,这是 [`crate::pages::model`] 存在的理由。 + +use super::super::model::{Detail, Instances, Meta, Row}; +use super::data::{DEMO, NOTE, SELECTED}; + +/// 把 M84 的演示条目 [`DEMO`] 转成视图模型(真页面走 `app/view.rs` 的 `AppState` 转换)。 +pub(crate) fn demo() -> Instances { + let rows = DEMO + .iter() + .map(|entry| Row { + id: entry.name.to_string(), + name: entry.name.to_string(), + supporting: format!("{} · {}", entry.version, entry.loader), + status: None, + }) + .collect(); + + let entry = &DEMO[SELECTED]; + let detail = Detail { + id: entry.name.to_string(), + name: entry.name.to_string(), + subtitle: format!("{} · {}", entry.version, entry.loader), + meta: vec![ + meta("VERSION", entry.version), + meta("LOADER", entry.loader), + meta("MEMORY", entry.memory), + meta("JAVA", entry.java), + meta("MODS", entry.mods), + meta("LAST PLAYED", entry.last_played), + ], + note: Some(NOTE.to_string()), + status: None, + can_launch: true, + can_stop: false, + }; + + Instances { + rows, + detail: Some(detail), + } +} + +fn meta(label: &str, value: &str) -> Meta { + Meta { + label: label.to_string(), + value: value.to_string(), + } +} diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index 2099dc8..6924629 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -1,17 +1,17 @@ -//! # 页面骨架 + 布局系统(P4 / M84 `PAGEFRAME`) +//! # 页面层(P4):布局系统 + 四页 + **路由/真组件的接入口**(M84 `PAGEFRAME` → M90 `APPSKEL`) //! -//! 本模块**不依赖组件库**(`components/` 由 M74/M75 并行建):它交付的是**布局系统**, -//! 四个页面只是它的用例。控件全是同一份令牌下的占位实现,等组件家族合入后由另一条 mission 替换。 +//! ## 这一层交了什么 //! -//! ## 为什么要做这一层(历史判词) +//! | 阶段 | 交付 | 判据 | +//! |---|---|---| +//! | M84 | **布局系统**(档位/版面/列宽)+ 四页占位视觉 | 下面的版面阈值表 | +//! | **M90(本文件当前状态)** | ①视图模型 [`model`] ②导航轨换成真组件并接上 `presenter::router::Route` ③**实例页整页接真组件 + 真数据** | 「接真组件后版面约束一条没破」(见 `pages/tests.rs` 的数值断言) | //! -//! M68 的骨架内容区是**一张全宽卡**。视觉复审的结论(用户认可): +//! 实例页之外的三页**仍是 M84 的占位视觉**(下载/日志/设置),但它们已经: +//! 走同一个 `Message` → `update` → `Route` 通道,导航轨点得动、路由栈按 `presenter::router` 的语义走。 +//! 页面美化是后续 mission 的事,本 mission 只保证「地基是通的」。 //! -//! > **不能把「全宽单卡」当页面模板** —— 正文单行 **712px**,那张卡里的 `BodySmall` 12px 文本 -//! > **实测装了 137 字符**(口径见 [`layout`] §3),远超舒适行宽; -//! > 官方桌面口径是 **supporting-pane / list-detail**。 -//! -//! 所以这里先立三条规矩,页面照着用: +//! ## 版面规矩(M84 立的三条,M90 一条没动) //! //! 1. **宽度档位**:官方响应式断点 **600 / 840**([`layout::WidthClass`]); //! 2. **版面**:单列 / supporting-pane / list-detail,各自的成立条件与退化阈值写在 [`layout`]; @@ -32,22 +32,33 @@ //! | 文件 | 职责 | //! |---|---| //! | [`layout`] | **布局系统**:档位 / 版面 / 列宽 / 阈值,纯函数 + 单测(无 iced 控件树) | -//! | [`frame`] | 页头 / 内容列 / 两列骨架 / 空态 / 卡片 / 分隔线(占位控件) | -//! | `instances` | 实例页(list-detail) | -//! | `settings` | 设置页(supporting-pane) | -//! | `download` / `logs` | 下载 / 日志页(单列,两行页头的差异) | +//! | [`model`] | **视图模型**:presenter 快照 → 页面要画的东西(页面层不认识 presenter 类型) | +//! | [`frame`] | 页头 / 内容列 / 两列骨架 / 空态 / 卡片 / 分隔线 | +//! | `instances` | 实例页(list-detail)——**M90 唯一打通到真组件 + 真数据真命令的页** | +//! | `settings` | 设置页(supporting-pane,占位视觉) | +//! | `download` / `logs` | 下载 / 日志页(单列,占位视觉) | //! | [`harness`] | 截图 harness(`examples/pages_demo.rs` 的入口,**不是** `cli.rs` 的一部分) | //! -//! ## 偏离登记(D30 要求逐条写清;视觉复审 r1 要求把末两条补进来) +//! ## 消息与路由(M90 新增) +//! +//! 页面**不 import `crate::app::Message`**(组件契约 §2 的同一条纪律):它有自己的 +//! [`Message`],app 层只做一次 `.map`。路由跳转的载荷是 `presenter::router::Route` 本身, +//! 于是「页面里点一行」= 「压一层栈」,语义与 presenter 的 `Router` 天然一致, +//! 中间不再有一层「页面自定义的选中项」需要同步。 +//! +//! ## 偏离登记(D30 要求逐条写清;M84 六条 + M90 三条) //! //! | # | 偏离 | 为什么 | 什么时候收口 | //! |---|---|---|---| //! | 1 | 页头按 M3 口径**铺满页面宽度**(贴页边距),不与正文列对齐 | AppBar 本来就该满宽;1180 下页头右缘 **1156** 与详情列右缘 **896** 有 **260px 分歧** | 真 top bar 落地时按 M3 定语(页头右侧是操作区,不跟随列宽) | //! | 2 | 空态标题 `HeadlineSmall 24` 比页标题 `TitleLarge 22` 大 | 12b §15.2 明确「空态是唯一允许 Headline 级的场景」,与 §16.4 的页标题档有 spec 张力 | 有意保留,不修 | -//! | 3 | 单列页(下载 / 日志)在 1180 下只用掉 **40.6%** 内容宽(424/1044) | 行宽上限的必然结果;空态居中不显空 | 版本浏览真正接数据后重新决议 `PaneKit`(现在写「一串线性进度项」是按单列定的) | +//! | 3 | 单列页(下载 / 日志)在 1180 下只用掉 **40.6%** 内容宽(424/1044) | 行宽上限的必然结果;空态居中不显空 | 版本浏览真正接数据后重新决议 `PaneKit` | //! | 4 | 空态图标字形 **40px**(12b 15.1 是 `[估]` 32~48) | 令牌 `IconSize` 只有 24 一档,`frame::glyph` 按尺寸重算 stroke | 组件库的 `EmptyState`(M77)落地时 | //! | 5 | 窄窗「Back to instances」没有前导箭头 | 占位文本按钮没有图标位 | 真 top bar / M78 的导航件落地时 | //! | 6 | 实例页「未选中详情」空态**没有出图** | 演示数据固定选中第 1 条;空态代码路径在(`detail_empty`) | D30 状态矩阵补齐时补一张,不必重跑全套 | +//! | 7 | **标题栏仍是 48px 留白带**(M90) | 真实标题栏与窗口拖拽/缩放热区在 `src/shell/mod.rs`,本 mission 的 scope 不含它;页面层拿不到「内容槽位」 | shell 暴露「内容槽位」的 mission(已报 tower) | +//! | 8 | 实例页的「New instance / Import」仍是**没有后端的按钮**(M90) | presenter 没有「打开新建向导」的指令(`wizard` 只有状态机,`AppCommand` 里无对应项),已报 finding | 向导 mission 补 `AppCommand::OpenWizard` 之后 | +//! | 9 | 实例页的正文段落只在**演示数据**里出现(M90) | 真快照里没有「正文描述」这种字段;M84 那段 361 字符的 `NOTE` 是给行宽测量当样本的,不能凭空造到真页面里 | i18n / 实例详情 mission 补真实描述字段 | //! //! ## 截图通道 //! @@ -57,25 +68,34 @@ //! --screenshot=out.png --screenshot-theme=light --software-renderer //! ``` //! -//! `cli.rs` 还没有 `--screen` / `--width`(M75 在改它),所以参数解析放在 [`harness`] 里, -//! **本模块一行没碰 `cli.rs` / `app.rs`**。 +//! 产品二进制的截图走 `--screenshot`(页面树由 app 层按路由装配),本 harness 只出**单页**图, +//! 用于页面级回归与宽度对照。 pub mod frame; pub mod harness; pub mod layout; +pub mod model; mod download; mod instances; mod logs; mod settings; -pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass}; +#[cfg(test)] +mod tests; -use iced::widget::{container, row, space}; +pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass}; +pub use model::{Detail, Instances, Meta, Model, Row}; + +use iced::widget::{column, container, row, space}; use iced::{Element, Length}; use unlucky_design::elevation::MaterialLayer; +use unlucky_design::icons::IconKind; +use unlucky_presenter::router::{NavSection, Route}; +use crate::app::components::navigation::drawer::{self, Navigation}; +use crate::app::components::navigation::navigation; use crate::theme::Ui; use frame::filled; @@ -83,24 +103,30 @@ use frame::filled; /// 页面消息。 /// /// **故意不用 `crate::app::Message`**(组件库契约 §2 的同一条理由):页面不绑 app 的状态层, -/// 接线时由 app 层把 [`Message::Action`] 映射成自己的消息。骨架期的动作只有「点了哪个按钮」, -/// 没有业务语义。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// 接线时由 app 层把这里的三类意图翻成 presenter 的 `AppCommand` / `Route`。 +#[derive(Debug, Clone)] pub enum Message { - /// 点了页面里的某个动作。 + /// 骨架期动作(三页的占位按钮 + 实例页上还没接后端的两个页头动作)。 Action(Action), + /// 路由跳转:导航轨、列表行、窄窗「返回」都走这一条(栈语义归 `presenter::router`)。 + Navigate(Route), + /// 启动某实例(app 层翻成 `AppCommand::LaunchInstance`)。 + Launch(String), + /// 停止某实例(app 层翻成 `AppCommand::StopInstance`)。 + Stop(String), } /// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Action { - /// 新建实例。 + /// 新建实例(**还没有后端**,见模块文档偏离 #8)。 NewInstance, - /// 导入。 + /// 导入(同上)。 Import, - /// 返回实例列表(窄窗整页切换)。 + /// 返回实例列表(窄窗整页切换)。**已由 `Message::Navigate(Route::Instances)` 取代**, + /// 这里保留枚举项是为了别把「返回」这件意图从动作表里抹掉(外壳回退路径仍可能用到)。 Back, - /// 启动。 + /// 启动(占位动作表里的「启动」;真启动走 `Message::Launch`)。 Play, /// 更多。 More, @@ -177,28 +203,77 @@ impl Screen { Screen::Download | Screen::Logs => PaneKit::Single, } } + + /// 路由落在哪一页上(`None` = 这一页还没有实现,由 app 层决定回退)。 + /// + /// `Route::Detail(_)` 仍属实例页(窄窗时是「整页切换」的那一屏)。 + pub fn of(route: &Route) -> Option { + Some(match route { + Route::Instances | Route::Detail(_) => Screen::Instances, + Route::Download => Screen::Download, + Route::Logs => Screen::Logs, + Route::Settings => Screen::Settings, + Route::Agent => return None, + }) + } + + /// 该页高亮的导航项(与 `Route::nav` 同源,但这里是「按页问」的口径)。 + pub const fn nav(self) -> NavSection { + match self { + Screen::Instances => NavSection::Instances, + Screen::Download => NavSection::Download, + Screen::Logs => NavSection::Logs, + Screen::Settings => NavSection::Settings, + } + } } -/// 单独渲染一页(截图 / 手看用,**不依赖业务状态机**)。 +/// 导航轨的固定项:图标 + 标签 + 目标路由(顺序 = 自上而下)。 /// -/// 窗口宽度是**含外壳的窗口宽度**(档位判据就是它):返回的树里,左侧 88px 与顶部 48px 是 -/// **留白带**,代表真实外壳的导航轨与标题栏——`shell::nav` 是私有模块,本 mission 不复制它的实现 -/// (真正的 `NavigationDrawer` 由 M78 提供,接线时把这两条带换成外壳即可)。 -pub fn demo<'a>(ui: &'a Ui, screen: Screen, window_width: f32) -> Element<'a, Message> { - let metrics = layout::frame_metrics(screen.kit(), window_width); +/// 顺序与 `presenter::router::NavSection` 的声明顺序一致,改这里等于改导航栏。 +const NAV: [(Option, IconKind, &str, Route); 5] = [ + ( + Some(NavSection::Instances), + IconKind::Instances, + "Instances", + Route::Instances, + ), + ( + Some(NavSection::Download), + IconKind::Download, + "Download", + Route::Download, + ), + (Some(NavSection::Logs), IconKind::Logs, "Logs", Route::Logs), + ( + Some(NavSection::Settings), + IconKind::Settings, + "Settings", + Route::Settings, + ), + // AI 独立页没有导航高亮项(`Route::nav()` 对它返回 `None`) + (None, IconKind::Ai, "Assistant", Route::Agent), +]; - let page = match screen { - Screen::Instances => instances::view(ui, metrics, Some(instances::SELECTED)), +/// 整窗:标题栏留白带 + 导航轨 + 当前路由的页面。 +/// +/// 视图模型由**调用方持有**(`App` 每拍重建一次 `Model`),这里只借用:`view()` 不能返回 +/// 一棵借用局部变量的树(iced 的 `view(&self)` 只借 `&self`)。 +pub fn app<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> { + let metrics = layout::frame_metrics(model.screen.kit(), model.window_width); + + let page = match model.screen { + Screen::Instances => instances::view(ui, metrics, &model.instances), Screen::Download => download::view(ui, metrics), Screen::Settings => settings::view(ui, metrics), Screen::Logs => logs::view(ui, metrics), }; - let shell = row![rail_placeholder(ui), page] + let shell = row![rail(ui, model.active), page] .width(Length::Fill) .height(Length::Fill); - let titled = iced::widget::column![titlebar_placeholder(ui), shell] + let titled = column![titlebar_placeholder(ui), shell] .width(Length::Fill) .height(Length::Fill); @@ -209,7 +284,37 @@ pub fn demo<'a>(ui: &'a Ui, screen: Screen, window_width: f32) -> Element<'a, Me .into() } +/// 演示模型(`pages_demo` example 与单测用;真页面走 `app/view.rs` 的 `AppState` 转换)。 +/// +/// 只有实例页带数据,其余三页是占位视觉(不读数据)。 +pub fn demo_model(screen: Screen, window_width: f32) -> Model { + let mut model = Model::new(screen, window_width, Some(screen.nav())); + if screen == Screen::Instances { + model.instances = instances::demo(); + } + model +} + +/// 导航轨:**真组件**(`components::navigation` 的 rail 形态,宽 = 令牌 `nav_rail_width` = 88)。 +/// +/// M84 这里是一条留白带;M90 换成 M78 的 `NavigationRail`,于是「导航项 → 路由」是真的可点。 +fn rail<'a>(ui: &'a Ui, active: Option) -> Element<'a, Message> { + let mut group = drawer::NavGroup::new(); + + for (section, icon, label, route) in NAV { + let mut item = drawer::NavItem::new(label, icon, Message::Navigate(route)); + if section.is_some() && active == section { + item = item.selected(); + } + group = group.item(item); + } + + navigation(ui, Navigation::rail().group(group)) +} + /// 标题栏留白带(高 = `LayoutMetrics::titlebar_height`)。 +/// +/// 真实标题栏在 `src/shell/mod.rs`(不在本 mission 的 scope 内),见模块文档偏离 #7。 fn titlebar_placeholder<'a>(ui: &'a Ui) -> Element<'a, Message> { container(space::horizontal()) .width(Length::Fill) @@ -217,77 +322,3 @@ fn titlebar_placeholder<'a>(ui: &'a Ui) -> Element<'a, Message> { .style(move |_theme| filled(ui.layer(MaterialLayer::Canvas))) .into() } - -/// 导航轨留白带(宽 = `LayoutMetrics::nav_rail_width`)。 -fn rail_placeholder<'a>(ui: &'a Ui) -> Element<'a, Message> { - container(space::horizontal()) - .width(Length::Fixed(layout::rail_width())) - .height(Length::Fill) - .style(move |_theme| filled(ui.layer(MaterialLayer::Canvas))) - .into() -} - -#[cfg(test)] -mod tests { - use super::*; - use unlucky_design::color::SchemeFlavor; - use unlucky_design::typography::Platform; - - use crate::theme::Families; - - const FAMILIES: Families = Families { - text: "Segoe UI", - display: "Segoe UI", - mono: "Consolas", - }; - - fn ui() -> Ui { - Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES) - } - - #[test] - fn four_screens_answer_the_mission_list() { - assert_eq!(Screen::ALL.len(), 4); - let slugs: Vec<&str> = Screen::ALL.iter().map(|screen| screen.slug()).collect(); - assert_eq!(slugs, vec!["instances", "download", "settings", "logs"]); - for screen in Screen::ALL { - assert_eq!(Screen::from_slug(screen.slug()), Some(screen)); - } - assert_eq!(Screen::from_slug("gallery"), None); - } - - #[test] - fn screens_declare_the_layout_their_page_needs() { - assert_eq!(Screen::Instances.kit(), PaneKit::ListDetail); - assert_eq!(Screen::Settings.kit(), PaneKit::Supporting); - assert_eq!(Screen::Download.kit(), PaneKit::Single); - assert_eq!(Screen::Logs.kit(), PaneKit::Single); - } - - /// 四个页面 × 三个对照宽度都能构图(不 panic、不依赖运行时)。 - #[test] - fn every_screen_builds_at_the_three_comparison_widths() { - let ui = ui(); - for width in [1180.0, 900.0, 700.0] { - for screen in Screen::ALL { - let _element: Element<'_, Message> = demo(&ui, screen, width); - } - } - } - - /// 版面对照:同一页在 1180 / 900 / 700 三个宽度上必须**真的换版面**。 - #[test] - fn the_width_comparison_really_changes_the_layout() { - let wide = layout::frame_metrics(Screen::Instances.kit(), 1180.0); - let mid = layout::frame_metrics(Screen::Instances.kit(), 900.0); - let narrow = layout::frame_metrics(Screen::Instances.kit(), 700.0); - - assert_eq!(wide.layout, PageLayout::ListDetail); - assert_eq!(mid.layout, PageLayout::ListDetail); - assert_eq!(narrow.layout, PageLayout::SingleColumn); - - // 900 与 1180 同为 list-detail,但详情列必须真的变窄(不是硬编码 1180 的宽度) - assert!(mid.column < wide.column, "900 的详情列应窄于 1180"); - assert!(narrow.secondary.is_none(), "700 不许再有列表列"); - } -} diff --git a/crates/app-iced/src/pages/model.rs b/crates/app-iced/src/pages/model.rs new file mode 100644 index 0000000..6d66d8c --- /dev/null +++ b/crates/app-iced/src/pages/model.rs @@ -0,0 +1,96 @@ +//! 视图模型:**presenter 快照 → 页面要画的东西**的唯一转换产物。 +//! +//! ## 为什么页面不直接吃 presenter 的快照 +//! +//! 页面层(`pages/*`)要能用**两套数据源**喂同一个渲染函数: +//! +//! | 数据源 | 谁转 | 用途 | +//! |---|---|---| +//! | presenter 的 `AppState` 快照 | `app/view.rs`(`From<&AppState>`) | 产品运行时的真页面 | +//! | `pages/instances/data.rs` 的演示条目 | [`crate::pages::demo`] | M84 骨架图的可复现基线、`pages_demo` example | +//! +//! 如果页面直接读 `InstanceSummary`,演示数据就得先伪造成 `InstanceSummary`(含 core 语义字段), +//! 基线图会跟着业务 DTO 的每次改动漂。所以中间加一层**纯展示字段**的模型:它只含「要画什么」。 +//! +//! 文本都是 `String`/`&'static str`,没有框架类型——这一层可以纯单测(见 `pages/tests.rs`)。 + +use unlucky_presenter::router::NavSection; + +use super::Screen; + +/// 实例列表的一行(`ListItem` 的槽位:overline = 运行状态,headline = 名字,supporting = 版本)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Row { + /// 实例 id(点行 → `Route::Detail(id)`) + pub id: String, + /// 显示名 + pub name: String, + /// 副文案(`1.20.4 · Fabric 0.16.5`) + pub supporting: String, + /// 运行状态(`None` = 空闲,不占 overline 槽位) + pub status: Option, +} + +/// 元信息卡:标签 + 值(`VERSION` / `Fabric 0.16.5`)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Meta { + /// 卡片上排的标签(`LabelMedium`) + pub label: String, + /// 卡片下排的值(`BodyMedium`) + pub value: String, +} + +/// 详情列要画的东西。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Detail { + /// 实例 id(启动/停止命令要用它) + pub id: String, + /// 标题(`TitleLarge`) + pub name: String, + /// 标题下的一行(`BodySmall`) + pub subtitle: String, + /// 元信息网格(顺序 = 展示顺序;列数仍由 `layout::meta_columns` 决定) + pub meta: Vec, + /// 正文档(演示数据有;真实快照没有对应字段时为 `None`) + pub note: Option, + /// 运行状态行(`None` = 空闲,不占版面) + pub status: Option, + /// 「Play」可用 + pub can_launch: bool, + /// 「Stop」可用 + pub can_stop: bool, +} + +/// 实例页要画的东西。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Instances { + /// 列表列(顺序稳定 = 实例列表顺序) + pub rows: Vec, + /// 当前选中的实例详情(`None` = 没选中 → 空态) + pub detail: Option, +} + +/// 一屏的视图模型。 +#[derive(Debug, Clone, PartialEq)] +pub struct Model { + /// 当前路由落在哪个页面上 + pub screen: Screen, + /// 窗口宽度(**含外壳**:布局档位判据就是它) + pub window_width: f32, + /// 导航轨高亮项(`None` = 没有高亮,如 AI 独立页) + pub active: Option, + /// 实例页的数据(其它三页还是占位视觉,不读) + pub instances: Instances, +} + +impl Model { + /// 空模型(只有外壳参数,页面数据全空)——窄窗退化与空态的构造入口。 + pub fn new(screen: Screen, window_width: f32, active: Option) -> Self { + Self { + screen, + window_width, + active, + instances: Instances::default(), + } + } +} diff --git a/crates/app-iced/src/pages/tests.rs b/crates/app-iced/src/pages/tests.rs new file mode 100644 index 0000000..5129a71 --- /dev/null +++ b/crates/app-iced/src/pages/tests.rs @@ -0,0 +1,202 @@ +//! 页面层的**数值判据**:M90 接真组件后,「M84 的版面约束一条没破」这件事在这里钉死。 +//! +//! 每条断言都注明了「抓得住什么 / 抓不住什么」——只测到纯函数,渲染真像素另见报告里的图与量测。 + +use super::layout::{self, frame_metrics, measure_cap_px, PaneKit, WidthClass}; +use super::*; +use unlucky_design::color::SchemeFlavor; +use unlucky_design::typography::Platform; +use unlucky_presenter::router::{NavSection, Route}; + +use crate::theme::Families; + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +fn ui() -> Ui { + Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES) +} + +#[test] +fn four_screens_answer_the_mission_list() { + assert_eq!(Screen::ALL.len(), 4); + let slugs: Vec<&str> = Screen::ALL.iter().map(|screen| screen.slug()).collect(); + assert_eq!(slugs, vec!["instances", "download", "settings", "logs"]); + for screen in Screen::ALL { + assert_eq!(Screen::from_slug(screen.slug()), Some(screen)); + } + assert_eq!(Screen::from_slug("gallery"), None); +} + +#[test] +fn screens_declare_the_layout_their_page_needs() { + assert_eq!(Screen::Instances.kit(), PaneKit::ListDetail); + assert_eq!(Screen::Settings.kit(), PaneKit::Supporting); + assert_eq!(Screen::Download.kit(), PaneKit::Single); + assert_eq!(Screen::Logs.kit(), PaneKit::Single); +} + +/// 四个页面 × 三个对照宽度都能构图(不 panic、不依赖运行时)。 +/// +/// **抓得住**:`view` 的每条分支都能产出一棵树(含真空列表、无选中详情)。 +/// **抓不住**:像素级观感(那要出图,见报告)。 +#[test] +fn every_screen_builds_at_the_three_comparison_widths() { + let ui = ui(); + for width in [1180.0, 900.0, 700.0] { + for screen in Screen::ALL { + let model = demo_model(screen, width); + let _element: Element<'_, Message> = app(&ui, &model); + } + } +} + +/// 空数据也要能构图(真实后端的「还没扫到实例」是常态,不能 panic)。 +#[test] +fn an_empty_snapshot_still_builds() { + let ui = ui(); + for screen in Screen::ALL { + let model = Model::new(screen, 1180.0, Some(screen.nav())); + let _element: Element<'_, Message> = app(&ui, &model); + } +} + +/// 版面对照:同一页在 1180 / 900 / 700 三个宽度上必须**真的换版面**。 +#[test] +fn the_width_comparison_really_changes_the_layout() { + let wide = frame_metrics(PaneKit::ListDetail, 1180.0); + let mid = frame_metrics(PaneKit::ListDetail, 900.0); + let narrow = frame_metrics(PaneKit::ListDetail, 700.0); + + assert_eq!(wide.layout, PageLayout::ListDetail); + assert_eq!(mid.layout, PageLayout::ListDetail); + assert_eq!(narrow.layout, PageLayout::SingleColumn); + + // 900 与 1180 同为 list-detail,但详情列必须真的变窄(不是硬编码 1180 的宽度) + assert!(mid.column < wide.column, "900 的详情列应窄于 1180"); + assert!(narrow.secondary.is_none(), "700 不许再有列表列"); +} + +/// **M84 的行宽红线**:任何页、任何窗口宽度、任何版面,正文列都不许超过 424px。 +/// +/// **抓得住**:`frame_metrics` 的 `column`(页面唯一的列宽来源)不会越界; +/// **抓不住**:页面自己绕过 metrics 写死宽度(那条由 `tests/no_hardcoded_tokens.rs` 的 +/// 「`.width(` 后面不许跟数字字面量」守门——注意它扫的是 `src/shell` + `src/pages`)。 +#[test] +fn every_page_any_width_stays_under_the_measure_cap() { + let cap = measure_cap_px(); + assert!((cap - 424.0).abs() < f32::EPSILON, "行宽上限必须是 424"); + + for screen in Screen::ALL { + for width in [416.0, 600.0, 700.0, 840.0, 900.0, 1180.0, 1600.0, 2560.0] { + let metrics = frame_metrics(screen.kit(), width); + assert!( + metrics.column <= cap + f32::EPSILON, + "{} @{width} 的正文列 {} 超过 424", + screen.slug(), + metrics.column + ); + } + } +} + +/// 断点 600 / 840 与 rail 88:M84 的三条硬值,接真组件后不许漂。 +/// +/// **抓得住**:布局系统的常量与 `WidthClass::of` 的分档; +/// **抓不住**:导航轨真画出来是不是 88(那条要量像素;组件自己声明 88 见下一个断言)。 +#[test] +fn the_three_frozen_numbers_are_unchanged() { + assert!((layout::BREAKPOINT_MEDIUM - 600.0).abs() < f32::EPSILON); + assert!((layout::BREAKPOINT_EXPANDED - 840.0).abs() < f32::EPSILON); + assert!((layout::rail_width() - 88.0).abs() < f32::EPSILON); + + assert_eq!(WidthClass::of(599.9), WidthClass::Compact); + assert_eq!(WidthClass::of(600.0), WidthClass::Medium); + assert_eq!(WidthClass::of(839.9), WidthClass::Medium); + assert_eq!(WidthClass::of(840.0), WidthClass::Expanded); +} + +/// 导航轨用的**真组件**(M78 的 `NavigationRail`)声明的宽度必须与布局系统同值。 +/// +/// **抓得住**:`NavPaneGeometry::rail().width` == `layout::rail_width()` == 88(组件与布局系统没分叉); +/// **抓不住**:容器圆角/药丸摆放的像素级正确性(组件自己的 probe 管)。 +#[test] +fn the_real_rail_agrees_with_the_layout_system() { + let ui = ui(); + let geometry = crate::app::components::navigation::geometry::NavPaneGeometry::of( + &ui, + crate::app::components::navigation::NavLayout::Rail, + ); + assert!( + (geometry.width - layout::rail_width()).abs() < f32::EPSILON, + "真组件 rail 宽 {} 与布局系统的 {} 分叉了", + geometry.width, + layout::rail_width() + ); +} + +/// 路由 → 页面:四个一级页各自落到自己那页,`Detail` 落在实例页,`Agent` 没有页面(app 层回退)。 +#[test] +fn routes_map_onto_pages() { + assert_eq!(Screen::of(&Route::Instances), Some(Screen::Instances)); + assert_eq!( + Screen::of(&Route::Detail("survival".to_string())), + Some(Screen::Instances), + "详情页仍是实例页的那一屏" + ); + assert_eq!(Screen::of(&Route::Download), Some(Screen::Download)); + assert_eq!(Screen::of(&Route::Logs), Some(Screen::Logs)); + assert_eq!(Screen::of(&Route::Settings), Some(Screen::Settings)); + assert_eq!(Screen::of(&Route::Agent), None, "AI 独立页还没有页面实现"); +} + +/// 导航轨的每一项都指向一个**它能高亮的**路由(点完不会出现「没有任何项高亮」的悬空态)。 +/// +/// **抓得住**:`NAV` 表的 5 项与 `Screen::nav` / `Route::nav` 三者没有错位; +/// **抓不住**:点下去真的换页(那是 `app/update.rs` 的 `Navigate` 分支 + 集成图)。 +#[test] +fn every_rail_item_points_at_a_route_that_highlights_it() { + assert_eq!(NAV.len(), 5, "导航轨 5 项(4 个一级页 + AI)"); + + for (section, _icon, label, route) in NAV { + assert!(!label.is_empty()); + assert_eq!( + route.nav(), + section, + "{label} 指向的 {route:?} 高亮的是 {:?}", + route.nav() + ); + } + assert_eq!( + NAV[4].0, None, + "AI 项没有导航高亮(Route::Agent.nav() 是 None)" + ); + + // 四个一级页的 page-nav 与 route-nav 同源 + assert_eq!(Screen::Instances.nav(), NavSection::Instances); + assert_eq!(Screen::Settings.nav(), NavSection::Settings); +} + +/// 演示模型:12 行 + 1 条选中详情(M84 的出图基线就靠这份数据)。 +#[test] +fn the_demo_model_carries_the_m84_baseline() { + let model = demo_model(Screen::Instances, 1180.0); + assert_eq!(model.instances.rows.len(), 12); + let detail = model.instances.detail.as_ref().expect("演示数据有选中项"); + assert_eq!(detail.meta.len(), 6, "元信息 6 格"); + assert!(detail.note.is_some(), "演示数据带正文段"); + assert_eq!(model.active, Some(NavSection::Instances)); + + // 其余三页不带实例数据(占位视觉不读它) + for screen in [Screen::Download, Screen::Settings, Screen::Logs] { + let model = demo_model(screen, 1180.0); + assert!( + model.instances.rows.is_empty(), + "{} 不该带实例数据", + screen.slug() + ); + } +}