diff --git a/crates/app-iced/src/app/message.rs b/crates/app-iced/src/app/message.rs index 24a885b..cc4e6af 100644 --- a/crates/app-iced/src/app/message.rs +++ b/crates/app-iced/src/app/message.rs @@ -69,3 +69,16 @@ pub(super) fn route_of(nav: NavItem) -> Route { NavItem::Ai => Route::Agent, } } + +/// 路由 → 外壳导航项([`route_of`] 的逆;外壳 rail 与 caption 键都按它高亮)。 +/// +/// `Route::Detail(_)` 高亮「实例」(与 `Route::nav()` 同源)。 +pub(super) fn nav_item_of(route: &Route) -> NavItem { + match route { + Route::Instances | Route::Detail(_) => NavItem::Instances, + Route::Download => NavItem::Download, + Route::Logs => NavItem::Logs, + Route::Settings => NavItem::Settings, + Route::Agent => NavItem::Ai, + } +} diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index 6093b5f..2a2e646 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -19,7 +19,7 @@ use std::time::Instant; use iced::window; use unlucky_design::color::SchemeFlavor; -use unlucky_presenter::backend::{build_backend, AppOptions}; +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; @@ -37,6 +37,11 @@ use super::WINDOW_SIZE; /// `theme::config_poll_due` 的计数都按这个节拍走,两个 UI 版的时间语义因此一致。 pub(super) const TICK: std::time::Duration = std::time::Duration::from_millis(33); +/// 派发指令后保持帧订阅的观察窗(见 [`App::watch_until`])。 +/// +/// 3 秒够真 host 把「命令已受理」的第一条事件交回来;之后由「有 Busy/Running 实例」这个条件续订。 +pub(super) const WATCH_WINDOW: std::time::Duration = std::time::Duration::from_secs(3); + /// 进程内「run() 开始」的时刻(首帧耗时基线)。 pub(super) static STARTED: OnceLock = OnceLock::new(); /// 首帧日志只打一次。 @@ -64,10 +69,20 @@ pub struct App { 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)。 + /// + /// 空闲态不订阅帧事件(不点亮窗口重绘),但派发之后必须有一段短窗口保持节拍, + /// 否则真 host 的异步事件永远抽不出来(`LaunchInstance` 之后状态还是 Idle ⇒ 帧订阅永不开启)。 + pub(super) watch_until: Option, /// 窗口句柄(`window::latest()` 之后才有)。 pub(super) window: Option, /// 已画的帧数(截图闸门用)。 pub(super) frames: u32, + /// 视图模型重建次数(P2-1 的可观测计数:空闲态 100 拍里必须是 0 增长)。 + pub(super) refreshes: u64, /// 首屏快照到位后是否已经自动选中过第一行(list-detail 的桌面惯例,见 `update::auto_select`)。 pub(super) auto_selected: bool, } @@ -80,7 +95,14 @@ impl App { /// [`build_backend`] 自己退回 mock)。 pub(super) fn new(options: Options, families: Families, flavor: SchemeFlavor) -> Self { let backend = build_backend(&app_options(&options)); - let bridge = SettingsBridge::open(); + // 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) } @@ -110,8 +132,11 @@ impl App { last_tick: Instant::now(), width, view, + view_dirty: false, + watch_until: None, window: None, frames: 0, + refreshes: 0, auto_selected: false, } } @@ -137,8 +162,12 @@ impl App { } /// 把 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.view = super::view::model(&self.state, self.width, self.options.force_state); + self.view_dirty = false; + self.refreshes += 1; } /// 按设置文件里的主题模式切一次外观;`--theme` 显式给了就不动。 diff --git a/crates/app-iced/src/app/subscription.rs b/crates/app-iced/src/app/subscription.rs index aa67f39..27b5a58 100644 --- a/crates/app-iced/src/app/subscription.rs +++ b/crates/app-iced/src/app/subscription.rs @@ -1,17 +1,26 @@ -//! 订阅:帧事件(截图闸门 + presenter 的 TICK 节拍)与窗口尺寸事件。 +//! 订阅:**按需**帧节拍(presenter 的 TICK)+ 窗口尺寸事件。 //! -//! ## 为什么节拍挂在帧事件上,而不是 `iced::time::every` +//! ## 为什么要「按需」 //! -//! `iced::time::every` 只有 `tokio` / `smol` 两个异步后端提供;本 crate 关掉了 iced 的默认 features, -//! 执行器是 `thread-pool`(M60 踩坑 Q2),拿不到那个函数。想用它就得改 `Cargo.toml` 开一个异步 -//! 运行时(不在本 mission 的 scope 内,已报 finding)。 +//! iced_winit 在处理完任何一条消息后都会 `request_redraw()`,而 `window::frames()` 监听的是 +//! `RedrawRequested` —— 于是「订阅帧 → 收 Frame → update → 再触发重绘」是一条**自持循环**: +//! 只要订阅着,窗口就永远在重绘(M90 r1 就是这样,CPU 空转)。 //! -//! 所以节拍走**帧事件 + 时间闸门**:`window::frames()` 每帧给一条 `Frame`,app 侧按真实流逝时间 -//! 判断「够不够 33 ms」,够了才推进一拍 presenter(`backend.tick(elapsed)` / `drain_events`)。 -//! 好处是**按真实时间推进**(不是「数了多少帧 × 33 ms」,掉帧也不会把游戏启动进度拖慢), -//! 代价是交互模式下窗口会持续重绘(30 帧的活干在 vsync 的节拍上)。 +//! 现在只在**真的需要时钟**的时候订阅([`App::needs_frames`]): //! -//! 截图模式下这条订阅原本就在(第 3 帧落盘),合并之后两条路径共用同一个计时器。 +//! | 条件 | 为什么需要 33 ms 节拍 | +//! |---|---| +//! | 截图通道 | 第 3 帧要落盘 | +//! | 有 `Busy` / `Running` 实例 | 启动进度、日志尾随都靠 `backend.tick` 推进 | +//! | 有未过期的 toast | toast 按毫秒倒计时 | +//! | 刚派发过指令(3 s 观察窗) | 真 host 的异步事件要靠节拍抽出来 | +//! +//! 空闲态**一条帧消息都不来** ⇒ 窗口不重绘、`refresh_view` 不跑(配合 `view_dirty`)。 +//! 已知代价:空闲态不再按 `config_poll_due` 轮询设置文件(外部 AI/CLI 改主题要等下次交互才生效)。 +//! 要同时拿回「省 CPU」与「后台轮询」,得用 `iced::time::every`,而它需要 Cargo.toml 开 +//! tokio/smol(不在本 mission 的 scope,已报 finding)。 + +use std::time::Instant; use iced::{window, Subscription}; @@ -21,18 +30,34 @@ use super::state::App; impl App { /// 本次进程的订阅表。 pub(super) fn subscription(&self) -> Subscription { - let mut subscriptions = vec![window::frames().map(|_| Message::Frame)]; + let frames = if self.needs_frames() { + window::frames().map(|_| Message::Frame) + } else { + Subscription::none() + }; // 断点 600 / 840 要跟着真窗口宽度走;截图模式的窗口尺寸由参数钉死,不吃 resize。 - if !self.has_screenshot() { - subscriptions.push(window::events().map(|(_, event)| Message::Window(event))); - } + let events = if self.has_screenshot() { + Subscription::none() + } else { + window::events().map(|(_, event)| Message::Window(event)) + }; - Subscription::batch(subscriptions) + Subscription::batch([frames, events]) } /// 是否订阅帧事件(= 截图模式要帧数闸门)。 pub(super) fn wants_frames(&self) -> bool { self.has_screenshot() } + + /// 现在要不要**时钟**(见模块文档的表)。 + pub(super) fn needs_frames(&self) -> bool { + self.has_screenshot() + || !self.state.toasts.is_empty() + || !self.state.running().is_empty() + || self + .watch_until + .is_some_and(|deadline| Instant::now() < deadline) + } } diff --git a/crates/app-iced/src/app/tests.rs b/crates/app-iced/src/app/tests.rs index c84414b..27277fe 100644 --- a/crates/app-iced/src/app/tests.rs +++ b/crates/app-iced/src/app/tests.rs @@ -9,6 +9,9 @@ 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}; @@ -96,145 +99,71 @@ fn frames_are_only_requested_in_screenshot_mode() { ); } -// --------------------------------------------------------------- presenter 闭环 +// --------------------------------------------------------------- P2-1 按需节拍 -/// **首屏快照在 Boot 就到位**(截图在第 3 帧落盘,等 30 fps 的 tick 会拍进空页面)。 +/// 空闲态**不要时钟**:没有忙碌实例、没有 toast、没有观察窗 ⇒ 不订阅帧事件。 +/// +/// 抓得住:`needs_frames()` 的三条触发条件(这也是 `subscription()` 的唯一判据)—— +/// 谁把「无条件订阅帧」改回来,这条立刻红。 +/// 抓不住:真实窗口的重绘次数(那要 `ULL_TICK_TRACE=1` 跑一次交互模式看日志,见报告)。 #[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"); +fn an_idle_app_asks_for_no_clock() { + let app = headless(screenshot_options(), SchemeFlavor::Light); + assert!(app.needs_frames(), "截图通道永远要帧(第 3 帧落盘)"); - 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 mut idle = headless(Options::default(), SchemeFlavor::Light); + let _ = idle.update(Message::Boot); + assert!( + idle.needs_frames(), + "Boot 刚派发过 RefreshAll:3 秒观察窗内要节拍(等真 host 的首批事件)" ); - let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances))); - assert_eq!(app.state.route(), &Route::Instances); + // 观察窗过期、也没有忙碌实例 ⇒ 空闲态不再要时钟 + idle.watch_until = None; + assert!(!idle.needs_frames(), "空闲交互态:不订阅帧事件"); - // 再推进一拍(会 drain_events + auto_select):不该把他弹回详情页 - app.advance(super::state::TICK); - assert_eq!(app.state.route(), &Route::Instances, "自动选中只做一次"); -} - -/// **点 Play → 命令真的派发出去 → 事件回流 → 页面真的变**(不做乐观回显)。 -#[test] -fn launching_goes_through_the_backend_and_comes_back_as_events() { - let mut app = headless(screenshot_options(), SchemeFlavor::Light); - let _ = app.update(Message::Boot); - let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( + // 派发之后观察窗重新打开 + let _ = idle.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( + let _ = idle.update(Message::Page(pages::Message::Launch( "survival".to_string(), ))); + assert!(idle.needs_frames(), "派发后观察窗内要有节拍"); - let detail = app.view.instances.detail.as_ref().expect("有选中项"); + // 进入 Busy 之后由「忙碌实例」这个条件自己续订(不靠观察窗) + idle.watch_until = None; assert!( - !detail.can_launch && detail.can_stop, - "派发 LaunchInstance 后后端发回了 Busy 事件:按钮组合必须已经翻转" - ); - assert_eq!( - detail.status.as_deref(), - Some("Downloading · 0%"), - "状态行来自事件里的进度,不是本地假造" + idle.needs_frames(), + "有 Busy 实例时即便观察窗过期也要继续 tick" ); } -/// 时间推进真的把状态往前推(后端的脚本按 tick 的累计时长走)。 +/// 空闲态每拍**不再重建视图模型**(P2-1 的另一半:`refresh_view` 的 30 Hz × N 实例白克隆)。 +/// +/// 抓得住:把 `advance()` 里的 `if self.view_dirty` 去掉(改回无条件 `refresh_view()`)时, +/// 这条断言里的 `refreshes` 会从「不增长」变成「每拍 +1」。 +/// 抓不住:「重建一次花了多少微秒」(没有基准测试;量级判断见报告)。 #[test] -fn ticks_advance_the_backend_script() { - let mut app = headless(screenshot_options(), SchemeFlavor::Light); +fn idle_ticks_do_not_rebuild_the_view_model() { + let mut app = headless(Options::default(), 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 { + let before = app.refreshes; + for _ in 0..100 { app.advance(super::state::TICK); } - - let detail = app.view.instances.detail.as_ref().expect("有选中项"); - let status = detail.status.as_deref().unwrap_or_default(); - assert!( - status.starts_with("Verifying") || status.starts_with("Starting"), - "50 拍之后应已过下载阶段,实际 {status:?}" - ); -} - -/// 停止也是真命令:`StopInstance` 让 mock 直接回一条 Idle 事件。 -#[test] -fn stopping_returns_to_idle_through_an_event() { - let mut app = headless(screenshot_options(), SchemeFlavor::Light); - let _ = app.update(Message::Boot); - let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( - "survival".to_string(), - )))); - let _ = app.update(Message::Page(pages::Message::Launch( - "survival".to_string(), - ))); - let _ = app.update(Message::Page(pages::Message::Stop("survival".to_string()))); - - let detail = app.view.instances.detail.as_ref().expect("有选中项"); - assert!(detail.can_launch && !detail.can_stop, "停下来回到空闲"); - assert!(detail.status.is_none(), "空闲没有状态行"); -} - -/// 导航:一级页替换、详情压栈、返回出栈——页面换的是 `Route`,不是页面自己的选中项。 -#[test] -fn navigation_follows_the_presenter_router() { - let mut app = headless(screenshot_options(), SchemeFlavor::Light); - let _ = app.update(Message::Boot); - - let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( - "survival".to_string(), - )))); - assert_eq!(app.view.screen, pages::Screen::Instances, "详情仍是实例页"); - assert!(app.state.router.can_go_back()); - - let _ = app.update(Message::Page(pages::Message::Navigate(Route::Settings))); - assert_eq!(app.view.screen, pages::Screen::Settings); assert_eq!( - app.view.active, - Some(pages::Screen::Settings.nav()), - "导航轨高亮跟着路由走" - ); - assert!( - !app.state.router.can_go_back(), - "一级页互相替换,不进栈(presenter::router 的语义)" + app.refreshes, + before, + "空闲 100 拍:视图模型一次都不该重建(实际 +{})", + app.refreshes - before ); - let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances))); - assert_eq!(app.view.screen, pages::Screen::Instances); + // 有事件进来时仍然要重建(否则页面不刷新) + 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() { diff --git a/crates/app-iced/src/app/tests/presenter.rs b/crates/app-iced/src/app/tests/presenter.rs new file mode 100644 index 0000000..af399f5 --- /dev/null +++ b/crates/app-iced/src/app/tests/presenter.rs @@ -0,0 +1,238 @@ +//! presenter 闭环与节拍的断言(M90 r2 从 `app/tests.rs` 外置:那边 416 行会被 file-size 判红)。 +//! +//! 这里的每条都依赖一个可注入的 `Backend`: +//! - `MockBackend`(presenter 自带)验「命令 → 事件 → 状态」的闭环; +//! - `RecorderBackend`(本文件,只记账不回事件)验「UI 侧不做乐观回显」——用 MockBackend 验这条 +//! 会得到**假绿**,因为它是同步发事件的,本地回显与事件回流终态相同(P3-2 的教训)。 + +use std::cell::RefCell; +use std::rc::Rc; + +use unlucky_presenter::dto::{AppCommand, AppEvent, Backend, CoreEvent}; +use unlucky_presenter::mock_data::mock_instances; + +use super::*; + +// --------------------------------------------------------------- presenter 闭环 + +/// **首屏快照在 Boot 就到位**(截图在第 3 帧落盘,等 30 fps 的 tick 会拍进空页面)。 +#[test] +fn boot_drains_the_first_snapshot_before_the_first_frame() { + let mut app = headless(screenshot_options(), SchemeFlavor::Light); + assert!(app.view.instances.rows.is_empty(), "还没 Boot"); + + let _ = app.update(Message::Boot); + + assert_eq!( + app.view.instances.rows.len(), + 4, + "mock 的四个实例已进视图模型" + ); + assert_eq!(app.view.screen, pages::Screen::Instances); +} + +/// 首屏自动选中第一行(右栏不该开局空白),且**只做一次**:返回列表后再刷新不弹回详情。 +#[test] +fn the_first_snapshot_selects_the_first_instance_once() { + let mut app = headless(screenshot_options(), SchemeFlavor::Light); + let _ = app.update(Message::Boot); + assert_eq!( + app.state.route(), + &Route::Detail("survival".to_string()), + "首屏快照到位后自动选中第一行" + ); + + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances))); + assert_eq!(app.state.route(), &Route::Instances); + + // 再推进一拍(会 drain_events + auto_select):不该把他弹回详情页 + app.advance(super::super::state::TICK); + assert_eq!(app.state.route(), &Route::Instances, "自动选中只做一次"); +} + +/// 只记账、**不回任何事件**的后端(P3-2)。 +/// +/// 为什么需要它:`MockBackend::dispatch` 是**同步**发事件的,于是「UI 本地回显」与「事件回流」 +/// 在 dispatch 之后终态相同——拿 MockBackend 测「不乐观回显」是假绿(r1 就是栽在这条上)。 +/// 这个后端把两件事分开:命令只进 `dispatched`,`drain_events` 永远空 ⇒ 状态**不可能**变; +/// 一旦有人在 `update` 里偷偷改本地状态,下面那条断言立刻红(实测见报告「P3-2 取证」)。 +#[derive(Debug, Default)] +struct RecorderBackend { + dispatched: Rc>>, + seeded: Vec, +} + +impl Backend for RecorderBackend { + fn drain_events(&mut self) -> Vec { + std::mem::take(&mut self.seeded) + } + + fn dispatch(&mut self, command: AppCommand) { + self.dispatched.borrow_mut().push(command); + } +} + +/// 用 recorder 后端造一个 App(首屏快照靠 `seeded` 一次性喂进去)。 +fn recording(options: Options, flavor: SchemeFlavor) -> (App, Rc>>) { + let recorded = Rc::new(RefCell::new(Vec::new())); + // 注意:`InstancesChanged` 是**整体替换**,所以四条必须合成一条事件; + // 分开发的话最后只会剩一个实例(r1 在这条上踩过)。 + let backend = RecorderBackend { + dispatched: Rc::clone(&recorded), + seeded: vec![AppEvent::Core( + CoreEvent::InstancesChanged(mock_instances()), + )], + }; + let app = App::with_presenter( + options, + FAMILIES, + flavor, + Box::new(backend), + SettingsBridge::memory_only(), + ); + (app, recorded) +} + +/// **P3-2:不做乐观回显**——命令必须真的出 UI 层,而状态在后端回事件之前**一个字节都不许变**。 +/// +/// 抓得住:`update` 里出现「本地先改状态」的写法(recorder 永远不回事件,本地一改这里就红)。 +/// 抓不住:「命令有没有真的到达 host」——那由 `MockBackend` 的闭环测试与真机验证负责。 +#[test] +fn launching_only_dispatches_and_never_echoes_locally() { + let mut options = screenshot_options(); + options.screenshot = None; // recorder 不依赖截图通道 + let (mut app, recorded) = recording(options, SchemeFlavor::Light); + + let _ = app.update(Message::Boot); + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( + "survival".to_string(), + )))); + + // Boot 自己发过一条 RefreshAll,先把账本清掉,下面只看「点 Play」这一个动作 + recorded.borrow_mut().clear(); + + let before = app.view.instances.detail.clone().expect("有选中项"); + assert!(before.can_launch && !before.can_stop && before.status.is_none()); + + let _ = app.update(Message::Page(pages::Message::Launch( + "survival".to_string(), + ))); + + // ① 命令真的出 UI 层了 + assert_eq!( + *recorded.borrow(), + vec![AppCommand::LaunchInstance("survival".to_string())], + "Launch 必须翻成一条 AppCommand::LaunchInstance 并交给 Backend::dispatch" + ); + + // ② 后端一条事件都没回 ⇒ 视图模型必须**原样不动**(任何本地回显都会让这条红) + let after = app.view.instances.detail.clone().expect("有选中项"); + assert_eq!( + after, before, + "没有事件回流时页面不许自己变(乐观回显);现在的差异是 {after:?}" + ); + assert!(!after.can_stop, "不许因为点了 Play 就本地切成「可停止」"); + assert!(after.status.is_none(), "不许本地造一条进度文案"); +} + +/// **点 Play → 命令真的派发出去 → 事件回流 → 页面真的变**(真后端的闭环)。 +#[test] +fn launching_goes_through_the_backend_and_comes_back_as_events() { + let mut app = headless(screenshot_options(), SchemeFlavor::Light); + let _ = app.update(Message::Boot); + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( + "survival".to_string(), + )))); + + let detail = app.view.instances.detail.as_ref().expect("有选中项"); + assert!( + detail.can_launch && !detail.can_stop, + "空闲:可启动不可停止" + ); + + let _ = app.update(Message::Page(pages::Message::Launch( + "survival".to_string(), + ))); + + let detail = app.view.instances.detail.as_ref().expect("有选中项"); + assert!( + !detail.can_launch && detail.can_stop, + "派发 LaunchInstance 后后端发回了 Busy 事件:按钮组合必须已经翻转" + ); + assert_eq!( + detail.status.as_deref(), + Some("Downloading · 0%"), + "状态行来自事件里的进度,不是本地假造" + ); +} + +/// 时间推进真的把状态往前推(后端的脚本按 tick 的累计时长走)。 +#[test] +fn ticks_advance_the_backend_script() { + let mut app = headless(screenshot_options(), SchemeFlavor::Light); + let _ = app.update(Message::Boot); + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( + "survival".to_string(), + )))); + let _ = app.update(Message::Page(pages::Message::Launch( + "survival".to_string(), + ))); + + // 33 ms × 50 ≈ 1.65 s:mock 的脚本在 1.5 s 从 Download 切到 Verify + for _ in 0..50 { + app.advance(super::super::state::TICK); + } + + let detail = app.view.instances.detail.as_ref().expect("有选中项"); + let status = detail.status.as_deref().unwrap_or_default(); + assert!( + status.starts_with("Verifying") || status.starts_with("Starting"), + "50 拍之后应已过下载阶段,实际 {status:?}" + ); +} + +/// 停止也是真命令:`StopInstance` 让 mock 直接回一条 Idle 事件。 +#[test] +fn stopping_returns_to_idle_through_an_event() { + let mut app = headless(screenshot_options(), SchemeFlavor::Light); + let _ = app.update(Message::Boot); + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( + "survival".to_string(), + )))); + let _ = app.update(Message::Page(pages::Message::Launch( + "survival".to_string(), + ))); + let _ = app.update(Message::Page(pages::Message::Stop("survival".to_string()))); + + let detail = app.view.instances.detail.as_ref().expect("有选中项"); + assert!(detail.can_launch && !detail.can_stop, "停下来回到空闲"); + assert!(detail.status.is_none(), "空闲没有状态行"); +} + +/// 导航:一级页替换、详情压栈、返回出栈——页面换的是 `Route`,不是页面自己的选中项。 +#[test] +fn navigation_follows_the_presenter_router() { + let mut app = headless(screenshot_options(), SchemeFlavor::Light); + let _ = app.update(Message::Boot); + + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( + "survival".to_string(), + )))); + assert_eq!(app.view.screen, pages::Screen::Instances, "详情仍是实例页"); + assert!(app.state.router.can_go_back()); + + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Settings))); + assert_eq!(app.view.screen, pages::Screen::Settings); + assert_eq!( + app.view.active, + Some(pages::Screen::Settings.nav()), + "导航轨高亮跟着路由走" + ); + assert!( + !app.state.router.can_go_back(), + "一级页互相替换,不进栈(presenter::router 的语义)" + ); + + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances))); + assert_eq!(app.view.screen, pages::Screen::Instances); +} diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index 833c343..6e46eec 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -27,7 +27,7 @@ use unlucky_presenter::theme::config_poll_due; use crate::pages; use super::message::{route_of, Message}; -use super::state::{App, TICK}; +use super::state::{App, TICK, WATCH_WINDOW}; use super::CAPTURE_AT_FRAME; impl App { @@ -52,6 +52,7 @@ impl App { Message::Window(event) => { if let window::Event::Resized(size) = event { self.width = size.width; + self.view_dirty = true; self.refresh_view(); } Task::none() @@ -63,6 +64,7 @@ impl App { // 外壳导航轨(`src/shell/nav.rs`)发来的点选:翻成同一次路由跳转 Message::Select(nav) => { self.state.navigate(route_of(nav)); + self.view_dirty = true; self.refresh_view(); Task::none() } @@ -111,6 +113,7 @@ impl App { // 「重新扫描实例目录 + 全部页面快照」:真 host 在这里发首批事件,mock 在构造时已预置 self.dispatch(AppCommand::RefreshAll); self.auto_select(); + self.view_dirty = true; self.refresh_view(); Task::batch(tasks) @@ -162,7 +165,21 @@ impl App { self.apply_config_theme(); } - self.refresh_view(); + if self.view_dirty { + self.refresh_view(); + } + + // 取证开关(`ULL_TICK_TRACE=1`):每 30 拍打一行。**空闲态应当打完一阵就彻底安静** + // —— 那正是「不再订阅帧事件」的可观测形式(没有 Frame ⇒ 这里根本不会被调用)。 + if self.ticks.is_multiple_of(30) && std::env::var_os("ULL_TICK_TRACE").is_some() { + eprintln!( + "[tick] n={} frames={} refreshes={} frames_wanted={}", + self.ticks, + self.frames, + self.refreshes, + self.needs_frames() + ); + } } /// 页面消息:导航走路由栈,动作翻成 `AppCommand`。 @@ -170,6 +187,7 @@ impl App { 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)), @@ -184,6 +202,9 @@ impl App { /// 派发一条指令并**立刻**抽干它带出来的事件(不本地回显,等价于 Slint 版的 `dispatch`)。 fn dispatch(&mut self, command: AppCommand) { self.backend.dispatch(command); + // 观察窗:命令发出后 3 秒内保持帧订阅,等后端把第一条事件交回来 + // (真 host 是异步的,派发瞬间状态还没变;不设这个窗口,空闲态的帧订阅永远不会打开)。 + self.watch_until = Some(Instant::now() + WATCH_WINDOW); self.pump(); } @@ -196,6 +217,7 @@ impl App { for event in events { self.state.apply(event); } + self.view_dirty = true; self.auto_select(); self.refresh_view(); } @@ -219,6 +241,7 @@ impl App { }; self.auto_selected = true; self.state.navigate(Route::Detail(first)); + self.view_dirty = true; } /// 截图回来了:落盘 + 打一行取证日志,然后退出。 diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index 1a23618..85fff4c 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -4,15 +4,16 @@ use iced::{Element, Theme}; -use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage, LoaderKind}; +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::Message; +use super::message::{nav_item_of, Message}; use super::state::{log_first_frame, App}; use super::APP_TITLE; @@ -37,10 +38,19 @@ impl App { 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(_) => pages::app(&self.ui, &self.view).map(Message::Page), - // `Route::Agent`:AI 独立页还没有页面实现(P1),暂时回退到 M68 的外壳卡。 - // 这条回退也是 `src/shell/` 目前唯一的调用点——见报告里的「谁该补」。 + 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, @@ -57,7 +67,13 @@ impl App { // --------------------------------------------------------------- 快照 → 视图模型 /// 把 presenter 的快照转成页面视图模型(**唯一**的展示转换点)。 -pub(super) fn model(state: &AppState, width: f32) -> pages::Model { +/// +/// `forced` 是 `--force-state` 的落点(P3-1):它只影响**画出来的**状态层,不改任何状态机。 +pub(super) fn model( + state: &AppState, + width: f32, + forced: Option, +) -> pages::Model { let route = state.route().clone(); let screen = pages::Screen::of(&route).unwrap_or(pages::Screen::Instances); @@ -86,6 +102,10 @@ pub(super) fn model(state: &AppState, width: f32) -> pages::Model { 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, + }), } } @@ -123,11 +143,10 @@ fn stage_label(stage: LaunchStage) -> &'static str { /// 详情列的内容(字段全部来自 [`InstanceSummary`])。 fn detail_of(instance: &InstanceSummary) -> Detail { - let loader = if instance.loader.kind == LoaderKind::Vanilla { - "Vanilla".to_string() - } else { - instance.loader.badge() - }; + // 视觉 P2-2(M90 r2):列表行与详情曾经「同物两名」(列表 `badge()` 出「原版」、详情硬编码 + // 「Vanilla」)。现在两边都走 `LoaderInfo::badge()` 这一个入口;等 i18n 落地后由词表统一, + // 届时两处会同时变——这正是要的。 + let loader = instance.loader.badge(); Detail { id: instance.id.clone(), @@ -217,7 +236,7 @@ mod tests { )); state.navigate(Route::Detail("survival".to_string())); - let model = model(&state, 1180.0); + 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); @@ -248,7 +267,7 @@ mod tests { )); state.navigate(Route::Detail("survival".to_string())); - let model = model(&state, 1180.0); + let model = model(&state, 1180.0, None); assert_eq!( model.instances.rows[0].status.as_deref(), Some("Downloading · 50%") @@ -286,7 +305,7 @@ mod tests { fn the_agent_route_has_no_page() { let mut state = AppState::new(); state.navigate(Route::Agent); - let model = model(&state, 1180.0); + let model = model(&state, 1180.0, None); // Agent 没有页面:模型退回实例页的空壳,view 会走外壳回退(`None` 分支) assert_eq!(pages::Screen::of(state.route()), None); assert!(model.instances.detail.is_none()); diff --git a/crates/app-iced/src/components/gate.rs b/crates/app-iced/src/components/gate.rs index 223ba69..1a023f5 100644 --- a/crates/app-iced/src/components/gate.rs +++ b/crates/app-iced/src/components/gate.rs @@ -22,6 +22,10 @@ use std::path::{Path, PathBuf}; /// 单文件行数上限(AGENTS.md §3-1)。 const MAX_LINES: usize = 400; +fn src_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src") +} + fn components_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("src") @@ -192,10 +196,25 @@ fn t2_no_size_literals_in_components() { ); } +/// 单文件 ≤400 行:**扫整个 `src/`**,不只 components。 +/// +/// M90 r2(P1-2)的教训:这条守门原来只遍历 `components/`,于是 `pages/harness.rs` 悄悄长到 +/// 402 行没人拦,直到 `guards.yml` 的 file-size job 在 CI 上判红才发现。`guards.yml` 的口径是 +/// **全仓**(`git ls-files '*.rs' '*.md' …`),这里至少把本 crate 的 `src/**` 对齐: +/// 谁在 `app/` / `pages/` / `shell/` 把文件推过线,`cargo test` 就先红,不用等 CI。 #[test] -fn every_component_file_stays_under_the_line_limit() { +fn every_source_file_stays_under_the_line_limit() { + let mut files = Vec::new(); + rust_files(&src_dir(), &mut files); + assert!( + files.len() >= 40, + "src/ 至少 40 个 .rs,实际 {}:扫描逻辑坏了,这条门禁等于没跑", + files.len() + ); + let mut offenders = Vec::new(); - for (path, text) in sources() { + for path in files { + let text = std::fs::read_to_string(&path).expect("读源文件"); let lines = text.lines().count(); if lines > MAX_LINES { offenders.push(format!( @@ -206,7 +225,7 @@ fn every_component_file_stays_under_the_line_limit() { } assert!( offenders.is_empty(), - "单文件 ≤400 行(AGENTS.md §3-1,guards.yml 会判红):\n{}", + "单文件 ≤400 行(AGENTS.md §3-1,guards.yml 按全仓判红):\n{}", offenders.join("\n") ); } diff --git a/crates/app-iced/src/pages/harness.rs b/crates/app-iced/src/pages/harness.rs index b74d20c..137a309 100644 --- a/crates/app-iced/src/pages/harness.rs +++ b/crates/app-iced/src/pages/harness.rs @@ -339,64 +339,5 @@ impl Harness { } #[cfg(test)] -mod tests { - use super::*; - - fn parse(args: &[&str]) -> Result { - Request::parse(args.iter().map(|arg| (*arg).to_owned())) - } - - #[test] - fn defaults_are_the_acceptance_frame() { - let request = parse(&[]).expect("空参数要能解析"); - assert_eq!(request.screen, Screen::Instances); - assert_eq!((request.width, request.height), (1180.0, 760.0)); - assert_eq!(request.flavor, SchemeFlavor::Light); - assert!(request.screenshot.is_none()); - } - - #[test] - fn screen_and_width_parse_in_both_argv_forms() { - let spaced = parse(&["--screen", "settings", "--width", "900"]).unwrap(); - let inline = parse(&["--screen=settings", "--width=900"]).unwrap(); - assert_eq!(spaced, inline); - assert_eq!(spaced.screen, Screen::Settings); - assert_eq!(spaced.width, 900.0); - assert!(parse(&["--screen=gallery"]).is_err()); - assert!(parse(&["--width=abc"]).is_err()); - } - - #[test] - fn screenshot_theme_wins_over_theme() { - let request = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap(); - assert_eq!(request.flavor, SchemeFlavor::Light); - assert_eq!(parse(&["--theme=dark"]).unwrap().flavor, SchemeFlavor::Dark); - } - - #[test] - fn software_renderer_is_an_alias_of_cpu() { - assert_eq!( - parse(&["--software-renderer"]).unwrap().backend, - Backend::Cpu - ); - assert_eq!(parse(&["--renderer=cpu"]).unwrap().backend, Backend::Cpu); - assert_eq!(parse(&["--renderer=gpu"]).unwrap().backend, Backend::Gpu); - } - - #[test] - fn slug_carries_screen_class_and_flavor() { - let request = parse(&[ - "--screen=instances", - "--width=700", - "--screenshot-theme=dark", - ]) - .unwrap(); - assert_eq!(request.slug(), "instances-medium-dark-w700"); - } - - #[test] - fn tiny_windows_are_rejected() { - assert!(parse(&["--width=200"]).is_err()); - assert!(parse(&["--height=100"]).is_err()); - } -} +#[path = "harness/tests.rs"] +mod tests; diff --git a/crates/app-iced/src/pages/harness/tests.rs b/crates/app-iced/src/pages/harness/tests.rs new file mode 100644 index 0000000..37a3e58 --- /dev/null +++ b/crates/app-iced/src/pages/harness/tests.rs @@ -0,0 +1,63 @@ +//! `pages::harness` 的断言(M90 r2 从 `harness.rs` 外置:那边曾 402 行,guards 的 file-size 会判红)。 +//! +//! 只测**参数解析**与**出图 slug**,不碰 iced 运行时。 + +use super::*; + +fn parse(args: &[&str]) -> Result { + Request::parse(args.iter().map(|arg| (*arg).to_owned())) +} + +#[test] +fn defaults_are_the_acceptance_frame() { + let request = parse(&[]).expect("空参数要能解析"); + assert_eq!(request.screen, Screen::Instances); + assert_eq!((request.width, request.height), (1180.0, 760.0)); + assert_eq!(request.flavor, SchemeFlavor::Light); + assert!(request.screenshot.is_none()); +} + +#[test] +fn screen_and_width_parse_in_both_argv_forms() { + let spaced = parse(&["--screen", "settings", "--width", "900"]).unwrap(); + let inline = parse(&["--screen=settings", "--width=900"]).unwrap(); + assert_eq!(spaced, inline); + assert_eq!(spaced.screen, Screen::Settings); + assert_eq!(spaced.width, 900.0); + assert!(parse(&["--screen=gallery"]).is_err()); + assert!(parse(&["--width=abc"]).is_err()); +} + +#[test] +fn screenshot_theme_wins_over_theme() { + let request = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap(); + assert_eq!(request.flavor, SchemeFlavor::Light); + assert_eq!(parse(&["--theme=dark"]).unwrap().flavor, SchemeFlavor::Dark); +} + +#[test] +fn software_renderer_is_an_alias_of_cpu() { + assert_eq!( + parse(&["--software-renderer"]).unwrap().backend, + Backend::Cpu + ); + assert_eq!(parse(&["--renderer=cpu"]).unwrap().backend, Backend::Cpu); + assert_eq!(parse(&["--renderer=gpu"]).unwrap().backend, Backend::Gpu); +} + +#[test] +fn slug_carries_screen_class_and_flavor() { + let request = parse(&[ + "--screen=instances", + "--width=700", + "--screenshot-theme=dark", + ]) + .unwrap(); + assert_eq!(request.slug(), "instances-medium-dark-w700"); +} + +#[test] +fn tiny_windows_are_rejected() { + assert!(parse(&["--width=200"]).is_err()); + assert!(parse(&["--height=100"]).is_err()); +} diff --git a/crates/app-iced/src/pages/instances.rs b/crates/app-iced/src/pages/instances.rs index 3d8ebce..ee720c1 100644 --- a/crates/app-iced/src/pages/instances.rs +++ b/crates/app-iced/src/pages/instances.rs @@ -30,6 +30,7 @@ mod data; mod demo; +mod detail; #[cfg(test)] mod tests; @@ -47,46 +48,54 @@ use unlucky_design::icons::IconKind; use unlucky_design::shape::ShapeScale; use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; -use unlucky_presenter::router::Route; use crate::app::components::buttons::{self, Button}; -use crate::app::components::divider; +use crate::app::components::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::model::{Detail, Instances, Row}; +use super::layout::{FrameMetrics, PageLayout}; +use super::model::{Instances, Model, Row}; use super::{Action, Message}; /// 列表行的间距(`Space::X1`;与 M84 同值,行高差异全部来自 `ListItem` 自己的几何)。 const ROW_GAP: Space = Space::X1; /// 整页(`block.detail = None` 时详情区显示空态)。 -pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, block: &'a Instances) -> Element<'a, Message> { +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(buttons::text( ui, - Button::new("New instance", Message::Action(Action::NewInstance)), + forced_state( + Button::new("New instance", Message::Action(Action::NewInstance)), + forced, + ), )) .action(buttons::text( ui, - Button::new("Import", Message::Action(Action::Import)), + forced_state( + Button::new("Import", Message::Action(Action::Import)), + forced, + ), )); let body = match metrics.layout { PageLayout::ListDetail => frame::panes( metrics, - list_column(ui, block), + list_column(ui, block, forced), match &block.detail { - Some(detail) => detail_column(ui, metrics, detail), - None => detail_empty(ui), + Some(detail) => detail::detail_column(ui, metrics, detail, forced), + None => detail::detail_empty(ui), }, ), _ => match &block.detail { - Some(detail) => narrow_detail(ui, metrics, detail), - None => frame::single_column(metrics, detail_empty(ui)), + Some(detail) => detail::narrow_detail(ui, metrics, detail, forced), + None => frame::single_column(metrics, detail::detail_empty(ui)), }, }; @@ -95,7 +104,11 @@ pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, block: &'a Instances) -> Elem // --------------------------------------------------------------- 列表列 -fn list_column<'a>(ui: &'a Ui, block: &'a Instances) -> Element<'a, Message> { +fn list_column<'a>( + ui: &'a Ui, + block: &'a Instances, + forced: Option, +) -> Element<'a, Message> { let head = container( row![ container(frame::text_role( @@ -130,7 +143,7 @@ fn list_column<'a>(ui: &'a Ui, block: &'a Instances) -> Element<'a, Message> { block .rows .iter() - .map(|entry| list_row(ui, entry, is_selected(block, entry))) + .map(|entry| list_row(ui, entry, is_selected(block, entry), forced)) .collect::>(), ) .spacing(ui.space(ROW_GAP)) @@ -169,11 +182,27 @@ fn is_selected(block: &Instances, entry: &Row) -> bool { .is_some_and(|detail| detail.id == entry.id) } +/// `--force-state` 的落点:只改**画出来的**状态,不动任何状态机(cli `ForcedState` 的同一条纪律)。 +pub(super) fn forced_state<'a, M>( + button: Button<'a, M>, + forced: Option, +) -> Button<'a, M> { + match forced { + Some(state) => button.state(state), + None => button, + } +} + /// 一行(`ListItem` 的两/三行档:overline = 运行状态,headline = 名字,supporting = 版本 + 加载器)。 /// /// 点一行 = **压一层路由栈**(`Route::Detail(id)`),页面自己不留「选中项」副本—— /// 于是「当前选中」与「返回栈」永远是同一份真相(`presenter::router`)。 -fn list_row<'a>(ui: &'a Ui, entry: &'a Row, selected: bool) -> Element<'a, Message> { +fn list_row<'a>( + ui: &'a Ui, + entry: &'a Row, + selected: bool, + forced: Option, +) -> Element<'a, Message> { let mut item = ListItem::new( entry.name.as_str(), Message::Navigate(Route::Detail(entry.id.clone())), @@ -188,194 +217,9 @@ fn list_row<'a>(ui: &'a Ui, entry: &'a Row, selected: bool) -> Element<'a, Messa if selected { item = item.selected(); } + if let Some(state) = forced { + item = item.state(state); + } list_item(ui, item) } - -// --------------------------------------------------------------- 详情列 - -fn detail_column<'a>( - ui: &'a Ui, - metrics: FrameMetrics, - detail: &'a Detail, -) -> Element<'a, Message> { - let mut body = column![ - detail_head(ui, detail), - meta_grid(ui, metrics, detail), - divider::full(ui), - ] - .spacing(ui.space(Space::X4)) - .width(Length::Fill); - - body = push_text(ui, body, detail); - - // 页头 + 元信息网格 + **一条**分隔线以下才是正文与操作区(M84 那两条线是占位时期的排版残留) - frame::left_aligned(metrics.column, body.push(action_row(ui))) -} - -fn narrow_detail<'a>( - ui: &'a Ui, - metrics: FrameMetrics, - detail: &'a Detail, -) -> Element<'a, Message> { - // 窄窗:列表退成上一屏,本屏只放详情(官方 compact 的 list-detail 口径)。 - let mut body = column![ - row![buttons::text( - ui, - Button::new("Back to instances", Message::Navigate(Route::Instances)), - )] - .width(Length::Fill) - .align_y(Alignment::Center), - detail_head(ui, detail), - meta_grid(ui, metrics, detail), - divider::full(ui), - ] - .spacing(ui.space(Space::X4)) - .width(Length::Fill); - - body = push_text(ui, body, detail); - - body = body.push(frame::text_role( - ui, - TypeRole::BodySmall, - false, - "Below 840 px the list and the detail share one column, so opening an instance replaces \ - this screen (full-page switch). Widen the window to see both panes again.", - ui.color(Role::OnSurfaceVariant), - )); - - frame::single_column(metrics, body.push(action_row(ui))) -} - -/// 正文段(演示数据才有)与运行状态行(真数据才有)——两者都是可选的,顺序固定。 -fn push_text<'a>( - ui: &'a Ui, - mut body: iced::widget::Column<'a, Message>, - detail: &'a Detail, -) -> iced::widget::Column<'a, Message> { - if let Some(note) = &detail.note { - body = body.push(frame::text_role( - ui, - TypeRole::BodyMedium, - false, - note.as_str(), - ui.color(Role::OnSurfaceVariant), - )); - } - if let Some(status) = &detail.status { - body = body.push(frame::text_role( - ui, - TypeRole::BodyMedium, - false, - status.as_str(), - ui.color(Role::OnSurface), - )); - } - body -} - -fn detail_head<'a>(ui: &'a Ui, detail: &'a Detail) -> Element<'a, Message> { - let titles = column![ - frame::text_role( - ui, - TypeRole::TitleLarge, - true, - 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, Button::new("Play", Message::Launch(detail.id.clone()))) - } else { - buttons::filled(ui, Button::disabled("Play")) - }; - let stop = if detail.can_stop { - buttons::text(ui, Button::new("Stop", Message::Stop(detail.id.clone()))) - } else { - buttons::text(ui, Button::disabled("Stop")) - }; - - row![titles, launch, stop] - .spacing(ui.space(Space::X2)) - .width(Length::Fill) - .align_y(Alignment::Center) - .into() -} - -/// 元信息网格:列数由 [`layout::meta_columns`](按正文列宽)决定 —— **1180 与 900 都是 2 列**。 -/// -/// 3 列需要 `3 × META_CARD_MIN_WIDTH + 2 × META_GAP` = 504px,而行宽上限是 424,**数学上不可行**; -/// 硬塞回 3 列会让 `NeoForge 20.4.237`(118px)这类值按词折行、同排卡底不齐。 -fn meta_grid<'a>(ui: &'a Ui, metrics: FrameMetrics, 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) -> Element<'a, Message> { - row![ - buttons::text( - ui, - Button::new("Duplicate", Message::Action(Action::Duplicate)) - ), - buttons::text( - ui, - Button::new("Open folder", Message::Action(Action::OpenFolder)) - ), - buttons::text(ui, Button::new("Delete", Message::Action(Action::Delete))), - ] - .spacing(ui.space(Space::X2)) - .align_y(Alignment::Center) - .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(), - ) -} diff --git a/crates/app-iced/src/pages/instances/detail.rs b/crates/app-iced/src/pages/instances/detail.rs new file mode 100644 index 0000000..fe7bd3f --- /dev/null +++ b/crates/app-iced/src/pages/instances/detail.rs @@ -0,0 +1,246 @@ +//! 实例页的**详情列**(M90 r2 从 `instances.rs` 拆出来:那边超过 400 行会被 guards 的 file-size 判红)。 +//! +//! 结构:标题行(名字 + 版本 + Play/Stop)→ 元信息网格(列数由 `layout::meta_columns` 决定) +//! → 一条分隔线 → 正文段 / 运行状态行 → 动作行(Duplicate / Open folder / Delete)。 +//! 窄窗(<840)时列表退成上一屏,本文件只画详情 + 一个「返回」按钮。 + +use iced::widget::{column, container, row}; +use iced::{Alignment, Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; +use unlucky_presenter::router::Route; + +use crate::app::components::buttons::{self, Button}; +use crate::app::components::divider; +use crate::app::components::interaction::Interaction; +use crate::theme::Ui; +use unlucky_design::icons::IconKind; + +use super::super::frame; +use super::super::layout::{self, FrameMetrics}; +use super::super::model::Detail; +use super::super::{Action, Message}; +use super::forced_state; + +// --------------------------------------------------------------- 详情列 + +pub(super) fn detail_column<'a>( + ui: &'a Ui, + metrics: FrameMetrics, + detail: &'a Detail, + forced: Option, +) -> Element<'a, Message> { + let mut body = column![ + detail_head(ui, detail, forced), + meta_grid(ui, metrics, detail), + divider::full(ui), + ] + .spacing(ui.space(Space::X4)) + .width(Length::Fill); + + body = push_text(ui, body, detail); + + // 页头 + 元信息网格 + **一条**分隔线以下才是正文与操作区(M84 那两条线是占位时期的排版残留) + frame::left_aligned(metrics.column, body.push(action_row(ui, forced))) +} + +pub(super) fn narrow_detail<'a>( + ui: &'a Ui, + metrics: FrameMetrics, + detail: &'a Detail, + forced: Option, +) -> Element<'a, Message> { + // 窄窗:列表退成上一屏,本屏只放详情(官方 compact 的 list-detail 口径)。 + let mut body = column![ + row![buttons::text( + ui, + forced_state( + Button::new("Back to instances", Message::Navigate(Route::Instances)), + forced, + ), + )] + .width(Length::Fill) + .align_y(Alignment::Center), + detail_head(ui, detail, forced), + meta_grid(ui, metrics, detail), + divider::full(ui), + ] + .spacing(ui.space(Space::X4)) + .width(Length::Fill); + + body = push_text(ui, body, detail); + + body = body.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + "Below 840 px the list and the detail share one column, so opening an instance replaces \ + this screen (full-page switch). Widen the window to see both panes again.", + ui.color(Role::OnSurfaceVariant), + )); + + frame::single_column(metrics, body.push(action_row(ui, forced))) +} + +/// 正文段(演示数据才有)与运行状态行(真数据才有)——两者都是可选的,顺序固定。 +fn push_text<'a>( + ui: &'a Ui, + mut body: iced::widget::Column<'a, Message>, + detail: &'a Detail, +) -> iced::widget::Column<'a, Message> { + if let Some(note) = &detail.note { + body = body.push(frame::text_role( + ui, + TypeRole::BodyMedium, + false, + note.as_str(), + ui.color(Role::OnSurfaceVariant), + )); + } + if let Some(status) = &detail.status { + body = body.push(frame::text_role( + ui, + TypeRole::BodyMedium, + false, + status.as_str(), + ui.color(Role::OnSurface), + )); + } + body +} + +fn detail_head<'a>( + ui: &'a Ui, + detail: &'a Detail, + forced: Option, +) -> Element<'a, Message> { + let titles = column![ + frame::text_role( + ui, + TypeRole::TitleLarge, + true, + detail.name.as_str(), + ui.color(Role::OnSurface) + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + detail.subtitle.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill); + + // 主操作:**全屏唯一**实心 accent(rubric K2)。不可用时退化成禁用态(只清消息,不改身份)。 + let launch = if detail.can_launch { + buttons::filled( + ui, + forced_state( + Button::new("Play", Message::Launch(detail.id.clone())), + forced, + ), + ) + } else { + buttons::filled(ui, Button::disabled("Play")) + }; + let stop = if detail.can_stop { + buttons::text( + ui, + forced_state( + Button::new("Stop", Message::Stop(detail.id.clone())), + forced, + ), + ) + } else { + buttons::text(ui, Button::disabled("Stop")) + }; + + row![titles, launch, stop] + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .align_y(Alignment::Center) + .into() +} + +/// 元信息网格:列数由 [`layout::meta_columns`](按正文列宽)决定 —— **1180 与 900 都是 2 列**。 +/// +/// 3 列需要 `3 × META_CARD_MIN_WIDTH + 2 × META_GAP` = 504px,而行宽上限是 424,**数学上不可行**; +/// 硬塞回 3 列会让 `NeoForge 20.4.237`(118px)这类值按词折行、同排卡底不齐。 +fn meta_grid<'a>(ui: &'a Ui, metrics: FrameMetrics, detail: &'a Detail) -> Element<'a, Message> { + let mut grid = column![].spacing(layout::META_GAP).width(Length::Fill); + let columns = metrics.meta_columns.clamp(1, detail.meta.len().max(1)); + + for chunk in detail.meta.chunks(columns) { + let mut cells_row = row![].spacing(layout::META_GAP).width(Length::Fill); + for cell in chunk { + let card = frame::card( + ui, + column![ + frame::text_role( + ui, + TypeRole::LabelMedium, + false, + cell.label.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + frame::text_role( + ui, + TypeRole::BodyMedium, + false, + cell.value.as_str(), + ui.color(Role::OnSurface) + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill), + ); + cells_row = cells_row.push(container(card).width(Length::Fill)); + } + grid = grid.push(cells_row); + } + + grid.into() +} + +fn action_row<'a>(ui: &'a Ui, forced: Option) -> Element<'a, Message> { + row![ + buttons::text( + ui, + forced_state( + Button::new("Duplicate", Message::Action(Action::Duplicate)), + forced, + ) + ), + buttons::text( + ui, + forced_state( + Button::new("Open folder", Message::Action(Action::OpenFolder)), + forced, + ) + ), + buttons::text( + ui, + forced_state( + Button::new("Delete", Message::Action(Action::Delete)), + forced + ), + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center) + .into() +} + +pub(super) fn detail_empty<'a>(ui: &'a Ui) -> Element<'a, Message> { + frame::empty_state( + ui, + IconKind::Instances, + "No instance selected", + "Pick an instance on the left to see its version, loader and launch options.", + Vec::new(), + ) +} diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index 6924629..20e516e 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -56,10 +56,23 @@ //! | 4 | 空态图标字形 **40px**(12b 15.1 是 `[估]` 32~48) | 令牌 `IconSize` 只有 24 一档,`frame::glyph` 按尺寸重算 stroke | 组件库的 `EmptyState`(M77)落地时 | //! | 5 | 窄窗「Back to instances」没有前导箭头 | 占位文本按钮没有图标位 | 真 top bar / M78 的导航件落地时 | //! | 6 | 实例页「未选中详情」空态**没有出图** | 演示数据固定选中第 1 条;空态代码路径在(`detail_empty`) | D30 状态矩阵补齐时补一张,不必重跑全套 | -//! | 7 | **标题栏仍是 48px 留白带**(M90) | 真实标题栏与窗口拖拽/缩放热区在 `src/shell/mod.rs`,本 mission 的 scope 不含它;页面层拿不到「内容槽位」 | shell 暴露「内容槽位」的 mission(已报 tower) | +//! | 7 | ~~标题栏仍是 48px 留白带~~ → **已于 M90 r2 修**:产品页现在走 `shell::view_with_content`,标题栏 / 拖动热区 / 两条缩放热区 / 三个 caption 键全在 | r1 的实质问题是**交互面缺失**(无边框窗口 + 产品页没有任何窗口装饰 ⇒ 拖不动、缩不了、点不到关闭),不是「少画了一条栏」 | ✅ 已收口(见下) | //! | 8 | 实例页的「New instance / Import」仍是**没有后端的按钮**(M90) | presenter 没有「打开新建向导」的指令(`wizard` 只有状态机,`AppCommand` 里无对应项),已报 finding | 向导 mission 补 `AppCommand::OpenWizard` 之后 | //! | 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` 的树里。 +//! //! ## 截图通道 //! //! ```text @@ -255,21 +268,34 @@ const NAV: [(Option, IconKind, &str, Route); 5] = [ (None, IconKind::Ai, "Assistant", Route::Agent), ]; -/// 整窗:标题栏留白带 + 导航轨 + 当前路由的页面。 +/// **单页内容**(不含外壳):交给 `shell::view_with_content` 塞进真标题栏与导航轨之间。 /// -/// 视图模型由**调用方持有**(`App` 每拍重建一次 `Model`),这里只借用:`view()` 不能返回 -/// 一棵借用局部变量的树(iced 的 `view(&self)` 只借 `&self`)。 -pub fn app<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> { +/// 产品路径(`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); - let page = match model.screen { - Screen::Instances => instances::view(ui, metrics, &model.instances), + 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(ui, model.active), 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); @@ -312,9 +338,10 @@ fn rail<'a>(ui: &'a Ui, active: Option) -> Element<'a, Message> { navigation(ui, Navigation::rail().group(group)) } -/// 标题栏留白带(高 = `LayoutMetrics::titlebar_height`)。 +/// 标题栏留白带(高 = `LayoutMetrics::titlebar_height`)——**只给 [`app`] 的 harness 路径用**。 /// -/// 真实标题栏在 `src/shell/mod.rs`(不在本 mission 的 scope 内),见模块文档偏离 #7。 +/// 产品路径的标题栏是 `shell::view_with_content` 画的那个真的(三个 caption 键 + 拖动热区), +/// 见模块文档偏离 #7(M90 r2 已修)。 fn titlebar_placeholder<'a>(ui: &'a Ui) -> Element<'a, Message> { container(space::horizontal()) .width(Length::Fill) diff --git a/crates/app-iced/src/pages/model.rs b/crates/app-iced/src/pages/model.rs index 6d66d8c..7da6ff1 100644 --- a/crates/app-iced/src/pages/model.rs +++ b/crates/app-iced/src/pages/model.rs @@ -16,6 +16,8 @@ use unlucky_presenter::router::NavSection; +use crate::app::components::interaction::Interaction; + use super::Screen; /// 实例列表的一行(`ListItem` 的槽位:overline = 运行状态,headline = 名字,supporting = 版本)。 @@ -81,6 +83,11 @@ pub struct Model { pub active: Option, /// 实例页的数据(其它三页还是占位视觉,不读) pub instances: Instances, + /// 取证用的强制交互态(`--force-state`):`Some` 时页面里的控件按该态画。 + /// + /// M90 r2(P3-1)之前这个开关只作用到 `shell` 回退路径,产品页是**静默失效**的; + /// 现在由 `app/view.rs` 把 `cli::ForcedState` 翻成 `Interaction` 灌进模型。 + pub forced: Option, } impl Model { @@ -91,6 +98,7 @@ impl Model { window_width, active, instances: Instances::default(), + forced: None, } } } diff --git a/crates/app-iced/src/shell/fallback.rs b/crates/app-iced/src/shell/fallback.rs new file mode 100644 index 0000000..1291988 --- /dev/null +++ b/crates/app-iced/src/shell/fallback.rs @@ -0,0 +1,75 @@ +//! M68 骨架卡:**只给 `Route::Agent` 的回落路径用**(AI 独立页 P1 才落地)。 +//! +//! 内容区 = 页头(`TitleLarge`)+ 一张 `OutlinedCard`("Shell ready" + 一段说明文字)。 +//! 它原本住在 `shell/mod.rs`,M90 r2 为了让 `mod.rs` 回到 400 行以内搬到这里; +//! 行为一个字没改(`shell::view` 的输出与拆前逐字节相同)。 +//! +//! 为什么不能直接删:删了 `shell::view` 就没有调用点,而 `mod shell` 是私有的 —— +//! 私有模块里的 `pub fn` 无人使用会被 `dead_code` 判死,`-D warnings` 直接红。 + +use iced::widget::{column, container}; +use iced::{Alignment, Background, Element, Length, Padding}; + +use unlucky_design::color::Role; +use unlucky_design::component_map::Component; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use crate::app::Message; +use crate::theme::Ui; + +use super::label; + +// ---------------------------------------------------------------- 内容区 + +pub(super) fn content<'a>(ui: &'a Ui, title: &'a str, body: &'a str) -> Element<'a, Message> { + let metrics = ui.metrics(); + // 卡片档位由组件表决定(圆角/字级),组件层不自己挑 + let card_spec = Component::OutlinedCard.spec(); + let body_role = card_spec.support.unwrap_or(card_spec.text); + + let header = container(label( + ui, + TypeRole::TitleLarge, + true, + title, + ui.color(Role::OnSurface), + )) + .width(Length::Fill) + .height(metrics.page_header_height) + .align_y(Alignment::Center) + .padding(Padding::from([0.0, ui.space(Space::X6)])); + + let card = container( + column![ + label( + ui, + card_spec.text, + true, + "Shell ready", + ui.color(Role::OnSurface), + ), + label(ui, body_role, false, body, ui.color(Role::OnSurfaceVariant)), + ] + .spacing(ui.space(Space::X2)), + ) + .width(Length::Fill) + .padding(metrics.card_padding) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.layer(MaterialLayer::Card))), + border: ui.outlined(card_spec.shape), + ..container::Style::default() + }); + + let body_area = container(card) + .width(Length::Fill) + .height(Length::Fill) + .padding(ui.space(Space::X6)) + .align_y(Alignment::Start); + + column![header, body_area] + .width(Length::Fill) + .height(Length::Fill) + .into() +} diff --git a/crates/app-iced/src/shell/mod.rs b/crates/app-iced/src/shell/mod.rs index 6dbc569..070ff0f 100644 --- a/crates/app-iced/src/shell/mod.rs +++ b/crates/app-iced/src/shell/mod.rs @@ -15,6 +15,7 @@ //! 一屏只出现 4 档字号(22 / 16 / 14 / 12),且**没有实心 accent**(rubric K2:一屏最多一个实心 //! Primary,骨架期一个都不要)。阴影只给浮层,骨架里没有浮层,所以**全屏零阴影**(rubric K3)。 +mod fallback; mod nav; pub use nav::NavItem; @@ -25,7 +26,6 @@ use iced::{mouse, Alignment, Background, Border, Color, Element, Length, Padding use iced::{window::Direction, Theme as IcedTheme}; use unlucky_design::color::{Role, StateLayer}; -use unlucky_design::component_map::Component; use unlucky_design::elevation::MaterialLayer; use unlucky_design::icons::IconKind; use unlucky_design::spacing::Space; @@ -36,7 +36,44 @@ use crate::cli::ForcedState; use crate::icons; use crate::theme::Ui; -/// 整棵树。 +/// 整棵树(**内容槽位版**:外壳画标题栏 / 缩放热区 / 导航轨,中间那块交给调用方)。 +/// +/// 这是产品页面走的入口(M90 r2 / P1-1)。为什么要它:`decorations: false` 的无边框窗口里, +/// 拖动 / 缩放 / 最小化 / 关闭这四条消息的构造点**只有本模块**,页面树自己画的「标题栏留白带」 +/// 没有任何控件,于是产品页会变成「拖不动、缩不了、点不到关闭」。 +/// +/// 分工写死: +/// +/// - 本模块负责:标题栏(含 8px 级拖动热区与三个 caption 键)、东/南两条缩放热区、导航轨、 +/// 根容器底色; +/// - 调用方负责:`content`(页面内容区)。它拿到的可用宽度是 `窗口宽 − rail 88`, +/// 而页面自己的 `layout::frame_metrics` 仍以**窗口宽**为输入(它内部会再减 rail 与页边距), +/// 两边的账不会算串。 +/// +/// [`view`] 是它的兼容包装:`content` 换成 M68 的「页头 + 一张卡」。 +pub fn view_with_content<'a>( + ui: &'a Ui, + selected: NavItem, + app_title: &'a str, + content: Element<'a, Message>, + forced: Option, +) -> Element<'a, Message> { + let work_area = column![ + titlebar(ui, app_title, forced), + row![nav::rail(ui, selected, forced), content] + .width(Length::Fill) + .height(Length::Fill), + ] + .width(Length::Fill) + .height(Length::Fill); + + window_frame(ui, work_area.into()) +} + +/// 整棵树(M68 骨架版:内容区是「页头 + 一张占位卡」)。 +/// +/// **保留给 `Route::Agent` 回落**(AI 独立页 P1 才落地)。删掉它会连带 +/// `content` / `label` / `nav::rail` 变成死代码,而 `mod shell` 是私有的 ⇒ `-D warnings` 直接红。 pub fn view<'a>( ui: &'a Ui, selected: NavItem, @@ -45,18 +82,17 @@ pub fn view<'a>( body: &'a str, forced: Option, ) -> Element<'a, Message> { - let work_area = column![ - titlebar(ui, app_title, forced), - row![ - nav::rail(ui, selected, forced), - content(ui, page_title, body) - ] - .width(Length::Fill) - .height(Length::Fill), - ] - .width(Length::Fill) - .height(Length::Fill); + view_with_content( + ui, + selected, + app_title, + fallback::content(ui, page_title, body), + forced, + ) +} +/// 无边框窗口的「外壳壳子」:工作区 + 两条缩放热区 + 根容器底色。 +fn window_frame<'a>(ui: &'a Ui, work_area: Element<'a, Message>) -> Element<'a, Message> { // 无边框窗口没有系统缩放边框 → 自己给两条热区(东/南)。 // **必须叠层而不是兄弟列**:当兄弟列会吃掉 4px 布局宽(右留白变 28、caption 按钮离窗角 4px, // 视觉复审 P3-1)。叠层容器自己铺满,再用 align 把 4px 热区贴到边上,布局宽保持 1180。 @@ -211,59 +247,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 +291,7 @@ pub(super) fn filled(color: Color) -> container::Style { mod tests { use super::*; use unlucky_design::color::SchemeFlavor; + use unlucky_design::component_map::Component; use unlucky_design::typography::Platform; use crate::theme::Families; diff --git a/crates/app-iced/tests/no_hardcoded_tokens.rs b/crates/app-iced/tests/no_hardcoded_tokens.rs index fc019ad..1645758 100644 --- a/crates/app-iced/tests/no_hardcoded_tokens.rs +++ b/crates/app-iced/tests/no_hardcoded_tokens.rs @@ -209,12 +209,13 @@ fn size_literal(argument: &str) -> Option { #[test] fn layout_sources_take_every_size_from_tokens() { let mut files = Vec::new(); - for dir in ["shell", "pages"] { + // P3-3(M90 r2):`app` 是 M90 新拆出来的模块,也是唯一没被这条守门看过的控件树。 + for dir in ["shell", "pages", "app"] { rust_files(&manifest_dir().join("src").join(dir), &mut files); } assert!( - files.len() >= 10, - "shell + pages 至少 10 个文件(shell 2 + pages 8+),实际扫到 {}:扫描逻辑坏了,这条门禁等于没跑", + files.len() >= 20, + "shell + pages + app 至少 20 个文件,实际扫到 {}:扫描逻辑坏了,这条门禁等于没跑", files.len() );