//! 消息目录:`Key` 枚举 + 中英两套常量表 + 取词 / 占位符渲染 / 缺译诊断。 //! //! ## 为什么 key 是枚举而不是字符串 //! //! 字符串 key(`"nav.instances"`)拼错了编译器一声不响,运行时才知道——本仓的说法是「运行时静默回落」。 //! 枚举 key 把这类错误前移到**编译期**:`Key::NavInstance`(真名是 `NavInstances`)直接 //! `error[E0599]`,不可能跑到运行时。 //! //! **这里没有守卫测试**:曾经写过一个 `compile_fail` 文档测试想守这条,但复审实测它对 //! 「把 `Key` 换成字符串包装类型」这类变异仍然全绿——它守的是「这段代码编译不过」,而不是 //! 「编译期校验还在」。说不清真假的守卫比没有更糟,所以删了;这条纪律只靠枚举这个事实本身。 //! //! ## 为什么两套译文写在**同一行** //! //! ```ignore //! NavInstances => ("Instances", "实例"), //! ``` //! //! 一行就是「一个 key 的全部语言」。于是「加了 key 忘了译文」在结构上不可能发生——这比「运行时发现 //! 缺译再诊断」更强。缺译诊断([`Catalog::diagnose`])因此有两个真实用途: //! //! 1. **注入目录**:后续 mission 用 [`Catalog::from_entries`] 加第三套(繁体 / 更多语言)时, //! 表格是手写的,缺条目的风险回来了; //! 2. **回归守门**:万一有人把这张表改回「两套各写一遍」,[`Catalog::missing`] 的守卫测试立刻红。 //! //! ## 缺译时给什么 //! //! **不回落**(绝不拿另一种语言顶替,用户会看到「一半英文一半中文」的界面而不自知): //! //! - [`Catalog::get`] → `None`(严格调用方用它); //! - [`Catalog::text`] → [`MISSING`](`⟪missing⟫`,UI 上刺眼但一眼可辨); //! - [`Catalog::render`] → `⟪missing:KeyName⟫`(带上 key 名,方便照着日志补译)。 use crate::lang::Lang; /// 缺译占位符(`text()` 的返回;故意刺眼——宁可难看,也不许静默顶替)。 pub const MISSING: &str = "⟪missing⟫"; /// 生成 `Key` 枚举 + 两套常量表(一行 = 一个 key 的全部语言)。 macro_rules! catalog { ($($variant:ident => ($en:literal, $zh:literal)),+ $(,)?) => { /// 可翻译文案的 key。**不存在时编译失败**(枚举变体名即契约)。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum Key { $( #[doc = concat!("en: `", $en, "` / zh-CN: `", $zh, "`")] $variant, )+ } impl Key { /// 全部 key(缺译诊断与完整性测试的遍历源)。 pub const ALL: &'static [Key] = &[$(Key::$variant),+]; /// 变体名(日志 / 缺译诊断里用来指认 key;不是文案)。 #[must_use] pub const fn name(self) -> &'static str { match self { $(Key::$variant => stringify!($variant),)+ } } } const EN: &[(Key, &str)] = &[$((Key::$variant, $en)),+]; const ZH_CN: &[(Key, &str)] = &[$((Key::$variant, $zh)),+]; }; } catalog! { // ── 导航轨(`pages::Route` 的四项) NavInstances => ("Instances", "实例"), NavDownload => ("Downloads", "下载"), NavLogs => ("Logs", "日志"), NavSettings => ("Settings", "设置"), // ── 实例页 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.", "选择一个实例查看详情。"), // ── 加载器(品牌名不翻译:Fabric / Forge / Quilt 一律原样,只有「原版」是词) LoaderVanilla => ("Vanilla", "原版"), // ── 启动结局(原来 UI 把 "Launch failed" 与 core 的「静默失败」拼成一句话,现在一句话 = 一个 key) LaunchOutcomeNormal => ("Finished normally", "正常结束"), LaunchOutcomeCrash => ("The game crashed", "游戏崩溃"), LaunchOutcomeSilent => ( "Suspected silent failure: exit code 0, but the game never really started", "疑似静默失败:退出码 0,但游戏没真正跑起来" ), LaunchFailedWithCode => ("Launch failed (exit {code}): {reason}", "启动失败(退出码 {code}):{reason}"), // ── 中文字体(D28:中文环境缺字体时的下载提示 / 失败回退) FontCjkNeedsDownload => ( "No Chinese font found on this system. Download an open-source (OFL) Chinese font?", "系统里没找到中文字体。要下载一套开源(OFL)中文字体吗?" ), FontCjkDownloading => ("Downloading Chinese font… {percent}%", "正在下载中文字体… {percent}%"), FontCjkDownloadFailed => ( "Chinese font download failed; the interface stays in English.", "中文字体下载失败,界面暂时保持英文。" ), FontCjkReady => ("Chinese font ready: {family}", "中文字体已就绪:{family}"), // ── 设置 SettingsLanguage => ("Language", "界面语言"), SettingsLanguageHint => ( "Switches the interface language. Chinese text needs a Chinese font.", "切换界面语言。显示中文需要中文字体。" ), // ── core / presenter 只给了中文串、UI 又不许画原文时的兜底(见 `bridge`) CoreTextUntranslated => ("[system text not translated]", "[未翻译的系统文案]"), // --- download page (M94) --- // 下载页(`crates/app-iced/src/pages/download`)。一句话一个 key:带变量的句子用 `{name}`, // 页面只做 `Catalog::render`,不拼片段(`bridge` 的规矩 1)。 // // 文案口径(M94 r1 / P2-2):**只说这个页面真能做到的动作**(切分类、刷新、看日志), // 不写「稍后会自动重试」这类 UI 没有的能力,也不拿文案去补功能。 DownloadTitle => ("Downloads", "下载"), DownloadSubtitle => ("Versions you can install", "可以安装的版本"), DownloadRefresh => ("Refresh", "刷新"), DownloadInstall => ("Install", "安装"), DownloadCancel => ("Cancel install", "取消安装"), DownloadInstalled => ("Installed", "已安装"), DownloadCount => ("{count} versions", "{count} 个版本"), DownloadRowSupporting => ("{kind} · released {date}", "{kind} · 发布于 {date}"), DownloadSelectHint => ("Pick a version, then install", "选一个版本再安装"), DownloadLoading => ("Loading versions…", "正在加载版本列表…"), DownloadLoadingHint => ( "If this stays here, press Refresh in the top right.", "如果一直停在这里,点右上角的刷新。" ), DownloadEmptyTitle => ("No versions to show", "没有可显示的版本"), DownloadEmptyBody => ( "Pick another category, or refresh the list.", "换一个分类,或刷新一次列表。" ), DownloadCategoryAll => ("All", "全部"), DownloadCategoryRelease => ("Release", "正式版"), DownloadCategorySnapshot => ("Snapshot", "预览版"), DownloadCategoryAncient => ("Ancient", "远古版"), DownloadStagePlanning => ("Planning", "解析版本"), DownloadStageDownloading => ("Downloading files", "下载文件"), DownloadStageInstalling => ("Building the version", "生成版本"), DownloadStageRegistering => ("Registering the instance", "登记实例"), DownloadStageDone => ("Done", "已完成"), DownloadStageFailed => ("Install failed", "安装失败"), DownloadStageCancelled => ("Install cancelled", "安装已取消"), DownloadProgress => ( "{done}/{total} files", "{done}/{total} 文件" ), DownloadProgressUnknown => ("Waiting for the first file…", "等第一个文件…"), // 速率与剩余时间是两个独立短语,各占一个 key(`DownloadRate` 只负责把它们排成一行) DownloadRate => ("{speed} · {eta}", "{speed},{eta}"), DownloadEtaSecs => ("{secs}s left", "剩 {secs}s"), DownloadEtaMins => ("{mins}m{secs}s left", "剩 {mins}m{secs}s"), DownloadFailedHint => ( "The Logs page has the full reason.", "完整原因在日志页。" ), DownloadOpenLogs => ("Open logs", "打开日志"), DownloadDismiss => ("Dismiss", "关闭"), // --- logs page (M95) --- // 日志页的**界面文案**(标题 / 按钮 / 分组名 / 空态)。诊断结论的正文不在这里: // 那是规则表 `rules.d/*.yaml` 的输出(数据),见 `pages/logs/diagnostic.rs` 的模块文档。 // `LogsStatus` 是**模板**:中英差别落在分隔符上(中文用全角竖线——`·` 是外来符号, // 中文正文里偏挤;这也让「中文目录必须与英文不同」这条守卫成立,不是为过测试硬造差异)。 LogsSubtitleIdle => ("Nothing is running right now", "当前没有运行中的实例"), LogsStatus => ("{instance} · {when} · {outcome}", "{instance}|{when}|{outcome}"), LogsOpenFolder => ("Open folder", "打开目录"), LogsClear => ("Clear", "清空"), LogsOutputTitle => ("OUTPUT", "输出"), LogsDiagnosisTitle => ("DIAGNOSIS", "诊断"), LogsConfidenceHigh => ("HIGH CONFIDENCE", "高置信度"), LogsConfidenceMedium => ("MEDIUM CONFIDENCE", "中置信度"), LogsConfidenceLow => ("LOW CONFIDENCE", "低置信度"), LogsSubtitleLive => ("Running now — logs stream as they arrive", "正在运行 — 日志实时滚动"), LogsConfidence => ("Confidence {percent}%", "置信度 {percent}%"), LogsSuggestedFix => ("Suggested fix", "建议动作"), LogsSummaryLabel => ("One-line summary", "一行摘要"), LogsCopySummary => ("Copy summary", "复制摘要"), LogsEmptyTitle => ("No output yet", "还没有输出"), LogsEmptyBody => ( "Logs stream here while the launcher or the game is running. Pick an instance and start it to see the first lines.", "启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。" ), LogsEmptyAction => ("Choose an instance", "选择实例"), } /// 一套语言的目录(不可变;`Copy`,随手传值)。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Catalog { lang: Lang, entries: &'static [(Key, &'static str)], } impl Catalog { /// 内置目录。 #[must_use] pub const fn of(lang: Lang) -> Catalog { let entries: &'static [(Key, &'static str)] = match lang { Lang::En => EN, Lang::ZhCn => ZH_CN, }; Catalog { lang, entries } } /// 用外部表构造目录(给后续 mission 的第三套语言 / 测试注入用)。 #[must_use] pub const fn from_entries(lang: Lang, entries: &'static [(Key, &'static str)]) -> Catalog { Catalog { lang, entries } } /// 这套目录是哪种语言。 #[must_use] pub const fn lang(&self) -> Lang { self.lang } /// 严格取词:缺译就是 `None`(**不回落**到别的语言)。 #[must_use] pub fn get(&self, key: Key) -> Option<&'static str> { self.entries .iter() .find(|(entry, _)| *entry == key) .map(|(_, text)| *text) } /// 这套目录的全部条目(声明顺序)。给导出 / 校对工具与完整性守门测试用。 #[must_use] pub const fn entries(&self) -> &'static [(Key, &'static str)] { self.entries } /// 取词(UI 便利路径):缺译返回 [`MISSING`]。 #[must_use] pub fn text(&self, key: Key) -> &'static str { self.get(key).unwrap_or(MISSING) } /// 带变量的句子:把 `{name}` 换掉。 /// /// 参数没给全时**占位符原样留在文案里**(`Launch failed (exit {code})`),不 panic、不猜空串—— /// 缺参在截图上一眼可见,比静默变成 `exit ` 好查。 #[must_use] pub fn render(&self, key: Key, args: &[(&str, &str)]) -> String { let Some(template) = self.get(key) else { return format!("⟪missing:{}⟫", key.name()); }; let mut out = template.to_owned(); for (name, value) in args { out = out.replace(&format!("{{{name}}}"), value); } out } /// 缺译的 key(自带语言里从来是空;注入目录与回归守门才用得上)。 #[must_use] pub fn missing(&self) -> Vec { Key::ALL .iter() .copied() .filter(|key| self.get(*key).is_none()) .collect() } /// 人话诊断(`None` = 没有缺译)。一条缺译一行,附带英文原文当参考。 #[must_use] pub fn diagnose(&self) -> Option { let missing = self.missing(); if missing.is_empty() { return None; } let mut out = format!("{} 缺 {} 条译文:", self.lang.tag(), missing.len()); for key in missing { let reference = Catalog::of(Lang::En) .get(key) .map_or_else(|| "".to_owned(), |text| format!("\"{text}\"")); out.push_str(&format!("\n - {} · en={reference}", key.name())); } Some(out) } } /// 抽取文案里的 `{name}` 占位符(守门测试用:同一 key 的两套译文占位符必须一致)。 #[must_use] pub fn placeholders(text: &str) -> Vec<&str> { let mut found = Vec::new(); let mut rest = text; while let Some(open) = rest.find('{') { let after = &rest[open + 1..]; let Some(close) = after.find('}') else { break; }; found.push(&after[..close]); rest = &after[close + 1..]; } found } #[cfg(test)] mod tests;