复审三条(p2-3items / fix-then-merge,其中两条按 p1 对待):
**P2-1 取消后仍为每个剩余文件各发一次请求(p1)**
`select` 先轮询左支 ⇒ `install_one` 第一次 poll 就把 `engine.download(…)` 派出去了;
只把标志检查写在右支,等于取消后「每一个剩余 item 都创建并至少 poll 一次」
(一次整文件 sha1 + 一次请求),盘上已就绪的还会第一 poll 就 `Ready` 并回调 `sink`。
修:`async move` 第一句就 `if is_cancelled(cancel) { return None; }`,这些 item 连
`install_one` 都不进。守门用例 `cancelling_mid_run_stops_creating_remaining_files`
(三文件全在盘上、并发 1、sink 首次回调即置标志 ⇒ 数 sink 回调次数):
修后 1,删掉那两行变异后 **3**(见报告,红/绿均实跑)。
模块文档同步改成与实现一致,并把复审点名的固有边界写进注释:取消瞬间最多有
`concurrency`(GUI 传 16)个 future 已经过了那道门,其中「盘上已就绪」的仍会跑完自己的
回调(靠 presenter 的安装代次丢掉这些进度事件),真在下载的会在同一次 poll 被取消。
**P2-3 真网络装置的断言比声称的弱,且有一条探的是恒不成立的路径(p1)**
① `.ullpart` 原来只断言「5 s 后不再增长」⇒「留下一个不增长的非 0 中间文件」会绿;
改成 `assert_eq!(parts_after, 0)`。
② 实例登记原来探 `<game>/instances`(根本不存在 ⇒ 恒 false,什么也没证明);
改成读 `LauncherLayout::profiles_config()` 的 `read_profiles()` 并断言 0 条。
③ 模块文档里那条探针的路径一并订正。
变异自证:留一个 20 字节 `.ullpart` ⇒ 红(`left: 20 / right: 0`);
走真 `instance::register()` 造一条 profile ⇒ 红(`profiles.json 里 1 条实例`)。
**P2-2 取消文案仍是硬编码中文,与新选项区同屏一中一英**
`presenter` 没有 i18n 依赖,所以不是搬常量能解决的。改成发稳定信号:
新增 `CoreEvent::InstallCancelled`(不带文本),`AppState::install_cancelled` 只计数;
app 层在 `refresh_view` 里比对计数、按 `Key::DownloadCancelled` 现求文本弹一次 Toast
(`pages::download::cancelled_toast`)。`dispatch.rs` 的中文 Toast 删掉。
语言入口收成 `app::view::ui_lang()` 一处(现恒为英文;M106 接上语言只改这里,
M106 合并前中文译文在产品路径不可达)。守门:`cancelling_announces_once_in_the_page_language`
(只弹一条、文案来自目录、不含中文、重复 refresh 不重复弹)。
门禁(真实退出码):fmt=0、clippy(--workspace --all-targets -j 2 -D warnings)=0、
`cargo test -p unlucky-app-iced --lib`=0(440 passed)、`cargo test -p unlucky-core`=0、
`cargo test --workspace --exclude unlucky-app -j 2`=0(57 个 target 全 ok,0 failed)。
400 lines
16 KiB
Rust
400 lines
16 KiB
Rust
//! `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<crate::cli::ForcedState>,
|
||
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<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,
|
||
};
|
||
|
||
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<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,
|
||
}
|
||
}
|
||
|
||
/// **取证不变量(M100)**:`ForcedState` 的三档在产品路径上必须映射成「页面控件怎么画」。
|
||
///
|
||
/// 抓得住:① 谁把 `Rest` 映射成 Hovered/Pressed(那会让 `--screenshot` 的默认值反而画出状态层,
|
||
/// 与「取证密闭」正好相反,而且图看起来完全正常);② 加了变体却映射到别的态这种复制粘贴错。
|
||
/// 抓不住:`shell::overridden`(标题栏 / 导航轨按钮走的**另一处** match,断言在 `shell/tests.rs`),
|
||
/// 也抓不住不消费 `model.forced` 的裸 `iced::button`(设置页「外观」区就是)。
|
||
/// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿。
|
||
fn at(state: &AppState, forced: Option<crate::cli::ForcedState>) -> 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 页不高亮任何导航项");
|
||
}
|
||
}
|