feat(app-iced): M90 —— app.rs 拆成 src/app/ + presenter 真接线 + 实例页端到端

拆分(行为不变,截图四件套逐字节相同):
- 400 行的 app.rs 拆成 src/app/{mod,state,message,update,subscription,view,boot,tests}.rs,
  每个文件 ≤ 300 行;components 的 #[path] 跟着搬到 src/app/mod.rs(逻辑路径仍是 crate::app::components),
  可见性放到 pub(crate) 让页面层能用真组件。
- 拆分前后各出 shell/gallery 浅深四张图,cmp 全部 IDENTICAL。

presenter 接线(crate 文档 §4):
- App 持有 Box<dyn Backend> + SettingsBridge + AppState;启动 build_backend + SettingsBridge::open + AppState::new。
- 截图通道强制 mock(D30 可复现);交互模式走真 host,--data-dir 从 M90 起真的被消费。
- Boot 抽干首屏快照(第 3 帧落盘,等节拍会把空页面拍进图);节拍 = 帧事件 + 33ms 时间闸门
  (iced::time::every 要 tokio/smol,见 finding),tick → drain_events → AppState::apply。
- 用户动作不乐观回显:Launch/Stop 走 Backend::dispatch,状态等事件回来才变。
- 路由:pages::Message::Navigate(Route) 直接进 AppState::navigate,选中项 = 路由栈顶(不再有本地副本);
  首屏自动选中第一行(list-detail 桌面惯例),只做一次。

实例页打通(其余三页保留占位视觉 + Message/update + 路由):
- 列表行换成 components::navigation::ListItem(真五态/选中),页头与详情动作换成 components::buttons
  (Play 是唯一的实心 accent),分隔线换成 components::divider。
- 数据全部来自 presenter 快照:VERSION/LOADER/ISOLATION/VERSION ID/FILES/LAST PLAYED + 运行状态行。
- 导航轨换成真 NavigationRail(88),5 项各自压/替换路由。

版面约束未破(实测):列表列 336、正文列 424(元信息卡 206+12+206,右缘 896)、rail 88(药丸 16..71)、
断点 600/840 走 frame_metrics;新增 pages/tests.rs 的 9 条数值断言钉住它们。
This commit is contained in:
AskaEth committed 2026-11-02 15:56:57 +08:00
1 parent 270026338f
commit b484998fc9
17 files changed
+1990 -676

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
//! 应用消息:单一大枚举。
//!
//! 纪律:本枚举是**唯一**的消息类型,`view` 产出的所有回调都必须落到这里;组件库与页面层
//! 各自用自己的 `Message`(组件契约 §2 的泛型纪律),由 `view` 做一次 `.map` 接上。
//!
//! ## 三类输入(M90)
//!
//! | 来源 | 变体 | 落点 |
//! |---|---|---|
//! | 窗口/框架 | `Boot` / `WindowReady` / `Window` / `Frame` / `Captured` / 三个 caption | [`super::update`] 直接处理 |
//! | **时间** | `Frame` + 33 ms 时间闸门 | presenter 的 TICK 节拍:`backend.tick` + `drain_events` + `apply` |
//! | **页面** | `Page(pages::Message)` | 导航 → `AppState::navigate`;启动/停止 → `AppCommand` |
use iced::{window, window::Direction};
use unlucky_presenter::router::Route;
use crate::pages;
use crate::shell::NavItem;
use super::components;
/// 消息(单一大枚举)。
#[derive(Debug, Clone)]
pub enum Message {
/// 启动即发一次(`iced::application` 的 boot 闭包)。
Boot,
/// `window::latest()` 的结果。
WindowReady(Option<window::Id>),
/// 系统主题探测结果(只可能在**交互模式**出现,截图模式必有主题)。
SystemTheme(iced::theme::Mode),
/// 一帧画完(只有截图模式才订阅)。
Frame,
/// 窗口事件(M90:`Resized` 决定布局档位,600 / 840 两条断点靠它生效)。
Window(window::Event),
/// 页面消息(导航 / 启动 / 停止 / 占位动作)。
Page(pages::Message),
/// 外壳导航轨点选(`src/shell/nav.rs` 的回调仍然发这一条;收到就翻成同一个路由跳转)。
///
/// M90 起产品界面用 `pages` 里的真 `NavigationRail`,这条变体是给**外壳回退路径**
/// (`Route::Agent`)留的——`shell` 不在本 mission 的 scope 里,不能删它的消息。
Select(NavItem),
/// 按下标题栏 → 拖窗。
DragWindow,
/// 边缘热区:开始缩放(方向由坐标决定)。
ResizeStart(Direction),
/// 最小化。
Minimize,
/// 最大化/还原。
ToggleMaximize,
/// 关窗退出。
Close,
/// 截图回来了。
Captured(window::Screenshot),
/// gallery(组件库 harness)里的演示交互。
Demo(components::gallery::DemoMessage),
}
/// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。
///
/// 这是 `NavItem → Route` 的**唯一**转换点:外壳(`src/shell/nav.rs`)与页面导航轨
/// (`pages::NAV`)最终都落到同一个 `Route`,所以两条入口不会各走一套页面语义。
pub(super) fn route_of(nav: NavItem) -> Route {
match nav {
NavItem::Instances => Route::Instances,
NavItem::Download => Route::Download,
NavItem::Logs => Route::Logs,
NavItem::Settings => Route::Settings,
NavItem::Ai => Route::Agent,
}
}