//! `view`:presenter 快照 → 页面视图模型 → 控件树。 //! //! 展示转换(快照字段 → 显示文本/网格)**全部在这里**,页面层只认 [`pages::Model`]。 use iced::{Element, Theme}; use unlucky_i18n::Lang; 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, agent, NavItem}; use super::message::{nav_item_of, Message}; use super::state::{log_first_frame, App}; use super::APP_TITLE; mod logs; use logs::logs_model; 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` 画,中间那块才是页面(`DragWindow`/`ResizeStart`/`Minimize`/`ToggleMaximize`/`Close` // 五种消息的构造点只在外壳里,页面树自己画的标题栏拖不动窗口)。 // // M93:三样东西从外观状态进来——①背景层(垫在最底下)②面板 α(外壳层的底色)③设置页要读 // 的那份状态。M101 r2 起 ③ 走 `page_with_settings`:外观状态 + 各组自己的状态一起递进去。 let background = pages::appearance::layer(&self.ui, &self.appearance, (self.width, self.height)) .map(|element| element.map(Message::Page)); // 右栗(M104):拆出档返回 `None`(会话已在独立页里);拖拽期间另有铺满窗口的取指针层。 let overlay = agent::drag_overlay(&self.agent).map(|element| element.map(Message::Agent)); match pages::Screen::of(self.state.route()) { Some(_) => shell::view_with_background( &self.ui, nav_item_of(self.state.route()), APP_TITLE, background, // M101 r2:设置页的只读状态(外观运行期状态 + 各组自己的状态)一次递进去。 pages::page_with_settings( &self.ui, &self.view, pages::settings::States { appearance: Some(&self.appearance), groups: &self.settings, }, ) .map(Message::Page), agent::docked(&self.ui, &self.agent, self.width) .map(|element| element.map(Message::Agent)), overlay, self.options.force_state, ), // `Route::Agent`:独立页与右栗共用同一个 `conversation::view`(`agent::page` 内部调的它)。 None => shell::view_with_background( &self.ui, NavItem::Ai, APP_TITLE, background, agent::page(&self.ui, &self.agent, self.width).map(Message::Agent), None, overlay, self.options.force_state, ), } } } // --------------------------------------------------------------- 快照 → 视图模型 /// **产品路径此刻的界面语言**(唯一入口):语言还没接到设置(M106 的范围),所以恒为英文。 /// 收成一处是为了别让第二个地方(如取消提示)各硬编码一次;M106 只改这里。 /// **M106 合并前中文译文在产品路径上不可达**(页面与提示语都是英文)。 pub(super) fn ui_lang() -> Lang { Lang::En } /// 把 presenter 的快照转成页面视图模型(**唯一**的展示转换点)。 /// /// `forced` 是 `--force-state` 的落点(P3-1):它只影响**画出来的**状态层,不改任何状态机。 /// /// `width` 是**中栗可用宽度**(窗口 − 右栗 − 拖拽缝,由 `AgentUi::content_width` 算),不是窗口宽: /// 页面布局系统仍按「一个窗口宽」扣左轨与页边距,所以右栗占掉的那一段必须在这里先减掉—— /// 否则助手栏一展开,页面还按 1180 排版,内容就顶到面板底下了(任务里的「收缩而不是溢出」)。 pub(super) fn model( state: &AppState, width: f32, forced: Option, category: pages::Category, draft: &pages::InstallDraft, ) -> pages::Model { let route = state.route().clone(); let screen = pages::Screen::of(&route).unwrap_or(pages::Screen::Instances); let rows: Vec = state .tiles() .into_iter() .map(|cell| Row { id: cell.instance.id.clone(), name: cell.instance.name.clone(), supporting: supporting_line(cell.instance), status: runtime_status(&cell.instance.runtime), }) .collect(); let detail = match &route { Route::Detail(id) => state .instances .iter() .find(|instance| &instance.id == id) .map(detail_of), _ => None, }; let lang = ui_lang(); pages::Model { screen, window_width: width, active: route.nav(), instances: pages::Instances { rows, detail }, download: pages::Download::from_snapshot( state, category, draft, unlucky_i18n::Lang::En.catalog(), ), logs: logs_model(state).with_status(lang), lang, forced: forced.map(|state| match state { // `Rest` = 常态(压掉系统光标 hover);`Interaction::Enabled` 就是静息态,不叠状态层。 crate::cli::ForcedState::Rest => Interaction::Enabled, crate::cli::ForcedState::Hover => Interaction::Hovered, crate::cli::ForcedState::Pressed => Interaction::Pressed, }), } } /// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版」,见 `LoaderInfo::badge`)。 fn supporting_line(instance: &InstanceSummary) -> String { format!("{} · {}", instance.mc_version, instance.loader.badge()) } /// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。 fn runtime_status(runtime: &InstanceRuntime) -> Option { match runtime { InstanceRuntime::Idle => None, InstanceRuntime::Busy { stage, progress } => Some(format!( "{} · {:.0}%", stage_label(*stage), progress.ratio() * 100.0 )), InstanceRuntime::Running { pid } => Some(format!("Running · pid {pid}")), InstanceRuntime::Failed { reason } => Some(format!("Launch failed · {reason}")), } } /// 阶段文案(英文基线;i18n mission 接手后统一走词表)。 /// /// 不复用 `LaunchStage::label()`:那是中文,而本 crate 的界面文案在 i18n 之前是英文基线 /// (见 `pages/instances/data.rs` 的同一条理由:干净 CI 上没有中文字体)。 fn stage_label(stage: LaunchStage) -> &'static str { match stage { LaunchStage::Download => "Downloading", LaunchStage::Verify => "Verifying", LaunchStage::Launch => "Starting", LaunchStage::Window => "Waiting for window", } } /// 详情列的内容(字段全部来自 [`InstanceSummary`])。 fn detail_of(instance: &InstanceSummary) -> Detail { // 视觉 P2-2(M90 r2):列表行与详情曾经「同物两名」(列表 `badge()` 出「原版」、详情硬编码 // 「Vanilla」)。现在两边都走 `LoaderInfo::badge()` 这一个入口;等 i18n 落地后由词表统一, // 届时两处会同时变——这正是要的。 let loader = instance.loader.badge(); Detail { id: instance.id.clone(), name: instance.name.clone(), subtitle: supporting_line(instance), meta: vec![ meta("VERSION", &instance.mc_version), meta("LOADER", &loader), meta("ISOLATION", &instance.isolation), meta("VERSION ID", &instance.version_id), meta( "FILES", if instance.version_present { "Present on disk" } else { "Missing" }, ), meta("LAST PLAYED", &last_played(instance.last_played.as_deref())), ], // 真快照没有「正文描述」这种字段(偏离 #9):演示数据才有正文段 note: None, status: runtime_status(&instance.runtime), can_launch: matches!( instance.runtime, InstanceRuntime::Idle | InstanceRuntime::Failed { .. } ), can_stop: matches!( instance.runtime, InstanceRuntime::Busy { .. } | InstanceRuntime::Running { .. } ), } } fn meta(label: &str, value: &str) -> Meta { Meta { label: label.to_string(), value: value.to_string(), } } /// ISO-8601(`2026-10-29T19:22:00Z`)→ `2026-10-29 19:22 UTC`;`None` = 从未启动。 /// /// 只做切片不做时区换算:core 给的是 UTC,`Z` 就是它的时区;本地时区换算等 i18n/时间格式 mission。 /// 非 ASCII 或长度不足时原样返回(不 panic、不猜)。 pub(super) fn last_played(raw: Option<&str>) -> String { let Some(text) = raw else { return "Never".to_string(); }; match (text.get(0..10), text.get(11..16)) { (Some(date), Some(time)) if date.starts_with(|c: char| c.is_ascii_digit()) => { format!("{date} {time} UTC") } _ => text.to_string(), } } #[cfg(test)] mod tests { use super::*; use unlucky_presenter::dto::{InstanceSummary, LoaderInfo, LoaderKind, Progress}; use unlucky_presenter::mock_data::mock_instances; fn instance(runtime: InstanceRuntime) -> InstanceSummary { InstanceSummary { id: "survival".to_string(), name: "Survival".to_string(), mc_version: "1.20.4".to_string(), loader: LoaderInfo { kind: LoaderKind::Fabric, version: "0.16.5".to_string(), }, last_played: Some("2026-10-29T19:22:00Z".to_string()), runtime, icon: None, isolation: "isolated".to_string(), version_id: "1.20.4".to_string(), version_present: true, } } /// **取证不变量(M100)**:`ForcedState` 的三档在产品路径上必须映射成「页面控件怎么画」。 /// /// 抓得住:① 谁把 `Rest` 映射成 Hovered/Pressed(那会让 `--screenshot` 的默认值反而画出状态层, /// 与「取证密闭」正好相反,而且图看起来完全正常);② 加了变体却映射到别的态这种复制粘贴错。 /// 抓不住:`shell::overridden`(标题栏 / 导航轨按钮走的**另一处** match,断言在 `shell/tests.rs`), /// 也抓不住不消费 `model.forced` 的裸 `iced::button`(设置页「外观」区就是)。 /// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿。 fn at(state: &AppState, forced: Option) -> pages::Model { let draft = pages::InstallDraft::default(); model(state, 1180.0, forced, pages::Category::All, &draft) } #[test] fn the_forced_state_maps_to_the_expected_interaction() { let state = AppState::new(); let forced = |forced| at(&state, Some(forced)).forced; assert_eq!( forced(crate::cli::ForcedState::Rest), Some(Interaction::Enabled), "Rest = 静息态(压掉系统光标 hover)" ); assert_eq!( forced(crate::cli::ForcedState::Hover), Some(Interaction::Hovered) ); assert_eq!( forced(crate::cli::ForcedState::Pressed), Some(Interaction::Pressed) ); assert_eq!(at(&state, None).forced, None); } #[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 = at(&state, 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 = at(&state, 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 = at(&state, None); // Agent 没有页面:模型退回实例页的空壳,view 会走外壳回退(`None` 分支) assert_eq!(pages::Screen::of(state.route()), None); assert!(model.instances.detail.is_none()); assert_eq!(model.active, None, "AI 页不高亮任何导航项"); } }