feat(app-iced): M120 实例页与启动阶段标签跟着界面语言走

来源:M106 r1 复审点名——中文机器上首屏(实例页)仍是英中混排(页头 / New instance /
Import / ALL INSTANCES / VERSION / Present on disk / Running · pid / Launch failed)。
用户的原话是「我系统语言是中文 为什么打开软件是英文」。

改了什么
- i18n:catalog/instances.rs 从 6 条死 key 补到 34 条(页头 / 列表列头 / 详情动作 /
  窄窗说明 / 元信息标签与值 / 隔离档位 / 运行状态 / 四个启动阶段)。族在装配链第 2 位,
  新 key 追加在本族末尾,golden 对应位置插入(不是文件末尾追加)。
- 页面:新增 pages/instances/text.rs(Texts)——页面唯一的取词入口,实例页里不再有
  任何可见英文字面量;detail / demo 也改从它取。
- 阶段标签:view/instances.rs 的 Running / Launch failed / 四阶段全部走目录;
  **数字与分隔符不随语言变形**(运行中 · pid 1234 与 Running · pid 1234 只换前半句)。
- 顺手修掉两处「语言反向泄漏」:原版徽标此前直接画 presenter 的中文「原版」(英文界面
  会漏中文),现在走 LoaderVanilla;隔离档位 isolated/shared 也从 core 的稳定 id 换成人话。
- 删掉 InstancesLastPlayed:M117 加的整句 key,从未接线,形状与「标签 + 值」的元信息卡
  不符,由 InstancesMetaLastPlayed + InstancesNeverPlayed 取代。

守门(pages/instances/tests/i18n.rs + app/view/tests.rs)
- 中文态无 ASCII 英文单词(品牌 Java / 技术标识 pid 白名单);
- 每一条 Instances* 的两套译文必须不同(切语言时真的变);
- Running 的数字与 · pid 分隔符在两套语言里逐字保留;
- 页面源码里 29 条旧英文基线不许回来;每条 key 都必须被页面引用(死 key)。

出图:DevDocs/artifacts/m120/01~12(有实例 / 空态 / 运行中 × 浅深 × 中英)。
This commit is contained in:
AskaEth committed 2026-11-05 13:36:44 +08:00
1 parent 2f491c31f9
commit 1a4e1fee12
17 files changed
+924 -119

No files matched your search

+10 -2
View File
@@ -8,6 +8,7 @@
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_i18n::Key;
use unlucky_presenter::dto::{AppCommand, AppEvent, Backend, CoreEvent};
use unlucky_presenter::mock_data::mock_instances;
@@ -159,9 +160,11 @@ fn launching_goes_through_the_backend_and_comes_back_as_events() {
!detail.can_launch && detail.can_stop,
"派发 LaunchInstance 后后端发回了 Busy 事件:按钮组合必须已经翻转"
);
// 阶段名跟界面语言走(M120):断言对着**当前生效语言**的目录算,别在中文机器上钉死英文
let stage = app.lang().catalog().text(Key::InstancesStageDownload);
assert_eq!(
detail.status.as_deref(),
Some("Downloading · 0%"),
Some(format!("{stage} · 0%").as_str()),
"状态行来自事件里的进度,不是本地假造"
);
}
@@ -185,8 +188,13 @@ fn ticks_advance_the_backend_script() {
let detail = app.view.instances.detail.as_ref().expect("有选中项");
let status = detail.status.as_deref().unwrap_or_default();
let catalog = app.lang().catalog();
let (verify, launch) = (
catalog.text(Key::InstancesStageVerify),
catalog.text(Key::InstancesStageLaunch),
);
assert!(
status.starts_with("Verifying") || status.starts_with("Starting"),
status.starts_with(verify) || status.starts_with(launch),
"50 拍之后应已过下载阶段,实际 {status:?}"
);
}
+6 -3
View File
@@ -144,14 +144,17 @@ pub(super) fn model(
) -> pages::Model {
let route = state.route().clone();
let screen = pages::Screen::of(&route).unwrap_or(pages::Screen::Instances);
// 实例页的文案真源:`App::lang` 的目录(M120)。列表 overline / 元信息标签 / 空态都从它取。
let catalog = lang.catalog();
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),
supporting: supporting_line(cell.instance, catalog),
status: runtime_status(&cell.instance.runtime, catalog),
})
.collect();
@@ -160,7 +163,7 @@ pub(super) fn model(
.instances
.iter()
.find(|instance| &instance.id == id)
.map(detail_of),
.map(|instance| detail_of(instance, catalog)),
_ => None,
};
+93 -42
View File
@@ -7,74 +7,125 @@
//! | [`detail_of`] | 详情列的全部字段 | [`crate::pages::Detail`] |
//! | [`last_played`] | `2026-10-29 19:22 UTC` / `Never` | 元信息卡 `LAST PLAYED` |
//!
//! 这里只做「快照字段 → 要画什么」,不碰控件树。文案的语言由调用方传给 `view::model` 的
//! `Lang` 决定(页面与这一段都从同一个 `Lang` 取目录,见 `pages::Model.lang`)。
//! 这里只做「快照字段 → 要画什么」,不碰控件树。**文案的语言由调用方的 [`Catalog`] 决定**
//! (`view::model` 把 `App::lang` 的目录传进来),不再是 M90 时期的英文基线字面量。
//!
//! ## 两条接法上的取舍
//!
//! 1. **原版徽标走词表**:`LoaderInfo::badge()` 对原版返回**中文**「原版」(那是 presenter 的
//! 面向中文日志的 `label()`),英文界面里直接画它就是「中文漏进英文界面」。所以原版这一支
//! 改走 [`Key::LoaderVanilla`],装加载器时仍是 `Fabric 0.16.5`(品牌 + 版本号,不翻译);
//! 2. **运行状态只翻标签本体**:`Running · pid 1234` → `运行中 · pid 1234`,`pid` 与数字、
//! `·` 分隔符一个字都不动([`Key::InstancesRunning`] 的占位符只有 `{pid}`)。
use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage};
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage, LoaderKind};
use crate::pages::{Detail, Meta};
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版」,见 `LoaderInfo::badge`)。
pub(super) fn supporting_line(instance: &InstanceSummary) -> String {
format!("{} · {}", instance.mc_version, instance.loader.badge())
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。
pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String {
format!(
"{} · {}",
instance.mc_version,
loader_badge(instance, catalog)
)
}
/// 加载器徽标:原版走词表,其余是品牌 + 版本号(原样)。
fn loader_badge(instance: &InstanceSummary, catalog: Catalog) -> String {
// `badge()` 在「原版 / 版本号为空」两种情况都给 `kind.label()`(中文「原版」),只有这一支要过词表。
if instance.loader.kind == LoaderKind::Vanilla || instance.loader.version.is_empty() {
catalog.text(Key::LoaderVanilla).to_string()
} else {
format!(
"{} {}",
instance.loader.kind.label(),
instance.loader.version
)
}
}
/// 隔离档位:`isolated` / `shared` 是两个稳定 id(`core::game::IsolationMode::as_str`),
/// 直接上屏就是英文界面漏中文、中文界面漏英文。只认这两档,**其余原样**(`inherit` / `custom` /
/// 未来新增的值不许在这里被猜成一个意思)。
fn isolation(raw: &str, catalog: Catalog) -> String {
match raw {
"isolated" => catalog.text(Key::InstancesIsolationIsolated).to_string(),
"shared" => catalog.text(Key::InstancesIsolationShared).to_string(),
other => other.to_string(),
}
}
/// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。
pub(super) fn runtime_status(runtime: &InstanceRuntime) -> Option<String> {
pub(super) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
match runtime {
InstanceRuntime::Idle => None,
// 「阶段 · 百分比」:百分比是数字,不过词表;只有阶段名换语言
InstanceRuntime::Busy { stage, progress } => Some(format!(
"{} · {:.0}%",
stage_label(*stage),
stage_label(*stage, catalog),
progress.ratio() * 100.0
)),
InstanceRuntime::Running { pid } => Some(format!("Running · pid {pid}")),
InstanceRuntime::Failed { reason } => Some(format!("Launch failed · {reason}")),
InstanceRuntime::Running { pid } => {
Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())]))
}
// `reason` 是 core 给的诊断原文(**数据**,不翻译);只有前半句是界面文案
InstanceRuntime::Failed { reason } => {
Some(catalog.render(Key::InstancesFailed, &[("reason", 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",
}
/// 阶段文案:**不**复用 `LaunchStage::label()`(那是核心层的中文诊断串,英文界面会漏中文)。
fn stage_label(stage: LaunchStage, catalog: Catalog) -> &'static str {
catalog.text(match stage {
LaunchStage::Download => Key::InstancesStageDownload,
LaunchStage::Verify => Key::InstancesStageVerify,
LaunchStage::Launch => Key::InstancesStageLaunch,
LaunchStage::Window => Key::InstancesStageWindow,
})
}
/// 详情列的内容(字段全部来自 [`InstanceSummary`])。
pub(super) fn detail_of(instance: &InstanceSummary) -> Detail {
// 视觉 P2-2(M90 r2):列表行与详情曾经「同物两名」(列表 `badge()` 出「原版」、详情硬编码
// 「Vanilla」)。现在两边都走 `LoaderInfo::badge()` 这一个入口;等 i18n 落地后由词表统一,
// 届时两处会同时变——这正是要的。
let loader = instance.loader.badge();
pub(super) fn detail_of(instance: &InstanceSummary, catalog: Catalog) -> Detail {
Detail {
id: instance.id.clone(),
name: instance.name.clone(),
subtitle: supporting_line(instance),
subtitle: supporting_line(instance, catalog),
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"
catalog.text(Key::InstancesMetaVersion),
&instance.mc_version,
),
meta(
catalog.text(Key::InstancesMetaLoader),
&loader_badge(instance, catalog),
),
meta(
catalog.text(Key::InstancesMetaIsolation),
&isolation(&instance.isolation, catalog),
),
meta(
catalog.text(Key::InstancesMetaVersionId),
&instance.version_id,
),
meta(
catalog.text(Key::InstancesMetaFiles),
catalog.text(if instance.version_present {
Key::InstancesPresentOnDisk
} else {
"Missing"
},
Key::InstancesMissing
}),
),
meta(
catalog.text(Key::InstancesMetaLastPlayed),
&last_played(instance.last_played.as_deref(), catalog),
),
meta("LAST PLAYED", &last_played(instance.last_played.as_deref())),
],
// 真快照没有「正文描述」这种字段(偏离 #9):演示数据才有正文段
note: None,
status: runtime_status(&instance.runtime),
status: runtime_status(&instance.runtime, catalog),
can_launch: matches!(
instance.runtime,
InstanceRuntime::Idle | InstanceRuntime::Failed { .. }
@@ -95,11 +146,11 @@ fn meta(label: &str, value: &str) -> Meta {
/// ISO-8601(`2026-10-29T19:22:00Z`)→ `2026-10-29 19:22 UTC`;`None` = 从未启动。
///
/// 只做切片不做时区换算:core 给的是 UTC,`Z` 就是它的时区;本地时区换算等 i18n/时间格式 mission。
/// 只做切片不做时区换算:core 给的是 UTC,`Z` 就是它的时区;本地时区换算等时间格式 mission。
/// 非 ASCII 或长度不足时原样返回(不 panic、不猜)。
pub(super) fn last_played(raw: Option<&str>) -> String {
pub(super) fn last_played(raw: Option<&str>, catalog: Catalog) -> String {
let Some(text) = raw else {
return "Never".to_string();
return catalog.text(Key::InstancesNeverPlayed).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()) => {
+123 -10
View File
@@ -1,7 +1,6 @@
//! `app/view.rs` 的守门测试(rebase 到 5adac23 之后 view.rs 贴 400 行上限,按「测试独立成文件」拆出)。
use super::*;
// 拆分后 `view.rs` 不再 import 这三个(它们只被搬走的实例页转换用),用到的地方自己引
use unlucky_presenter::dto::{
InstanceRuntime, InstanceSummary, LaunchStage, LoaderInfo, LoaderKind, Progress,
};
@@ -31,9 +30,9 @@ fn instance(runtime: InstanceRuntime) -> InstanceSummary {
/// 与「取证密闭」正好相反,而且图看起来完全正常);② 加了变体却映射到别的态这种复制粘贴错。
/// 抓不住:`shell::overridden`(标题栏 / 导航轨按钮走的**另一处** match,断言在 `shell/tests.rs`),
/// 也抓不住不消费 `model.forced` 的裸 `iced::button`(设置页「外观」区就是)。
/// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿。
fn at(state: &AppState, forced: Option<crate::cli::ForcedState>) -> pages::Model {
let (draft, lang) = (pages::InstallDraft::default(), Lang::En);
/// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿 / **给定语言**。
fn at_lang(state: &AppState, forced: Option<crate::cli::ForcedState>, lang: Lang) -> pages::Model {
let draft = pages::InstallDraft::default();
let page = pages::content::PageState::default();
model(
state,
@@ -46,6 +45,11 @@ fn at(state: &AppState, forced: Option<crate::cli::ForcedState>) -> pages::Model
)
}
/// 同 [`at_lang`],语言取英文(绝大多数断言只关心「快照 → 模型」的映射,不关心译文)。
fn at(state: &AppState, forced: Option<crate::cli::ForcedState>) -> pages::Model {
at_lang(state, forced, Lang::En)
}
#[test]
fn the_forced_state_maps_to_the_expected_interaction() {
let state = AppState::new();
@@ -122,22 +126,131 @@ fn failed_instances_can_be_retried() {
reason: "exit 1".to_string(),
});
assert_eq!(
runtime_status(&failed.runtime).as_deref(),
runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(),
Some("Launch failed · exit 1")
);
assert!(detail_of(&failed).can_launch, "失败后要能重试");
assert!(
detail_of(&failed, Lang::En.catalog()).can_launch,
"失败后要能重试"
);
}
#[test]
fn timestamps_are_sliced_not_guessed() {
let en = Lang::En.catalog();
assert_eq!(
last_played(Some("2026-10-29T19:22:00Z")),
last_played(Some("2026-10-29T19:22:00Z"), en),
"2026-10-29 19:22 UTC"
);
assert_eq!(last_played(None), "Never");
assert_eq!(last_played(None, en), "Never");
// 短串 / 非 ASCII 原样返回(不 panic)
assert_eq!(last_played(Some("2026-10")), "2026-10");
assert_eq!(last_played(Some("昨天")), "昨天");
assert_eq!(last_played(Some("2026-10"), en), "2026-10");
assert_eq!(last_played(Some("昨天"), en), "昨天");
// 「从未游玩」是唯一走词表的那一支
assert_eq!(last_played(None, Lang::ZhCn.catalog()), "从未游玩");
}
/// **快照 → 页面文案跟着 `App::lang` 走**(M120 的核心接线断言,模型这一层)。
///
/// 抓得住:`view::model` 又把语言写死成 `Lang::En`(M106 之前的原样)、阶段/元信息标签还留着
/// 英文基线字面量、原版徽标直接画 presenter 的中文「原版」。这三种都不会让任何别的测试红。
/// 抓不住:页面把 key 画没画上屏(那是 `pages/instances/tests/i18n.rs` 的取样口 + 出图)。
#[test]
fn the_runtime_lines_and_meta_labels_follow_the_language() {
let mut state = AppState::new();
state.apply(unlucky_presenter::dto::AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![
instance(InstanceRuntime::Running { pid: 1234 }),
instance(InstanceRuntime::Busy {
stage: LaunchStage::Verify,
progress: Progress {
done: 750,
total: 1_500,
},
}),
]),
));
state.navigate(Route::Detail("survival".to_string()));
let en = at_lang(&state, None, Lang::En);
let zh = at_lang(&state, None, Lang::ZhCn);
// 列表 overline:中英各一份,数字与分隔符不动
assert_eq!(
en.instances.rows[0].status.as_deref(),
Some("Running · pid 1234")
);
assert_eq!(
zh.instances.rows[0].status.as_deref(),
Some("运行中 · pid 1234")
);
assert_eq!(
en.instances.rows[1].status.as_deref(),
Some("Verifying · 50%")
);
assert_eq!(zh.instances.rows[1].status.as_deref(), Some("校验中 · 50%"));
// 元信息卡的标签与值
let (en_detail, zh_detail) = (
en.instances.detail.expect("有详情"),
zh.instances.detail.expect("有详情"),
);
let labels = |detail: &pages::Detail| -> Vec<String> {
detail.meta.iter().map(|cell| cell.label.clone()).collect()
};
assert_eq!(
labels(&en_detail),
vec![
"VERSION",
"LOADER",
"ISOLATION",
"VERSION ID",
"FILES",
"LAST PLAYED"
]
);
assert_eq!(
labels(&zh_detail),
vec!["版本", "加载器", "隔离方式", "版本标识", "文件", "上次游玩"]
);
// 值里只有「是否在磁盘上」与「上次游玩」是可翻译的;版本号 / 隔离方式 / 版本标识是数据
// 隔离档位是 core 的稳定 id(`isolated` / `shared`),也要换成人话;认不出来的原样上屏
assert_eq!(en_detail.meta[2].value, "Isolated");
assert_eq!(zh_detail.meta[2].value, "独立目录");
assert_eq!(en_detail.meta[4].value, "Present on disk");
assert_eq!(zh_detail.meta[4].value, "已在磁盘上");
assert_eq!(
en_detail.meta[5].value, "2026-10-29 19:22 UTC",
"时间戳是数据:只切片,不随语言变形"
);
assert_eq!(zh_detail.meta[5].value, "2026-10-29 19:22 UTC");
// 详情状态行与列表 overline 同源
assert_eq!(zh_detail.status.as_deref(), Some("运行中 · pid 1234"));
}
/// 原版徽标:`LoaderInfo::badge()` 对原版返回**中文**「原版」,英文界面里必须换回 `Vanilla`。
///
/// 抓得住:`loader_badge` 退回直接调 `badge()`(M90 ~ M119 的形状)——那样英文界面会漏中文,
/// 而中文界面又是对的,所以只有这条双语断言抓得住它。
#[test]
fn the_vanilla_badge_follows_the_language() {
let mut bare = instance(InstanceRuntime::Idle);
bare.loader = LoaderInfo {
kind: LoaderKind::Vanilla,
version: String::new(),
};
let mut state = AppState::new();
state.apply(unlucky_presenter::dto::AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![bare]),
));
state.navigate(Route::Detail("survival".to_string()));
let en = at_lang(&state, None, Lang::En);
let zh = at_lang(&state, None, Lang::ZhCn);
assert_eq!(en.instances.rows[0].supporting, "1.20.4 · Vanilla");
assert_eq!(zh.instances.rows[0].supporting, "1.20.4 · 原版");
}
#[test]
+17 -10
View File
@@ -48,6 +48,7 @@
mod data;
mod demo;
mod detail;
mod text;
#[cfg(test)]
mod tests;
@@ -55,6 +56,7 @@ mod tests;
#[cfg(test)]
pub(crate) use data::{DEMO, NOTE, SELECTED};
pub(super) use demo::demo;
pub(super) use text::Texts;
use iced::widget::{column, container, row, scrollable};
use iced::{Alignment, Background, Border, Element, Length, Padding};
@@ -92,22 +94,26 @@ pub(super) const ROW_SPACING: Option<Space> = None;
pub(super) const LIST_BOTTOM_INSET: Space = Space::X2;
/// 整页(`block.detail = None` 时详情区显示空态)。
///
/// 文案全部走 [`Texts`](`model.lang` 的语言):这个函数是实例页唯一的取词入口,
/// 页面里不留任何可见英文字面量(守门见 `instances/tests/i18n.rs`)。
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")
let texts = Texts::of(model.lang.catalog());
let header = Header::new(texts.title())
.subtitle(texts.subtitle())
.action(buttons::text(
ui,
forced_state(
Button::new("New instance", Message::Action(Action::NewInstance)),
Button::new(texts.new_instance(), Message::Action(Action::NewInstance)),
forced,
),
))
.action(buttons::text(
ui,
forced_state(
Button::new("Import", Message::Action(Action::Import)),
Button::new(texts.import(), Message::Action(Action::Import)),
forced,
),
));
@@ -115,15 +121,15 @@ pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, model: &'a Model) -> Element<
let body = match metrics.layout {
PageLayout::ListDetail => frame::panes(
metrics,
list_column(ui, block, forced),
list_column(ui, block, forced, texts),
match &block.detail {
Some(detail) => detail::detail_column(ui, metrics, detail, forced),
None => detail::detail_empty(ui),
Some(detail) => detail::detail_column(ui, metrics, detail, forced, texts),
None => detail::detail_empty(ui, texts),
},
),
_ => match &block.detail {
Some(detail) => detail::narrow_detail(ui, metrics, detail, forced),
None => frame::body(metrics, detail::detail_empty(ui)),
Some(detail) => detail::narrow_detail(ui, metrics, detail, forced, texts),
None => frame::body(metrics, detail::detail_empty(ui, texts)),
},
};
@@ -136,6 +142,7 @@ fn list_column<'a>(
ui: &'a Ui,
block: &'a Instances,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
let head = container(
row![
@@ -143,7 +150,7 @@ fn list_column<'a>(
ui,
TypeRole::LabelMedium,
false,
"ALL INSTANCES",
texts.all_title(),
ui.color(Role::OnSurfaceVariant),
))
.width(Length::Fill)
+9 -2
View File
@@ -1,9 +1,16 @@
//! 实例页的**演示数据**(骨架期没有业务状态机,也没有业务数据)。
//!
//! 12 条,**故意比可视区高**:列表列的独立滚动条要能在截图里看见(单测钉住这条)。
//! 文案是英文基线:i18n 是后续 mission,干净 CI 上没有中文字体,写中文会变豆腐块。
//!
//! **这里的数据是夹具(用户数据),不是界面文案**:`1.20.4` / `Fabric 0.15.7` / `4 GiB` /
//! `2 hours ago` 这些是「实例清单里会长成什么样」的样本,M120 没有把它们搬进词表——它们不是
//! 界面说的那句话。界面文案(标题 / 按钮 / 元信息标签 / 空态 / 阶段)走 `catalog/instances.rs`,
//! 见 `text.rs` 与 `app/view/instances.rs`。
//!
//! 这条路径(`pages_demo` harness / 单测)**恒英文**:`pages::demo_model` 走 `Model::new`,
//! `lang` 是 `Lang::En`。产品路径不读这份数据,它画的是 presenter 快照。
/// 演示条目(骨架期没有业务数据;文案是英文基线,i18n 是后续 mission)。
/// 演示条目(骨架期没有业务数据;夹具文案,见模块文档)。
pub(crate) struct Entry {
pub(crate) name: &'static str,
pub(crate) version: &'static str,
+22 -6
View File
@@ -3,9 +3,25 @@
//! 只被 [`crate::pages::demo_model`] 用:产品路径的转换在 `app/view.rs`(presenter 快照 → 模型)。
//! 两份数据源喂同一个渲染函数,这是 [`crate::pages::model`] 存在的理由。
use unlucky_i18n::{Catalog, Key, Lang};
use super::super::model::{Detail, Instances, Meta, Row};
use super::data::{DEMO, NOTE, SELECTED};
/// 演示路径的目录:**恒英文**。
///
/// 为什么不是 `model.lang`:本函数只被 `pages::demo_model` 调用,而那条路(`pages_demo` harness /
/// 单测)走 `Model::new`,`lang` 恒为 `Lang::En`(harness 没有换语言的开关)。产品路径不读这里
/// ——它走 `app/view.rs` 的快照转换。等 harness 有 `--lang` 时把这一行换成参数即可。
fn catalog() -> Catalog {
Lang::En.catalog()
}
/// 元信息卡的标签(与 `app/view/instances.rs` 的 `detail_of` 同一批 key,只是这里恒英文)。
fn label(key: Key) -> &'static str {
catalog().text(key)
}
/// 把 M84 的演示条目 [`DEMO`] 转成视图模型(真页面走 `app/view.rs` 的 `AppState` 转换)。
pub(crate) fn demo() -> Instances {
let rows = DEMO
@@ -24,12 +40,12 @@ pub(crate) fn demo() -> Instances {
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),
meta(label(Key::InstancesMetaVersion), entry.version),
meta(label(Key::InstancesMetaLoader), entry.loader),
meta(label(Key::InstancesMetaMemory), entry.memory),
meta(label(Key::InstancesMetaJava), entry.java),
meta(label(Key::InstancesMetaMods), entry.mods),
meta(label(Key::InstancesMetaLastPlayed), entry.last_played),
],
note: Some(NOTE.to_string()),
status: None,
+21 -18
View File
@@ -23,6 +23,7 @@ use super::super::layout::{self, FrameMetrics};
use super::super::model::Detail;
use super::super::{Action, Message};
use super::forced_state;
use super::Texts;
// --------------------------------------------------------------- 详情列
@@ -31,9 +32,10 @@ pub(super) fn detail_column<'a>(
metrics: FrameMetrics,
detail: &'a Detail,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
let mut body = column![
detail_head(ui, detail, forced),
detail_head(ui, detail, forced, texts),
meta_grid(ui, metrics, detail),
divider::full(ui),
]
@@ -43,7 +45,7 @@ pub(super) fn detail_column<'a>(
body = push_text(ui, metrics, body, detail);
// 页头 + 元信息网格 + **一条**分隔线以下才是正文与操作区(M84 那两条线是占位时期的排版残留)
frame::left_aligned(metrics.column, body.push(action_row(ui, forced)))
frame::left_aligned(metrics.column, body.push(action_row(ui, forced, texts)))
}
pub(super) fn narrow_detail<'a>(
@@ -51,19 +53,20 @@ pub(super) fn narrow_detail<'a>(
metrics: FrameMetrics,
detail: &'a Detail,
forced: Option<Interaction>,
texts: Texts,
) -> 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)),
Button::new(texts.back(), Message::Navigate(Route::Instances)),
forced,
),
)]
.width(Length::Fill)
.align_y(Alignment::Center),
detail_head(ui, detail, forced),
detail_head(ui, detail, forced, texts),
meta_grid(ui, metrics, detail),
divider::full(ui),
]
@@ -76,12 +79,11 @@ pub(super) fn narrow_detail<'a>(
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.",
texts.narrow_note(),
ui.color(Role::OnSurfaceVariant),
));
frame::body(metrics, body.push(action_row(ui, forced)))
frame::body(metrics, body.push(action_row(ui, forced, texts)))
}
/// 正文段(演示数据才有)与运行状态行(真数据才有)——两者都是可选的,顺序固定。
@@ -122,6 +124,7 @@ fn detail_head<'a>(
ui: &'a Ui,
detail: &'a Detail,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
let titles = column![
frame::text_role(
@@ -147,23 +150,23 @@ fn detail_head<'a>(
buttons::filled(
ui,
forced_state(
Button::new("Play", Message::Launch(detail.id.clone())),
Button::new(texts.play(), Message::Launch(detail.id.clone())),
forced,
),
)
} else {
buttons::filled(ui, Button::disabled("Play"))
buttons::filled(ui, Button::disabled(texts.play()))
};
let stop = if detail.can_stop {
buttons::text(
ui,
forced_state(
Button::new("Stop", Message::Stop(detail.id.clone())),
Button::new(texts.stop(), Message::Stop(detail.id.clone())),
forced,
),
)
} else {
buttons::text(ui, Button::disabled("Stop"))
buttons::text(ui, Button::disabled(texts.stop()))
};
row![titles, launch, stop]
@@ -213,26 +216,26 @@ fn meta_grid<'a>(ui: &'a Ui, metrics: FrameMetrics, detail: &'a Detail) -> Eleme
grid.into()
}
fn action_row<'a>(ui: &'a Ui, forced: Option<Interaction>) -> Element<'a, Message> {
fn action_row<'a>(ui: &'a Ui, forced: Option<Interaction>, texts: Texts) -> Element<'a, Message> {
row![
buttons::text(
ui,
forced_state(
Button::new("Duplicate", Message::Action(Action::Duplicate)),
Button::new(texts.duplicate(), Message::Action(Action::Duplicate)),
forced,
)
),
buttons::text(
ui,
forced_state(
Button::new("Open folder", Message::Action(Action::OpenFolder)),
Button::new(texts.open_folder(), Message::Action(Action::OpenFolder)),
forced,
)
),
buttons::text(
ui,
forced_state(
Button::new("Delete", Message::Action(Action::Delete)),
Button::new(texts.delete(), Message::Action(Action::Delete)),
forced
),
),
@@ -242,12 +245,12 @@ fn action_row<'a>(ui: &'a Ui, forced: Option<Interaction>) -> Element<'a, Messag
.into()
}
pub(super) fn detail_empty<'a>(ui: &'a Ui) -> Element<'a, Message> {
pub(super) fn detail_empty<'a>(ui: &'a Ui, texts: Texts) -> 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.",
texts.empty_title(),
texts.empty_body(),
Vec::new(),
)
}
@@ -1,6 +1,9 @@
//! `instances` 的断言:演示数据、窄窗退化,以及**页面摆法**(源码结构断言)。
use super::*;
/// M120 的文案守门(中文无英文词 / 切语言真的变 / 旧串不许回来)。
mod i18n;
use super::super::layout::source;
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
@@ -0,0 +1,263 @@
//! 实例页的**文案守门**(M120):中文态不许漏英文、切语言必须真的变、运行状态的数字不许变形。
//!
//! | 用例 | 抓得住什么 |
//! |---|---|
//! | [`the_chinese_page_has_no_english_words`] | 目录里某条中文忘了翻(或翻完还留着英文词) |
//! | [`every_instances_text_changes_with_the_language`] | 两套语言写成一样(= 切换语言时那一处不动,看着像「已 i18n」其实是死文案) |
//! | [`the_running_line_keeps_its_number_and_separator`] | `Running · pid 1234` 的数字 / `pid` / `·` 被翻译走样 |
//! | [`the_page_sources_have_no_english_baseline_left`] | 页面里又写回旧英文基线字面量(M120 之前的形状) |
//! | [`every_instances_key_is_referenced_by_the_page`] | 目录里加了 key、页面没人用(死 key,M117 那七条就是这么来的) |
//!
//! ## 边界(别把这张表读成「视图覆盖」)
//!
//! iced 0.14 的 `Element` **没有内省接口**(`instances/tests.rs` 里那条「为什么读源码」写的就是它),
//! 所以「上屏文本」取样只能从页面取词的那**一个入口** [`Texts`] 拿:页面用它画、测试用同一个
//! `Texts` 断言,不是另写一份平行实现。仍然抓不住的是「页面又冒出一条**新**硬编码字面量」——
//! 那由 [`the_page_sources_have_no_english_baseline_left`] 兜已知的那批 + 出图人工看。
//! 「模型这一层(快照 → 状态行 / 元信息)跟着 `Lang` 变」由 `app/view/tests.rs` 守。
use unlucky_i18n::catalog::MISSING;
use unlucky_i18n::{Catalog, Key, Lang};
use super::super::Texts;
/// 中文界面里允许出现的 ASCII 词:**品牌名与技术标识**。
///
/// 每一条都要说清为什么:
///
/// - `Java`:运行时品牌名(`JAVA` 那格的值域里也全是 `Temurin 21` 这类品牌串),翻成「爪哇」是错的;
/// - `pid`:进程号的技术标识。用户拿它去任务管理器里搜进程,翻成「进程」就丢了这条可操作性。
const ALLOWED: &[&str] = &["Java", "pid"];
/// 页面取词的五个源码文件(死 key 扫描与旧串扫描共用)。
const PAGE_SOURCES: [(&str, &str); 5] = [
("instances.rs", include_str!("../../instances.rs")),
("instances/detail.rs", include_str!("../detail.rs")),
("instances/demo.rs", include_str!("../demo.rs")),
("instances/text.rs", include_str!("../text.rs")),
(
"app/view/instances.rs",
include_str!("../../../app/view/instances.rs"),
),
];
/// M120 之前页面里那些**英文基线字面量**。它们现在只许活在目录里。
///
/// 页头标题 `Instances` 不在清单里:它在后面由结构断言「`Header::new` 的实参必须是
/// `texts.title()`」单独守(`"Instances"` 这三个字在 `text.rs` 里是**族名前缀**,进清单会假红)。
const OLD_LITERALS: [&str; 29] = [
"ALL INSTANCES",
"Local versions and their mod loaders",
"New instance",
"Import",
"Back to instances",
"Play",
"Stop",
"Duplicate",
"Open folder",
"Delete",
"No instance selected",
"Present on disk",
"Missing",
"Never",
"Running · pid ",
"Launch failed · ",
"Downloading",
"Verifying",
"Starting",
"Waiting for window",
"VERSION",
"VERSION ID",
"LOADER",
"ISOLATION",
"FILES",
"LAST PLAYED",
"MEMORY",
"JAVA",
"MODS",
];
/// 把 `{占位符}` 抹掉再切 ASCII 词:占位符是**运行时数据**(pid / 原因 / 时间),不是文案。
fn ascii_words(text: &str) -> Vec<String> {
let mut stripped = String::new();
let mut depth = 0usize;
for ch in text.chars() {
match ch {
'{' => depth += 1,
'}' if depth > 0 => depth -= 1,
_ if depth > 0 => {}
_ => stripped.push(ch),
}
}
let mut words = Vec::new();
let mut current = String::new();
for ch in stripped.chars() {
if ch.is_ascii_alphabetic() {
current.push(ch);
} else if !current.is_empty() {
words.push(std::mem::take(&mut current));
}
}
if !current.is_empty() {
words.push(current);
}
words
}
/// 有没有中日韩统一表意文字(「这条文案真的翻成中文了吗」的最直接判据)。
fn has_cjk(text: &str) -> bool {
text.chars().any(|c| {
matches!(c, '\u{3400}'..='\u{4DBF}' | '\u{4E00}'..='\u{9FFF}' | '\u{3000}'..='\u{303F}')
})
}
/// **中文界面无英文词**:实例页这一族的每一条中文译文,除了 [`ALLOWED`] 之外不许出现 ASCII 单词。
///
/// 抓得住:漏翻(中英写成一样)、翻了一半(`Present on disk` → `已在磁盘上 Present`)、
/// 新加 key 时中文那栏顺手抄了英文。
/// 抓不住:译文本身读着别扭;也抓不住「页面另写了一条字面量」(见模块文档的边界一节)。
///
/// **变异实测**(M120 交付时跑过):把 `InstancesPresentOnDisk` 的中文改回 `Present on disk`,
/// 这条立刻红并指出 key 名。
#[test]
fn the_chinese_page_has_no_english_words() {
let zh = Lang::ZhCn.catalog();
let mut offenders = Vec::new();
let mut checked = 0;
for key in Texts::keys() {
let text = zh.text(key);
assert_ne!(text, MISSING, "{} 没有中文译文", key.name());
checked += 1;
for word in ascii_words(text) {
if !ALLOWED.contains(&word.as_str()) {
offenders.push(format!("{} · 出现 {word:?} ← 「{text}」", key.name()));
}
}
}
assert!(
offenders.is_empty(),
"中文界面上这些英文词会漏出来(品牌名 / 技术标识请加进 ALLOWED 并写明理由):\n{}",
offenders.join("\n")
);
assert!(
checked >= 30,
"实例页的 key 不该被成批删掉(只查到 {checked} 条)"
);
}
/// **切换语言,文本真的变**:这一族的每一条中英译文都必须不同。
///
/// 抓得住:翻完没改(两栏抄同一句)——那种 key 在切语言时**一个字都不动**,界面上看起来
/// 「已经国际化」,其实那一处永远是英文。今夜反复出现的「测试绿着但什么都没守住」就是这一类。
/// 抓不住:某条译文翻错了词。
#[test]
fn every_instances_text_changes_with_the_language() {
let en = Lang::En.catalog();
let zh = Lang::ZhCn.catalog();
for key in Texts::keys() {
let (a, b) = (en.text(key), zh.text(key));
assert_ne!(a, MISSING, "{} 没有英文译文", key.name());
assert_ne!(b, MISSING, "{} 没有中文译文", key.name());
assert_ne!(
a,
b,
"{} 的两套语言写成了一样({a:?})——切语言时这一处不会变",
key.name()
);
// 中文那栏必须真的有汉字——**整句只有品牌名时例外**(`InstancesMetaJava` = "Java":
// 那一格的值域是 `Temurin 21` 这种品牌串,翻成「爪哇」是错的)。
let brand_only = {
let words = ascii_words(b);
!words.is_empty() && words.iter().all(|word| ALLOWED.contains(&word.as_str()))
};
assert!(
has_cjk(b) || brand_only,
"{} 的中文译文里一个汉字都没有:{b:?}",
key.name()
);
}
}
/// **数字与分隔符不随语言变形**:`Running · pid 1234` 的两套语言只换前半句。
///
/// 抓得住:把 `pid` 翻成「进程」、把 `·` 换成 `-`、把 `{pid}` 写成固定文字、少一个空格。
/// 抓不住:真实 pid 的取值(那是 core 的事,出图那台机器上也没真跑过)。
#[test]
fn the_running_line_keeps_its_number_and_separator() {
let en = Catalog::of(Lang::En).render(Key::InstancesRunning, &[("pid", "1234")]);
let zh = Catalog::of(Lang::ZhCn).render(Key::InstancesRunning, &[("pid", "1234")]);
assert_eq!(en, "Running · pid 1234");
assert_eq!(zh, "运行中 · pid 1234");
assert_ne!(en, zh, "切换语言时这一行要变(只换标签本体)");
for text in [en, zh] {
assert!(text.contains("1234"), "数字丢了:{text}");
assert!(text.contains(" · pid "), "分隔符或 pid 变形了:{text}");
}
// 失败那一行同理:core 给的原因原文原样保留,只翻前半句
let failed_en = Catalog::of(Lang::En).render(Key::InstancesFailed, &[("reason", "exit 1")]);
let failed_zh = Catalog::of(Lang::ZhCn).render(Key::InstancesFailed, &[("reason", "exit 1")]);
assert_eq!(failed_en, "Launch failed · exit 1");
assert_eq!(failed_zh, "启动失败 · exit 1");
}
/// **旧串不许回来**:M120 之前页面里的英文基线字面量,现在只许活在目录里。
///
/// 为什么用「已知清单」而不是「扫所有英文串」:合法写法太多了(`Action::Import` 这类枚举、
/// 品牌名、文档里的反引号代码),扫宽了会假红。清单里这 29 条是**这次真的搬走的那批**,
/// 谁把它们抄回页面,这条就红——那正是「页面又变成中英混排」的唯一来源。
///
/// **变异实测**(M120 交付时跑过):把 `"ALL INSTANCES"` 写回 `instances.rs` 的页头,立刻红
/// 并指出是哪个文件、哪一句。
#[test]
fn the_page_sources_have_no_english_baseline_left() {
for (name, source) in PAGE_SOURCES {
for literal in OLD_LITERALS {
let needle = format!("\"{literal}\"");
assert!(
!source.contains(&needle),
"{name} 里又写回了英文基线字面量 {literal:?}(走 Texts / 目录,别写死)"
);
}
}
// 反向锚:这些来源**必须**真的在用 Texts / 目录,否则上面那条是空转
assert!(
PAGE_SOURCES[0].1.contains("Texts::of("),
"实例页没有从 Texts 取词"
);
assert!(
PAGE_SOURCES[0].1.contains("Header::new(texts.title())"),
"页头标题必须是词表里的那条(`Header::new` 里写死字符串 = 中文界面上顶着英文标题)"
);
assert!(
PAGE_SOURCES[3].1.contains("Key::Instances"),
"text.rs 没有引用本族的 key"
);
assert!(
PAGE_SOURCES[4].1.contains("Key::InstancesStage"),
"阶段标签没有走目录"
);
}
/// **死 key 守门**:目录里每一条 `Instances*` 都必须被页面(或产品快照转换)真的引用。
///
/// M117 加的那七条就是这么沉的:目录里写着,页面上一个都没用。扫描是字面出现的
/// `Key::<名字>`,所以用宏拼 key 名能骗过它——这个局限写在这里,不假装它更强。
#[test]
fn every_instances_key_is_referenced_by_the_page() {
for key in Texts::keys() {
let needle = format!("Key::{}", key.name());
assert!(
PAGE_SOURCES
.iter()
.any(|(_, source)| source.contains(&needle)),
"{needle} 在目录里定义了,但实例页没有任何地方用它(死 key,删掉或接上)"
);
}
}
+102
View File
@@ -0,0 +1,102 @@
//! 实例页的**文案入口**:页面上一处可见英文字面量都不留,全部从目录取。
//!
//! ## 为什么要有这一层(而不是在控件树里直接 `catalog.text(..)`)
//!
//! 页面要的文案有 20 多条,直接写的话每个调用点都是「key 名 + 这一处画什么」的重复劳动,
//! 而且**守门测试没法取样**:iced 0.14 的 `Element` 没有内省接口(`instances/tests.rs` 里
//! 「读源码」那条注释解释过同一件事),所以「上屏文本」只能从**页面取词的那一个入口**拿。
//! 于是所有取词收进 [`Texts`],页面用 `texts.xxx()` 画,测试用同一个 `Texts` 断言——
//! 一处定义、两处消费,不存在「测试抄了一份平行实现」。
//!
//! ## 它抓得住什么 / 抓不住什么
//!
//! - **抓得住**:目录里某条中文忘了翻(`pages/instances/tests/i18n.rs` 的「无 ASCII 英文单词」)、
//! 某条中英写成一样(「切换语言文本真的变」)、`Running · pid {pid}` 的数字与分隔符被翻译走样;
//! - **抓不住**:页面上又冒出一条**新**硬编码字面量——那由同文件里「旧串不许回来」的源码扫描
//! 兜已知的那批,加上出图人工看(`DevDocs/artifacts/m120/`);
//! - **抓不住**:译文本身翻得顺不顺(那只能人工审图)。
//!
//! ## 口径
//!
//! 品牌名不翻译(`Java` / `Fabric` / `Forge` 原样);`pid` 是进程号的技术标识,两套语言都写
//! `pid`(改成「进程」会丢掉「拿它去任务管理器里搜」这条可操作性)。
//!
//! **启动阶段(下载中 / 校验中 / …)不在这张表里**:它在 `app/view/instances.rs` 拼进
//! `Row.status`(「阶段 · 百分比」是一整句),与元信息卡同一层——页面只画模型给的字符串。
use unlucky_i18n::{Catalog, Key};
/// 实例页 + 详情列的文案(一个 `Catalog` 的薄包装,`Copy`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Texts(Catalog);
impl Texts {
/// 从目录取一套(调用方给 `model.lang.catalog()`)。
pub(super) fn of(catalog: Catalog) -> Self {
Self(catalog)
}
// ------------------------------------------------------------- 页头
pub(super) fn title(self) -> &'static str {
self.text(Key::InstancesTitle)
}
pub(super) fn subtitle(self) -> &'static str {
self.text(Key::InstancesSubtitle)
}
pub(super) fn new_instance(self) -> &'static str {
self.text(Key::InstancesNew)
}
pub(super) fn import(self) -> &'static str {
self.text(Key::InstancesImport)
}
// --------------------------------------------------------------- 列表列
pub(super) fn all_title(self) -> &'static str {
self.text(Key::InstancesAllTitle)
}
// --------------------------------------------------------------- 详情列
pub(super) fn back(self) -> &'static str {
self.text(Key::InstancesBack)
}
pub(super) fn play(self) -> &'static str {
self.text(Key::InstancesPlay)
}
pub(super) fn stop(self) -> &'static str {
self.text(Key::InstancesStop)
}
pub(super) fn duplicate(self) -> &'static str {
self.text(Key::InstancesDuplicate)
}
pub(super) fn open_folder(self) -> &'static str {
self.text(Key::InstancesOpenFolder)
}
pub(super) fn delete(self) -> &'static str {
self.text(Key::InstancesDelete)
}
pub(super) fn empty_title(self) -> &'static str {
self.text(Key::InstancesEmptyTitle)
}
pub(super) fn empty_body(self) -> &'static str {
self.text(Key::InstancesEmptyBody)
}
pub(super) fn narrow_note(self) -> &'static str {
self.text(Key::InstancesNarrowNote)
}
fn text(self, key: Key) -> &'static str {
self.0.text(key)
}
/// 这一页的全部 key(守门测试的取样口:**从 `Key::ALL` 过滤出来**,不是手抄一份清单)。
///
/// 域判据是前缀 `Instances`——本族的 key 名是契约(`catalog/instances.rs` 里一条一条写着),
/// 新加的 key 只要还在这个族里就自动进守门范围,不需要有人记得来登记。
#[cfg(test)]
pub(super) fn keys() -> impl Iterator<Item = Key> {
Key::ALL
.iter()
.copied()
.filter(|key| key.name().starts_with("Instances"))
}
}
+14
View File
@@ -159,6 +159,20 @@ mod settings;
///
/// 其余各行仍逐字节等于拆分前那份快照——也就是说这张表现在证明的是「除这几条按令改过的,
/// 其余一字未动」,不再是「整表一字未动」。
///
/// ## M120 动了实例页那一族(1 条改译文 + 1 条删除 + 28 条新增)
///
/// 实例页的文案以前是英文基线字面量,M120 全搬进 `catalog/instances.rs`。那一族在装配链
/// **第 2 位**(紧跟 nav),所以这批行是**插在中间**的,不是文件末尾追加:
///
/// | 动作 | 条目 |
/// |---|---|
/// | 改译文 | [`Key::InstancesEmptyBody`](换成详情列真正画的那句) |
/// | 删除 | `InstancesLastPlayed`(M117 加的整句,从未接线;页面是「标签 + 值」的元信息卡) |
/// | 新增 | `InstancesTitle` … `InstancesStageWindow`(页头 / 动作 / 空态 / 元信息标签与值 / 隔离档位 / 运行状态 / 阶段,共 28 条) |
///
/// 位置跟着族走这个事实本身就是判据:金表是 `Key::ALL` 的逐 index 镜像,
/// 「为把新 key 排到文件末尾而去重排别人的族」会让这里整片红。
#[cfg(test)]
mod golden;
+31 -6
View File
@@ -1,7 +1,5 @@
//! 金表的**表数据**(`Key::ALL` 顺序 × (en, zh-CN) 的逐字快照)。
//!
//! 「为什么有这么一张表、它抓得住什么」写在 `catalog.rs` 中 `mod golden;` 的文档里——
//! M119 r2 把它搬到那儿,是为了给这张表腾出追加新 key 的行数(400 行硬线),一个字的说明都没删。
//! 金表的**表数据**(`Key::ALL` 顺序 × (en, zh-CN) 的逐字快照)。「为什么有这张表、它抓得住
//! 什么」全在 `catalog.rs` 的 `mod golden;` 文档里——本文件只放数据,是因为每加一条 key 就多一行。
/// 拆分前 `Key::ALL` 的顺序 × (en, zh-CN)。第一列是变体名(`Key::name()` 的返回值)。
pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
@@ -14,9 +12,36 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("InstancesImport", "Import", "导入"),
("InstancesBack", "Back to instances", "返回实例列表"),
("InstancesPlay", "Play", "启动"),
("InstancesLastPlayed", "Last played {when}", "上次游玩 {when}"),
("InstancesEmptyTitle", "No instance selected", "未选择实例"),
("InstancesEmptyBody", "Pick an instance to see its details.", "选择一个实例查看详情。"),
("InstancesEmptyBody", "Pick an instance on the left to see its version, loader and launch options.", "在左侧选择一个实例,查看它的版本、加载器与启动选项。"),
("InstancesTitle", "Instances", "实例"),
("InstancesSubtitle", "Local versions and their mod loaders", "本地版本与它们的模组加载器"),
("InstancesAllTitle", "ALL INSTANCES", "全部实例"),
("InstancesStop", "Stop", "停止"),
("InstancesDuplicate", "Duplicate", "复制"),
("InstancesOpenFolder", "Open folder", "打开文件夹"),
("InstancesDelete", "Delete", "删除"),
("InstancesNarrowNote", "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.", "窗口窄于 840 像素时,列表与详情共用一列:打开一个实例会整页替换这一屏(整页切换)。把窗口拉宽就能重新同时看见两栏。"),
("InstancesMetaVersion", "VERSION", "版本"),
("InstancesMetaLoader", "LOADER", "加载器"),
("InstancesMetaMemory", "MEMORY", "内存"),
("InstancesMetaJava", "JAVA", "Java"),
("InstancesMetaMods", "MODS", "模组"),
("InstancesMetaIsolation", "ISOLATION", "隔离方式"),
("InstancesMetaVersionId", "VERSION ID", "版本标识"),
("InstancesMetaFiles", "FILES", "文件"),
("InstancesMetaLastPlayed", "LAST PLAYED", "上次游玩"),
("InstancesPresentOnDisk", "Present on disk", "已在磁盘上"),
("InstancesMissing", "Missing", "缺失"),
("InstancesNeverPlayed", "Never", "从未游玩"),
("InstancesIsolationIsolated", "Isolated", "独立目录"),
("InstancesIsolationShared", "Shared", "共用目录"),
("InstancesRunning", "Running · pid {pid}", "运行中 · pid {pid}"),
("InstancesFailed", "Launch failed · {reason}", "启动失败 · {reason}"),
("InstancesStageDownload", "Downloading", "下载中"),
("InstancesStageVerify", "Verifying", "校验中"),
("InstancesStageLaunch", "Starting", "启动中"),
("InstancesStageWindow", "Waiting for window", "等待游戏窗口"),
("LoaderVanilla", "Vanilla", "原版"),
("LaunchOutcomeNormal", "Finished normally", "正常结束"),
("LaunchOutcomeCrash", "The game crashed", "游戏崩溃"),
+68 -4
View File
@@ -1,19 +1,83 @@
//! 实例页文案:页头动作、返回、上次游玩时间、空态。
//! 实例页文案:页头、列表列头、详情动作、元信息卡的标签与值、运行状态与启动阶段。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
//!
//! ## 这一族在装配链的**第 2 位**(M120 钉住)
//!
//! `instances_entries!` 紧跟 `nav_entries!`,所以本族的 key 落在 `Key::ALL` 的前段、
//! `LoaderVanilla` 之前。M120 的新 key 因此**追加在本域末尾**(= `InstancesEmptyBody` 之后),
//! 既没插到别的族中间,也没为了「排在文件末尾」去重排别人的族:
//! 金表([`crate::catalog::golden`])是 `Key::ALL` 的逐 index 镜像,位置跟着族走。
//!
//! ## M120 之前这七条全是**死 key**
//!
//! M117 加 `InstancesNew` / `InstancesImport` / `InstancesBack` / `InstancesPlay` /
//! `InstancesLastPlayed` / `InstancesEmptyTitle` / `InstancesEmptyBody` 时,实例页并没有接它们
//! ——页面上还是英文基线字面量(`"New instance"` / `"ALL INSTANCES"` / `"Present on disk"`…)。
//! M120 一次接完,同时:
//!
//! - `InstancesEmptyBody` 的译文换成详情列真正画的那句(原来那句是「看详情」的泛说);
//! - **`InstancesLastPlayed` 删掉**:它的形状是「上一次游玩 {when}」这种整句,而页面上是
//! 元信息卡(标签 `LAST PLAYED` + 值 `2026-10-29 19:22 UTC`,或值 `Never`)。留着它只会
//! 多出一条没人用的 key,所以拆成 [`Key::InstancesMetaLastPlayed`](标签)与
//! [`Key::InstancesNeverPlayed`](值)。
//!
//! ## 两条口径(复审会盯)
//!
//! 1. **品牌与技术标识不翻译**:`Java`(元信息标签的值域)与 `pid`(进程号)原样保留两套语言;
//! 2. **数字与分隔符不随语言变形**:[`Key::InstancesRunning`] 的中英都写成
//! `… · pid {pid}`,只有前半句换了;`{pid}` 之外一个字都不许动(`pages/instances/tests/i18n.rs`
//! 用「中英同位置取占位符 + 数字原样」的断言钉这条)。
macro_rules! instances_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// ── 实例页
// ── 实例页(M117 的七条:其中 `InstancesLastPlayed` 见模块文档,M120 删除)
InstancesNew => ("New instance", "新建实例"),
InstancesImport => ("Import", "导入"),
InstancesBack => ("Back to instances", "返回实例列表"),
InstancesPlay => ("Play", "启动"),
InstancesLastPlayed => ("Last played {when}", "上次游玩 {when}"),
InstancesEmptyTitle => ("No instance selected", "未选择实例"),
InstancesEmptyBody => ("Pick an instance to see its details.", "选择一个实例查看详情。"),
InstancesEmptyBody => ("Pick an instance on the left to see its version, loader and launch options.", "在左侧选择一个实例,查看它的版本、加载器与启动选项。"),
// ── 页头与列表列头(M120)
InstancesTitle => ("Instances", "实例"),
InstancesSubtitle => ("Local versions and their mod loaders", "本地版本与它们的模组加载器"),
InstancesAllTitle => ("ALL INSTANCES", "全部实例"),
// ── 详情动作(M120)
InstancesStop => ("Stop", "停止"),
InstancesDuplicate => ("Duplicate", "复制"),
InstancesOpenFolder => ("Open folder", "打开文件夹"),
InstancesDelete => ("Delete", "删除"),
InstancesNarrowNote => (
"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.",
"窗口窄于 840 像素时,列表与详情共用一列:打开一个实例会整页替换这一屏(整页切换)。把窗口拉宽就能重新同时看见两栏。"
),
// ── 元信息卡的标签(M120;`Java` 是品牌名,两套语言都写 Java)
InstancesMetaVersion => ("VERSION", "版本"),
InstancesMetaLoader => ("LOADER", "加载器"),
InstancesMetaMemory => ("MEMORY", "内存"),
InstancesMetaJava => ("JAVA", "Java"),
InstancesMetaMods => ("MODS", "模组"),
InstancesMetaIsolation => ("ISOLATION", "隔离方式"),
InstancesMetaVersionId => ("VERSION ID", "版本标识"),
InstancesMetaFiles => ("FILES", "文件"),
InstancesMetaLastPlayed => ("LAST PLAYED", "上次游玩"),
// ── 元信息卡的值(M120)
InstancesPresentOnDisk => ("Present on disk", "已在磁盘上"),
InstancesMissing => ("Missing", "缺失"),
InstancesNeverPlayed => ("Never", "从未游玩"),
// 隔离档位的两个稳定 id(`core::game::IsolationMode::as_str`)→ 人话。
// 白名单只覆盖真实存在的这两档;`inherit` / `custom` / 未知值原样上屏(不猜)。
InstancesIsolationIsolated => ("Isolated", "独立目录"),
InstancesIsolationShared => ("Shared", "共用目录"),
// ── 运行状态与启动阶段(M120;数字与分隔符不随语言变形)
InstancesRunning => ("Running · pid {pid}", "运行中 · pid {pid}"),
InstancesFailed => ("Launch failed · {reason}", "启动失败 · {reason}"),
InstancesStageDownload => ("Downloading", "下载中"),
InstancesStageVerify => ("Verifying", "校验中"),
InstancesStageLaunch => ("Starting", "启动中"),
InstancesStageWindow => ("Waiting for window", "等待游戏窗口"),
} }
};
}
+4 -2
View File
@@ -17,9 +17,11 @@ const PARTIAL_ZH: &[(Key, &str)] = &[
(Key::InstancesImport, "导入"),
(Key::InstancesBack, "返回实例列表"),
(Key::InstancesPlay, "启动"),
(Key::InstancesLastPlayed, "上次游玩 {when}"),
(Key::InstancesEmptyTitle, "未选择实例"),
(Key::InstancesEmptyBody, "选择一个实例查看详情。"),
(
Key::InstancesEmptyBody,
"在左侧选择一个实例,查看它的版本、加载器与启动选项。",
),
(Key::LoaderVanilla, "原版"),
(Key::LaunchOutcomeNormal, "正常结束"),
(Key::LaunchOutcomeCrash, "游戏崩溃"),
+16 -14
View File
@@ -13,11 +13,13 @@ use crate::dto::{
AgentEvent, AppCommand, AppEvent, Backend, ChatMessage, ChatRole, CoreEvent, InstanceId,
InstanceRuntime, LaunchStage, Progress,
};
use crate::mock_data::{mock_instances, mock_log_lines, mock_log_sessions, mock_versions};
use crate::mock_data::{mock_log_lines, mock_log_sessions, mock_versions};
use crate::toast::{Toast, ToastKind};
#[path = "mock/accounts.rs"]
mod accounts_screen;
#[path = "mock/instances.rs"]
mod instances_screen;
/// 启动脚本:按时间推进 download → verify → launch → running。
#[derive(Debug)]
@@ -55,19 +57,19 @@ impl MockBackend {
}
fn seed(&mut self) {
self.events.push_back(AppEvent::Core(
CoreEvent::InstancesChanged(mock_instances()),
));
self.events
.push_back(AppEvent::Core(CoreEvent::VersionListChanged(
mock_versions(),
)));
self.events
.push_back(AppEvent::Core(CoreEvent::LogSessionsChanged(
mock_log_sessions(),
)));
self.events
.push_back(AppEvent::Core(CoreEvent::LogLinesChanged(mock_log_lines())));
// 实例的截图矩阵(`ULL_INSTANCE_STATE=empty|demo|running`,见 `mock/instances.rs`)。
// 缺省与 `mock_data::mock_instances()` 逐字段相同——这里的开关只给出图用。
self.emit(AppEvent::Core(CoreEvent::InstancesChanged(
instances_screen::instances(&instances_screen::scenario()),
)));
// 三条都走 `emit`(同一个队尾),与上面那条实例事件的形状一致
self.emit(AppEvent::Core(CoreEvent::VersionListChanged(
mock_versions(),
)));
self.emit(AppEvent::Core(CoreEvent::LogSessionsChanged(
mock_log_sessions(),
)));
self.emit(AppEvent::Core(CoreEvent::LogLinesChanged(mock_log_lines())));
self.seed_accounts();
}
+122
View File
@@ -0,0 +1,122 @@
//! 截图/演示用的**实例场景**(只在 mock 后端里被调用)。
//!
//! D30 要求实例页出状态矩阵:空态 / 有实例 / 运行中(带 pid)/ 启动中(带百分比)。
//! 截图通道强制 mock 后端(`AppOptions::mock`),所以这四态的**数据**必须由 mock 造:
//! 走 `ULL_INSTANCE_STATE` 选场景,与 `ULL_ACCOUNT_STATE`(M114)/ `ULL_CONTENT_STATE`(M116)
//! 同一套取证开关。
//!
//! ## 三个约束(塔台 M120 批的)
//!
//! 1. **不设变量 = 今天那 4 条**(Idle×3 + Failed×1)——[`crate::mock_data::mock_instances`]
//! 原样返回,产品/单测的行为一字未变;
//! 2. **这个开关只影响 mock 通道**:真 host 的实例来自 `core::game` 的快照,读不到这里;
//! 3. `running` 的 pid 是**写死的 mock 值**(`1234`),不是真进程 pid——它证明的是
//! 「`运行中 · pid {pid}` 这条文案的数字与分隔符不随语言变形」,不是「我能读到真 pid」。
//!
//! ```text
//! ULL_INSTANCE_STATE=empty ULL_START_ROUTE=instances ULL_LANG=zh_cn \
//! unlucky-launcher-iced --screenshot=instances-empty-zh.png --software-renderer
//! ```
use crate::dto::{InstanceRuntime, InstanceSummary, LaunchStage, Progress};
use crate::mock_data::mock_instances;
/// 挑演示场景的环境变量。
pub(crate) const STATE_ENV: &str = "ULL_INSTANCE_STATE";
/// 场景名(未知值按 `demo` 处理,与 `ULL_ACCOUNT_STATE` 同一条兜底口径)。
pub(super) fn scenario() -> String {
std::env::var(STATE_ENV)
.unwrap_or_else(|_| "demo".to_string())
.trim()
.to_lowercase()
}
/// 场景对应的实例列表。
///
/// | 场景 | 列表 | 页面看起来 |
/// |---|---|---|
/// | `empty` | 空表 | 空列表 + 「未选择实例」空态(`detail: None`) |
/// | `demo`(缺省 / 未知值) | 4 条 Idle×3 + Failed×1 | 今天的样子(M90 起的基线) |
/// | `running` | 4 条,其中 1 条 `Running{pid}`、1 条 `Busy{42%}` | 列表 overline 与详情状态行都带运行态 |
pub(super) fn instances(scenario: &str) -> Vec<InstanceSummary> {
match scenario {
"empty" => Vec::new(),
"running" => running(),
_ => mock_instances(),
}
}
/// 运行态矩阵:同一批实例换 runtime(名字/版本/加载器都还是 `mock_data` 那一份)。
///
/// 三种态各留一条,是为了**一屏看完**状态文案的三种形状:正在跑(`pid`)、启动中(百分比)、
/// 启动失败(原因)。少一条就得再出一张图,而图多一张就多一处可以撒谎的地方。
fn running() -> Vec<InstanceSummary> {
let mut list = mock_instances();
for instance in &mut list {
match instance.id.as_str() {
"survival" => instance.runtime = InstanceRuntime::Running { pid: 1234 },
"vanilla-latest" => {
instance.runtime = InstanceRuntime::Busy {
stage: LaunchStage::Download,
progress: Progress {
done: 630,
total: 1_500,
},
};
}
_ => {}
}
}
list
}
#[cfg(test)]
mod tests {
use super::*;
/// **默认行为不变**(塔台三条约束的第 1 条):不设变量就是 `mock_data` 那一份。
///
/// 抓得住:有人把 `demo` 分支改成了「顺手多造几条」——那会让所有既有截图与断言漂。
#[test]
fn the_demo_scenario_is_exactly_the_old_mock_list() {
for unknown in ["demo", "", "bogus"] {
let got = instances(unknown);
let want = mock_instances();
assert_eq!(got.len(), want.len(), "{unknown:?}");
assert_eq!(got, want, "{unknown:?} 必须与 mock_data 逐字段相同");
}
}
#[test]
fn the_empty_scenario_has_no_instances() {
assert!(instances("empty").is_empty());
}
/// 运行态矩阵:`Running` / `Busy` 各一条,且**名字与版本没被动手脚**(它们不是场景的一部分)。
#[test]
fn the_running_scenario_covers_running_and_busy() {
let list = instances("running");
assert_eq!(list.len(), mock_instances().len());
assert!(
list.iter().any(
|item| matches!(item.runtime, InstanceRuntime::Running { pid } if pid == 1234)
),
"要有一条带固定 pid 的运行中实例"
);
assert!(list.iter().any(|item| matches!(
item.runtime,
InstanceRuntime::Busy {
stage: LaunchStage::Download,
..
}
)));
assert_eq!(list[0].name, mock_instances()[0].name);
}
/// 环境变量名是**契约**(出图脚本与报告都写它,改名等于让文档说谎)。
#[test]
fn the_scenario_variable_is_named() {
assert_eq!(STATE_ENV, "ULL_INSTANCE_STATE");
}
}