Merge branch 'feat/p4-iced-app-split-presenter-wire-one-pag'

This commit is contained in:
AskaEth committed 2026-11-02 15:57:07 +08:00
commit 1331d90ff4
24 files changed
+2896 -886

No files matched your search

-400
View File
@@ -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<Instant> = OnceLock::new();
/// 首帧日志只打一次。
static FIRST_FRAME_LOGGED: AtomicBool = AtomicBool::new(false);
/// 应用状态(**只有 UI 状态**;业务状态等页面 mission 接 `host`/`state` 时再进)。
pub struct App {
options: Options,
families: Families,
ui: Ui,
nav: NavItem,
window: Option<window::Id>,
frames: u32,
}
/// 消息(单一大枚举;页面接进来时按页拆子枚举)。
#[derive(Debug, Clone)]
pub enum Message {
/// 启动即发一次(`iced::application` 的 boot 闭包)。
Boot,
/// `window::latest()` 的结果。
WindowReady(Option<window::Id>),
/// 系统主题探测结果(只可能在**交互模式**出现,截图模式必有主题)。
SystemTheme(iced::theme::Mode),
/// 一帧画完(只有截图模式才订阅)。
Frame,
/// 导航项切换。
Select(NavItem),
/// 按下标题栏 → 拖窗 / 边缘热区 → 缩放窗口。
DragWindow,
ResizeStart(Direction),
/// 最小化 / 最大化还原 / 关闭(三个 caption 按钮)。
Minimize,
ToggleMaximize,
Close,
/// 截图回来了。
Captured(window::Screenshot),
/// gallery(组件库 harness)里的演示交互。
Demo(components::gallery::DemoMessage),
}
impl App {
fn new(options: Options, families: Families, flavor: SchemeFlavor) -> Self {
Self {
ui: Ui::new(flavor, families),
families,
options,
nav: NavItem::Instances,
window: None,
frames: 0,
}
}
fn has_screenshot(&self) -> bool {
self.options.screenshot.is_some()
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Boot => {
let mut tasks = vec![window::latest().map(Message::WindowReady)];
// 交互模式且没显式指定主题 → 探系统;截图模式永不等(见 cli 模块文档)
if self.options.wants_system_theme() {
tasks.push(iced::system::theme().map(Message::SystemTheme));
}
Task::batch(tasks)
}
Message::WindowReady(id) => {
self.window = id;
Task::none()
}
Message::SystemTheme(mode) => {
let flavor = match mode {
iced::theme::Mode::Dark => SchemeFlavor::Dark,
iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light,
};
eprintln!("[theme] 系统主题 = {mode:?}");
self.set_flavor(flavor);
Task::none()
}
Message::Frame => self.on_frame(),
Message::Select(nav) => {
self.nav = nav;
Task::none()
}
Message::DragWindow => match self.window {
Some(id) => window::drag(id),
None => Task::none(),
},
Message::ResizeStart(direction) => match self.window {
Some(id) => window::drag_resize(id, direction),
None => Task::none(),
},
Message::Minimize => match self.window {
Some(id) => window::minimize(id, true),
None => Task::none(),
},
Message::ToggleMaximize => match self.window {
Some(id) => window::toggle_maximize(id),
None => Task::none(),
},
Message::Close => iced::exit(),
Message::Captured(screenshot) => self.on_captured(&screenshot),
Message::Demo(message) => {
// gallery 只是 harness:按下只打日志,不接业务(组件库要能脱离业务验收)
eprintln!("[gallery] {} pressed", message.id());
Task::none()
}
}
}
/// 切主题:只重建 [`Ui`](= 重建 Style),**不动任何 UI 状态**。
fn set_flavor(&mut self, flavor: SchemeFlavor) {
if self.ui.flavor() == flavor {
return;
}
let next = Ui::new(flavor, self.families);
eprintln!(
"[theme] {} -> {} (Theme::name 变化触发缓存重算)",
self.ui.name(),
next.name()
);
self.ui = next;
}
fn on_frame(&mut self) -> Task<Message> {
self.frames += 1;
// 主题在启动时就已确定(截图模式必有主题,见 cli 模块文档),这里只剩帧数闸门
if !self.has_screenshot() || self.frames < CAPTURE_AT_FRAME {
return Task::none();
}
match self.window {
Some(id) => window::screenshot(id).map(Message::Captured),
None => Task::none(),
}
}
fn on_captured(&mut self, screenshot: &window::Screenshot) -> Task<Message> {
let Some(path) = self.options.screenshot.clone() else {
return Task::none();
};
match shot::save(screenshot, &path) {
Ok(summary) => {
eprintln!("{}", summary.describe());
eprintln!(
"[shot] screen={} theme={} renderer={}",
self.options.screen.name(),
shot::flavor_slug(&self.ui),
self.options.backend.note()
);
}
Err(error) => eprintln!("[shot] 失败:{error}"),
}
iced::exit()
}
/// 只有截图模式才订阅帧事件:否则「每帧一条消息 → 每帧重绘」会白烧 CPU。
fn subscription(&self) -> Subscription<Message> {
if self.wants_frames() {
window::frames().map(|_| Message::Frame)
} else {
Subscription::none()
}
}
/// 是否订阅帧事件(= 截图模式)。
fn wants_frames(&self) -> bool {
self.has_screenshot()
}
fn theme(&self) -> iced::Theme {
self.ui.theme()
}
fn title(&self) -> String {
APP_TITLE.to_owned()
}
fn view(&self) -> Element<'_, Message> {
if !FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) {
if let Some(started) = STARTED.get() {
eprintln!(
"[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)",
started.elapsed().as_secs_f64() * 1000.0
);
}
}
// `--screen=gallery`:组件库 harness(D39 §5)。它用自己的 DemoMessage,
// 这里只做一次 map,组件库与业务 Message 依然解耦。
if self.options.screen == Screen::Gallery {
return components::gallery::view(&self.ui).map(Message::Demo);
}
shell::view(
&self.ui,
self.nav,
APP_TITLE,
self.nav.label(),
"Skeleton only: titlebar, navigation rail and one card. \
Business pages, the component library and the glass shader are follow-up missions.",
self.options.force_state,
)
}
}
/// 启动 iced(阻塞到退出)。`--diff` 不走这里,见 `main.rs`。
pub(crate) fn run(options: Options) -> iced::Result {
let _ = STARTED.set(Instant::now());
let probe_started = Instant::now();
// 只有 `auto` 需要探测:`cpu` 与 `gpu` 的决策与「有没有硬件 GPU」无关,
// 而枚举 13 个适配器在本机要 0.65~1.2 s(实测),纯属白花。
let needs_probe = options.backend == crate::Backend::Auto;
let probe = if needs_probe {
probe::Probe::run()
} else {
probe::Probe::default()
};
let probe_ms = probe_started.elapsed().as_secs_f64() * 1000.0;
probe::apply(options.backend, &probe);
if cfg!(feature = "gpu") {
if needs_probe {
eprintln!("[probe] 探测耗时 {probe_ms:.0} ms(wgpu 实例 + 全后端适配器枚举)");
} else {
eprintln!(
"[probe] 探测耗时 {probe_ms:.0} ms(--renderer={} 与硬件无关,跳过枚举)",
options.backend.name()
);
}
}
let font_started = Instant::now();
let resolved = fonts::resolve();
eprintln!(
"[font] 求值耗时 {:.0} ms",
font_started.elapsed().as_secs_f64() * 1000.0
);
eprintln!("{}", resolved.describe());
if resolved.has_synthetic() {
eprintln!(
"[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换族)要处理"
);
}
let families = resolved.families();
// 截图模式:`--screenshot-theme` > `--theme` > **Light**(唯一取值口在 `Options::capture_flavor`)。
// 这里**不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。
if options.screenshot.is_some() && options.shot_flavor().is_none() {
eprintln!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)");
}
let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light);
let boot_options = options.clone();
let application = iced::application(
move || {
(
App::new(boot_options.clone(), families, flavor),
Task::done(Message::Boot),
)
},
App::update,
App::view,
)
.title(App::title)
.theme(App::theme)
.subscription(App::subscription)
.default_font(iced::Font {
family: iced::font::Family::Name(families.text),
..iced::Font::DEFAULT
})
.window(window::Settings {
size: WINDOW_SIZE,
min_size: Some(MIN_SIZE),
decorations: false,
resizable: true,
..window::Settings::default()
});
match options.scale_factor {
Some(scale) => application.scale_factor(move |_| scale).run(),
None => application.run(),
}
}
#[cfg(test)]
mod tests {
use super::*;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
#[test]
fn switching_flavor_rebuilds_styles_but_keeps_ui_state() {
let mut app = App::new(Options::default(), FAMILIES, SchemeFlavor::Light);
app.nav = NavItem::Logs;
assert_eq!(app.ui.name(), "ull-m3-light");
app.set_flavor(SchemeFlavor::Dark);
assert_eq!(app.ui.name(), "ull-m3-dark", "切主题要换主题名");
assert_eq!(app.nav, NavItem::Logs, "切主题不许重置 UI 状态");
assert!(app.options.screenshot.is_none(), "切主题不许改启动参数");
}
#[test]
fn window_size_is_the_acceptance_size() {
assert_eq!((WINDOW_SIZE.width, WINDOW_SIZE.height), (1180.0, 760.0));
}
#[test]
fn screenshot_mode_never_waits_for_the_system_theme() {
// 门禁依赖:CI 的 runtime-smoke 不带主题,xvfb 里也没有主题会话。
let options = Options {
screenshot: Some(std::path::PathBuf::from("x.png")),
..Options::default()
};
assert!(!options.wants_system_theme(), "截图模式不许等系统主题");
assert_eq!(options.capture_flavor(), Some(SchemeFlavor::Light));
let app = App::new(options, FAMILIES, SchemeFlavor::Light);
assert!(app.has_screenshot() && app.ui.name() == "ull-m3-light");
}
#[test]
fn gallery_screen_builds_a_tree() {
assert_eq!(Options::default().screen, Screen::Shell, "默认仍是产品骨架");
let options = Options {
screen: Screen::Gallery,
..Options::default()
};
let app = App::new(options, FAMILIES, SchemeFlavor::Light);
// 真实像素由 `--screen=gallery --screenshot` 出图 + `gallery::probe` 量
let _ = app.view();
}
#[test]
fn frames_are_only_requested_in_screenshot_mode() {
let mut app = App::new(Options::default(), FAMILIES, SchemeFlavor::Light);
assert!(!app.wants_frames(), "普通运行不订阅帧,避免每帧重绘");
app.options.screenshot = Some(std::path::PathBuf::from("x.png"));
assert!(
app.wants_frames(),
"截图模式必须订阅帧事件才能在第 3 帧出图"
);
}
}
+105
View File
@@ -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
}
}
+89
View File
@@ -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<window::Id>),
/// 系统主题探测结果(只可能在**交互模式**出现,截图模式必有主题)。
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,
}
}
+68
View File
@@ -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;
+248
View File
@@ -0,0 +1,248 @@
//! 应用状态:**只放字段与构造**,不放消息分支(那是 [`super::update`] 的事)。
//!
//! ## presenter 三件(`crates/presenter/src/lib.rs` crate 文档 §4)
//!
//! | 字段 | 谁给 | 干什么 |
//! |---|---|---|
//! | `backend: Box<dyn Backend>` | [`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<Instant> = 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<dyn Backend>,
/// 设置持久化桥。
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<Instant>,
/// 窗口句柄(`window::latest()` 之后才有)。
pub(super) window: Option<window::Id>,
/// 已画的帧数(截图闸门用)。
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<dyn Backend>,
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 <tmp>` 不会往那个目录写东西。
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
);
}
}
+82
View File
@@ -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<Message> {
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)
}
}
+272
View File
@@ -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 优先级最高,系统主题不许盖"
);
}
+238
View File
@@ -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<RefCell<Vec<AppCommand>>>,
seeded: Vec<AppEvent>,
}
impl Backend for RecorderBackend {
fn drain_events(&mut self) -> Vec<AppEvent> {
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<RefCell<Vec<AppCommand>>>) {
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);
}
+332
View File
@@ -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<Message> {
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<Message> {
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<Message> {
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<Message> {
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<Message> {
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<Message> {
Task::perform(async { std::thread::sleep(IDLE_TICK) }, |()| {
Message::Heartbeat
})
}
+314
View File
@@ -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<crate::cli::ForcedState>,
) -> pages::Model {
let route = state.route().clone();
let screen = pages::Screen::of(&route).unwrap_or(pages::Screen::Instances);
let rows: Vec<Row> = 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<String> {
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 页不高亮任何导航项");
}
}
+22 -3
View File
@@ -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")
);
}
+2 -1
View File
@@ -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};
+26
View File
@@ -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,
+7 -63
View File
@@ -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<window::Id>,
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, String> {
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;
@@ -0,0 +1,63 @@
//! `pages::harness` 的断言(M90 r2 从 `harness.rs` 外置:那边曾 402 行,guards 的 file-size 会判红)。
//!
//! 只测**参数解析**与**出图 slug**,不碰 iced 运行时。
use super::*;
fn parse(args: &[&str]) -> Result<Request, String> {
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());
}
+111 -237
View File
@@ -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<usize>,
) -> 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<usize>) -> Element<'a, Message> {
fn list_column<'a>(
ui: &'a Ui,
block: &'a Instances,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let head = container(
row![
container(frame::text_role(
@@ -82,11 +120,11 @@ fn list_column<'a>(ui: &'a Ui, selected: Option<usize>) -> 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<usize>) -> 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::<Vec<_>>(),
)
.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<usize>) -> 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<Interaction>,
) -> 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<Interaction>,
) -> 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)
}
@@ -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(),
}
}
@@ -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<Interaction>,
) -> 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<Interaction>,
) -> 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<Interaction>,
) -> 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<Interaction>) -> 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(),
)
}
+179 -113
View File
@@ -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<Self> {
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<NavSection>, 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<NavSection>) -> 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 不许再有列表列");
}
}
+104
View File
@@ -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<String>,
}
/// 元信息卡:标签 + 值(`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<Meta>,
/// 正文档(演示数据有;真实快照没有对应字段时为 `None`)
pub note: Option<String>,
/// 运行状态行(`None` = 空闲,不占版面)
pub status: Option<String>,
/// 「Play」可用
pub can_launch: bool,
/// 「Stop」可用
pub can_stop: bool,
}
/// 实例页要画的东西。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Instances {
/// 列表列(顺序稳定 = 实例列表顺序)
pub rows: Vec<Row>,
/// 当前选中的实例详情(`None` = 没选中 → 空态)
pub detail: Option<Detail>,
}
/// 一屏的视图模型。
#[derive(Debug, Clone, PartialEq)]
pub struct Model {
/// 当前路由落在哪个页面上
pub screen: Screen,
/// 窗口宽度(**含外壳**:布局档位判据就是它)
pub window_width: f32,
/// 导航轨高亮项(`None` = 没有高亮,如 AI 独立页)
pub active: Option<NavSection>,
/// 实例页的数据(其它三页还是占位视觉,不读)
pub instances: Instances,
/// 取证用的强制交互态(`--force-state`):`Some` 时页面里的控件按该态画。
///
/// M90 r2(P3-1)之前这个开关只作用到 `shell` 回退路径,产品页是**静默失效**的;
/// 现在由 `app/view.rs` 把 `cli::ForcedState` 翻成 `Interaction` 灌进模型。
pub forced: Option<Interaction>,
}
impl Model {
/// 空模型(只有外壳参数,页面数据全空)——窄窗退化与空态的构造入口。
pub fn new(screen: Screen, window_width: f32, active: Option<NavSection>) -> Self {
Self {
screen,
window_width,
active,
instances: Instances::default(),
forced: None,
}
}
}
+207
View File
@@ -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()
);
}
}
+75
View File
@@ -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()
}
+51 -66
View File
@@ -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<ForcedState>,
) -> 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<ForcedState>,
) -> 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;
+4 -3
View File
@@ -209,12 +209,13 @@ fn size_literal(argument: &str) -> Option<String> {
#[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()
);