diff --git a/crates/app-iced/src/app/tests/presenter.rs b/crates/app-iced/src/app/tests/presenter.rs index af399f5..f6b5abe 100644 --- a/crates/app-iced/src/app/tests/presenter.rs +++ b/crates/app-iced/src/app/tests/presenter.rs @@ -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:?}" ); } diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index 6017fcd..4576bd6 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -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 = 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, }; diff --git a/crates/app-iced/src/app/view/instances.rs b/crates/app-iced/src/app/view/instances.rs index 605809f..b8b5ea0 100644 --- a/crates/app-iced/src/app/view/instances.rs +++ b/crates/app-iced/src/app/view/instances.rs @@ -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 { +pub(super) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option { 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()) => { diff --git a/crates/app-iced/src/app/view/tests.rs b/crates/app-iced/src/app/view/tests.rs index 4b6d874..31d6eb3 100644 --- a/crates/app-iced/src/app/view/tests.rs +++ b/crates/app-iced/src/app/view/tests.rs @@ -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) -> pages::Model { - let (draft, lang) = (pages::InstallDraft::default(), Lang::En); +/// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿 / **给定语言**。 +fn at_lang(state: &AppState, forced: Option, 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) -> pages::Model ) } +/// 同 [`at_lang`],语言取英文(绝大多数断言只关心「快照 → 模型」的映射,不关心译文)。 +fn at(state: &AppState, forced: Option) -> 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 { + 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] diff --git a/crates/app-iced/src/pages/instances.rs b/crates/app-iced/src/pages/instances.rs index 735ce15..43b9ac6 100644 --- a/crates/app-iced/src/pages/instances.rs +++ b/crates/app-iced/src/pages/instances.rs @@ -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 = 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, + 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) diff --git a/crates/app-iced/src/pages/instances/data.rs b/crates/app-iced/src/pages/instances/data.rs index d0c8c55..df9899e 100644 --- a/crates/app-iced/src/pages/instances/data.rs +++ b/crates/app-iced/src/pages/instances/data.rs @@ -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, diff --git a/crates/app-iced/src/pages/instances/demo.rs b/crates/app-iced/src/pages/instances/demo.rs index f473897..64443c9 100644 --- a/crates/app-iced/src/pages/instances/demo.rs +++ b/crates/app-iced/src/pages/instances/demo.rs @@ -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, diff --git a/crates/app-iced/src/pages/instances/detail.rs b/crates/app-iced/src/pages/instances/detail.rs index 59a12e3..4d6d41c 100644 --- a/crates/app-iced/src/pages/instances/detail.rs +++ b/crates/app-iced/src/pages/instances/detail.rs @@ -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, + 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, + 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, + 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) -> Element<'a, Message> { +fn action_row<'a>(ui: &'a Ui, forced: Option, 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) -> 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(), ) } diff --git a/crates/app-iced/src/pages/instances/tests.rs b/crates/app-iced/src/pages/instances/tests.rs index 88629a9..cb0ba0b 100644 --- a/crates/app-iced/src/pages/instances/tests.rs +++ b/crates/app-iced/src/pages/instances/tests.rs @@ -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}; diff --git a/crates/app-iced/src/pages/instances/tests/i18n.rs b/crates/app-iced/src/pages/instances/tests/i18n.rs new file mode 100644 index 0000000..9808465 --- /dev/null +++ b/crates/app-iced/src/pages/instances/tests/i18n.rs @@ -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 { + 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,删掉或接上)" + ); + } +} diff --git a/crates/app-iced/src/pages/instances/text.rs b/crates/app-iced/src/pages/instances/text.rs new file mode 100644 index 0000000..03fa3aa --- /dev/null +++ b/crates/app-iced/src/pages/instances/text.rs @@ -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 { + Key::ALL + .iter() + .copied() + .filter(|key| key.name().starts_with("Instances")) + } +} diff --git a/crates/i18n/src/catalog.rs b/crates/i18n/src/catalog.rs index c3e329a..3f76194 100644 --- a/crates/i18n/src/catalog.rs +++ b/crates/i18n/src/catalog.rs @@ -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; diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 489ec58..9b24afb 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -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", "游戏崩溃"), diff --git a/crates/i18n/src/catalog/instances.rs b/crates/i18n/src/catalog/instances.rs index dd4ca9a..9bbb84f 100644 --- a/crates/i18n/src/catalog/instances.rs +++ b/crates/i18n/src/catalog/instances.rs @@ -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", "等待游戏窗口"), } } }; } diff --git a/crates/i18n/src/catalog/tests.rs b/crates/i18n/src/catalog/tests.rs index e329bbc..1518e4c 100644 --- a/crates/i18n/src/catalog/tests.rs +++ b/crates/i18n/src/catalog/tests.rs @@ -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, "游戏崩溃"), diff --git a/crates/presenter/src/mock.rs b/crates/presenter/src/mock.rs index a8fd500..9ad3ea7 100644 --- a/crates/presenter/src/mock.rs +++ b/crates/presenter/src/mock.rs @@ -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(); } diff --git a/crates/presenter/src/mock/instances.rs b/crates/presenter/src/mock/instances.rs new file mode 100644 index 0000000..e320005 --- /dev/null +++ b/crates/presenter/src/mock/instances.rs @@ -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 { + match scenario { + "empty" => Vec::new(), + "running" => running(), + _ => mock_instances(), + } +} + +/// 运行态矩阵:同一批实例换 runtime(名字/版本/加载器都还是 `mock_data` 那一份)。 +/// +/// 三种态各留一条,是为了**一屏看完**状态文案的三种形状:正在跑(`pid`)、启动中(百分比)、 +/// 启动失败(原因)。少一条就得再出一张图,而图多一张就多一处可以撒谎的地方。 +fn running() -> Vec { + 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"); + } +}