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..9ac2a06 --- /dev/null +++ b/crates/app-iced/src/app/message.rs @@ -0,0 +1,89 @@ +//! 应用消息:单一大枚举。 +//! +//! 纪律:本枚举是**唯一**的消息类型,`view` 产出的所有回调都必须落到这里;组件库与页面层 +//! 各自用自己的 `Message`(组件契约 §2 的泛型纪律),由 `view` 做一次 `.map` 接上。 +//! +//! ## 三类输入(M90) +//! +//! | 来源 | 变体 | 落点 | +//! |---|---|---| +//! | 窗口/框架 | `Boot` / `WindowReady` / `Window` / `Frame` / `Captured` / 三个 caption | [`super::update`] 直接处理 | +//! | **时间** | `Frame`(忙碌,33 ms 闸门)+ `Heartbeat`(空闲,1 Hz) | 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), + /// 一帧画完。**只在需要 33 ms 节拍时订阅**:截图通道 / 有 Busy-Running 实例 / 有未过期 + /// toast / 派发后的 3 s 观察窗(见 `subscription::needs_frames`)。 + Frame, + /// **空闲态**的 1 Hz 心跳:`Task::perform(sleep(1s))` 的自续链,用来替代空闲时的帧循环 + /// (M90 r3 / P2-2)。它保证 `theme::config_poll_due` 仍按「30 × 33 ms ≈ 1 秒」的 + /// presenter 语义跑,外部 CLI/AI 改配置后主题 1 秒内生效。 + Heartbeat, + /// 窗口事件(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, + } +} + +/// 路由 → 外壳导航项([`route_of`] 的逆;外壳 rail 与 caption 键都按它高亮)。 +/// +/// `Route::Detail(_)` 高亮「实例」(与 `Route::nav()` 同源)。 +pub(super) fn nav_item_of(route: &Route) -> NavItem { + match route { + Route::Instances | Route::Detail(_) => NavItem::Instances, + Route::Download => NavItem::Download, + Route::Logs => NavItem::Logs, + Route::Settings => NavItem::Settings, + Route::Agent => NavItem::Ai, + } +} 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..e7c491f --- /dev/null +++ b/crates/app-iced/src/app/state.rs @@ -0,0 +1,248 @@ +//! 应用状态:**只放字段与构造**,不放消息分支(那是 [`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 快照 → 页面要画的东西。 +//! 它在**状态真的变了**时重建([`App::view_dirty`] 门控,M90 r2/P2-1 起),不是每拍重建—— +//! 重建要把 N 个实例的名字/版本串克隆一遍,30 Hz 空转没有意义。缓存成字段是为了让 +//! `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, config_path, 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_MS: u64 = 33; + +/// [`TICK_MS`] 的 `Duration` 形态。 +pub(super) const TICK: std::time::Duration = std::time::Duration::from_millis(TICK_MS); + +/// **空闲态**的低频心跳(1 Hz,M90 r3 / P2-2)。 +/// +/// 空闲时不能订阅 `window::frames()`:那条订阅会自持重绘循环(实测软件渲染约 500 帧/秒), +/// 而空闲时唯一需要的时钟是「偶发一次」——外部 CLI / AI 工具改 `launcher.json` 后的热切 +/// (presenter 的 `theme::config_poll_due` 语义是 30 × 33 ms ≈ 1 秒一拍)。 +/// +/// 实现用的是 `Task::perform(async { sleep(1s) })` 的自续链:[`App::heartbeat`]。它每秒只唤醒 +/// 一次、只触发**一帧**重绘,而 `frames()` 是每秒几百帧。为什么不用 `iced::time::every`: +/// 它挂在 tokio/smol 后端下,本 crate 的执行器是 thread-pool(Cargo.toml 不在 scope)。 +pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs(1); + +/// 派发指令后保持帧订阅的观察窗(见 [`App::watch_until`])。 +/// +/// 3 秒够真 host 把「命令已受理」的第一条事件交回来;之后由「有 Busy/Running 实例」这个条件续订。 +pub(super) const WATCH_WINDOW: std::time::Duration = std::time::Duration::from_secs(3); + +/// 进程内「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` 的输入)。单位是 **33 ms 一拍**,不是调用次数。 + pub(super) ticks: u64, + /// 下一次「比对配置文件 mtime」的 tick 号(见 `update::advance` 的注释)。 + pub(super) config_due_at: u64, + /// 设置文件里的主题模式(`auto` / `light` / `dark`)。 + /// + /// 用来定优先级:`--theme` > 配置文件 > 系统主题。没有它,`Message::SystemTheme` 会无条件 + /// 盖掉配置文件的显式主题(M90 r3 修)。 + pub(super) theme_mode: ThemeMode, + /// 上一次推进节拍的时刻(节拍由帧事件驱动,按真实流逝时间累加)。 + pub(super) last_tick: Instant, + /// 窗口宽度(布局档位的判据;交互模式跟着 resize 事件走)。 + pub(super) width: f32, + /// 展示转换缓存(presenter 快照 → 页面视图模型)。 + pub(super) view: pages::Model, + /// 视图模型是否过期(P2-1):只有 `apply` / `navigate` / resize / 主题这类**真的改了状态** + /// 的事情才置位,`advance()` 每拍只在置位时重建模型(否则 30 Hz × N 实例的白克隆)。 + pub(super) view_dirty: bool, + /// 「刚派发过指令,等后端回事件」的观察窗(P2-1)。 + /// + /// 空闲态**不订阅帧事件**(只有 1 Hz 的 `heartbeat`),所以派发之后必须有这段短窗口让 + /// 33 ms 节拍跑起来,否则真 host 的异步事件永远抽不出来。 + /// + /// 空闲态不订阅帧事件(不点亮窗口重绘),但派发之后必须有一段短窗口保持节拍, + /// 否则真 host 的异步事件永远抽不出来(`LaunchInstance` 之后状态还是 Idle ⇒ 帧订阅永不开启)。 + pub(super) watch_until: Option, + /// 窗口句柄(`window::latest()` 之后才有)。 + pub(super) window: Option, + /// 已画的帧数(截图闸门用)。 + pub(super) frames: u32, + /// 视图模型重建次数(P2-1 的可观测计数:空闲态 100 拍里必须是 0 增长)。 + pub(super) refreshes: u64, + /// 首屏快照到位后是否已经自动选中过第一行(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)); + // P2-2:设置桥必须与后端指向**同一个数据根**。早先这里无条件 `SettingsBridge::open()` + // (按便携配置解析),于是 `--data-dir X` 会出现「实例来自 X、主题/设置读写默认根」的一进程 + // 两个数据根。Slint 版是同一条口径:`app/src/ui_bind.rs` 的 + // `Some(root) => SettingsBridge::with_path(config_path(root))`。 + let bridge = match &options.data_dir { + Some(root) => SettingsBridge::with_path(config_path(root)), + None => 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 theme_mode = bridge.load().1; + 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, + config_due_at: unlucky_presenter::theme::CONFIG_POLL_TICKS, + theme_mode, + last_tick: Instant::now(), + width, + view, + view_dirty: false, + watch_until: None, + window: None, + frames: 0, + refreshes: 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 侧做**)。 + /// + /// 代价是「克隆 N 个实例的名字/版本串」,所以只在 [`App::view_dirty`] 置位时调用(P2-1)。 + pub(super) fn refresh_view(&mut self) { + self.view = super::view::model(&self.state, self.width, self.options.force_state); + self.view_dirty = false; + self.refreshes += 1; + } + + /// 按设置文件里的主题模式切一次外观;`--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; + } + let mode = self.bridge.load().1; + self.theme_mode = mode; + match mode { + 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..bb6e8f0 --- /dev/null +++ b/crates/app-iced/src/app/subscription.rs @@ -0,0 +1,82 @@ +//! 订阅:**按需**帧节拍(presenter 的 TICK)+ 窗口尺寸事件。 +//! +//! ## 为什么要「按需」 +//! +//! iced_winit 在处理完任何一条消息后都会 `request_redraw()`,而 `window::frames()` 监听的是 +//! `RedrawRequested` —— 于是「订阅帧 → 收 Frame → update → 再触发重绘」是一条**自持循环**: +//! 只要订阅着,窗口就永远在重绘(M90 r1 就是这样,CPU 空转)。 +//! +//! 现在只在**真的需要 33 ms 节拍**的时候订阅([`App::needs_frames`]): +//! +//! | 条件 | 为什么需要 33 ms 节拍 | +//! |---|---| +//! | 截图通道 | 第 3 帧要落盘 | +//! | 有 `Busy` / `Running` 实例 | 启动进度、日志尾随都靠 `backend.tick` 推进 | +//! | 有未过期的 toast | toast 按毫秒倒计时 | +//! | 刚派发过指令(3 s 观察窗) | 真 host 的异步事件要靠节拍抽出来 | +//! +//! 空闲态**一条帧消息都不来** ⇒ 窗口不重绘、`refresh_view` 不跑(配合 `view_dirty`)。 +//! +//! ## 空闲态的时钟从哪来(M90 r3 / P2-2) +//! +//! r2 把空闲的帧订阅掐掉之后,`config_poll_due` 就再也没被调用过 —— 外部 CLI / AI 工具改 +//! `launcher.json` 后主题**永不生效**(不是「等下次交互」,是「等下一次 dispatch」)。这与 +//! D4「AI 能读改 ULL 配置」直接冲突。 +//! +//! 所以空闲态换成一个 **1 Hz 的自续心跳**([`crate::app::message::Message::Heartbeat`], +//! 由 `Task::perform(sleep(1s))` 驱动,见 `update::heartbeat`):每秒醒一次、推进一拍 +//! presenter(内部按 33 ms 折算 30 拍,于是 `config_poll_due` 仍是「约 1 秒比对一次 mtime」)。 +//! +//! 为什么不用 `window::frames()` + 1 s 闸门来实现这个低频时钟:帧订阅**本身就是**自持重绘循环 +//! (iced_winit 处理完任何消息都 `request_redraw()`),闸门只能省掉 `advance()` 里的活,省不掉 +//! 整窗重绘——实测软件渲染约 **500 帧/秒**。心跳是「每秒一帧」,差三个数量级。 +//! +//! ## 已知代价(都登记,不藏) +//! +//! 1. **忙碌态仍是自持帧循环**:`Running`/`Busy` 一天不结束,就一天按帧刷(软件渲染实测约 +//! 970 帧/秒,refreshes 不增长 = 纯像素空转)。功能上需要 tick 来发现游戏退出,但「整段 +//! 运行期近千帧/秒重绘」是真实开销;要收口得让 `Running` 走更慢的时钟(follow-up)。 +//! 2. 心跳用 `thread::sleep` 占线程池一个线程 1 秒;1 Hz 下可接受,但不是优雅解法(正解仍是 +//! `iced::time::every` + tokio/smol,Cargo.toml 在 scope 外,已报 finding)。 + +use std::time::Instant; + +use iced::{window, Subscription}; + +use super::message::Message; +use super::state::App; + +impl App { + /// 本次进程的订阅表。 + pub(super) fn subscription(&self) -> Subscription { + let frames = if self.needs_frames() { + window::frames().map(|_| Message::Frame) + } else { + Subscription::none() + }; + + // 断点 600 / 840 要跟着真窗口宽度走;截图模式的窗口尺寸由参数钉死,不吃 resize。 + let events = if self.has_screenshot() { + Subscription::none() + } else { + window::events().map(|(_, event)| Message::Window(event)) + }; + + Subscription::batch([frames, events]) + } + + /// 是否订阅帧事件(= 截图模式要帧数闸门)。 + pub(super) fn wants_frames(&self) -> bool { + self.has_screenshot() + } + + /// 现在要不要**时钟**(见模块文档的表)。 + pub(super) fn needs_frames(&self) -> bool { + self.has_screenshot() + || !self.state.toasts.is_empty() + || !self.state.running().is_empty() + || self + .watch_until + .is_some_and(|deadline| Instant::now() < deadline) + } +} diff --git a/crates/app-iced/src/app/tests.rs b/crates/app-iced/src/app/tests.rs new file mode 100644 index 0000000..37823ed --- /dev/null +++ b/crates/app-iced/src/app/tests.rs @@ -0,0 +1,272 @@ +//! `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; + +#[cfg(test)] +mod presenter; + +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 帧出图" + ); +} + +// --------------------------------------------------------------- P2-1 按需节拍 + +/// 空闲态**不要每帧节拍**(但要有 1 Hz 心跳)。 +/// +/// 意图与 r2 那条 `an_idle_app_asks_for_no_clock` 一致:空闲时不许按帧循环跑(那会以每秒几百帧 +/// 的速度重绘整窗),改成「低频心跳」。断言拆成两半: +/// +/// 1. `needs_frames()` 为假 ⇒ **不订阅** `window::frames()`; +/// 2. 空闲时钟的周期 = [`IDLE_TICK`] = 1 s,且心跳每拍推进的 tick 预算 = 30(= 1 s / 33 ms), +/// 这样 `theme::config_poll_due`(30 拍)仍是「约 1 秒比对一次 mtime」。 +/// +/// 抓得住:有人把「无条件订阅帧」改回来(第 1 半红);有人把心跳周期改快/改慢(第 2 半红)。 +/// 抓不住:真实窗口的重绘次数(那要 `ULL_TICK_TRACE=1` 跑一次交互模式看日志,见报告)。 +#[test] +fn an_idle_app_uses_a_slow_heartbeat_instead_of_frames() { + let app = headless(screenshot_options(), SchemeFlavor::Light); + assert!(app.needs_frames(), "截图通道永远要帧(第 3 帧落盘)"); + + let mut idle = headless(Options::default(), SchemeFlavor::Light); + let _ = idle.update(Message::Boot); + assert!( + idle.needs_frames(), + "Boot 刚派发过 RefreshAll:3 秒观察窗内要节拍(等真 host 的首批事件)" + ); + + // 观察窗过期、也没有忙碌实例 ⇒ 空闲态不订阅帧事件(改走 1 Hz 心跳) + idle.watch_until = None; + assert!(!idle.needs_frames(), "空闲交互态:不订阅帧事件(只有心跳)"); + + // 派发之后观察窗重新打开 + let _ = idle.update(Message::Page(pages::Message::Navigate(Route::Detail( + "survival".to_string(), + )))); + let _ = idle.update(Message::Page(pages::Message::Launch( + "survival".to_string(), + ))); + assert!(idle.needs_frames(), "派发后观察窗内要有节拍"); + + // 进入 Busy 之后由「忙碌实例」这个条件自己续订(不靠观察窗) + idle.watch_until = None; + assert!( + idle.needs_frames(), + "有 Busy 实例时即便观察窗过期也要继续 tick" + ); +} + +/// 心跳的**节奏**:1 Hz,且每拍折算 30 个 33 ms 单位 ⇒ `config_poll_due` ≈ 每秒一次。 +/// +/// 抓得住:心跳周期常量、以及 `advance()` 里「按流逝时间折算 ticks」这条记账(改回 +/// `ticks += 1` 会让空闲态的配置轮询变成 30 秒一次,这条就红)。 +/// 抓不住:`Task::perform` 那条自续链真的每秒触发一次(那是运行期 trace 的事,见报告)。 +#[test] +fn the_idle_heartbeat_budgets_one_config_poll_per_second() { + use unlucky_presenter::theme::config_poll_due; + + assert_eq!( + super::state::IDLE_TICK, + std::time::Duration::from_secs(1), + "空闲心跳周期必须是 1 s" + ); + + let mut app = headless(Options::default(), SchemeFlavor::Light); + let _ = app.update(Message::Boot); + app.watch_until = None; + + // 一次空闲心跳(1 s)之后:ticks 增加 30,且正好落在 config 轮询点上 + let before = app.ticks; + app.advance(super::state::IDLE_TICK); + assert_eq!(app.ticks - before, 30, "1 s 应折算 30 个 33 ms 拍"); + assert!( + config_poll_due(app.ticks), + "空闲心跳每拍都要落在 config 轮询点上(ticks={})", + app.ticks + ); +} + +/// 空闲态每拍**不再重建视图模型**(P2-1 的另一半:`refresh_view` 的 30 Hz × N 实例白克隆)。 +/// +/// 抓得住:把 `advance()` 里的 `if self.view_dirty` 去掉(改回无条件 `refresh_view()`)时, +/// 这条断言里的 `refreshes` 会从「不增长」变成「每拍 +1」。 +/// 抓不住:「重建一次花了多少微秒」(没有基准测试;量级判断见报告)。 +#[test] +fn idle_ticks_do_not_rebuild_the_view_model() { + let mut app = headless(Options::default(), SchemeFlavor::Light); + let _ = app.update(Message::Boot); + + let before = app.refreshes; + for _ in 0..100 { + app.advance(super::state::TICK); + } + assert_eq!( + app.refreshes, + before, + "空闲 100 拍:视图模型一次都不该重建(实际 +{})", + app.refreshes - before + ); + + // 有事件进来时仍然要重建(否则页面不刷新) + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Logs))); + assert!(app.refreshes > before, "导航之后必须重建一次"); +} +/// 视图模型跟着窗口宽度走(交互模式下 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", "截图模式的主题只认命令行"); +} + +/// 主题优先级:`--theme` > 配置文件 `appearance.theme_mode` > 系统主题。 +/// +/// r2 之前 `Message::SystemTheme` 无条件 `set_flavor`,于是「配置文件写 dark、系统是浅色」会 +/// 出现 `light -> dark -> light` 的一次抖动、配置永远不生效(r3 做 P2-2 的配置热切实测时暴露 +/// 出来,一并修)。 +/// +/// 抓得住:系统主题不许盖掉显式配置/命令行给出的主题(三条分支各一条断言)。 +/// 抓不住:真实桌面切换系统主题时的事件时序(只有消息级验证)。 +#[test] +fn the_system_theme_never_overrides_an_explicit_one() { + use unlucky_presenter::prefs::ThemeMode; + + // ① auto(内存桥默认):系统主题说了算 + let mut auto = headless(Options::default(), SchemeFlavor::Light); + assert_eq!(auto.theme_mode, ThemeMode::Auto); + let _ = auto.update(Message::SystemTheme(iced::theme::Mode::Dark)); + assert_eq!(auto.ui().name(), "ull-m3-dark", "auto 模式要跟随系统"); + + // ② 配置文件显式 dark:系统给浅色也不许改 + let mut pinned = headless(Options::default(), SchemeFlavor::Dark); + pinned.theme_mode = ThemeMode::Dark; + let _ = pinned.update(Message::SystemTheme(iced::theme::Mode::Light)); + assert_eq!( + pinned.ui().name(), + "ull-m3-dark", + "配置文件说 dark,系统说 light:不许改" + ); + + // ③ 命令行 `--theme` 优先级最高 + let mut explicit = headless( + Options { + theme: Some(SchemeFlavor::Dark), + ..Options::default() + }, + SchemeFlavor::Dark, + ); + let _ = explicit.update(Message::SystemTheme(iced::theme::Mode::Light)); + assert_eq!( + explicit.ui().name(), + "ull-m3-dark", + "--theme 优先级最高,系统主题不许盖" + ); +} diff --git a/crates/app-iced/src/app/tests/presenter.rs b/crates/app-iced/src/app/tests/presenter.rs new file mode 100644 index 0000000..af399f5 --- /dev/null +++ b/crates/app-iced/src/app/tests/presenter.rs @@ -0,0 +1,238 @@ +//! presenter 闭环与节拍的断言(M90 r2 从 `app/tests.rs` 外置:那边 416 行会被 file-size 判红)。 +//! +//! 这里的每条都依赖一个可注入的 `Backend`: +//! - `MockBackend`(presenter 自带)验「命令 → 事件 → 状态」的闭环; +//! - `RecorderBackend`(本文件,只记账不回事件)验「UI 侧不做乐观回显」——用 MockBackend 验这条 +//! 会得到**假绿**,因为它是同步发事件的,本地回显与事件回流终态相同(P3-2 的教训)。 + +use std::cell::RefCell; +use std::rc::Rc; + +use unlucky_presenter::dto::{AppCommand, AppEvent, Backend, CoreEvent}; +use unlucky_presenter::mock_data::mock_instances; + +use super::*; + +// --------------------------------------------------------------- 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::super::state::TICK); + assert_eq!(app.state.route(), &Route::Instances, "自动选中只做一次"); +} + +/// 只记账、**不回任何事件**的后端(P3-2)。 +/// +/// 为什么需要它:`MockBackend::dispatch` 是**同步**发事件的,于是「UI 本地回显」与「事件回流」 +/// 在 dispatch 之后终态相同——拿 MockBackend 测「不乐观回显」是假绿(r1 就是栽在这条上)。 +/// 这个后端把两件事分开:命令只进 `dispatched`,`drain_events` 永远空 ⇒ 状态**不可能**变; +/// 一旦有人在 `update` 里偷偷改本地状态,下面那条断言立刻红(实测见报告「P3-2 取证」)。 +#[derive(Debug, Default)] +struct RecorderBackend { + dispatched: Rc>>, + seeded: Vec, +} + +impl Backend for RecorderBackend { + fn drain_events(&mut self) -> Vec { + std::mem::take(&mut self.seeded) + } + + fn dispatch(&mut self, command: AppCommand) { + self.dispatched.borrow_mut().push(command); + } +} + +/// 用 recorder 后端造一个 App(首屏快照靠 `seeded` 一次性喂进去)。 +fn recording(options: Options, flavor: SchemeFlavor) -> (App, Rc>>) { + let recorded = Rc::new(RefCell::new(Vec::new())); + // 注意:`InstancesChanged` 是**整体替换**,所以四条必须合成一条事件; + // 分开发的话最后只会剩一个实例(r1 在这条上踩过)。 + let backend = RecorderBackend { + dispatched: Rc::clone(&recorded), + seeded: vec![AppEvent::Core( + CoreEvent::InstancesChanged(mock_instances()), + )], + }; + let app = App::with_presenter( + options, + FAMILIES, + flavor, + Box::new(backend), + SettingsBridge::memory_only(), + ); + (app, recorded) +} + +/// **P3-2:不做乐观回显**——命令必须真的出 UI 层,而状态在后端回事件之前**一个字节都不许变**。 +/// +/// 抓得住:`update` 里出现「本地先改状态」的写法(recorder 永远不回事件,本地一改这里就红)。 +/// 抓不住:「命令有没有真的到达 host」——那由 `MockBackend` 的闭环测试与真机验证负责。 +#[test] +fn launching_only_dispatches_and_never_echoes_locally() { + let mut options = screenshot_options(); + options.screenshot = None; // recorder 不依赖截图通道 + let (mut app, recorded) = recording(options, SchemeFlavor::Light); + + let _ = app.update(Message::Boot); + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( + "survival".to_string(), + )))); + + // Boot 自己发过一条 RefreshAll,先把账本清掉,下面只看「点 Play」这一个动作 + recorded.borrow_mut().clear(); + + let before = app.view.instances.detail.clone().expect("有选中项"); + assert!(before.can_launch && !before.can_stop && before.status.is_none()); + + let _ = app.update(Message::Page(pages::Message::Launch( + "survival".to_string(), + ))); + + // ① 命令真的出 UI 层了 + assert_eq!( + *recorded.borrow(), + vec![AppCommand::LaunchInstance("survival".to_string())], + "Launch 必须翻成一条 AppCommand::LaunchInstance 并交给 Backend::dispatch" + ); + + // ② 后端一条事件都没回 ⇒ 视图模型必须**原样不动**(任何本地回显都会让这条红) + let after = app.view.instances.detail.clone().expect("有选中项"); + assert_eq!( + after, before, + "没有事件回流时页面不许自己变(乐观回显);现在的差异是 {after:?}" + ); + assert!(!after.can_stop, "不许因为点了 Play 就本地切成「可停止」"); + assert!(after.status.is_none(), "不许本地造一条进度文案"); +} + +/// **点 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::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); +} diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs new file mode 100644 index 0000000..8ee14fc --- /dev/null +++ b/crates/app-iced/src/app/update.rs @@ -0,0 +1,332 @@ +//! `update`:消息分支的**唯一**落点(含「用户动作 → 后端指令」的派发与事件回流)。 +//! +//! ## 数据流的三个入口(presenter crate 文档 §2) +//! +//! ```text +//! 用户动作 ──dispatch(AppCommand)──► backend ──► drain_events() ──► AppState::apply ──► 视图模型 +//! ▲ ▲ +//! └── pages::Message(导航/启动/停止)──────────────────┘ 忙碌 33 ms / 空闲 1 Hz +//! ``` +//! +//! 三条纪律: +//! +//! 1. **不做乐观回显**:点了「Play」不改本地状态,等 `InstanceRuntimeChanged` 事件回来才变 +//! (否则事件与本地状态打架时,磁贴与进度条会各说一套); +//! 2. **状态只有一个写入口**:`AppState::apply`(事件)与 `AppState::navigate`(路由); +//! 3. **只在状态变了才重建视图模型**:`refresh_view()` 是快照 → 页面的唯一转换点, +//! 由 `view_dirty` 门控(M90 r2/P2-1 起;此前是每拍无条件重建)。 +//! +//! ## 两个时钟(M90 r3 / P2-2) +//! +//! | 场景 | 时钟 | 周期 | 谁触发 | +//! |---|---|---|---| +//! | 截图 / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | [`App::needs_frames`] | +//! | 其余(空闲) | `Heartbeat` 自续链 | 1 s | [`App::heartbeat`] | +//! +//! 两条时钟不会同时干活:心跳在 `needs_frames()` 为真时**只续排、不推进**,忙碌结束自动接回。 + +use std::time::{Duration, Instant}; + +use iced::{window, Task}; + +use unlucky_design::color::SchemeFlavor; +use unlucky_presenter::dto::AppCommand; +use unlucky_presenter::prefs::ThemeMode; +use unlucky_presenter::router::Route; +use unlucky_presenter::theme::CONFIG_POLL_TICKS; + +use crate::pages; + +use super::message::{route_of, Message}; +use super::state::{App, IDLE_TICK, TICK, TICK_MS, WATCH_WINDOW}; +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:?}"); + // 优先级:`--theme` > 配置文件里的 `appearance.theme_mode` > 系统。 + // 早先这里无条件盖掉,于是「配置文件写 dark、系统是浅色」会出现 + // `light -> dark -> light` 的一次抖动,配置永远不生效(M90 r3 顺手修)。 + if self.options.theme.is_none() && self.theme_mode == ThemeMode::Auto { + self.set_flavor(flavor); + } + Task::none() + } + Message::Frame => self.on_frame(), + Message::Heartbeat => self.on_heartbeat(), + Message::Window(event) => { + if let window::Event::Resized(size) = event { + self.width = size.width; + self.view_dirty = true; + 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.view_dirty = true; + 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.view_dirty = true; + self.refresh_view(); + + // 空闲心跳从这里开始自续(它是唯一能在「没有帧事件」时戳醒 app 的时钟) + tasks.push(heartbeat()); + + Task::batch(tasks) + } + + /// 空闲心跳:只有「不需要帧节拍」时才推进一拍,然后**无条件续排**下一拍。 + /// + /// 为什么无条件续排:忙碌结束(游戏退出、toast 过期、观察窗关闭)之后没有别的东西会叫醒 + /// app,心跳链必须一直在,才能把低频时钟接回去。忙碌期间它只是每秒醒一次、什么都不做。 + fn on_heartbeat(&mut self) -> Task { + if !self.needs_frames() { + let now = Instant::now(); + let elapsed = now.saturating_duration_since(self.last_tick).max(IDLE_TICK); + self.last_tick = now; + self.advance(elapsed); + } + heartbeat() + } + + /// 一帧画完:①够 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)); + // `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s, + // 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。 + let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS)); + let ticks = u64::try_from(ticks).unwrap_or(u64::MAX).max(1); + let bucket_before = self.ticks / 30; + self.ticks += ticks; + let bucket_after = self.ticks / 30; + + // 外部(AI / CLI)改了配置文件:主题跟着换,其余外观项等玻璃/背景 mission。 + // + // 判据用**跨越**而不是 presenter 的 `config_poll_due(ticks)`(整除):两个时钟的相位不同 + // (帧节拍按 33 ms 累加、空闲心跳按 30 拍累加),整除判定一旦错过一次就**再也命中不了** + // ——实测 ticks=137 之后每拍 +30 永远不整除,配置轮询静默死掉。间隔真源仍是 + // `CONFIG_POLL_TICKS`(30 拍 × 33 ms ≈ 1 秒)。截图通道不轮询(D30 可复现)。 + let poll_due = self.ticks >= self.config_due_at; + if poll_due { + // 追到「当前 ticks 之后的第一个间隔点」,避免心跳把相位拖飞 + self.config_due_at = self.ticks + CONFIG_POLL_TICKS; + } + let reloaded = + !self.has_screenshot() && poll_due && self.bridge.reload_if_changed().is_some(); + if reloaded { + eprintln!("[settings] 配置文件变了,重读主题"); + self.apply_config_theme(); + } + + if self.view_dirty { + self.refresh_view(); + } + + // 取证开关(`ULL_TICK_TRACE=1`):每跨过 30 拍(≈1 秒)打一行。 + // 忙碌态由帧节拍跨桶(≈1 行/秒),空闲态由心跳一次 +30 跨桶(**也是 1 行/秒**)—— + // 两种时钟都用同一把尺子,所以 trace 的密度本身就是「时钟在跑」的证据。 + if bucket_after != bucket_before && std::env::var_os("ULL_TICK_TRACE").is_some() { + eprintln!( + "[tick] n={} frames={} refreshes={} clock={}", + self.ticks, + self.frames, + self.refreshes, + if self.needs_frames() { + "frames" + } else { + "idle-1hz" + } + ); + } + } + + /// 页面消息:导航走路由栈,动作翻成 `AppCommand`。 + fn on_page(&mut self, message: pages::Message) { + match message { + pages::Message::Navigate(route) => { + self.state.navigate(route); + self.view_dirty = true; + 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); + // 观察窗:命令发出后 3 秒内保持帧订阅,等后端把第一条事件交回来 + // (真 host 是异步的,派发瞬间状态还没变;不设这个窗口,空闲态的帧订阅永远不会打开)。 + self.watch_until = Some(Instant::now() + WATCH_WINDOW); + 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.view_dirty = true; + 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)); + self.view_dirty = true; + } + + /// 截图回来了:落盘 + 打一行取证日志,然后退出。 + 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() + } +} + +/// 空闲心跳的一拍:睡 1 秒再发 [`Message::Heartbeat`]。 +/// +/// 用 `Task::perform` + `std::thread::sleep` 而不是 `iced::time::every`:后者只在 tokio/smol +/// 后端提供(本 crate 的执行器是 thread-pool,Cargo.toml 不在 scope)。代价是每拍让线程池里 +/// 一个线程阻塞 1 秒——按 1 Hz 算,对线程池(默认 = CPU 数)是可接受的占用。 +fn heartbeat() -> Task { + Task::perform(async { std::thread::sleep(IDLE_TICK) }, |()| { + Message::Heartbeat + }) +} diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs new file mode 100644 index 0000000..85fff4c --- /dev/null +++ b/crates/app-iced/src/app/view.rs @@ -0,0 +1,314 @@ +//! `view`:presenter 快照 → 页面视图模型 → 控件树。 +//! +//! 展示转换(快照字段 → 显示文本/网格)**全部在这里**,页面层只认 [`pages::Model`]。 + +use iced::{Element, Theme}; + +use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage}; +use unlucky_presenter::router::Route; +use unlucky_presenter::state::AppState; + +use crate::app::components::interaction::Interaction; +use crate::cli::Screen; +use crate::pages::{self, Detail, Meta, Row}; +use crate::shell::{self, NavItem}; + +use super::message::{nav_item_of, 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); + } + + // 产品页走**内容槽位**:外壳(标题栏 / 拖动与缩放热区 / 三个 caption 键 / rail)由 + // `shell` 画,中间那块才是页面。M90 r2 之前这里用的是 `pages::app`(自带一条**没有任何 + // 控件**的标题栏留白带),而 `DragWindow` / `ResizeStart` / `Minimize` / `ToggleMaximize` / + // `Close` 五个消息只在外壳里构造 ⇒ 产品页「拖不动、缩不了、点不到关闭」。 + match pages::Screen::of(self.state.route()) { + Some(_) => shell::view_with_content( + &self.ui, + nav_item_of(self.state.route()), + APP_TITLE, + pages::page(&self.ui, &self.view).map(Message::Page), + self.options.force_state, + ), + // `Route::Agent`:AI 独立页还没有页面实现(P1),回退到 M68 的外壳卡(同一套装饰)。 + 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 的快照转成页面视图模型(**唯一**的展示转换点)。 +/// +/// `forced` 是 `--force-state` 的落点(P3-1):它只影响**画出来的**状态层,不改任何状态机。 +pub(super) fn model( + state: &AppState, + width: f32, + forced: Option, +) -> 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 }, + forced: forced.map(|state| match state { + crate::cli::ForcedState::Hover => Interaction::Hovered, + crate::cli::ForcedState::Pressed => Interaction::Pressed, + }), + } +} + +/// 列表行的副文案:`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 { + // 视觉 P2-2(M90 r2):列表行与详情曾经「同物两名」(列表 `badge()` 出「原版」、详情硬编码 + // 「Vanilla」)。现在两边都走 `LoaderInfo::badge()` 这一个入口;等 i18n 落地后由词表统一, + // 届时两处会同时变——这正是要的。 + let loader = 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, None); + 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, None); + 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, None); + // 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/components/gate.rs b/crates/app-iced/src/components/gate.rs index 223ba69..1a023f5 100644 --- a/crates/app-iced/src/components/gate.rs +++ b/crates/app-iced/src/components/gate.rs @@ -22,6 +22,10 @@ use std::path::{Path, PathBuf}; /// 单文件行数上限(AGENTS.md §3-1)。 const MAX_LINES: usize = 400; +fn src_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src") +} + fn components_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("src") @@ -192,10 +196,25 @@ fn t2_no_size_literals_in_components() { ); } +/// 单文件 ≤400 行:**扫整个 `src/`**,不只 components。 +/// +/// M90 r2(P1-2)的教训:这条守门原来只遍历 `components/`,于是 `pages/harness.rs` 悄悄长到 +/// 402 行没人拦,直到 `guards.yml` 的 file-size job 在 CI 上判红才发现。`guards.yml` 的口径是 +/// **全仓**(`git ls-files '*.rs' '*.md' …`),这里至少把本 crate 的 `src/**` 对齐: +/// 谁在 `app/` / `pages/` / `shell/` 把文件推过线,`cargo test` 就先红,不用等 CI。 #[test] -fn every_component_file_stays_under_the_line_limit() { +fn every_source_file_stays_under_the_line_limit() { + let mut files = Vec::new(); + rust_files(&src_dir(), &mut files); + assert!( + files.len() >= 40, + "src/ 至少 40 个 .rs,实际 {}:扫描逻辑坏了,这条门禁等于没跑", + files.len() + ); + let mut offenders = Vec::new(); - for (path, text) in sources() { + for path in files { + let text = std::fs::read_to_string(&path).expect("读源文件"); let lines = text.lines().count(); if lines > MAX_LINES { offenders.push(format!( @@ -206,7 +225,7 @@ fn every_component_file_stays_under_the_line_limit() { } assert!( offenders.is_empty(), - "单文件 ≤400 行(AGENTS.md §3-1,guards.yml 会判红):\n{}", + "单文件 ≤400 行(AGENTS.md §3-1,guards.yml 按全仓判红):\n{}", offenders.join("\n") ); } 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..137a309 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,69 +334,10 @@ 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) } } #[cfg(test)] -mod tests { - use super::*; - - fn parse(args: &[&str]) -> Result { - Request::parse(args.iter().map(|arg| (*arg).to_owned())) - } - - #[test] - fn defaults_are_the_acceptance_frame() { - let request = parse(&[]).expect("空参数要能解析"); - assert_eq!(request.screen, Screen::Instances); - assert_eq!((request.width, request.height), (1180.0, 760.0)); - assert_eq!(request.flavor, SchemeFlavor::Light); - assert!(request.screenshot.is_none()); - } - - #[test] - fn screen_and_width_parse_in_both_argv_forms() { - let spaced = parse(&["--screen", "settings", "--width", "900"]).unwrap(); - let inline = parse(&["--screen=settings", "--width=900"]).unwrap(); - assert_eq!(spaced, inline); - assert_eq!(spaced.screen, Screen::Settings); - assert_eq!(spaced.width, 900.0); - assert!(parse(&["--screen=gallery"]).is_err()); - assert!(parse(&["--width=abc"]).is_err()); - } - - #[test] - fn screenshot_theme_wins_over_theme() { - let request = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap(); - assert_eq!(request.flavor, SchemeFlavor::Light); - assert_eq!(parse(&["--theme=dark"]).unwrap().flavor, SchemeFlavor::Dark); - } - - #[test] - fn software_renderer_is_an_alias_of_cpu() { - assert_eq!( - parse(&["--software-renderer"]).unwrap().backend, - Backend::Cpu - ); - assert_eq!(parse(&["--renderer=cpu"]).unwrap().backend, Backend::Cpu); - assert_eq!(parse(&["--renderer=gpu"]).unwrap().backend, Backend::Gpu); - } - - #[test] - fn slug_carries_screen_class_and_flavor() { - let request = parse(&[ - "--screen=instances", - "--width=700", - "--screenshot-theme=dark", - ]) - .unwrap(); - assert_eq!(request.slug(), "instances-medium-dark-w700"); - } - - #[test] - fn tiny_windows_are_rejected() { - assert!(parse(&["--width=200"]).is_err()); - assert!(parse(&["--height=100"]).is_err()); - } -} +#[path = "harness/tests.rs"] +mod tests; diff --git a/crates/app-iced/src/pages/harness/tests.rs b/crates/app-iced/src/pages/harness/tests.rs new file mode 100644 index 0000000..37a3e58 --- /dev/null +++ b/crates/app-iced/src/pages/harness/tests.rs @@ -0,0 +1,63 @@ +//! `pages::harness` 的断言(M90 r2 从 `harness.rs` 外置:那边曾 402 行,guards 的 file-size 会判红)。 +//! +//! 只测**参数解析**与**出图 slug**,不碰 iced 运行时。 + +use super::*; + +fn parse(args: &[&str]) -> Result { + Request::parse(args.iter().map(|arg| (*arg).to_owned())) +} + +#[test] +fn defaults_are_the_acceptance_frame() { + let request = parse(&[]).expect("空参数要能解析"); + assert_eq!(request.screen, Screen::Instances); + assert_eq!((request.width, request.height), (1180.0, 760.0)); + assert_eq!(request.flavor, SchemeFlavor::Light); + assert!(request.screenshot.is_none()); +} + +#[test] +fn screen_and_width_parse_in_both_argv_forms() { + let spaced = parse(&["--screen", "settings", "--width", "900"]).unwrap(); + let inline = parse(&["--screen=settings", "--width=900"]).unwrap(); + assert_eq!(spaced, inline); + assert_eq!(spaced.screen, Screen::Settings); + assert_eq!(spaced.width, 900.0); + assert!(parse(&["--screen=gallery"]).is_err()); + assert!(parse(&["--width=abc"]).is_err()); +} + +#[test] +fn screenshot_theme_wins_over_theme() { + let request = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap(); + assert_eq!(request.flavor, SchemeFlavor::Light); + assert_eq!(parse(&["--theme=dark"]).unwrap().flavor, SchemeFlavor::Dark); +} + +#[test] +fn software_renderer_is_an_alias_of_cpu() { + assert_eq!( + parse(&["--software-renderer"]).unwrap().backend, + Backend::Cpu + ); + assert_eq!(parse(&["--renderer=cpu"]).unwrap().backend, Backend::Cpu); + assert_eq!(parse(&["--renderer=gpu"]).unwrap().backend, Backend::Gpu); +} + +#[test] +fn slug_carries_screen_class_and_flavor() { + let request = parse(&[ + "--screen=instances", + "--width=700", + "--screenshot-theme=dark", + ]) + .unwrap(); + assert_eq!(request.slug(), "instances-medium-dark-w700"); +} + +#[test] +fn tiny_windows_are_rejected() { + assert!(parse(&["--width=200"]).is_err()); + assert!(parse(&["--height=100"]).is_err()); +} diff --git a/crates/app-iced/src/pages/instances.rs b/crates/app-iced/src/pages/instances.rs index 1da057f..ee720c1 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,58 +10,92 @@ //! 为什么窄窗不是「上下堆」:官方 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; +mod detail; #[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 crate::app::components::buttons::{self, Button}; +use crate::app::components::interaction::Interaction; +use crate::app::components::navigation::list_item::{list_item, ListItem}; use crate::theme::Ui; +use unlucky_presenter::router::Route; use super::frame::{self, Header}; -use super::layout::{self, FrameMetrics, PageLayout}; +use super::layout::{FrameMetrics, PageLayout}; +use super::model::{Instances, Model, 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, model: &'a Model) -> Element<'a, Message> { + let block = &model.instances; + let forced = model.forced; 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, + forced_state( + Button::new("New instance", Message::Action(Action::NewInstance)), + forced, + ), + )) + .action(buttons::text( + ui, + forced_state( + Button::new("Import", Message::Action(Action::Import)), + forced, + ), + )); let body = match metrics.layout { - PageLayout::ListDetail => { - let detail = match selected { - Some(_) => detail(ui, metrics), - None => detail_empty(ui), - }; - frame::panes(metrics, list_column(ui, selected), detail) - } - _ => match selected { - Some(_) => narrow_detail(ui, metrics), - None => frame::single_column(metrics, detail_empty(ui)), + PageLayout::ListDetail => frame::panes( + metrics, + list_column(ui, block, forced), + match &block.detail { + Some(detail) => detail::detail_column(ui, metrics, detail, forced), + None => detail::detail_empty(ui), + }, + ), + _ => match &block.detail { + Some(detail) => detail::narrow_detail(ui, metrics, detail, forced), + None => frame::single_column(metrics, detail::detail_empty(ui)), }, }; @@ -70,7 +104,11 @@ 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, + forced: Option, +) -> Element<'a, Message> { let head = container( row![ container(frame::text_role( @@ -82,11 +120,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 +140,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), forced)) .collect::>(), ) - .spacing(ui.space(Space::X1)) + .spacing(ui.space(ROW_GAP)) .padding(Padding::from([0.0, ui.space(Space::X2)])) .width(Length::Fill); @@ -136,216 +175,51 @@ 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(); - - 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) - }; - - 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); - - 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() +fn is_selected(block: &Instances, entry: &Row) -> bool { + block + .detail + .as_ref() + .is_some_and(|detail| detail.id == entry.id) } -// --------------------------------------------------------------- 详情列 - -fn detail<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> { - let entry = &DEMO[SELECTED]; - - 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), - ) +/// `--force-state` 的落点:只改**画出来的**状态,不动任何状态机(cli `ForcedState` 的同一条纪律)。 +pub(super) fn forced_state<'a, M>( + button: Button<'a, M>, + forced: Option, +) -> Button<'a, M> { + match forced { + Some(state) => button.state(state), + None => button, + } } -fn narrow_detail<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> { - // 窄窗:列表退成上一屏,本屏只放详情(官方 compact 的 list-detail 口径)。 - let entry = &DEMO[SELECTED]; - - 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), - ) -} - -fn detail_head<'a>(ui: &'a Ui, entry: &'a Entry) -> Element<'a, Message> { - let titles = column![ - frame::text_role( - ui, - TypeRole::TitleLarge, - true, - entry.name, - ui.color(Role::OnSurface) - ), - frame::text_role( - ui, - TypeRole::BodySmall, - false, - entry.version, - 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() -} - -/// 元信息网格:列数由 [`layout::meta_columns`](按正文列宽)决定 —— **1180 与 900 都是 2 列**。 +/// 一行(`ListItem` 的两/三行档:overline = 运行状态,headline = 名字,supporting = 版本 + 加载器)。 /// -/// 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), - ]; +/// 点一行 = **压一层路由栈**(`Route::Detail(id)`),页面自己不留「选中项」副本—— +/// 于是「当前选中」与「返回栈」永远是同一份真相(`presenter::router`)。 +fn list_row<'a>( + ui: &'a Ui, + entry: &'a Row, + selected: bool, + forced: Option, +) -> 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 mut grid = column![].spacing(layout::META_GAP).width(Length::Fill); - let columns = metrics.meta_columns.clamp(1, cells.len()); - - for chunk in cells.chunks(columns) { - let mut cells_row = row![].spacing(layout::META_GAP).width(Length::Fill); - for (label, value) in chunk { - let cell = frame::card( - ui, - column![ - frame::text_role( - ui, - TypeRole::LabelMedium, - false, - label, - ui.color(Role::OnSurfaceVariant), - ), - frame::text_role( - ui, - TypeRole::BodyMedium, - false, - value, - ui.color(Role::OnSurface) - ), - ] - .spacing(ui.space(Space::X1)) - .width(Length::Fill), - ); - cells_row = cells_row.push(container(cell).width(Length::Fill)); - } - grid = grid.push(cells_row); + if let Some(status) = &entry.status { + item = item.overline(status.as_str()); + } + if selected { + item = item.selected(); + } + if let Some(state) = forced { + item = item.state(state); } - 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), - ] - .spacing(ui.space(Space::X2)) - .align_y(Alignment::Center) - .into() -} - -fn detail_empty<'a>(ui: &'a Ui) -> Element<'a, Message> { - frame::empty_state( - ui, - IconKind::Instances, - "No instance selected", - "Pick an instance on the left to see its version, loader and launch options.", - Vec::new(), - ) + list_item(ui, item) } 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/instances/detail.rs b/crates/app-iced/src/pages/instances/detail.rs new file mode 100644 index 0000000..fe7bd3f --- /dev/null +++ b/crates/app-iced/src/pages/instances/detail.rs @@ -0,0 +1,246 @@ +//! 实例页的**详情列**(M90 r2 从 `instances.rs` 拆出来:那边超过 400 行会被 guards 的 file-size 判红)。 +//! +//! 结构:标题行(名字 + 版本 + Play/Stop)→ 元信息网格(列数由 `layout::meta_columns` 决定) +//! → 一条分隔线 → 正文段 / 运行状态行 → 动作行(Duplicate / Open folder / Delete)。 +//! 窄窗(<840)时列表退成上一屏,本文件只画详情 + 一个「返回」按钮。 + +use iced::widget::{column, container, row}; +use iced::{Alignment, Element, Length}; + +use unlucky_design::color::Role; +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::interaction::Interaction; +use crate::theme::Ui; +use unlucky_design::icons::IconKind; + +use super::super::frame; +use super::super::layout::{self, FrameMetrics}; +use super::super::model::Detail; +use super::super::{Action, Message}; +use super::forced_state; + +// --------------------------------------------------------------- 详情列 + +pub(super) fn detail_column<'a>( + ui: &'a Ui, + metrics: FrameMetrics, + detail: &'a Detail, + forced: Option, +) -> Element<'a, Message> { + let mut body = column![ + detail_head(ui, detail, forced), + meta_grid(ui, metrics, detail), + divider::full(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, forced))) +} + +pub(super) fn narrow_detail<'a>( + ui: &'a Ui, + metrics: FrameMetrics, + detail: &'a Detail, + forced: Option, +) -> Element<'a, Message> { + // 窄窗:列表退成上一屏,本屏只放详情(官方 compact 的 list-detail 口径)。 + let mut body = column![ + row![buttons::text( + ui, + forced_state( + Button::new("Back to instances", Message::Navigate(Route::Instances)), + forced, + ), + )] + .width(Length::Fill) + .align_y(Alignment::Center), + detail_head(ui, detail, forced), + meta_grid(ui, metrics, detail), + divider::full(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, forced))) +} + +/// 正文段(演示数据才有)与运行状态行(真数据才有)——两者都是可选的,顺序固定。 +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, + forced: Option, +) -> Element<'a, Message> { + let titles = column![ + frame::text_role( + ui, + TypeRole::TitleLarge, + true, + detail.name.as_str(), + ui.color(Role::OnSurface) + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + detail.subtitle.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill); + + // 主操作:**全屏唯一**实心 accent(rubric K2)。不可用时退化成禁用态(只清消息,不改身份)。 + let launch = if detail.can_launch { + buttons::filled( + ui, + forced_state( + Button::new("Play", Message::Launch(detail.id.clone())), + forced, + ), + ) + } else { + buttons::filled(ui, Button::disabled("Play")) + }; + let stop = if detail.can_stop { + buttons::text( + ui, + forced_state( + Button::new("Stop", Message::Stop(detail.id.clone())), + forced, + ), + ) + } 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, detail: &'a Detail) -> Element<'a, Message> { + let mut grid = column![].spacing(layout::META_GAP).width(Length::Fill); + let columns = metrics.meta_columns.clamp(1, detail.meta.len().max(1)); + + for chunk in detail.meta.chunks(columns) { + let mut cells_row = row![].spacing(layout::META_GAP).width(Length::Fill); + for cell in chunk { + let card = frame::card( + ui, + column![ + frame::text_role( + ui, + TypeRole::LabelMedium, + false, + cell.label.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + frame::text_role( + ui, + TypeRole::BodyMedium, + false, + cell.value.as_str(), + ui.color(Role::OnSurface) + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill), + ); + cells_row = cells_row.push(container(card).width(Length::Fill)); + } + grid = grid.push(cells_row); + } + + grid.into() +} + +fn action_row<'a>(ui: &'a Ui, forced: Option) -> Element<'a, Message> { + row![ + buttons::text( + ui, + forced_state( + Button::new("Duplicate", Message::Action(Action::Duplicate)), + forced, + ) + ), + buttons::text( + ui, + forced_state( + Button::new("Open folder", Message::Action(Action::OpenFolder)), + forced, + ) + ), + buttons::text( + ui, + forced_state( + Button::new("Delete", Message::Action(Action::Delete)), + forced + ), + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center) + .into() +} + +pub(super) fn detail_empty<'a>(ui: &'a Ui) -> Element<'a, Message> { + frame::empty_state( + ui, + IconKind::Instances, + "No instance selected", + "Pick an instance on the left to see its version, loader and launch options.", + Vec::new(), + ) +} diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index 2099dc8..2f2b00f 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -1,17 +1,24 @@ -//! # 页面骨架 + 布局系统(P4 / M84 `PAGEFRAME`) +//! # 页面层(P4):布局系统 + 四页 + **路由/真组件的接入口**(M84 `PAGEFRAME` → M90 `APPSKEL`) //! -//! 本模块**不依赖组件库**(`components/` 由 M74/M75 并行建):它交付的是**布局系统**, -//! 四个页面只是它的用例。控件全是同一份令牌下的占位实现,等组件家族合入后由另一条 mission 替换。 +//! ## 这一层交了什么 //! -//! ## 为什么要做这一层(历史判词) +//! | 阶段 | 交付 | 判据 | +//! |---|---|---| +//! | M84 | **布局系统**(档位/版面/列宽)+ 四页占位视觉 | 下面的版面阈值表 | +//! | **M90(本文件当前状态)** | ①视图模型 [`model`] ②导航项接上 `presenter::router::Route` ③**实例页整页接真组件 + 真数据** | 「接真组件后版面约束一条没破」(见 `pages/tests.rs` 的数值断言) | //! -//! M68 的骨架内容区是**一张全宽卡**。视觉复审的结论(用户认可): +//! **导航轨归属(M90 r3 订正,复审 P2-1)**:产品路径(`app/view.rs`)走 +//! `shell::view_with_content`,导航轨由 **`shell/nav.rs` 的手搓实现**画——它与 M78 组件几何同值 +//! (宽 88、药丸 56×32),复审逐像素量过「r1 产品图(真组件 rail)vs r2 产品图(shell rail) +//! 在 y≥48 区域差异为 0」;组件版的 [`rail`] 现在只服务 `pages::app`(`pages_demo` 出口图、 +//! harness)与单测。为什么不是「把 rail 挪进内容槽位」:那需要把 `shell` 的消息类型泛型化/映射, +//! 成本远大于收益(见偏离表 #10)。 //! -//! > **不能把「全宽单卡」当页面模板** —— 正文单行 **712px**,那张卡里的 `BodySmall` 12px 文本 -//! > **实测装了 137 字符**(口径见 [`layout`] §3),远超舒适行宽; -//! > 官方桌面口径是 **supporting-pane / list-detail**。 +//! 实例页之外的三页**仍是 M84 的占位视觉**(下载/日志/设置),但它们已经: +//! 走同一个 `Message` → `update` → `Route` 通道,导航轨点得动、路由栈按 `presenter::router` 的语义走。 +//! 页面美化是后续 mission 的事,本 mission 只保证「地基是通的」。 //! -//! 所以这里先立三条规矩,页面照着用: +//! ## 版面规矩(M84 立的三条,M90 一条没动) //! //! 1. **宽度档位**:官方响应式断点 **600 / 840**([`layout::WidthClass`]); //! 2. **版面**:单列 / supporting-pane / list-detail,各自的成立条件与退化阈值写在 [`layout`]; @@ -32,22 +39,47 @@ //! | 文件 | 职责 | //! |---|---| //! | [`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 r2 修**:产品页现在走 `shell::view_with_content`,标题栏 / 拖动热区 / 两条缩放热区 / 三个 caption 键全在 | r1 的实质问题是**交互面缺失**(无边框窗口 + 产品页没有任何窗口装饰 ⇒ 拖不动、缩不了、点不到关闭),不是「少画了一条栏」 | ✅ 已收口(见下) | +//! | 8 | 实例页的「New instance / Import」仍是**没有后端的按钮**(M90) | presenter 没有「打开新建向导」的指令(`wizard` 只有状态机,`AppCommand` 里无对应项),已报 finding | 向导 mission 补 `AppCommand::OpenWizard` 之后 | +//! | 10 | **两套 rail 各测各的**(M90 r3):产品走 `shell/nav.rs`,组件版只给 harness/单测 | r2 把产品页接进真外壳后,rail 自然落到 `shell/nav.rs`(像素等价,见上)。但两边的守门是分开的:`shell/nav.rs` 自带 indicator 56×32 的单测,`pages/tests.rs` 守的是组件 rail 的 88 ⇒ **将来哪边先漂没人拦**。统一要泛型化 shell 的消息类型,成本大于收益 | 谁做「rail 单一实现」的 mission(已在报告登记);在那之前任何改 rail 几何的改动都要两处一起改并各自跑守门 | +//! | 9 | 实例页的正文段落只在**演示数据**里出现(M90) | 真快照里没有「正文描述」这种字段;M84 那段 361 字符的 `NOTE` 是给行宽测量当样本的,不能凭空造到真页面里 | i18n / 实例详情 mission 补真实描述字段 | +//! +//! ### 偏离 #7 的收口记录(M90 r2 / P1-1) +//! +//! r1 的产品页渲染的是 `pages::app`:一条**只有高度、没有任何控件**的标题栏留白带。而 +//! `DragWindow` / `ResizeStart` / `Minimize` / `ToggleMaximize` / `Close` 五种消息的**构造点只有 +//! `shell/mod.rs`**,窗口又是 `decorations: false` ⇒ 交互模式下拖不动、缩不了、点不到关闭。 +//! +//! 修法:`shell::view_with_content(ui, nav, title, content, forced)` 把外壳(标题栏 + 三条热区 + +//! 导航轨)与内容区拆开,产品页把 [`page`] 的树塞进内容槽位;`shell::view`(M68 骨架卡)保留给 +//! `Route::Agent` 回落 —— 删了会让私有模块里的 `view` / `nav::rail` 变死代码,`-D warnings` 会红。 +//! +//! [`app`](本文件)现在**只服务 harness**(`pages_demo` example 与单测):它的标题栏仍是留白带, +//! 因为 `shell` 的回调类型是 `crate::app::Message`,套不进 `pages::Message` 的树里。 //! //! ## 截图通道 //! @@ -57,25 +89,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 +124,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 +224,90 @@ 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)), +/// **单页内容**(不含外壳):交给 `shell::view_with_content` 塞进真标题栏与导航轨之间。 +/// +/// 产品路径(`app/view.rs`)走这个入口:外壳由 `shell` 画(拖动/缩放/三个 caption 键、rail 88), +/// 这里只画「页头 + 内容列」。列宽仍按**整个窗口宽**算(`layout::frame_metrics` 内部会扣掉 +/// rail 88 与页边距),所以外壳换了不影响版面判据。 +/// +/// 视图模型由**调用方持有**(`App` 在状态变化时重建一次 `Model`),这里只借用: +/// `view(&self)` 不能返回一棵借用局部变量的树。 +pub fn page<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> { + let metrics = layout::frame_metrics(model.screen.kit(), model.window_width); + + match model.screen { + Screen::Instances => instances::view(ui, metrics, model), 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] +/// 整窗(**harness 专用**):标题栏留白带 + 导航轨 + 当前路由的页面。 +/// +/// 产品路径**不走这里**(它走 [`page`] + `shell::view_with_content`,用真标题栏)。这个入口留给 +/// `pages_demo` example 与单测:它们要一张「只有页面、不接 app 消息」的独立图,而 `shell` 的 +/// 回调类型是 `crate::app::Message`,套不进 `pages::Message` 的树里。 +pub fn app<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> { + let body = page(ui, model); + + let shell = row![rail(ui, model.active), body] .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 +318,38 @@ pub fn demo<'a>(ui: &'a Ui, screen: Screen, window_width: f32) -> Element<'a, Me .into() } -/// 标题栏留白带(高 = `LayoutMetrics::titlebar_height`)。 +/// 演示模型(`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`)——**只给 [`app`] 的 harness 路径用**。 +/// +/// 产品路径的标题栏是 `shell::view_with_content` 画的那个真的(三个 caption 键 + 拖动热区), +/// 见模块文档偏离 #7(M90 r2 已修)。 fn titlebar_placeholder<'a>(ui: &'a Ui) -> Element<'a, Message> { container(space::horizontal()) .width(Length::Fill) @@ -217,77 +357,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..f0261e8 --- /dev/null +++ b/crates/app-iced/src/pages/model.rs @@ -0,0 +1,104 @@ +//! 视图模型:**presenter 快照 → 页面要画的东西**的唯一转换产物。 +//! +//! ## 为什么页面不直接吃 presenter 的快照 +//! +//! 页面层(`pages/*`)要能用**两套数据源**喂同一个渲染函数: +//! +//! | 数据源 | 谁转 | 用途 | +//! |---|---|---| +//! | presenter 的 `AppState` 快照 | `app/view.rs` 的自由函数 `model(&AppState, width, forced)` | 产品运行时的真页面 | +//! | `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 crate::app::components::interaction::Interaction; + +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, + /// 取证用的强制交互态(`--force-state`):`Some` 时页面里的控件按该态画。 + /// + /// M90 r2(P3-1)之前这个开关只作用到 `shell` 回退路径,产品页是**静默失效**的; + /// 现在由 `app/view.rs` 把 `cli::ForcedState` 翻成 `Interaction` 灌进模型。 + pub forced: Option, +} + +impl Model { + /// 空模型(只有外壳参数,页面数据全空)——窄窗退化与空态的构造入口。 + pub fn new(screen: Screen, window_width: f32, active: Option) -> Self { + Self { + screen, + window_width, + active, + instances: Instances::default(), + forced: None, + } + } +} diff --git a/crates/app-iced/src/pages/tests.rs b/crates/app-iced/src/pages/tests.rs new file mode 100644 index 0000000..c6b088f --- /dev/null +++ b/crates/app-iced/src/pages/tests.rs @@ -0,0 +1,207 @@ +//! 页面层的**数值判据**: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` + `src/app` +/// (M90 r2/P3-3 把 `app` 加进去了)。 +#[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`)声明的宽度必须与布局系统同值。 +/// +/// 注意这不是产品路径的实现(M90 r3 订正):产品页的 rail 由 `shell/nav.rs` 画,组件版只服务 +/// `pages::app`(harness 出口图)与单测。这条守的是「组件与布局系统没分叉」,**不是**「产品 rail +/// 没问题」——两套实现的漂移风险见 `pages/mod.rs` 偏离表 #10。 +/// +/// **抓得住**:`NavPaneGeometry::rail().width` == `layout::rail_width()` == 88; +/// **抓不住**:容器圆角/药丸摆放的像素级正确性(组件自己的 probe 管),也抓不住 `shell/nav.rs`。 +#[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() + ); + } +} diff --git a/crates/app-iced/src/shell/fallback.rs b/crates/app-iced/src/shell/fallback.rs new file mode 100644 index 0000000..1291988 --- /dev/null +++ b/crates/app-iced/src/shell/fallback.rs @@ -0,0 +1,75 @@ +//! M68 骨架卡:**只给 `Route::Agent` 的回落路径用**(AI 独立页 P1 才落地)。 +//! +//! 内容区 = 页头(`TitleLarge`)+ 一张 `OutlinedCard`("Shell ready" + 一段说明文字)。 +//! 它原本住在 `shell/mod.rs`,M90 r2 为了让 `mod.rs` 回到 400 行以内搬到这里; +//! 行为一个字没改(`shell::view` 的输出与拆前逐字节相同)。 +//! +//! 为什么不能直接删:删了 `shell::view` 就没有调用点,而 `mod shell` 是私有的 —— +//! 私有模块里的 `pub fn` 无人使用会被 `dead_code` 判死,`-D warnings` 直接红。 + +use iced::widget::{column, container}; +use iced::{Alignment, Background, Element, Length, Padding}; + +use unlucky_design::color::Role; +use unlucky_design::component_map::Component; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use crate::app::Message; +use crate::theme::Ui; + +use super::label; + +// ---------------------------------------------------------------- 内容区 + +pub(super) fn content<'a>(ui: &'a Ui, title: &'a str, body: &'a str) -> Element<'a, Message> { + let metrics = ui.metrics(); + // 卡片档位由组件表决定(圆角/字级),组件层不自己挑 + let card_spec = Component::OutlinedCard.spec(); + let body_role = card_spec.support.unwrap_or(card_spec.text); + + let header = container(label( + ui, + TypeRole::TitleLarge, + true, + title, + ui.color(Role::OnSurface), + )) + .width(Length::Fill) + .height(metrics.page_header_height) + .align_y(Alignment::Center) + .padding(Padding::from([0.0, ui.space(Space::X6)])); + + let card = container( + column![ + label( + ui, + card_spec.text, + true, + "Shell ready", + ui.color(Role::OnSurface), + ), + label(ui, body_role, false, body, ui.color(Role::OnSurfaceVariant)), + ] + .spacing(ui.space(Space::X2)), + ) + .width(Length::Fill) + .padding(metrics.card_padding) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.layer(MaterialLayer::Card))), + border: ui.outlined(card_spec.shape), + ..container::Style::default() + }); + + let body_area = container(card) + .width(Length::Fill) + .height(Length::Fill) + .padding(ui.space(Space::X6)) + .align_y(Alignment::Start); + + column![header, body_area] + .width(Length::Fill) + .height(Length::Fill) + .into() +} diff --git a/crates/app-iced/src/shell/mod.rs b/crates/app-iced/src/shell/mod.rs index 6dbc569..f4be16b 100644 --- a/crates/app-iced/src/shell/mod.rs +++ b/crates/app-iced/src/shell/mod.rs @@ -15,6 +15,7 @@ //! 一屏只出现 4 档字号(22 / 16 / 14 / 12),且**没有实心 accent**(rubric K2:一屏最多一个实心 //! Primary,骨架期一个都不要)。阴影只给浮层,骨架里没有浮层,所以**全屏零阴影**(rubric K3)。 +mod fallback; mod nav; pub use nav::NavItem; @@ -25,7 +26,6 @@ use iced::{mouse, Alignment, Background, Border, Color, Element, Length, Padding use iced::{window::Direction, Theme as IcedTheme}; use unlucky_design::color::{Role, StateLayer}; -use unlucky_design::component_map::Component; use unlucky_design::elevation::MaterialLayer; use unlucky_design::icons::IconKind; use unlucky_design::spacing::Space; @@ -36,7 +36,45 @@ use crate::cli::ForcedState; use crate::icons; use crate::theme::Ui; -/// 整棵树。 +/// 整棵树(**内容槽位版**:外壳画标题栏 / 缩放热区 / 导航轨,中间那块交给调用方)。 +/// +/// 这是产品页面走的入口(M90 r2 / P1-1)。为什么要它:`decorations: false` 的无边框窗口里, +/// 拖动 / 缩放 / 最小化 / 关闭这四条消息的构造点**只有本模块**,页面树自己画的「标题栏留白带」 +/// 没有任何控件,于是产品页会变成「拖不动、缩不了、点不到关闭」。 +/// +/// 分工写死: +/// +/// - 本模块负责:标题栏(拖动面 = **整条 48px 栏里从窗口左缘到第一个 caption** 的 `mouse_area`, +/// 没有单独的 8px 热区;三个 caption 键各 46×48)、东/南两条缩放热区(各 `Space::X1` = 4px)、 +/// 导航轨、根容器底色; +/// - 调用方负责:`content`(页面内容区)。它拿到的可用宽度是 `窗口宽 − rail 88`, +/// 而页面自己的 `layout::frame_metrics` 仍以**窗口宽**为输入(它内部会再减 rail 与页边距), +/// 两边的账不会算串。 +/// +/// [`view`] 是它的兼容包装:`content` 换成 M68 的「页头 + 一张卡」。 +pub fn view_with_content<'a>( + ui: &'a Ui, + selected: NavItem, + app_title: &'a str, + content: Element<'a, Message>, + forced: Option, +) -> Element<'a, Message> { + let work_area = column![ + titlebar(ui, app_title, forced), + row![nav::rail(ui, selected, forced), content] + .width(Length::Fill) + .height(Length::Fill), + ] + .width(Length::Fill) + .height(Length::Fill); + + window_frame(ui, work_area.into()) +} + +/// 整棵树(M68 骨架版:内容区是「页头 + 一张占位卡」)。 +/// +/// **保留给 `Route::Agent` 回落**(AI 独立页 P1 才落地)。删掉它会连带 +/// `content` / `label` / `nav::rail` 变成死代码,而 `mod shell` 是私有的 ⇒ `-D warnings` 直接红。 pub fn view<'a>( ui: &'a Ui, selected: NavItem, @@ -45,18 +83,17 @@ pub fn view<'a>( body: &'a str, forced: Option, ) -> Element<'a, Message> { - let work_area = column![ - titlebar(ui, app_title, forced), - row![ - nav::rail(ui, selected, forced), - content(ui, page_title, body) - ] - .width(Length::Fill) - .height(Length::Fill), - ] - .width(Length::Fill) - .height(Length::Fill); + view_with_content( + ui, + selected, + app_title, + fallback::content(ui, page_title, body), + forced, + ) +} +/// 无边框窗口的「外壳壳子」:工作区 + 两条缩放热区 + 根容器底色。 +fn window_frame<'a>(ui: &'a Ui, work_area: Element<'a, Message>) -> Element<'a, Message> { // 无边框窗口没有系统缩放边框 → 自己给两条热区(东/南)。 // **必须叠层而不是兄弟列**:当兄弟列会吃掉 4px 布局宽(右留白变 28、caption 按钮离窗角 4px, // 视觉复审 P3-1)。叠层容器自己铺满,再用 align 把 4px 热区贴到边上,布局宽保持 1180。 @@ -211,59 +248,6 @@ fn caption<'a>( .into() } -// ---------------------------------------------------------------- 内容区 - -fn content<'a>(ui: &'a Ui, title: &'a str, body: &'a str) -> Element<'a, Message> { - let metrics = ui.metrics(); - // 卡片档位由组件表决定(圆角/字级),组件层不自己挑 - let card_spec = Component::OutlinedCard.spec(); - let body_role = card_spec.support.unwrap_or(card_spec.text); - - let header = container(label( - ui, - TypeRole::TitleLarge, - true, - title, - ui.color(Role::OnSurface), - )) - .width(Length::Fill) - .height(metrics.page_header_height) - .align_y(Alignment::Center) - .padding(Padding::from([0.0, ui.space(Space::X6)])); - - let card = container( - column![ - label( - ui, - card_spec.text, - true, - "Shell ready", - ui.color(Role::OnSurface), - ), - label(ui, body_role, false, body, ui.color(Role::OnSurfaceVariant)), - ] - .spacing(ui.space(Space::X2)), - ) - .width(Length::Fill) - .padding(metrics.card_padding) - .style(move |_theme| container::Style { - background: Some(Background::Color(ui.layer(MaterialLayer::Card))), - border: ui.outlined(card_spec.shape), - ..container::Style::default() - }); - - let body_area = container(card) - .width(Length::Fill) - .height(Length::Fill) - .padding(ui.space(Space::X6)) - .align_y(Alignment::Start); - - column![header, body_area] - .width(Length::Fill) - .height(Length::Fill) - .into() -} - // ---------------------------------------------------------------- 小工具 /// 一个文字元素:字号/行高/字重全部来自令牌排版档。 @@ -308,6 +292,7 @@ pub(super) fn filled(color: Color) -> container::Style { mod tests { use super::*; use unlucky_design::color::SchemeFlavor; + use unlucky_design::component_map::Component; use unlucky_design::typography::Platform; use crate::theme::Families; diff --git a/crates/app-iced/tests/no_hardcoded_tokens.rs b/crates/app-iced/tests/no_hardcoded_tokens.rs index fc019ad..1645758 100644 --- a/crates/app-iced/tests/no_hardcoded_tokens.rs +++ b/crates/app-iced/tests/no_hardcoded_tokens.rs @@ -209,12 +209,13 @@ fn size_literal(argument: &str) -> Option { #[test] fn layout_sources_take_every_size_from_tokens() { let mut files = Vec::new(); - for dir in ["shell", "pages"] { + // P3-3(M90 r2):`app` 是 M90 新拆出来的模块,也是唯一没被这条守门看过的控件树。 + for dir in ["shell", "pages", "app"] { rust_files(&manifest_dir().join("src").join(dir), &mut files); } assert!( - files.len() >= 10, - "shell + pages 至少 10 个文件(shell 2 + pages 8+),实际扫到 {}:扫描逻辑坏了,这条门禁等于没跑", + files.len() >= 20, + "shell + pages + app 至少 20 个文件,实际扫到 {}:扫描逻辑坏了,这条门禁等于没跑", files.len() );