Unlucky Launcher v0.2.0
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

首个 iced + Material 3 Expressive 版本的源码快照。
完整历史与日常提交在 dev 分支;main 只放阶段性快照。
This commit is contained in:
AskaEth committed 2026-11-05 14:22:11 +08:00
commit 8a8e5ca0c0
1079 files changed
+208035

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
[package]
name = "unlucky-i18n"
description = "Unlucky-Launcher 国际化地基(D28):语言探测 / 中英消息目录 + 编译期 key 校验 / 缺译诊断 / 中文字体策略与注入式下载器接口"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
rust-version.workspace = true
publish.workspace = true
# 依赖面必须保持**空**(与 `unlucky-design` 同一条纪律):
# 1. 它要被 UI 层(iced / 将来的任何壳)与 `presenter` 同时依赖,一旦引入 iced/slint 就锁死了渲染框架;
# 2. 它是**纯数据 + 纯函数**:消息目录是 `&'static str` 常量表,探测与字体扫描全部靠**注入**拿外部世界;
# 3. 语言探测/字体策略是启动路径上的第一件事,不该为了一句 "en"/"zh-CN" 拉进任何运行时。
# 证据命令:cargo tree -p unlucky-i18n --edges normal(应当只打印它自己)。
[dependencies]
[lints]
workspace = true
+184
View File
@@ -0,0 +1,184 @@
//! 文案边界(示例契约):core / presenter 的**状态**怎么进 UI。
//!
//! ## 现状(为什么需要这份契约)
//!
//! UI 文案硬编码英文,而 `core` / `presenter` 产出的状态串是中文,于是界面上已经出现
//! `Launch failed · exit0 静默失败` 这种**一句话里中英混排**的东西,加载器徽标也在 `原版` / `Fabric`
//! 之间混用。根因不是「译文不够」,是**边界没划**:谁产出文案、谁负责翻译,没有规则。
//!
//! ## 三条规则
//!
//! **R1 · UI 侧不产生文案,只翻译 key。**
//! UI 不许把片段拼成句子(`"Launch failed · " + reason`),也不许写中文字面量。
//! 一句话 = 一个 key([`crate::catalog`]),带变量的用 `{name}` 交给
//! [`crate::catalog::Catalog::render`]。拼接一旦发生,任何一套语言都拼不对(语序、标点、
//! 量词都不一样),而且混排会立刻出现在截图里。
//!
//! **R2 · core 的中文串不进 UI。**
//! core / presenter 的中文(`原版`、`静默失败(exit 0 但游戏没起来)`、
//! `缺少库文件 foo.jar(…)`)是**日志 / CLI / 诊断报告**的文案,不是界面文案。
//! 界面只能通过两条合法通道拿到「要显示什么」:
//! 1. **结构化出口**(首选):枚举 / DTO 字段 → 映射到 [`crate::Key`]。
//! 例:`presenter::dto::LoaderKind::as_str()` 的稳定 id(`vanilla` / `fabric`)→
//! [`crate::Key::LoaderVanilla`];`LogOutcome` 三态 → `LaunchOutcome*` 三 key。
//! 好处是**新增一个枚举变体时编译器会逼你补映射**(`match` 非穷尽即报错)。
//! 2. **过渡表**([`classify`]):只覆盖「已知会漏到界面的少数几个串」,逐条硬编码。
//! 它是**债**,不是终点——新出现的未知串一律走第三条。
//!
//! **R3 · 认不出的串,不许画原文。**
//! [`CoreText::Untranslated`] 就是那种情况:UI 只许显示 [`crate::Key::CoreTextUntranslated`]
//! 这个**故意难看**的兜底(`[system text not translated]`),并把原文写进日志。
//! 这条比「先画上去以后再翻」严格,是因为后者永远不会被回头修——难看才会被修。
//! 同时它是一件**上游缺陷的证据**:说明 core 欠一个结构化出口,应当报 finding 而不是在 UI 贴创可贴。
//!
//! ## 品牌专名(唯一的豁免)
//!
//! `Fabric` / `Forge` / `NeoForge` / `Quilt` / `OptiFine` / `LiteLoader` 是专有名词,**不翻译**
//! (翻成「锻造」就没人认识了)。它们可以原样显示,但必须经 [`CoreText::Brand`] 判定——
//! 也就是说「原样显示」是一个**显式决定**,不是「反正没译文就画原文」的默认行为。
//!
//! ## 诚实边界(抓得住什么 / 抓不住什么)
//!
//! - **抓得住**:本表里列到的串(映射正确、未知串不被放行);
//! - **抓不住**:core 明天新加一句中文——本 crate 不依赖 core(零依赖纪律),没有跨 crate 的一致性检查。
//! 那条只能靠「core 侧只给枚举」的纪律 + 后续在 `crates/presenter` 里加一条守门测试
//! (例如:`*_view` 的字符串字段不许含 CJK 字符)。
use crate::catalog::Key;
/// 一段来自 `core` / `presenter` 的显示文本该怎么处理。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CoreText {
/// 品牌专名:原样显示(不翻译,见模块文档的豁免)。
Brand(&'static str),
/// 有对应 key:按当前语言取词。
Key(Key),
/// 不认识:只许显示 [`Key::CoreTextUntranslated`],原文进日志。
Untranslated,
}
/// 已知会漏到界面的中文状态串 → key。
///
/// 每一条都得在 `crates/presenter` / `crates/core` 里找得到出处(注释里给位置),
/// 不许凭想象加——凭空加的条目会让 [`classify`] 假装覆盖了实际没覆盖的东西。
const LEGACY_STATUS: &[(&str, Key)] = &[
// `presenter::dto::instances::LoaderKind::label()` / `host::launch::loader_label()`
("原版", Key::LoaderVanilla),
// `presenter::host::job.rs`:`LogOutcome::SilentFail` 的展示文字
("静默失败(exit 0 但游戏没起来)", Key::LaunchOutcomeSilent),
// `presenter::mock_data.rs` 里的演示数据
("exit0 静默失败", Key::LaunchOutcomeSilent),
];
/// 品牌专名(原样显示;**不得**翻译或拼进句子,UI 拼装仍然禁止)。
const BRANDS: &[&str] = &[
"Fabric",
"Forge",
"NeoForge",
"Quilt",
"OptiFine",
"LiteLoader",
];
/// 契约总入口:core / presenter 的显示文本 → 该画什么。
///
/// 这是**示例契约**:形状(三分类 + 未知不放行)比表里的具体条目重要,接线 mission 按这个形状
/// 把每条 UI 路径改过来,条目随上游结构化出口的落地逐条删掉。
#[must_use]
pub fn classify(text: &str) -> CoreText {
let trimmed = text.trim();
if trimmed.is_empty() {
return CoreText::Untranslated;
}
if let Some((_, key)) = LEGACY_STATUS.iter().find(|(legacy, _)| *legacy == trimmed) {
return CoreText::Key(*key);
}
if let Some(brand) = BRANDS.iter().find(|brand| **brand == trimmed) {
return CoreText::Brand(brand);
}
// `Fabric 0.16.5` 这类「品牌 + 版本号」也是合法显示:品牌之后只跟一个版本号时放行。
// 注意这里是**保守**放行:只有「一个词 + 空格 + 版本号」的形状才算,含中文的串绝不会进来。
if let Some((head, tail)) = trimmed.split_once(' ') {
if let Some(brand) = BRANDS.iter().find(|brand| **brand == head) {
if !tail.is_empty() && !tail.contains(char::is_whitespace) && !tail.chars().any(is_cjk)
{
return CoreText::Brand(brand);
}
}
}
CoreText::Untranslated
}
/// 是否 CJK 字符(`classify` 的保守放行判据用)。
fn is_cjk(ch: char) -> bool {
// 0x2E80..=0x9FFF 已含 CJK 部首 / 假名 / 统一表意文字与 0x3000 段的中文标点,不再单列。
matches!(ch as u32, 0x2E80..=0x9FFF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_chinese_status_strings_map_to_keys() {
// 抓得住:过渡表条目的映射写错(把「原版」映到别的 key),以及「同一段意思两个 key」的漂移。
// 抓不住:core 那边改了这个中文串的措辞——那时这里会落到 Untranslated(**故意的**,
// 让漏译可见,而不是继续拿旧文案糊过去)。
assert_eq!(classify("原版"), CoreText::Key(Key::LoaderVanilla));
assert_eq!(
classify("静默失败(exit 0 但游戏没起来)"),
CoreText::Key(Key::LaunchOutcomeSilent)
);
assert_eq!(
classify("exit0 静默失败"),
CoreText::Key(Key::LaunchOutcomeSilent)
);
assert_eq!(
classify(" 原版 "),
CoreText::Key(Key::LoaderVanilla),
"前后空白要吃掉"
);
}
#[test]
fn brands_pass_through_and_are_never_translated() {
// 抓得住:品牌名被当成待翻译文案(`Fabric` → 某个 key 的译文就没人认识它了),
// 以及「品牌 + 版本号」这种合法徽标被判成未翻译。
// 抓不住:品牌名与版本号之间该用什么分隔(M3 令牌 / 排版层的事,不是本契约的事)。
assert_eq!(classify("Fabric"), CoreText::Brand("Fabric"));
assert_eq!(classify("Fabric 0.16.5"), CoreText::Brand("Fabric"));
assert_eq!(classify("NeoForge 21.1.72"), CoreText::Brand("NeoForge"));
assert_eq!(classify("Fabric 中文"), CoreText::Untranslated);
assert_eq!(classify("Fabric 0.16.5 beta"), CoreText::Untranslated);
assert_eq!(classify("FabricX"), CoreText::Untranslated);
}
#[test]
fn unknown_core_prose_is_refused_instead_of_printed_raw() {
// 抓得住:R3——未知中文(core 的诊断prose、`describe_warning` 的拼句)被放行到界面,
// 那正是 `Launch failed · exit0 静默失败` 这类混排的来源。
// 抓不住:UI 拿到 Untranslated 之后有没有真的只显示兜底(页面侧测试 + 截图验收)。
let leaks = [
"缺少库文件 lwjgl.jar(C:\\Users\\x\\.minecraft\\libs\\lwjgl.jar)",
"版本 JSON 没有 mainClass",
"退出码为 0 但疑似静默失败(3 条错误行):游戏可能刚起来就被 mod 异常终止",
"",
" ",
];
for leak in leaks {
assert_eq!(classify(leak), CoreText::Untranslated, "{leak}");
}
}
#[test]
fn classify_never_returns_a_raw_unknown_string() {
// 抓得住:将来给 `CoreText` 加了「原样字符串」变体(那等于把 R3 废掉)。
// 抓不住:枚举里已有变体被误用——那要靠调用点 review。
let odd = "some untranslated english";
assert_eq!(classify(odd), CoreText::Untranslated);
}
}
+342
View File
@@ -0,0 +1,342 @@
//! 消息目录:`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`] 的守卫测试立刻红。
//!
//! ## 按域拆开(M99 `SETTINGSFRAME`)
//!
//! 条目表原先整张写在本文件里,而**设置页正文接下来由四条 mission 并行接**(java / download /
//! language+appearance / ai+about),每条都要往这张表里加一批 key——同一张表就是四条线的冲突点。
//! 现在一个域一个文件(`catalog/<域>.rs`),本文件只留:`catalog!` 宏、装配链、[`Catalog`] 与对外 API。
//!
//! | 文件 | 域 | 谁会长它 |
//! |---|---|---|
//! | [`nav`] / [`instances`] / [`loader`] / [`launch`] / [`font`] / [`core_text`] | 页面与运行时文案 | 各自页面 |
//! | [`download`] / [`logs`] | 下载页 / 日志页(M94 / M95) | 那两页 |
//! | `settings/`(`general` / `appearance` / `java` / `download` / `ai`) | 设置页,**一个分组一个文件** | 设置页四条线 |
//! | [`general`] | 设置页 General 分组的**真控件**文案(语言选择器三档 + 主题/启动页/关闭三行) | M106 `I18NFOLLOW`(挂在链尾,既有顺序不动) |
//!
//! ### 域文件的形状(加 key 时照抄)
//!
//! ```ignore
//! macro_rules! <域>_entries {
//! ($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
//! $next! { $($rest,)* {
//! $($acc)*
//! NavInstances => ("Instances", "实例"),
//! } }
//! };
//! }
//! pub(super) use <域>_entries;
//! ```
//!
//! 它把本域条目**追加**到累积表 `$acc` 之后,再把「还没装配的域」原样交给 `$next`——最后交给
//! [`finish`],由它把整张表喂给 [`catalog!`]。**为什么非得这么绕**:`macro_rules!` 不能在
//! 宏参数里展开另一个宏(`catalog! { nav_entries!() }` 只会把 `nav_entries!()` 当成普通 token),
//! 也不能用元变量当宏名(`macro_rules! $name` 非法),所以只能「回调链」式累积。代价是每个域文件
//! 都要重抄上面那 6 行签名——这是 `macro_rules!` 的固有成本,不是可以省掉的样板。
//!
//! ### 拆开之后仍然只有一个真相源
//!
//! 装配顺序写在下面那条调用里,**它等于 `Key` 的声明顺序**;[`golden`] 把拆分前那份
//! `key -> (en, zh-CN)` 全量逐字节钉住(连顺序一起钉),所以「搬丢一条 / 两条互抄 / 顺序被调换」
//! 都会红。
//!
//! ## 缺译时给什么
//!
//! **不回落**(绝不拿另一种语言顶替,用户会看到「一半英文一半中文」的界面而不自知):
//!
//! - [`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!`],生成 `Key` 与两张表。
macro_rules! finish {
({ $($acc:tt)* }) => { catalog! { $($acc)* } };
}
mod account;
mod agent;
mod content;
mod core_text;
mod download;
mod font;
mod general;
mod instances;
mod launch;
mod loader;
mod logs;
mod nav;
mod settings;
/// 拆分前的 `key -> (en, zh-CN)` **逐字快照**(金表)。
///
/// ## 它是什么
///
/// M99 把 `catalog.rs` 的单张条目表按域拆到 `catalog/<域>.rs` 之后,这 72 行就是「拆分前那一份」
/// 的原样导出:由拆分前的 `Catalog::of(En)` / `Catalog::of(ZhCn)` 逐条 `println!` 出来,
/// 再原封不动抄进来(生成方式见 `tests.rs` 里 `split_keeps_every_translation` 的注释)。
/// M108 在它上面改了 3 条(`NavDownload` / `DownloadTitle` / `DownloadSubtitle`)、追加了 1 条
/// (`NavDownloadExpanded`);M105/M106/M107 各自追加的条目也都在,整表现在是 138 条。
///
/// ## 为什么不是「数条数」
///
/// 条数相等抓不住任何一种真会发生的坏法:某条译文在搬移时被漏掉、另一条被复制两次,条数照旧。
/// 这张表比的是**全量、逐字节、按声明顺序**的三元组,于是下面四种坏法都会红:
///
/// | 坏法 | 红在哪 |
/// |---|---|
/// | 某个 key 整条被搬丢 | 条数不等 / 该行 key 名对不上 |
/// | 两条 key 的译文互抄(同 key 被覆盖到别的译文) | 该行 en 或 zh 不等 |
/// | 某条译文改了一个字符 | 该行不等(`assert_eq!` 带原文) |
/// | 域的顺序被调换(`Key` 的 `Ord` 与导出表顺序都靠它) | 该行 key 名对不上 |
///
/// **抓不住**:译文本身翻得对不对(金表是「拆分前」的快照,它只能证明「没变」)。
///
/// ## M108 更新了三条、新增了一条(版本管理改名)
///
/// 用户令(2026-11-04)把这页的显示名从「下载 / Downloads」改成「版本管理 / Version management」:
/// [`Key::NavDownload`] / [`Key::DownloadTitle`] / [`Key::DownloadSubtitle`]
/// 三条译文按令改掉,并**新增** [`Key::NavDownloadExpanded`](240 展开态的全标签,
/// 88 轨放不下 18 字符的英文,见 `catalog/nav.rs` 的「两条 key」一节)。
///
/// 其余各行仍逐字节等于拆分前那份快照——也就是说这张表现在证明的是「除这几条按令改过的,
/// 其余一字未动」,不再是「整表一字未动」。
///
/// ## 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;
#[cfg(test)]
mod tests;
use account::account_entries;
use agent::agent_entries;
use content::content_entries;
use core_text::core_text_entries;
use download::{download_entries, version_entries};
use font::font_entries;
use general::general_entries;
use instances::instances_entries;
use launch::launch_entries;
use loader::loader_entries;
use logs::logs_entries;
use nav::nav_entries;
use settings::{
settings_ai_entries, settings_appearance_entries, settings_download_entries,
settings_general_entries, settings_java_entries,
};
// 装配顺序 = `Key` 的声明顺序。**与拆分前逐条一致**,动它等于动 `Key` 的 `Ord` 与导出表顺序,
// 金表(`catalog/golden.rs`)会红。设置页那五个域按页面上的分组顺序排,中间三个现在是空的。
nav_entries! {
instances_entries,
loader_entries,
launch_entries,
font_entries,
settings_general_entries,
settings_appearance_entries,
settings_java_entries,
settings_download_entries,
settings_ai_entries,
core_text_entries,
download_entries,
logs_entries,
agent_entries,
general_entries,
// M107:版本管理页的侧栏 / 搜索文案**追加在链尾**(金表只追加不重排,理由见
// `catalog/download.rs` 里 `version_entries` 的文档)。M104 的 `agent_entries` 与 M106 的
// `general_entries` 已经在 main 上占了链尾,所以本域排在它们**之后**:
// `Key::ALL` 顺序 = 金表顺序 = 先 main 的全部域,再 version。
version_entries,
// M109:内容库页。与 M107 同一条纪律——本域排在 main 已占住的链尾**之后**,
// 于是 `Key::ALL` 顺序 = 金表顺序 = 先 main 的全部域,再 version,最后 content。
content_entries,
// M114:账户页。同理**追加在 main 已占住的链尾之后**(先 content 再 account),
// 金表只追加不重排。
account_entries,
finish,
{}
}
/// 一套语言的目录(不可变;`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> {
Key::ALL
.iter()
.copied()
.filter(|key| self.get(*key).is_none())
.collect()
}
/// 人话诊断(`None` = 没有缺译)。一条缺译一行,附带英文原文当参考。
#[must_use]
pub fn diagnose(&self) -> Option<String> {
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(|| "<en 也缺>".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
}
+113
View File
@@ -0,0 +1,113 @@
//! 账户页文案(M114 三轨:微软正版 / 第三方皮肤站 / 本地档案)。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。本域**追加在装配链末尾**,
//! 所以金表(`catalog::golden`)里这批 key 也排在最后。
//!
//! ## 口径(不许改写成别的说法)
//!
//! 本地档案的说明必须带「仅本地/隔离网络测试用,无法连接启用正版验证的服务器」——
//! 这是 README 已定的口径(`AccountTrackOfflineBody` / `AccountOfflineWarning` 两处都写全),
//! 不许写成「兼容旧启动器迁移」这种暗示能进正版服的说法。
//!
//! 文案里**没有任何 token 字样**:账户页从不展示凭据(M114 审核红线),
//! 「密码只用于这一次登录」这条提示是唯一提到密码的地方。
macro_rules! account_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// ── 页头 / 列表
AccountTitle => ("Accounts", "账户"),
AccountSubtitle => ("Microsoft, third-party skin servers and local profiles", "微软正版、第三方皮肤站与本地档案"),
AccountListTitle => ("All accounts", "全部账户"),
AccountAddedOn => ("Added {date}", "添加于 {date}"),
AccountActiveChip => ("Active", "当前"),
AccountSwitch => ("Use for launch", "切换为启动账户"),
AccountRelogin => ("Sign in again", "重新登录"),
AccountRemove => ("Remove", "删除"),
AccountKindMicrosoft => ("Microsoft", "微软正版"),
AccountKindAuthlib => ("Third-party skin server", "第三方皮肤站"),
AccountKindOffline => ("Local profile", "本地档案"),
AccountServer => ("Server {server}", "服务器 {server}"),
// ── 空态
AccountEmptyTitle => ("No accounts yet", "还没有账户"),
AccountEmptyBody => ("Add a Microsoft account, a third-party skin server account, or a local profile.", "添加微软正版账户、第三方皮肤站账户,或建一个本地档案。"),
AccountEmptyAction => ("Add an account", "添加账户"),
// ── 三条轨道的入口卡
AccountTrackMicrosoftTitle => ("Microsoft account", "微软正版"),
AccountTrackMicrosoftBody => ("Sign in with a device code or in your browser. Online servers require this kind of account.", "用设备码或浏览器登录。联机服务器必须用这种账户。"),
AccountTrackAuthlibTitle => ("Third-party skin server", "第三方皮肤站"),
AccountTrackAuthlibBody => ("An authlib-injector compatible server (LittleSkin and friends) brings its own skins and capes.", "兼容 authlib-injector 的皮肤站(如 LittleSkin)自带皮肤与披风。"),
AccountTrackOfflineTitle => ("Local profile", "本地档案"),
AccountTrackOfflineBody => ("Local / isolated-network testing only — it cannot join servers that require a genuine Minecraft account.", "仅本地/隔离网络测试用,无法连接启用正版验证的服务器。"),
AccountTrackOpen => ("Sign in", "登录"),
AccountTrackCreate => ("Create profile", "创建档案"),
AccountDialogClose => ("Close", "关闭"),
// ── 本地档案表单
AccountOfflineNameLabel => ("Profile name", "档案名"),
AccountOfflineNameHint => ("3–16 letters, digits or underscore", "3–16 位字母、数字或下划线"),
AccountOfflineSubmit => ("Create profile", "创建档案"),
AccountOfflineWarning => ("Local / isolated-network testing only — it cannot join servers that require a genuine Minecraft account.", "仅本地/隔离网络测试用,无法连接启用正版验证的服务器。"),
// ── 微软登录面板(设备码 / 授权码)
AccountDeviceCodeLabel => ("Enter this code", "输入这个用户码"),
AccountDeviceCodeCopy => ("Copy code", "复制用户码"),
AccountDeviceLinkCopy => ("Copy link", "复制链接"),
AccountDeviceCountdown => ("{time} left", "剩 {time}"),
AccountDeviceExpired => ("This code has expired. Start again for a fresh one.", "这个用户码已过期,重新开始换一个新的。"),
AccountDeviceRetry => ("Try again", "重试"),
AccountDeviceCancel => ("Cancel", "取消"),
AccountDeviceBrowser => ("Sign in with browser", "用浏览器登录"),
AccountDeviceUseCode => ("Use a device code instead", "改用设备码"),
// ── 第三方站表单
AccountServerLabel => ("Server address", "服务器地址"),
AccountServerHint => ("API root, e.g. https://littleskin.cn/api/yggdrasil/", "API 根,例:https://littleskin.cn/api/yggdrasil/"),
AccountServerProbe => ("Check server", "校验服务器"),
AccountLoginNameLabel => ("Email or username", "邮箱或用户名"),
AccountPasswordLabel => ("Password", "密码"),
AccountPasswordHint => ("Used once for this sign-in. It is never stored.", "只在这次登录里用一次,不会保存。"),
AccountAuthlibSubmit => ("Sign in", "登录"),
AccountAuthlibIdleHint => ("The server is checked automatically when you sign in; Check server only does it up front.", "登录时会自动校验服务器;「校验服务器」只是提前跑一遍。"),
// ── 皮肤区
AccountSkinTitle => ("Skin", "皮肤"),
AccountSkinNone => ("No skin available", "没有可用皮肤"),
AccountSkinHint => ("Skins come from the account's own server; nothing is generated locally.", "皮肤来自账户自己的服务器,本地不生成任何假皮肤。"),
// ── 导航轨底部头像
AccountRailSignedOut => ("Not signed in", "未登录"),
AccountDeviceHint => ("Open the link in your browser and enter the code above.", "在浏览器里打开链接,输入上面的用户码。"),
AccountDeviceBrowserHint => ("Finish the sign-in in the browser window we just opened; the link below also works.", "在我们刚打开的浏览器窗口里完成登录;下面的链接也能手动打开。"),
AccountServerChecking => ("Checking the server…", "正在校验服务器…"),
AccountServerOk => ("Server {server} is ready", "服务器 {server} 可用"),
AccountLoginFailed => ("Sign-in failed", "登录失败"),
AccountCurrentTitle => ("Current account", "当前账户"),
// ── 失败原因码(M114 r2:后端只给码,文案在这里;原文只进日志)
AccountFailureCode => ("Code: {code}", "代码:{code}"),
AccountFailureDenied => ("The server refused this sign-in (access denied).", "服务器拒绝了这次登录(拒绝访问)。"),
AccountFailureWhitelist => ("Microsoft has not approved this launcher's app registration yet. An administrator has to accept the app, then try again.", "微软还没批准本启动器的应用注册:需要管理员同意该应用后再试。"),
AccountFailureUnreachable => ("Could not reach the server. Check the network connection or the address, then try again.", "连不上服务器:检查网络或地址后重试。"),
AccountFailureNotAuthlib => ("This address does not look like an authlib-injector server (the API root usually ends with /api/yggdrasil/).", "这个地址不像 authlib-injector 服务器(API 根通常以 /api/yggdrasil/ 结尾)。"),
AccountFailureBadCredentials => ("The server rejected the username or password.", "服务器不认这个用户名或密码。"),
AccountFailureNoProfile => ("The account has no Minecraft profile on this server yet. Create the character on the server first.", "这个账号在该服务器上还没有角色:先在皮肤站里创建角色。"),
AccountFailureStore => ("Could not save the account to disk.", "账户没能写到磁盘上。"),
AccountFailureOther => ("The sign-in failed. The full reason is in the launcher log.", "登录失败,完整原因在启动器日志里。"),
AccountDeviceRequesting => ("Contacting Microsoft…", "正在联系微软…"),
AccountLoginSucceeded => ("Signed in as {name}", "已登录 {name}"),
// ── 账户页横幅(添加/删除/切换的结果;页面上真实可见,不是 toast)
AccountNoticeAdded => ("Added {name}", "已添加 {name}"),
AccountNoticeRemoved => ("Removed {name}", "已删除 {name}"),
AccountNoticeSwitched => ("{name} will be used for the next launch", "{name} 将作为下次启动的账户"),
AccountNoticeOfflineNameInvalid => ("Profile names must be 3–16 letters, digits or underscores.", "档案名必须是 3–16 位字母、数字或下划线。"),
AccountNoticeAccountExists => ("An account with that name already exists.", "同名账户已经存在。"),
AccountNoticeStoreFailed => ("Could not save the account store. Check the disk and try again.", "账户库写不进去:检查磁盘后重试。"),
AccountNoticeNotFound => ("That account no longer exists.", "这个账户已经不在了。"),
AccountNoticeOther => ("The account action failed. The full reason is in the launcher log.", "账户操作失败,完整原因在启动器日志里。"),
AccountNoticeDismiss => ("Dismiss", "关闭"),
AccountNoticeTitleFailed => ("Account action failed", "账户操作失败"),
AccountNoticeTitleDone => ("Account updated", "账户已更新"),
// ── 重新登录的引导
AccountHintAuthlibReauth => ("Enter the password again for {server}.", "请输入 {server} 的密码。"),
AccountHintOfflineNoRelogin => ("Local profiles have no server account to sign in again — create a new profile instead.", "本地档案没有可重新登录的服务器账户,请新建一个档案。"),
} }
};
}
pub(crate) use account_entries;
+66
View File
@@ -0,0 +1,66 @@
//! AI 助手栏 / AI 独立页的文案(M104 `AGENTRAIL`)。
//!
//! 这两处**共用同一套会话视图**(`crates/app-iced/src/pages/agent/`),所以文案也只在这里出现一次:
//! 栏上的折叠/拆出、页上的收回、两处都要的发送/中断/空会话引导。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节:本宏把条目**追加**到 `$acc` 之后,
//! 再把「还没装配的域」原样交给 `$next`。条目顺序 = `Key` 的声明顺序(金表 `catalog::golden` 守门)。
//!
//! **新增 key 时按顺序追加在最后**:金表是「拆分前那份表的逐字快照」,重排既有行会让它红。
macro_rules! agent_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// ── AI 助手(右栗 + 独立页共用)
AgentTitle => ("Assistant", "助手"),
// M119 r1 复审 P2-1:这条一度被改成中性的「会话」,理由是「跑什么后端由端点标签说」——
// 但端点标签在壳里根本不存在,于是默认配置下用户看到的是一个像是接上了模型的助手。
// 现在把诚实的话写回来,而且它是**常驻**的(标题下就一行,不点也能看到)。
AgentSubtitle => ("Simulated assistant · no real model is connected", "模拟助手 · 还没接真实模型"),
AgentCollapse => ("Collapse", "收起"),
AgentDetach => ("Pop out", "拆出"),
AgentDock => ("Dock back", "收回"),
AgentOpen => ("Open assistant", "打开助手"),
AgentStripLabel => ("AI", "助手"),
AgentSend => ("Send", "发送"),
AgentStop => ("Stop", "中断"),
// 输入框上再说一次:用户真要动手的位置就在这儿
AgentPlaceholder => ("Message the simulated assistant…", "给模拟助手发消息…"),
AgentEmptyTitle => ("Nothing in this session yet", "这个会话还没有内容"),
// 空会话是最容易被误读的一屏(没有人说过话,很容易以为是「连上了但还没问」)
AgentEmptyBody => ("Ask about instances, downloads or logs. This assistant is simulated: no real model is connected.", "可以问实例、下载或日志。当前是模拟助手,没有接真实模型。"),
AgentNotePending => ("Placeholder shell: your message is kept here, nothing was sent to a model.", "占位外壳:消息留在这里,没有发给任何模型。"),
AgentNoteStopped => ("Stopped by you.", "你中断了这次回复。"),
AgentUnread => ("{count} unread", "{count} 条未读"),
AgentResize => ("Drag to resize the assistant rail", "拖动调整助手栏宽度"),
AgentWidthLabel => ("Width {width}", "宽度 {width}"),
AgentRoleYou => ("You", "你"),
AgentRoleAssistant => ("Assistant", "助手"),
AgentRoleNotice => ("Notice", "提示"),
// ── M119:失败态与写操作确认(接线后才有;`AgentRail::error` / `rail.confirm`)
//
// 为什么必须成 key:任务要求「presenter 出错时 UI 显示**具名**错误」,
// 而 M106 立的规矩是界面文案一律走 catalog——没有键就只能硬编码中英。
AgentErrorTitle => ("Assistant error", "助手出错"),
AgentErrorUnavailable => ("Couldn't reach the assistant backend. Check the endpoint, then try again.", "连不上助手后端:检查端点后重试。"),
AgentErrorNotImplemented => ("This assistant request isn't wired up yet.", "这个助手请求还没接线。"),
AgentConfirmRequest => ("The assistant wants to change something: {summary}", "助手想改一项设置:{summary}"),
AgentConfirmAllow => ("Allow", "允许"),
AgentConfirmDeny => ("Deny", "拒绝"),
// 头部常驻小标记(P2-1):与副标题/占位符互为冗余,三个位置都能看到「这是模拟」
AgentSimulated => ("Simulated", "模拟"),
// ── M119 r2:三处文案的**真实态**配对(模拟态用上面那三条已有的 key)
//
// 复审 r2 的 P2-1:上一轮只有标题胶囊由 `simulated` 驱动,副标题/占位符/空态正文是
// 无条件取词,而那三条译文里写着「模拟 / 没接真实模型」——接上真 transport 之后
// 这三句会**反方向**地留着。现在配成对:`simulated == true` 走模拟态,
// `false` 走这三条中性话(`pages/agent/tests/disclaimer.rs` 钉住四处一起变)。
AgentSubtitleReal => ("Conversation", "会话"),
AgentPlaceholderReal => ("Message the assistant…", "给助手发消息…"),
AgentEmptyBodyReal => ("Ask about instances, downloads or logs.", "可以问实例、下载或日志。"),
} }
};
}
pub(crate) use agent_entries;
+269
View File
@@ -0,0 +1,269 @@
//! 内容库文案(M109 `CONTENTDL`)。
//!
//! 一句话一个 key:带变量的句子用 `{name}`,页面只做 [`crate::Catalog::render`],不拼片段。
//!
//! ## 三条口径
//!
//! 1. **只说能做的事**:整合包那条写的是「要等导入管线」,不是「稍后自动支持」;
//! 2. **站名也进词表**:`Modrinth` / `CurseForge` 的**中文译文必须与英文不同**
//! (`catalog/tests.rs` 的 `chinese_catalog_is_actually_chinese` 会红),所以中文写成「Modrinth 源」;
//! 3. **禁用态要能解释自己**:`ContentIncompatible*` / `ContentModpackNotReady` 就是禁用按钮旁边那几句话——
//! 没有它们,禁用就等于「点了没反应」。
//!
//! 域文件的形状见 `crate::catalog` 的「域装配」一节。
macro_rules! content_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// --- content library page (M109) ---
ContentTitle => ("Content", "内容库"),
ContentSubtitle => (
"Modpacks, mods, resource packs and shaders",
"整合包、Mod、资源包与光影"
),
ContentSectionVersions => ("Version management", "版本管理"),
ContentSectionContent => ("Content library", "内容库"),
ContentKindModpack => ("Modpacks", "整合包"),
ContentKindMod => ("Mods", "Mod"),
ContentKindResourcepack => ("Resource packs", "资源包"),
ContentKindShader => ("Shaders", "光影"),
ContentSourceModrinth => ("Modrinth", "Modrinth 源"),
ContentSourceCurseforge => ("CurseForge", "CurseForge 源"),
ContentSortRelevance => ("Relevance", "相关度"),
ContentSortDownloads => ("Downloads", "下载量"),
ContentSortNewest => ("Newest", "最新发布"),
ContentSortUpdated => ("Recently updated", "最近更新"),
ContentSearchPlaceholder => ("Search content…", "搜索内容…"),
ContentSearch => ("Search", "搜索"),
ContentFilterVersion => ("Game version", "游戏版本"),
ContentFilterLoader => ("Loader", "加载器"),
ContentFilterAny => ("Any", "不限"),
ContentCount => ("{count} results", "{count} 个结果"),
ContentLoading => ("Loading content…", "正在加载内容…"),
ContentLoadingHint => (
"Results come from the network; nothing is cached offline yet.",
"结果来自网络,暂时没有离线缓存。"
),
ContentEmptyTitle => ("Nothing matched", "没有匹配的内容"),
ContentEmptyBody => (
"Try another keyword, or clear a filter.",
"换个关键词,或清掉一个筛选条件。"
),
ContentFailedTitle => ("Could not load content", "内容加载失败"),
ContentFailedBody => ("Check the network, then retry.", "检查网络后重试。"),
ContentRetry => ("Retry", "重试"),
ContentLoadMore => ("Load more", "加载更多"),
ContentNoMore => ("No more results", "没有更多了"),
ContentDetailEmpty => (
"Pick an item to see what it supports.",
"选一个项目看它支持什么。"
),
ContentDetailDownloads => ("{count} downloads", "下载量 {count}"),
ContentDetailUpdated => ("Updated {date}", "更新于 {date}"),
ContentDetailLoaders => ("Loaders: {list}", "加载器:{list}"),
ContentDetailVersions => ("Game versions: {list}", "游戏版本:{list}"),
ContentInstall => ("Install into instance", "装入实例"),
ContentTargetInstance => ("Target instance", "目标实例"),
ContentNoInstances => ("No instance to install into", "没有可装入的实例"),
ContentIncompatibleVersion => (
"Needs {version}; this instance runs {actual}",
"需要 {version},该实例运行的是 {actual}"
),
ContentIncompatibleLoader => ("Not built for {loader}", "不支持 {loader}"),
ContentModpackNotReady => (
"Installing modpacks from the library into an instance is not available yet",
"从内容库一键安装整合包到实例还没做(本地的包可以直接导入)"
),
ContentSourceUnavailable => (
"CurseForge needs an API key: add one in Settings",
"CurseForge 需要在设置里配置密钥才能搜索"
),
ContentSourceNotWired => (
"Installing from CurseForge is not available yet",
"CurseForge 来源的安装尚未开放"
),
// --- M113: pack import / export(PACKUI)---
PackTitle => ("Modpack import & export", "整合包导入 / 导出"),
PackImportTitle => ("Import a modpack", "导入整合包"),
PackDropHint => (
"Drop a .mrpack, .zip or a MultiMC/Prism instance folder here",
"把 .mrpack / .zip 或 MultiMC·Prism 实例目录拖到这里"
),
PackDropActive => ("Release to probe the pack", "松手开始探测"),
PackNoDialog => (
"This build has no system file picker: drag the file in, or paste its path below.",
"本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径。"
),
PackPathLabel => ("Pack path", "整合包路径"),
PackPathPlaceholder => (
"D:\\packs\\Better MC.mrpack",
"D:\\整合包\\更好的MC.mrpack"
),
PackImportAction => ("Import", "导入"),
PackCancel => ("Cancel import", "取消导入"),
PackDismiss => ("Dismiss", "关闭"),
PackTargetLabel => ("Install into", "装到实例"),
PackTargetNew => ("New instance", "新建实例"),
PackInstanceName => ("Instance name", "实例名"),
PackStageLine => ("Step {index}/{total} · {stage}", "步骤 {index}/{total} · {stage}"),
PackStageProbe => ("Reading the manifest", "读取清单"),
PackStageExtract => ("Unpacking overrides", "解包覆盖内容"),
PackStageFetch => ("Fetching missing files", "补下载文件"),
PackStageCommit => ("Writing files", "落地写盘"),
PackStageRegister => ("Registering the instance", "登记实例"),
PackProbeTitle => (
"This pack is almost a known format",
"这个包差一点就是已知格式"
),
PackProbeLooksLike => (
"Looks like {format}, but missing: {missing}",
"看起来像 {format},但缺:{missing}"
),
PackProbeUnknown => (
"No known format claims this pack",
"没有任何已知格式认领这个包"
),
PackProbeSaw => ("Found in the pack: {saw}", "包里看到:{saw}"),
PackProbeNothing => ("nothing recognisable", "没有可识别的东西"),
PackStatusSuccess => ("Imported", "已导入"),
PackStatusPartial => ("Imported with gaps", "导入完成,但有缺口"),
PackStatusFailed => ("Import failed — rolled back", "导入失败 —— 已整包回滚"),
PackReportWritten => ("Files written: {count} · {bytes}", "{count} 个文件已写盘 · {bytes}"),
PackReportMissing => (
"Could not fetch (tolerated): {count}",
"有 {count} 个文件没搞到(可容忍)"
),
PackReportConflicts => (
"Conflicts: {count}",
"冲突:{count} 个"
),
PackReportRejected => (
"The client does not support these: {count}",
"这些组件客户端不支持:{count} 个"
),
PackReportRejectedNote => ("not supported by the client", "客户端不支持"),
PackCancelled => (
"Import keeps running in the background — this result is discarded. Files already written stay.",
"导入会在后台继续跑完 —— 本次结果作废;已经写下的文件会留下。"
),
PackOverwriteAction => ("Re-import, the pack wins", "以包为准重新导入"),
PackOverwriteNote => (
"Keeping both (renamed) is not supported yet: the import pipeline has one global conflict policy and no per-file decision channel.",
"「两者都留(改名)」暂不支持:导入管线只有一套全局冲突策略,没有逐文件决策通道。"
),
PackMissingOffline => ("offline import", "离线导入"),
PackMissingNoSource => (
"no download source available yet",
"暂时拿不到下载地址"
),
PackMissingFetchFailed => ("download failed: {detail}", "下载失败:{detail}"),
PackFatalTitle => (
"Rolled back — nothing was left behind",
"已回滚 —— 目标目录零残留"
),
PackFatalHint => ("What to try next", "下一步可以试"),
PackConflictUserModified => ("you changed it", "你改过"),
PackConflictUserDeleted => ("you deleted it", "你删过"),
PackConflictPreExisting => ("was already there", "本来就在"),
PackChoiceKeepUser => ("kept yours", "保留我的"),
PackChoiceOverwrite => ("pack version used", "用包里的"),
PackConflictHashes => (
"pack {pack} · base {base} · disk {disk}",
"包内 {pack} · 基线 {base} · 磁盘 {disk}"
),
PackConflictDetail => ("Hashes", "三方哈希"),
PackFormatModrinth => ("Modrinth .mrpack", "Modrinth 的 .mrpack"),
PackFormatCurseforge => ("CurseForge manifest", "CurseForge 清单"),
PackFormatMultimc => ("MultiMC / Prism instance", "MultiMC·Prism 实例"),
PackExportTitle => ("Export this instance", "导出这个实例"),
PackExportOpen => ("Export…", "导出…"),
PackExportFormat => ("Format", "格式"),
PackExportName => ("Pack name", "包名"),
PackExportVersion => ("Version", "版本"),
PackExportOutput => ("Save to", "保存到"),
PackExportOutputPlaceholder => (
"empty = suggested path",
"留空 = 自动给一个路径"
),
PackExportPrecheck => ("Dry run", "预检"),
PackExportConfirm => ("Write the pack", "确认写盘"),
PackExportAbort => ("Back", "返回"),
PackExportMultimcNote => (
"MultiMC/Prism can only be imported, not exported (by design).",
"MultiMC·Prism 只能导入、不能导出(有意省略)。"
),
PackExportDone => ("Wrote {path} · {bytes}", "已写出 {path} · {bytes}"),
PackExportFailed => ("Export failed: {reason}", "导出失败:{reason}"),
PackExportPending => ("Scanning the instance…", "正在扫描实例…"),
PackPreviewHead => (
"Will inline {inline} files · cannot export: {skipped}",
"将内联 {inline} 个文件 · 无法导出 {skipped} 项"
),
PackPreviewRemote => (
"{count} files can be referenced remotely",
"{count} 个文件可走远程引用"
),
PackPreviewInlineBytes => (
"{bytes} copied into overrides/",
"{bytes} 会原样拷进 overrides/"
),
PackPreviewInlineTitle => (
"Copied into overrides/ (first {shown}):",
"原样拷进 overrides/(只列前 {shown} 个):"
),
PackPreviewSkippedTitle => (
"Not in the pack: {count}",
"没进包:{count} 项"
),
PackPreviewNotes => ("Known losses", "已知损失"),
PackPreviewTarget => ("Will be written to {path}", "将写到 {path}"),
PackPreviewMore => (
"…and {count} more files (not shown here)",
"…还有 {count} 个文件(这里不展开)"
),
PackPreviewComplete => ("Nothing is left out", "没有遗漏任何文件"),
PackPreviewIncomplete => (
"Some files will not be in the pack",
"有文件不会进包"
),
PackReasonModrinthSha1 => ("Modrinth match (SHA-1)", "Modrinth 命中(SHA-1)"),
PackReasonCurseFingerprint => (
"CurseForge match (fingerprint)",
"CurseForge 命中(文件指纹)"
),
PackReasonNoRemoteMatch => (
"unknown to both catalogs, inlined",
"两个目录都不认识,已内联"
),
PackReasonCurseOnly => (
"only CurseForge knows it, this format cannot use it, inlined",
"只有 CF 认识,目标格式用不上,已内联"
),
PackReasonModrinthOnly => (
"only Modrinth knows it, the CF manifest cannot use it, inlined",
"只有 Modrinth 认识,CF 清单用不上,已内联"
),
PackReasonMissingSha512 => (
"the catalogue entry has no sha512 hash, inlined",
"命中缺 sha512,已内联"
),
PackReasonMissingCurseIds => (
"the catalogue entry has no CurseForge ids, inlined",
"命中缺 projectID/fileID,已内联"
),
PackReasonDisabled => (
"disabled file, this format cannot express it (.disabled kept)",
"禁用文件,目标格式表达不了(保留 .disabled)"
),
PackReasonSymlink => ("symbolic link, skipped", "符号链接,已跳过"),
PackReasonUnsafePath => (
"path fails the pack path rules, skipped",
"路径过不了包内规整,已跳过"
),
PackReasonUnreadable => ("cannot read it, skipped", "读不出来,已跳过"),
} }
};
}
pub(crate) use content_entries;
+15
View File
@@ -0,0 +1,15 @@
//! core / presenter 只给了中文串、UI 又不许画原文时的兜底文案(口径见 [`crate::bridge`])。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
macro_rules! core_text_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// ── core / presenter 只给了中文串、UI 又不许画原文时的兜底(见 `bridge`)
CoreTextUntranslated => ("[system text not translated]", "[未翻译的系统文案]"),
} }
};
}
pub(crate) use core_text_entries;
+150
View File
@@ -0,0 +1,150 @@
//! 版本管理页文案(M94 建页时叫「下载页」,M108 起显示名改成「版本管理」)。
//!
//! 一句话一个 key:带变量的句子用 `{name}`,页面只做 [`crate::Catalog::render`],不拼片段
//! ([`crate::bridge`] 的规矩 1)。
//!
//! 文案口径(M94 r1 / P2-2):**只说这个页面真能做到的动作**(切分类、刷新、看日志),
//! 不写「稍后会自动重试」这类 UI 没有的能力,也不拿文案去补功能。
//!
//! **「下载」字样不许再指向这一页**(M108 用户令):这一页管的是「版本与加载器」,用户点它
//! 不是为了下载点什么。唯一的例外是 [`Key::DownloadStageDownloading`]——那是安装流程里**真在
//! 传文件**的那一步,说的不是页面身份而是当时的动作,留着才对(守门见 `pages/tests.rs` 的
//! `the_download_route_is_presented_as_version_management`,它把本域每条 key 的两种语言都扫一遍)。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
macro_rules! download_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// --- version management page (M94; renamed in M108) ---
DownloadTitle => ("Version management", "版本管理"),
// 例外说明(M108 复审 p3):副标题承诺「安装、升级、修复」,但页面目前只有
// 「选版本 → 安装」一条路,「修复」与「加载器选择」都还没有入口
// (`InstallView::of` 连 `InstallProgressView.loader` 都没读)。
// 这是用户令(2026-11-04)定的口径,能力由 M105 / M113 补齐;
// 补齐之前不许拿这句话当已实现功能宣传。
DownloadSubtitle => (
"Manage versions and loaders: install, upgrade, repair",
"管理版本与加载器:安装、升级、修复"
),
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", "关闭"),
// --- 安装选项(M105)---
DownloadOptionsTitle => ("Install options", "安装选项"),
DownloadOptionsLoaderVersion => ("Loader version", "加载器版本"),
DownloadOptionsLoaderVersionsLoading => (
"Fetching loader versions…",
"正在获取加载器版本…"
),
DownloadOptionsLoaderVersionsFailed => (
"Could not fetch loader versions: {reason}",
"取不到加载器版本:{reason}"
),
DownloadOptionsLoaderVersionsEmpty => (
"No loader builds published for {version}",
"官方没有为 {version} 发布加载器版本"
),
DownloadOptionsInstanceName => ("Instance name", "实例名"),
DownloadOptionsInstanceNameHint => (
"Empty = use the version id",
"留空就用版本号"
),
DownloadOptionsIncompatible => (
"{loader} has no builds for {version}",
"{loader} 没有适配 {version} 的构建"
),
DownloadOptionsNeedsAtLeast => (
"{loader} needs Minecraft {min} or newer",
"{loader} 需要 Minecraft {min} 或更新"
),
DownloadCancelled => (
"Install cancelled — finished files are kept, the instance was not created",
"安装已取消 —— 已下好的文件保留,实例未登记"
),
} }
};
}
pub(crate) use download_entries;
/// **M107 版本管理页**的侧栏 / 搜索文案。
///
/// 为什么单独一个宏、并**接在装配链最后**:`catalog/golden.rs` 是「`Key` 声明顺序 × 译文」的逐字节
/// 快照,M107 的纪律是**只追加不重排**。把新 key 插在 `download_entries` 中间会让 `Key::ALL`
/// 的顺序在 `DownloadDismiss` 与 `LogsSubtitleIdle` 之间断开,金表就得整体重排(那正是要避免的
/// 风险)。追加在链尾 ⇒ 新 key 落在 `Key::ALL` 末尾 ⇒ 金表末尾追加同样几行即可。
macro_rules! version_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// --- version management page (M107) ---
VersionRailTitle => ("Version type", "版本类型"),
VersionMajorTitle => ("Major version", "主版本"),
VersionMajorAll => ("All versions", "全部版本"),
VersionSearchPlaceholder => ("Search version numbers", "搜版本号"),
VersionSearchClear => ("Clear search", "清空搜索"),
VersionSearchHits => ("{count} matches", "{count} 个匹配"),
VersionSearchEmptyTitle => ("No matching versions", "没有匹配的版本"),
VersionSearchEmptyBody => (
"Try a shorter piece of the number — 1.21, 24w, a1.",
"试试更短的片段 — 1.21、24w、a1。"
),
VersionSearchElsewhere => (
"{count} versions match, but they are in another type.",
"有 {count} 个版本匹配,但它们在别的类型里。"
),
VersionSearchElsewhereAction => ("Show all types", "看全部类型"),
// --- M107 r2:复数口径与「在别处」的标题(追加在域尾 ⇒ `Key::ALL` 顺序只增不动) ---
VersionSearchHitsOne => ("1 match", "1 个匹配"),
VersionSearchElsewhereTitle => ("Found in another type", "在别的类型里找到了"),
VersionSearchElsewhereOne => (
"1 version matches, but it is in another type.",
"有 1 个版本匹配,但它在别的类型里。"
),
} }
};
}
pub(crate) use version_entries;
+24
View File
@@ -0,0 +1,24 @@
//! 中文字体文案(D28):中文环境缺字体时的下载提示 / 失败回退。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
macro_rules! font_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// ── 中文字体(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}"),
} }
};
}
pub(crate) use font_entries;
+60
View File
@@ -0,0 +1,60 @@
//! 设置页 General 分组**真控件**的文案(M106 `I18NFOLLOW`):语言选择器 + 其余三行。
//!
//! ## 与 `catalog/settings/general.rs` 的分工
//!
//! `catalog/settings/general.rs`(M99 建的)只有两条**标题**([`SettingsLanguage`] /
//! [`SettingsLanguageHint`]),是设置页五域装配的一部分;本文件是 M106 接真控件时新增的
//! **选择器文案**:三档选项名、选中后的「界面当前使用」回显,以及 General 分组里另外三行
//! (主题 / 启动页 / 关闭按钮)。
//!
//! 为什么要分两个文件:`catalog.rs` 的装配链是**追加**式的,新域挂在链尾就不会动到既有
//! `Key` 的顺序(= `catalog/golden.rs` 那张逐字节金表)。两个文件同属 General 分组,读代码时
//! 按「标题 vs 选项」区分即可;真要合并也是一个文件搬十行的活,不急着在接线的 mission 里做。
//!
//! ## 选项名为什么不是「语言自称」
//!
//! 常见做法是三档都写 `简体中文` / `English` 这样的自称。这里没有:`catalog/tests.rs` 有一条
//! `chinese_catalog_is_actually_chinese`(英文表与中文表逐 key 必须不同),自称会让「中文表里
//! 写着 English」被判成漏译。所以选项名**跟着界面语言走**:中文界面「跟随系统 / 简体中文 / 英文」,
//! 英文界面 "Follow system / Chinese (Simplified) / English"——这也是多数启动器的做法。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
macro_rules! general_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// ── General 分组(语言选择器 + 其余三行)
SettingsLanguageFollowSystem => ("Follow system", "跟随系统"),
SettingsLanguageZhCn => ("Chinese (Simplified)", "简体中文"),
SettingsLanguageEn => ("English", "英文"),
SettingsLanguageEffective => ("Interface right now: {language}", "界面当前使用:{language}"),
SettingsTheme => ("Theme", "主题"),
SettingsThemeFollowSystem => ("Follow system", "跟随系统"),
SettingsStartupPage => ("Startup page", "启动页"),
SettingsStartupInstances => ("Instances", "实例"),
SettingsCloseButton => ("Close button", "关闭按钮"),
SettingsCloseToTray => ("Exit to tray", "退出到托盘"),
// 设置页页头(M106):标题复用 `NavSettings`,这三条是副标题与两个动作。
SettingsSubtitle => ("Launcher behaviour, Java runtimes and download sources", "启动器行为、Java 运行时与下载源"),
SettingsResetSection => ("Reset section", "重置本组"),
SettingsSave => ("Save", "保存"),
// 第五条导航项(M106 给导航轨补的最后一条)。放在本域而不是 `catalog/nav.rs`:
// 金表按**位置**逐字节比,插进 nav 域会把既有 4 条导航行整体平移一位。
NavAi => ("Assistant", "助手"),
// 设置页左列的分组名(M106)。`Group.name` 是**id**(测试按它认组),显示名走这里。
SettingsGroupGeneral => ("General", "通用"),
SettingsGroupAppearance => ("Appearance", "外观"),
SettingsGroupJava => ("Java", "Java 运行时"),
SettingsGroupDownloads => ("Downloads", "下载"),
// 逐实例覆盖的空态(M106)
SettingsOverridesEmptyTitle => ("No per-instance overrides", "没有逐实例覆盖"),
SettingsOverridesEmptyBody => ("Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest of the launcher keeps the defaults above.", "逐实例覆盖让某个实例保留自己的 Java 运行时、内存或 JVM 参数,其余实例沿用上面的默认值。"),
SettingsOverridesAdd => ("Add override", "新增覆盖"),
// `Route::Agent` 的回退卡正文(M106:`app/view.rs` 里最后一条硬编码英文)
AgentFallbackBody => ("The assistant page is not built yet. Chat lives in the docked panel once the AI mission lands; this screen is the M68 skeleton fallback.", "助手页还没做。AI 接线落地后对话会出现在右侧停靠面板;这一屏是 M68 的骨架回退。"),
} }
};
}
pub(super) use general_entries;
+399
View File
@@ -0,0 +1,399 @@
//! 金表的**表数据**(`Key::ALL` 顺序 × (en, zh-CN) 的逐字快照)。「为什么有这张表、它抓得住
//! 什么」全在 `catalog.rs` 的 `mod golden;` 文档里——本文件只放数据,是因为每加一条 key 就多一行。
/// 拆分前 `Key::ALL` 的顺序 × (en, zh-CN)。第一列是变体名(`Key::name()` 的返回值)。
pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("NavInstances", "Instances", "实例"),
("NavDownload", "Versions", "版本管理"),
("NavDownloadExpanded", "Version management", "版本管理"),
("NavLogs", "Logs", "日志"),
("NavSettings", "Settings", "设置"),
("InstancesNew", "New instance", "新建实例"),
("InstancesImport", "Import", "导入"),
("InstancesBack", "Back to instances", "返回实例列表"),
("InstancesPlay", "Play", "启动"),
("InstancesEmptyTitle", "No instance selected", "未选择实例"),
("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", "游戏崩溃"),
("LaunchOutcomeSilent", "Suspected silent failure: exit code 0, but the game never really started", "疑似静默失败:退出码 0,但游戏没真正跑起来"),
("LaunchFailedWithCode", "Launch failed (exit {code}): {reason}", "启动失败(退出码 {code}):{reason}"),
("LaunchMsaNotConfigured", "Microsoft sign-in is not configured: it needs an Azure public client id (the login flow itself is implemented). Build with ULL_MSA_CLIENT_ID=<id>, or set that environment variable before launching.", "微软登录未配置:缺少 Azure 公共客户端 id(登录功能已实现,只差这一项配置)。构建时注入:ULL_MSA_CLIENT_ID=<id> cargo build;或启动前设置同名环境变量 ULL_MSA_CLIENT_ID=<id>。"),
("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.", "切换界面语言。显示中文需要中文字体。"),
("SettingsAppearanceCorner", "Window corners", "窗口圆角"),
("SettingsAppearanceCornerSystem", "Follow system", "跟随系统"),
("SettingsAppearanceCornerSquare", "Square", "直角"),
("SettingsAppearanceCornerHint", "On Windows 11 and Server 2025 we ask the system for rounded corners; Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply. We never draw corners ourselves.", "Windows 11 与 Windows Server 2025 上我们请求系统处理外框圆角;Windows 10 保持直角;macOS 与 Linux 交给桌面合成器自己的规则;我们不自己画圆角。"),
("SettingsAppearanceCornerSuspended", "Not in effect right now: window transparency is below 100%. Setting it back to 100% restores the rounded corners.", "当前因整窗透明度不生效:把整窗透明度调回 100% 圆角即恢复。"),
("CoreTextUntranslated", "[system text not translated]", "[未翻译的系统文案]"),
("DownloadTitle", "Version management", "版本管理"),
("DownloadSubtitle", "Manage versions and loaders: install, upgrade, repair", "管理版本与加载器:安装、升级、修复"),
("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…", "等第一个文件…"),
("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", "关闭"),
("DownloadOptionsTitle", "Install options", "安装选项"),
("DownloadOptionsLoaderVersion", "Loader version", "加载器版本"),
("DownloadOptionsLoaderVersionsLoading", "Fetching loader versions…", "正在获取加载器版本…"),
("DownloadOptionsLoaderVersionsFailed", "Could not fetch loader versions: {reason}", "取不到加载器版本:{reason}"),
("DownloadOptionsLoaderVersionsEmpty", "No loader builds published for {version}", "官方没有为 {version} 发布加载器版本"),
("DownloadOptionsInstanceName", "Instance name", "实例名"),
("DownloadOptionsInstanceNameHint", "Empty = use the version id", "留空就用版本号"),
("DownloadOptionsIncompatible", "{loader} has no builds for {version}", "{loader} 没有适配 {version} 的构建"),
("DownloadOptionsNeedsAtLeast", "{loader} needs Minecraft {min} or newer", "{loader} 需要 Minecraft {min} 或更新"),
("DownloadCancelled", "Install cancelled — finished files are kept, the instance was not created", "安装已取消 —— 已下好的文件保留,实例未登记"),
("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", "选择实例"),
("AgentTitle", "Assistant", "助手"),
("AgentSubtitle", "Simulated assistant · no real model is connected", "模拟助手 · 还没接真实模型"),
("AgentCollapse", "Collapse", "收起"),
("AgentDetach", "Pop out", "拆出"),
("AgentDock", "Dock back", "收回"),
("AgentOpen", "Open assistant", "打开助手"),
("AgentStripLabel", "AI", "助手"),
("AgentSend", "Send", "发送"),
("AgentStop", "Stop", "中断"),
("AgentPlaceholder", "Message the simulated assistant…", "给模拟助手发消息…"),
("AgentEmptyTitle", "Nothing in this session yet", "这个会话还没有内容"),
("AgentEmptyBody", "Ask about instances, downloads or logs. This assistant is simulated: no real model is connected.", "可以问实例、下载或日志。当前是模拟助手,没有接真实模型。"),
("AgentNotePending", "Placeholder shell: your message is kept here, nothing was sent to a model.", "占位外壳:消息留在这里,没有发给任何模型。"),
("AgentNoteStopped", "Stopped by you.", "你中断了这次回复。"),
("AgentUnread", "{count} unread", "{count} 条未读"),
("AgentResize", "Drag to resize the assistant rail", "拖动调整助手栏宽度"),
("AgentWidthLabel", "Width {width}", "宽度 {width}"),
("AgentRoleYou", "You", "你"),
("AgentRoleAssistant", "Assistant", "助手"),
("AgentRoleNotice", "Notice", "提示"),
("AgentErrorTitle", "Assistant error", "助手出错"),
("AgentErrorUnavailable", "Couldn't reach the assistant backend. Check the endpoint, then try again.", "连不上助手后端:检查端点后重试。"),
("AgentErrorNotImplemented", "This assistant request isn't wired up yet.", "这个助手请求还没接线。"),
("AgentConfirmRequest", "The assistant wants to change something: {summary}", "助手想改一项设置:{summary}"),
("AgentConfirmAllow", "Allow", "允许"),
("AgentConfirmDeny", "Deny", "拒绝"),
("AgentSimulated", "Simulated", "模拟"),
("AgentSubtitleReal", "Conversation", "会话"),
("AgentPlaceholderReal", "Message the assistant…", "给助手发消息…"),
("AgentEmptyBodyReal", "Ask about instances, downloads or logs.", "可以问实例、下载或日志。"),
// ── M106 `I18NFOLLOW`:General 分组真控件(语言选择器 + 其余三行)。**只追加在链尾**,
// 上一行到 `LogsEmptyAction` 的既有顺序一字未动(金表要证明的正是这件事)。
("SettingsLanguageFollowSystem", "Follow system", "跟随系统"),
("SettingsLanguageZhCn", "Chinese (Simplified)", "简体中文"),
("SettingsLanguageEn", "English", "英文"),
("SettingsLanguageEffective", "Interface right now: {language}", "界面当前使用:{language}"),
("SettingsTheme", "Theme", "主题"),
("SettingsThemeFollowSystem", "Follow system", "跟随系统"),
("SettingsStartupPage", "Startup page", "启动页"),
("SettingsStartupInstances", "Instances", "实例"),
("SettingsCloseButton", "Close button", "关闭按钮"),
("SettingsCloseToTray", "Exit to tray", "退出到托盘"),
("SettingsSubtitle", "Launcher behaviour, Java runtimes and download sources", "启动器行为、Java 运行时与下载源"),
("SettingsResetSection", "Reset section", "重置本组"),
("SettingsSave", "Save", "保存"),
("NavAi", "Assistant", "助手"),
("SettingsGroupGeneral", "General", "通用"),
("SettingsGroupAppearance", "Appearance", "外观"),
("SettingsGroupJava", "Java", "Java 运行时"),
("SettingsGroupDownloads", "Downloads", "下载"),
("SettingsOverridesEmptyTitle", "No per-instance overrides", "没有逐实例覆盖"),
("SettingsOverridesEmptyBody", "Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest of the launcher keeps the defaults above.", "逐实例覆盖让某个实例保留自己的 Java 运行时、内存或 JVM 参数,其余实例沿用上面的默认值。"),
("SettingsOverridesAdd", "Add override", "新增覆盖"),
("AgentFallbackBody", "The assistant page is not built yet. Chat lives in the docked panel once the AI mission lands; this screen is the M68 skeleton fallback.", "助手页还没做。AI 接线落地后对话会出现在右侧停靠面板;这一屏是 M68 的骨架回退。"),
// --- M107 版本管理页(追加在链尾:`Key::ALL` 顺序 = 金表顺序) ---
("VersionRailTitle", "Version type", "版本类型"),
("VersionMajorTitle", "Major version", "主版本"),
("VersionMajorAll", "All versions", "全部版本"),
("VersionSearchPlaceholder", "Search version numbers", "搜版本号"),
("VersionSearchClear", "Clear search", "清空搜索"),
("VersionSearchHits", "{count} matches", "{count} 个匹配"),
("VersionSearchEmptyTitle", "No matching versions", "没有匹配的版本"),
("VersionSearchEmptyBody", "Try a shorter piece of the number — 1.21, 24w, a1.", "试试更短的片段 — 1.21、24w、a1。"),
("VersionSearchElsewhere", "{count} versions match, but they are in another type.", "有 {count} 个版本匹配,但它们在别的类型里。"),
("VersionSearchElsewhereAction", "Show all types", "看全部类型"),
// --- M107 r2(追加在末尾:`Key::ALL` 顺序 = 金表顺序) ---
("VersionSearchHitsOne", "1 match", "1 个匹配"),
("VersionSearchElsewhereTitle", "Found in another type", "在别的类型里找到了"),
("VersionSearchElsewhereOne", "1 version matches, but it is in another type.", "有 1 个版本匹配,但它在别的类型里。"),
// --- M109 内容库页(同样追加在链尾:`Key::ALL` 顺序 = 金表顺序) ---
("ContentTitle", "Content", "内容库"),
("ContentSubtitle", "Modpacks, mods, resource packs and shaders", "整合包、Mod、资源包与光影"),
("ContentSectionVersions", "Version management", "版本管理"),
("ContentSectionContent", "Content library", "内容库"),
("ContentKindModpack", "Modpacks", "整合包"),
("ContentKindMod", "Mods", "Mod"),
("ContentKindResourcepack", "Resource packs", "资源包"),
("ContentKindShader", "Shaders", "光影"),
("ContentSourceModrinth", "Modrinth", "Modrinth 源"),
("ContentSourceCurseforge", "CurseForge", "CurseForge 源"),
("ContentSortRelevance", "Relevance", "相关度"),
("ContentSortDownloads", "Downloads", "下载量"),
("ContentSortNewest", "Newest", "最新发布"),
("ContentSortUpdated", "Recently updated", "最近更新"),
("ContentSearchPlaceholder", "Search content…", "搜索内容…"),
("ContentSearch", "Search", "搜索"),
("ContentFilterVersion", "Game version", "游戏版本"),
("ContentFilterLoader", "Loader", "加载器"),
("ContentFilterAny", "Any", "不限"),
("ContentCount", "{count} results", "{count} 个结果"),
("ContentLoading", "Loading content…", "正在加载内容…"),
("ContentLoadingHint", "Results come from the network; nothing is cached offline yet.", "结果来自网络,暂时没有离线缓存。"),
("ContentEmptyTitle", "Nothing matched", "没有匹配的内容"),
("ContentEmptyBody", "Try another keyword, or clear a filter.", "换个关键词,或清掉一个筛选条件。"),
("ContentFailedTitle", "Could not load content", "内容加载失败"),
("ContentFailedBody", "Check the network, then retry.", "检查网络后重试。"),
("ContentRetry", "Retry", "重试"),
("ContentLoadMore", "Load more", "加载更多"),
("ContentNoMore", "No more results", "没有更多了"),
("ContentDetailEmpty", "Pick an item to see what it supports.", "选一个项目看它支持什么。"),
("ContentDetailDownloads", "{count} downloads", "下载量 {count}"),
("ContentDetailUpdated", "Updated {date}", "更新于 {date}"),
("ContentDetailLoaders", "Loaders: {list}", "加载器:{list}"),
("ContentDetailVersions", "Game versions: {list}", "游戏版本:{list}"),
("ContentInstall", "Install into instance", "装入实例"),
("ContentTargetInstance", "Target instance", "目标实例"),
("ContentNoInstances", "No instance to install into", "没有可装入的实例"),
("ContentIncompatibleVersion", "Needs {version}; this instance runs {actual}", "需要 {version},该实例运行的是 {actual}"),
("ContentIncompatibleLoader", "Not built for {loader}", "不支持 {loader}"),
("ContentModpackNotReady", "Installing modpacks from the library into an instance is not available yet", "从内容库一键安装整合包到实例还没做(本地的包可以直接导入)"),
("ContentSourceUnavailable", "CurseForge needs an API key: add one in Settings", "CurseForge 需要在设置里配置密钥才能搜索"),
("ContentSourceNotWired", "Installing from CurseForge is not available yet", "CurseForge 来源的安装尚未开放"),
("PackTitle", "Modpack import & export", "整合包导入 / 导出"),
("PackImportTitle", "Import a modpack", "导入整合包"),
("PackDropHint", "Drop a .mrpack, .zip or a MultiMC/Prism instance folder here", "把 .mrpack / .zip 或 MultiMC·Prism 实例目录拖到这里"),
("PackDropActive", "Release to probe the pack", "松手开始探测"),
("PackNoDialog", "This build has no system file picker: drag the file in, or paste its path below.", "本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径。"),
("PackPathLabel", "Pack path", "整合包路径"),
("PackPathPlaceholder", "D:\\packs\\Better MC.mrpack", "D:\\整合包\\更好的MC.mrpack"),
("PackImportAction", "Import", "导入"),
("PackCancel", "Cancel import", "取消导入"),
("PackDismiss", "Dismiss", "关闭"),
("PackTargetLabel", "Install into", "装到实例"),
("PackTargetNew", "New instance", "新建实例"),
("PackInstanceName", "Instance name", "实例名"),
("PackStageLine", "Step {index}/{total} · {stage}", "步骤 {index}/{total} · {stage}"),
("PackStageProbe", "Reading the manifest", "读取清单"),
("PackStageExtract", "Unpacking overrides", "解包覆盖内容"),
("PackStageFetch", "Fetching missing files", "补下载文件"),
("PackStageCommit", "Writing files", "落地写盘"),
("PackStageRegister", "Registering the instance", "登记实例"),
("PackProbeTitle", "This pack is almost a known format", "这个包差一点就是已知格式"),
("PackProbeLooksLike", "Looks like {format}, but missing: {missing}", "看起来像 {format},但缺:{missing}"),
("PackProbeUnknown", "No known format claims this pack", "没有任何已知格式认领这个包"),
("PackProbeSaw", "Found in the pack: {saw}", "包里看到:{saw}"),
("PackProbeNothing", "nothing recognisable", "没有可识别的东西"),
("PackStatusSuccess", "Imported", "已导入"),
("PackStatusPartial", "Imported with gaps", "导入完成,但有缺口"),
("PackStatusFailed", "Import failed — rolled back", "导入失败 —— 已整包回滚"),
("PackReportWritten", "Files written: {count} · {bytes}", "{count} 个文件已写盘 · {bytes}"),
("PackReportMissing", "Could not fetch (tolerated): {count}", "有 {count} 个文件没搞到(可容忍)"),
("PackReportConflicts", "Conflicts: {count}", "冲突:{count} 个"),
("PackReportRejected", "The client does not support these: {count}", "这些组件客户端不支持:{count} 个"),
("PackReportRejectedNote", "not supported by the client", "客户端不支持"),
("PackCancelled", "Import keeps running in the background — this result is discarded. Files already written stay.", "导入会在后台继续跑完 —— 本次结果作废;已经写下的文件会留下。"),
("PackOverwriteAction", "Re-import, the pack wins", "以包为准重新导入"),
("PackOverwriteNote", "Keeping both (renamed) is not supported yet: the import pipeline has one global conflict policy and no per-file decision channel.", "「两者都留(改名)」暂不支持:导入管线只有一套全局冲突策略,没有逐文件决策通道。"),
("PackMissingOffline", "offline import", "离线导入"),
("PackMissingNoSource", "no download source available yet", "暂时拿不到下载地址"),
("PackMissingFetchFailed", "download failed: {detail}", "下载失败:{detail}"),
("PackFatalTitle", "Rolled back — nothing was left behind", "已回滚 —— 目标目录零残留"),
("PackFatalHint", "What to try next", "下一步可以试"),
("PackConflictUserModified", "you changed it", "你改过"),
("PackConflictUserDeleted", "you deleted it", "你删过"),
("PackConflictPreExisting", "was already there", "本来就在"),
("PackChoiceKeepUser", "kept yours", "保留我的"),
("PackChoiceOverwrite", "pack version used", "用包里的"),
("PackConflictHashes", "pack {pack} · base {base} · disk {disk}", "包内 {pack} · 基线 {base} · 磁盘 {disk}"),
("PackConflictDetail", "Hashes", "三方哈希"),
("PackFormatModrinth", "Modrinth .mrpack", "Modrinth 的 .mrpack"),
("PackFormatCurseforge", "CurseForge manifest", "CurseForge 清单"),
("PackFormatMultimc", "MultiMC / Prism instance", "MultiMC·Prism 实例"),
("PackExportTitle", "Export this instance", "导出这个实例"),
("PackExportOpen", "Export…", "导出…"),
("PackExportFormat", "Format", "格式"),
("PackExportName", "Pack name", "包名"),
("PackExportVersion", "Version", "版本"),
("PackExportOutput", "Save to", "保存到"),
("PackExportOutputPlaceholder", "empty = suggested path", "留空 = 自动给一个路径"),
("PackExportPrecheck", "Dry run", "预检"),
("PackExportConfirm", "Write the pack", "确认写盘"),
("PackExportAbort", "Back", "返回"),
("PackExportMultimcNote", "MultiMC/Prism can only be imported, not exported (by design).", "MultiMC·Prism 只能导入、不能导出(有意省略)。"),
("PackExportDone", "Wrote {path} · {bytes}", "已写出 {path} · {bytes}"),
("PackExportFailed", "Export failed: {reason}", "导出失败:{reason}"),
("PackExportPending", "Scanning the instance…", "正在扫描实例…"),
("PackPreviewHead", "Will inline {inline} files · cannot export: {skipped}", "将内联 {inline} 个文件 · 无法导出 {skipped} 项"),
("PackPreviewRemote", "{count} files can be referenced remotely", "{count} 个文件可走远程引用"),
("PackPreviewInlineBytes", "{bytes} copied into overrides/", "{bytes} 会原样拷进 overrides/"),
("PackPreviewInlineTitle", "Copied into overrides/ (first {shown}):", "原样拷进 overrides/(只列前 {shown} 个):"),
("PackPreviewSkippedTitle", "Not in the pack: {count}", "没进包:{count} 项"),
("PackPreviewNotes", "Known losses", "已知损失"),
("PackPreviewTarget", "Will be written to {path}", "将写到 {path}"),
("PackPreviewMore", "…and {count} more files (not shown here)", "…还有 {count} 个文件(这里不展开)"),
("PackPreviewComplete", "Nothing is left out", "没有遗漏任何文件"),
("PackPreviewIncomplete", "Some files will not be in the pack", "有文件不会进包"),
("PackReasonModrinthSha1", "Modrinth match (SHA-1)", "Modrinth 命中(SHA-1)"),
("PackReasonCurseFingerprint", "CurseForge match (fingerprint)", "CurseForge 命中(文件指纹)"),
("PackReasonNoRemoteMatch", "unknown to both catalogs, inlined", "两个目录都不认识,已内联"),
("PackReasonCurseOnly", "only CurseForge knows it, this format cannot use it, inlined", "只有 CF 认识,目标格式用不上,已内联"),
("PackReasonModrinthOnly", "only Modrinth knows it, the CF manifest cannot use it, inlined", "只有 Modrinth 认识,CF 清单用不上,已内联"),
("PackReasonMissingSha512", "the catalogue entry has no sha512 hash, inlined", "命中缺 sha512,已内联"),
("PackReasonMissingCurseIds", "the catalogue entry has no CurseForge ids, inlined", "命中缺 projectID/fileID,已内联"),
("PackReasonDisabled", "disabled file, this format cannot express it (.disabled kept)", "禁用文件,目标格式表达不了(保留 .disabled)"),
("PackReasonSymlink", "symbolic link, skipped", "符号链接,已跳过"),
("PackReasonUnsafePath", "path fails the pack path rules, skipped", "路径过不了包内规整,已跳过"),
("PackReasonUnreadable", "cannot read it, skipped", "读不出来,已跳过"),
// ── M114 账户页:追加在 main 的全部域之后(金表只追加不重排)
("AccountTitle", "Accounts", "账户"),
("AccountSubtitle", "Microsoft, third-party skin servers and local profiles", "微软正版、第三方皮肤站与本地档案"),
("AccountListTitle", "All accounts", "全部账户"),
("AccountAddedOn", "Added {date}", "添加于 {date}"),
("AccountActiveChip", "Active", "当前"),
("AccountSwitch", "Use for launch", "切换为启动账户"),
("AccountRelogin", "Sign in again", "重新登录"),
("AccountRemove", "Remove", "删除"),
("AccountKindMicrosoft", "Microsoft", "微软正版"),
("AccountKindAuthlib", "Third-party skin server", "第三方皮肤站"),
("AccountKindOffline", "Local profile", "本地档案"),
("AccountServer", "Server {server}", "服务器 {server}"),
("AccountEmptyTitle", "No accounts yet", "还没有账户"),
("AccountEmptyBody", "Add a Microsoft account, a third-party skin server account, or a local profile.", "添加微软正版账户、第三方皮肤站账户,或建一个本地档案。"),
("AccountEmptyAction", "Add an account", "添加账户"),
("AccountTrackMicrosoftTitle", "Microsoft account", "微软正版"),
("AccountTrackMicrosoftBody", "Sign in with a device code or in your browser. Online servers require this kind of account.", "用设备码或浏览器登录。联机服务器必须用这种账户。"),
("AccountTrackAuthlibTitle", "Third-party skin server", "第三方皮肤站"),
("AccountTrackAuthlibBody", "An authlib-injector compatible server (LittleSkin and friends) brings its own skins and capes.", "兼容 authlib-injector 的皮肤站(如 LittleSkin)自带皮肤与披风。"),
("AccountTrackOfflineTitle", "Local profile", "本地档案"),
("AccountTrackOfflineBody", "Local / isolated-network testing only — it cannot join servers that require a genuine Minecraft account.", "仅本地/隔离网络测试用,无法连接启用正版验证的服务器。"),
("AccountTrackOpen", "Sign in", "登录"),
("AccountTrackCreate", "Create profile", "创建档案"),
("AccountDialogClose", "Close", "关闭"),
("AccountOfflineNameLabel", "Profile name", "档案名"),
("AccountOfflineNameHint", "3–16 letters, digits or underscore", "3–16 位字母、数字或下划线"),
("AccountOfflineSubmit", "Create profile", "创建档案"),
("AccountOfflineWarning", "Local / isolated-network testing only — it cannot join servers that require a genuine Minecraft account.", "仅本地/隔离网络测试用,无法连接启用正版验证的服务器。"),
("AccountDeviceCodeLabel", "Enter this code", "输入这个用户码"),
("AccountDeviceCodeCopy", "Copy code", "复制用户码"),
("AccountDeviceLinkCopy", "Copy link", "复制链接"),
("AccountDeviceCountdown", "{time} left", "剩 {time}"),
("AccountDeviceExpired", "This code has expired. Start again for a fresh one.", "这个用户码已过期,重新开始换一个新的。"),
("AccountDeviceRetry", "Try again", "重试"),
("AccountDeviceCancel", "Cancel", "取消"),
("AccountDeviceBrowser", "Sign in with browser", "用浏览器登录"),
("AccountDeviceUseCode", "Use a device code instead", "改用设备码"),
("AccountServerLabel", "Server address", "服务器地址"),
("AccountServerHint", "API root, e.g. https://littleskin.cn/api/yggdrasil/", "API 根,例:https://littleskin.cn/api/yggdrasil/"),
("AccountServerProbe", "Check server", "校验服务器"),
("AccountLoginNameLabel", "Email or username", "邮箱或用户名"),
("AccountPasswordLabel", "Password", "密码"),
("AccountPasswordHint", "Used once for this sign-in. It is never stored.", "只在这次登录里用一次,不会保存。"),
("AccountAuthlibSubmit", "Sign in", "登录"),
("AccountAuthlibIdleHint", "The server is checked automatically when you sign in; Check server only does it up front.", "登录时会自动校验服务器;「校验服务器」只是提前跑一遍。"),
("AccountSkinTitle", "Skin", "皮肤"),
("AccountSkinNone", "No skin available", "没有可用皮肤"),
("AccountSkinHint", "Skins come from the account's own server; nothing is generated locally.", "皮肤来自账户自己的服务器,本地不生成任何假皮肤。"),
("AccountRailSignedOut", "Not signed in", "未登录"),
("AccountDeviceHint", "Open the link in your browser and enter the code above.", "在浏览器里打开链接,输入上面的用户码。"),
("AccountDeviceBrowserHint", "Finish the sign-in in the browser window we just opened; the link below also works.", "在我们刚打开的浏览器窗口里完成登录;下面的链接也能手动打开。"),
("AccountServerChecking", "Checking the server…", "正在校验服务器…"),
("AccountServerOk", "Server {server} is ready", "服务器 {server} 可用"),
("AccountLoginFailed", "Sign-in failed", "登录失败"),
("AccountCurrentTitle", "Current account", "当前账户"),
("AccountFailureCode", "Code: {code}", "代码:{code}"),
("AccountFailureDenied", "The server refused this sign-in (access denied).", "服务器拒绝了这次登录(拒绝访问)。"),
("AccountFailureWhitelist", "Microsoft has not approved this launcher's app registration yet. An administrator has to accept the app, then try again.", "微软还没批准本启动器的应用注册:需要管理员同意该应用后再试。"),
("AccountFailureUnreachable", "Could not reach the server. Check the network connection or the address, then try again.", "连不上服务器:检查网络或地址后重试。"),
("AccountFailureNotAuthlib", "This address does not look like an authlib-injector server (the API root usually ends with /api/yggdrasil/).", "这个地址不像 authlib-injector 服务器(API 根通常以 /api/yggdrasil/ 结尾)。"),
("AccountFailureBadCredentials", "The server rejected the username or password.", "服务器不认这个用户名或密码。"),
("AccountFailureNoProfile", "The account has no Minecraft profile on this server yet. Create the character on the server first.", "这个账号在该服务器上还没有角色:先在皮肤站里创建角色。"),
("AccountFailureStore", "Could not save the account to disk.", "账户没能写到磁盘上。"),
("AccountFailureOther", "The sign-in failed. The full reason is in the launcher log.", "登录失败,完整原因在启动器日志里。"),
("AccountDeviceRequesting", "Contacting Microsoft…", "正在联系微软…"),
("AccountLoginSucceeded", "Signed in as {name}", "已登录 {name}"),
("AccountNoticeAdded", "Added {name}", "已添加 {name}"),
("AccountNoticeRemoved", "Removed {name}", "已删除 {name}"),
("AccountNoticeSwitched", "{name} will be used for the next launch", "{name} 将作为下次启动的账户"),
("AccountNoticeOfflineNameInvalid", "Profile names must be 3–16 letters, digits or underscores.", "档案名必须是 3–16 位字母、数字或下划线。"),
("AccountNoticeAccountExists", "An account with that name already exists.", "同名账户已经存在。"),
("AccountNoticeStoreFailed", "Could not save the account store. Check the disk and try again.", "账户库写不进去:检查磁盘后重试。"),
("AccountNoticeNotFound", "That account no longer exists.", "这个账户已经不在了。"),
("AccountNoticeOther", "The account action failed. The full reason is in the launcher log.", "账户操作失败,完整原因在启动器日志里。"),
("AccountNoticeDismiss", "Dismiss", "关闭"),
("AccountNoticeTitleFailed", "Account action failed", "账户操作失败"),
("AccountNoticeTitleDone", "Account updated", "账户已更新"),
("AccountHintAuthlibReauth", "Enter the password again for {server}.", "请输入 {server} 的密码。"),
("AccountHintOfflineNoRelogin", "Local profiles have no server account to sign in again — create a new profile instead.", "本地档案没有可重新登录的服务器账户,请新建一个档案。"),
];
+85
View File
@@ -0,0 +1,85 @@
//! 实例页文案:页头、列表列头、详情动作、元信息卡的标签与值、运行状态与启动阶段。
//!
//! 域文件的形状见 [`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", "启动"),
InstancesEmptyTitle => ("No instance selected", "未选择实例"),
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", "等待游戏窗口"),
} }
};
}
pub(crate) use instances_entries;
+59
View File
@@ -0,0 +1,59 @@
//! 启动结局文案:一句话 = 一个 key(原来 UI 把 `"Launch failed"` 与 core 的「静默失败」拼成一句话)。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
macro_rules! launch_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// ── 启动结局(原来 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}"),
// M117:微软轨的 client id 没配是**配置缺失**,不是「本期未支持」。这句话是那条
// 启动失败文案的**双语口径**(真源在 `core::auth::microsoft` 的
// `MISSING_CLIENT_ID_MESSAGE`,中文逐字一致;英文这条给 En 界面用)。
// 判据:中英都点名 `ULL_MSA_CLIENT_ID` 并给出构建期注入这条路,且**不许**出现
// 「unsupported / 未支持」——用户实跑就是被那句误导读成「功能没做」。
LaunchMsaNotConfigured => (
"Microsoft sign-in is not configured: it needs an Azure public client id (the login flow itself is implemented). Build with ULL_MSA_CLIENT_ID=<id>, or set that environment variable before launching.",
"微软登录未配置:缺少 Azure 公共客户端 id(登录功能已实现,只差这一项配置)。构建时注入:ULL_MSA_CLIENT_ID=<id> cargo build;或启动前设置同名环境变量 ULL_MSA_CLIENT_ID=<id>。"
),
} }
};
}
pub(crate) use launch_entries;
#[cfg(test)]
mod tests {
use crate::{Catalog, Key, Lang};
#[test]
fn msa_not_configured_copy_is_accurate_in_both_languages() {
// 抓得住:文案退回「本期未支持」这类说法(M117 用户实跑踩到的原 bug),
// 以及两条译文只剩一句「需要 id」却不给怎么配。
// 抓不住:读起来顺不顺——那要靠 review 与截图。
let en = Catalog::of(Lang::En).text(Key::LaunchMsaNotConfigured);
let zh = Catalog::of(Lang::ZhCn).text(Key::LaunchMsaNotConfigured);
for (tag, text) in [("en", en), ("zh-CN", zh)] {
assert!(text.contains("ULL_MSA_CLIENT_ID"), "{tag}: {text}");
}
assert!(en.contains("client id"), "{en}");
assert!(en.contains("not configured"), "{en}");
assert!(!en.to_lowercase().contains("unsupported"), "{en}");
assert!(!en.to_lowercase().contains("not implemented"), "{en}");
assert!(zh.contains("客户端 id"), "{zh}");
assert!(zh.contains("未配置"), "{zh}");
assert!(!zh.contains("未支持"), "{zh}");
assert!(!zh.contains("未实现"), "{zh}");
// 硬约束:词条里只许出现 `<id>` 占位,不出现真 id(repo 任何文件都不许带)。
assert!(en.contains("<id>") && zh.contains("<id>"));
}
}
+15
View File
@@ -0,0 +1,15 @@
//! 加载器文案:品牌名不翻译(Fabric / Forge / Quilt 一律原样),只有「原版」是一个词。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
macro_rules! loader_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// ── 加载器(品牌名不翻译:Fabric / Forge / Quilt 一律原样,只有「原版」是词)
LoaderVanilla => ("Vanilla", "原版"),
} }
};
}
pub(crate) use loader_entries;
+40
View File
@@ -0,0 +1,40 @@
//! 日志页文案(M95)。
//!
//! 日志页的**界面文案**(标题 / 按钮 / 分组名 / 空态)。诊断结论的正文不在这里:
//! 那是规则表 `rules.d/*.yaml` 的输出(数据),见 `pages/logs/diagnostic.rs` 的模块文档。
//!
//! `LogsStatus` 是**模板**:中英差别落在分隔符上(中文用全角竖线——`·` 是外来符号,
//! 中文正文里偏挤;这也让「中文目录必须与英文不同」这条守卫成立,不是为过测试硬造差异)。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
macro_rules! logs_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// --- logs page (M95) ---
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", "选择实例"),
} }
};
}
pub(crate) use logs_entries;
+42
View File
@@ -0,0 +1,42 @@
//! 导航文案:四个一级页。AI 独立页没有导航高亮项(`Route::Agent.nav()` 是 `None`),所以没有 key。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节:本宏把条目**追加**到 `$acc` 之后,
//! 再把「还没装配的域」原样交给 `$next`。条目顺序 = `Key` 的声明顺序(金表 `catalog::golden` 守门)。
//!
//! **显示名与标识符不同名**(M108):`NavDownload` 这条说的是「版本管理」,而它指向的仍是
//! `Route::Download` / `NavSection::Download`。用户令(2026-11-04)只改**显示层**:全仓重命名
//! 标识符会与在途的多条线全面撞车,收益只在观感,所以标识符改名后置。
//!
//! ## 为什么「版本管理」有两条 key(M108)
//!
//! D33 定死了导航两态:**88 收起轨 ⇄ 240 展开抽屉**。88px 里塞得下中文「版本管理」(4 字 ≈48px),
//! 但塞不下英文 `Version management`(18 字符 ≈101px > 88)——那会变成截断或两行,两个都是视觉缺陷
//! (两行还要把项高从 52 抬到 68,等于为一个词改全局导航节奏)。
//!
//! 所以按**容器宽度**分两条:
//!
//! | key | 用在哪 | en | zh-CN |
//! |---|---|---|---|
//! | [`NavDownload`] | 88 收起轨(`shell/nav.rs` / `pages/nav.rs`) | `Versions` | 版本管理 |
//! | [`NavDownloadExpanded`] | 240 展开抽屉(D33 的展开态) | `Version management` | 版本管理 |
//!
//! 中文两条**故意同值**:48px 放得下就没有理由为「对称」砍短(那是视觉缺陷,不是风格)。
//! 英文短名选 `Versions` 而不是 `Versions list` / `Downloads`:单一词最省宽度,且不与「下载」
//! (真在传文件的动作、设置页的下载分组)混淆。切换点只有 [`crate::catalog`] 的调用方一处,
//! 见 `pages/nav.rs` 的 `label_key`。
macro_rules! nav_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// ── 导航轨(`pages::Route` 的四项)
NavInstances => ("Instances", "实例"),
NavDownload => ("Versions", "版本管理"),
NavDownloadExpanded => ("Version management", "版本管理"),
NavLogs => ("Logs", "日志"),
NavSettings => ("Settings", "设置"),
} }
};
}
pub(crate) use nav_entries;
+13
View File
@@ -0,0 +1,13 @@
//! 设置页「AI / About」分组文案:**预留**(页面上还没有这个分组,见 `pages/settings/mod.rs` 的
//! 分组注册表)。
//!
//! 加 key 时把条目写进 `$($acc)*` 后面即可(形状见 [`crate::catalog`] 的「域装配」一节)。
//! 现在没有条目,所以本宏是**空转移器**:只把累积表原样交给下一个域。
macro_rules! settings_ai_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* { $($acc)* } }
};
}
pub(crate) use settings_ai_entries;
@@ -0,0 +1,35 @@
//! 设置页「Appearance」分组文案(M116 首次落地:窗口圆角一档)。
//!
//! 加 key 时把条目写进 `$($acc)*` 后面即可(形状见 [`crate::catalog`] 的「域装配」一节)。
//!
//! ## 为什么只有圆角这一组的 key
//!
//! 本分组的既有控件(整窗/面板透明度、背景图、配色来源……)是 M93 落的,文案是英文基线字面量
//! (`pages/appearance/section.rs`)。M116 新增的圆角档位**从一开始就走词表**(tower 口径:
//! 设置项文案不许硬编码),所以这里先只有它这一组;老控件接词表归 i18n 接线 mission。
//!
//! ## 文案口径(不许暗示所有平台一致)
//!
//! 用户定案:Win11 / Server 2025 开系统圆角,Win10 可以不开,其他系统按窗口合成器的默认规则。
//! 所以说明句必须写清「谁负责画圆」而不是「我们支持圆角」——后半句在 Win10 上是假的。
macro_rules! settings_appearance_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
SettingsAppearanceCorner => ("Window corners", "窗口圆角"),
SettingsAppearanceCornerSystem => ("Follow system", "跟随系统"),
SettingsAppearanceCornerSquare => ("Square", "直角"),
SettingsAppearanceCornerHint => (
"On Windows 11 and Server 2025 we ask the system for rounded corners; Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply. We never draw corners ourselves.",
"Windows 11 与 Windows Server 2025 上我们请求系统处理外框圆角;Windows 10 保持直角;macOS 与 Linux 交给桌面合成器自己的规则;我们不自己画圆角。"
),
SettingsAppearanceCornerSuspended => (
"Not in effect right now: window transparency is below 100%. Setting it back to 100% restores the rounded corners.",
"当前因整窗透明度不生效:把整窗透明度调回 100% 圆角即恢复。"
),
} }
};
}
pub(crate) use settings_appearance_entries;
@@ -0,0 +1,12 @@
//! 设置页「Downloads」分组文案:**预留**(页面侧的真控件还没接)。
//!
//! 加 key 时把条目写进 `$($acc)*` 后面即可(形状见 [`crate::catalog`] 的「域装配」一节)。
//! 现在没有条目,所以本宏是**空转移器**:只把累积表原样交给下一个域。
macro_rules! settings_download_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* { $($acc)* } }
};
}
pub(crate) use settings_download_entries;
@@ -0,0 +1,19 @@
//! 设置页「General」分组文案:语言(Theme / Startup page / Close button 的 key 还没建)。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
macro_rules! settings_general_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// ── 设置
SettingsLanguage => ("Language", "界面语言"),
SettingsLanguageHint => (
"Switches the interface language. Chinese text needs a Chinese font.",
"切换界面语言。显示中文需要中文字体。"
),
} }
};
}
pub(crate) use settings_general_entries;
+12
View File
@@ -0,0 +1,12 @@
//! 设置页「Java」分组文案:**预留**(页面侧的真控件还没接)。
//!
//! 加 key 时把条目写进 `$($acc)*` 后面即可(形状见 [`crate::catalog`] 的「域装配」一节)。
//! 现在没有条目,所以本宏是**空转移器**:只把累积表原样交给下一个域。
macro_rules! settings_java_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* { $($acc)* } }
};
}
pub(crate) use settings_java_entries;
+28
View File
@@ -0,0 +1,28 @@
//! 设置页文案:**一个分组一个文件**,与页面侧的 `pages/settings/**` 同构。
//!
//! 为什么拆到这个粒度:设置页正文接下来由四条并行 mission 分别接(java / download /
//! language+appearance / ai+about),它们都会往设置页加 key。一个分组一个文件之后,每条线只改
//! 自己那个文件,`catalog.rs` 的装配链一行都不用动(链里已经把这五个域接好了)。
//!
//! | 文件 | 分组(`pages/settings` 的 `GROUPS`) | 现状 |
//! |---|---|---|
//! | [`general`] | General(Language / Theme / Startup page / Close button) | 2 条 key(语言) |
//! | [`appearance`] | Appearance(Density / Corner style / Motion / Text scale) | 预留,暂无 key |
//! | [`java`] | Java(Runtime / Default memory / JVM arguments) | 预留,暂无 key |
//! | [`download`] | Downloads(Preferred source / Concurrency / Verify checksums) | 预留,暂无 key |
//! | [`ai`] | AI / About(页面上还没有这个分组) | 预留,暂无 key |
//!
//! 「预留」的文件里是**空转移器**(只把累积表原样交给下一个域):加 key 时把条目写进它
//! `$($acc)*` 后面即可——签名不用改,`catalog.rs` 不用碰,跨线零冲突。
mod ai;
mod appearance;
mod download;
mod general;
mod java;
pub(crate) use ai::settings_ai_entries;
pub(crate) use appearance::settings_appearance_entries;
pub(crate) use download::settings_download_entries;
pub(crate) use general::settings_general_entries;
pub(crate) use java::settings_java_entries;
+203
View File
@@ -0,0 +1,203 @@
//! `Catalog` 的守门测试(从 `catalog.rs` 原样搬出来:那边已经贴着 400 行硬上限,
//! 而页面每加一批 key 都要在这个模块里占一行)。
//!
//! **零改动搬运**:用例、注释、断言一字未动;`use super::*` 指向的仍是 `crate::catalog`。
//! M94(下载页)与 M95(日志页)几乎同时撞到这堵墙、各自搬了一次:两条 branch 的**用例部分
//! 逐字相同**(解冲突时按 M94 那份合并,测试数量 7 条不变)。
use super::*;
/// 手写一套**故意缺条**的目录(不经过 `catalog!`,模拟后续 mission 的注入表)。
const PARTIAL_ZH: &[(Key, &str)] = &[
(Key::NavInstances, "实例"),
(Key::NavLogs, "日志"),
(Key::NavSettings, "设置"),
(Key::NavDownload, "下载"),
(Key::InstancesNew, "新建实例"),
(Key::InstancesImport, "导入"),
(Key::InstancesBack, "返回实例列表"),
(Key::InstancesPlay, "启动"),
(Key::InstancesEmptyTitle, "未选择实例"),
(
Key::InstancesEmptyBody,
"在左侧选择一个实例,查看它的版本、加载器与启动选项。",
),
(Key::LoaderVanilla, "原版"),
(Key::LaunchOutcomeNormal, "正常结束"),
(Key::LaunchOutcomeCrash, "游戏崩溃"),
// 缺:LaunchOutcomeSilent / LaunchFailedWithCode / FontCjk* / Settings* / CoreTextUntranslated
];
#[test]
fn built_in_catalogs_have_every_key() {
// 抓得住:`catalog!` 的成对生成被绕开(有人改回「两套各写一遍」并漏了一条 / 抄重了一条)。
// 判据是**集合相等**:`Key` 枚举拦住了「表里有、枚举没有」,这里拦反方向。
// 抓不住:译文对不对(这只能是人工 / 后续的语言审查)。
for lang in Lang::ALL {
let catalog = Catalog::of(*lang);
assert_eq!(catalog.missing(), Vec::new(), "{}", catalog.lang().tag());
assert_eq!(catalog.diagnose(), None);
let mut keys = catalog
.entries()
.iter()
.map(|(key, _)| *key)
.collect::<Vec<_>>();
let total = keys.len();
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), total, "{} 目录里有重复条目", lang.tag());
assert_eq!(
keys.len(),
Key::ALL.len(),
"{} 条目数与 key 数不等",
lang.tag()
);
for key in Key::ALL {
assert!(
catalog.get(*key).is_some(),
"{} 缺 {}",
lang.tag(),
key.name()
);
}
}
assert!(!Key::ALL.is_empty());
}
#[test]
fn key_names_are_unique_and_stable() {
// 抓得住:同一个 key 被写成两个变体(名字重复会让日志/诊断指认失准)。
// 抓不住:重命名变体对下游源码的影响(那是编译错误,本来就跑不过)。
let mut names = Key::ALL.iter().map(|key| key.name()).collect::<Vec<_>>();
let total = names.len();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), total);
assert_eq!(Key::NavInstances.name(), "NavInstances");
}
#[test]
fn chinese_catalog_is_actually_chinese() {
// 抓得住:整句漏译(英文被抄进中文表);一条都没有说明这张表是真翻过的。
// 抓不住:语义翻错、语气不合(机器能查的只有「与英文不同」这一层)。
let en = Catalog::of(Lang::En);
let zh = Catalog::of(Lang::ZhCn);
for key in Key::ALL {
let english = en.text(*key);
assert_ne!(english, zh.text(*key), "{} 未翻译", key.name());
assert!(!english.is_empty() && !zh.text(*key).is_empty());
}
}
#[test]
fn placeholder_sets_match_across_languages() {
// 抓得住:一处文案的 `{code}` / `{when}` 在另一套译文里丢了或写错名——
// 那会让 render() 输出半截句子,或让参数静默失效。
// 抓不住:占位符本身是不是正确的变量(那是调用方的职责,由页面侧测试守)。
let en = Catalog::of(Lang::En);
let zh = Catalog::of(Lang::ZhCn);
for key in Key::ALL {
let mut left = placeholders(en.text(*key));
let mut right = placeholders(zh.text(*key));
left.sort_unstable();
right.sort_unstable();
assert_eq!(left, right, "{}", key.name());
}
assert_eq!(placeholders("exit {code} at {when}"), vec!["code", "when"]);
assert_eq!(placeholders("没有占位符"), Vec::<&str>::new());
}
#[test]
fn missing_rows_are_diagnosed_in_plain_words() {
// 抓得住:缺译诊断的**可读性**(必须点名语言、key 名与英文原文)——注入目录缺条时靠它定位。
// 抓不住:内置目录真的缺译(内置表由 catalog! 结构保证,见上一条测试)。
let partial = Catalog::from_entries(Lang::ZhCn, PARTIAL_ZH);
assert_eq!(partial.missing().len(), Key::ALL.len() - PARTIAL_ZH.len());
let report = partial.diagnose().expect("应当报缺译");
assert!(report.starts_with("zh-CN 缺 "), "{report}");
assert!(report.contains("LaunchOutcomeSilent"), "{report}");
assert!(
report.contains("Suspected silent failure"),
"诊断要带上英文原文当参考:{report}"
);
assert!(
!report.contains("NavInstances"),
"已译的 key 不该出现在报告里:{report}"
);
}
#[test]
fn missing_translation_never_falls_back_silently() {
// 抓得住:缺译被静默顶替(回落英文 / 回落中文 / 返回空串)——那会产出「半句中文半句英文」的界面。
// 抓不住:MISSING 标记真的被画到屏幕上(那是 UI 侧截图验收的事)。
let partial = Catalog::from_entries(Lang::ZhCn, PARTIAL_ZH);
assert_eq!(partial.get(Key::LaunchOutcomeSilent), None);
assert_eq!(partial.text(Key::LaunchOutcomeSilent), MISSING);
assert_eq!(
partial.render(
Key::LaunchFailedWithCode,
&[("code", "0"), ("reason", "JVM 拒绝启动")]
),
"⟪missing:LaunchFailedWithCode⟫"
);
}
#[test]
fn render_substitutes_and_keeps_unfilled_placeholders_visible() {
// 抓得住:占位符替换的回归(漏参时**保留** `{code}` 而不是清空、不 panic)。
// 抓不住:调用方传没传对值(`("code", "0")` 是不是真的退出码)。
let zh = Catalog::of(Lang::ZhCn);
assert_eq!(
zh.render(
Key::LaunchFailedWithCode,
&[("code", "0"), ("reason", "mod 冲突")]
),
"启动失败(退出码 0):mod 冲突"
);
assert_eq!(
zh.render(Key::LaunchFailedWithCode, &[("code", "0")]),
"启动失败(退出码 0):{reason}"
);
assert_eq!(zh.text(Key::InstancesNew), "新建实例");
assert_eq!(zh.lang(), Lang::ZhCn);
}
/// M99:把单张条目表按域拆到 `catalog/<域>.rs` 之后,`key -> (en, zh-CN)` 必须**逐条逐字节**不变。
///
/// **抓得住**:① 某个 key 整条被搬丢(金表有、现在没有 ⇒ 条数或 key 名对不上);② 两条 key 的译文
/// 互抄 / 同 key 被覆盖成别人的译文(该行 en 或 zh 不等);③ 某条译文改了一个字符(逐字节比,
/// **不是**数条数——「A 丢了一条、B 复制成两条」条数照旧);④ 域的顺序被调换(`Key` 的 `Ord` 与
/// `entries()` 的声明顺序都靠它,金表按原顺序比)。
/// **抓不住**:译文本身翻得对不对——金表是「拆分前那一份」的快照,只能证明没变,不能证明翻得好。
///
/// 金表怎么来的:拆分前在这里临时加过一条 `println!("GOLD\t{}\t{}\t{}", …)` 的用例,把
/// `Key::ALL` ×(`Catalog::of(En)`, `Catalog::of(ZhCn)`)导出成 72 行 TSV,再原样抄成
/// `catalog/golden.rs`;那条导出用例随后已删除(不在提交里)。
#[test]
fn split_keeps_every_translation() {
let en = Catalog::of(Lang::En);
let zh = Catalog::of(Lang::ZhCn);
let actual = Key::ALL
.iter()
.map(|key| (key.name(), en.text(*key), zh.text(*key)))
.collect::<Vec<_>>();
assert_eq!(
actual.len(),
super::golden::GOLDEN.len(),
"key 条数与拆分前不一致(某条被搬丢,或多出来一条)"
);
for (index, expected) in super::golden::GOLDEN.iter().enumerate() {
let got = actual[index];
assert_eq!(
got.0, expected.0,
"第 {index} 条的 key 变了:拆分的域顺序被打乱,或某条被搬到了别处"
);
assert_eq!(got.1, expected.1, "{} 的英文译文变了", expected.0);
assert_eq!(got.2, expected.2, "{} 的中文译文变了", expected.0);
}
}
+258
View File
@@ -0,0 +1,258 @@
//! 设置页的**界面语言档位**(M106 `I18NFOLLOW`):跟随系统 / 手选某一门。
//!
//! ## 为什么单独一个文件
//!
//! `lang.rs` 讲的是「系统说哪种语言」(locale → [`Lang`] 的探测,M92),本文件讲的是「用户要哪种
//! 语言」——两者的判据不一样:探测只能尽量理解别人给的字符串,档位是我们自己写进配置的,写错就该
//! 暴露。放在一起会让 `lang.rs` 顶破 400 行(任务书那条硬线要求按功能拆文件)。
//!
//! ## D28 的判定顺序(整条挂在 [`LangChoice::resolve`] 上)
//!
//! **手选 > 系统语言 > 环境变量 > 英文**。最后一级「英文」不是偷懒:纯英文环境不扫、不下载中文
//! 字体(零额外体积 / 零网络),这条由 [`crate::font::decide`] 兜住,真值表见下面
//! `english_never_needs_a_chinese_font_and_chinese_always_resolves_one`。
//!
//! ## 「跟随系统」怎么表达
//!
//! **不写 `ui.language` 这个键**([`LangChoice::setting_value`] 返回 `None`)。不写哨兵值是因为
//! 两件事:① schema 的允许值只有 `zh_cn` / `en_us`(`core::setting::keys::LANGUAGES`),写别的
//! 会被枚举校验拒掉;② 读的那一头(`SettingBridge::load_ui_language`)只认显式值,所以「键不存在」
//! 与「用户选了跟随系统」本来就是同一件事。
use crate::catalog::Key;
use crate::lang::{resolve, Detection, Lang, LocaleSource, ORIGIN_SETTING};
/// 设置页「界面语言」的三档选择。
///
/// 为什么不是 `Option<Lang>`:`None` 与「用户明确勾了跟随系统」在类型上分不开,而设置页要把那一档
/// **画成选中**。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LangChoice {
/// 跟随系统(= 配置里没有 `ui.language`)。
System,
/// 用户手选的固定语言。
Fixed(Lang),
}
impl LangChoice {
/// 全部档位(顺序即设置页语言行的按钮顺序)。
pub const ALL: &'static [LangChoice] = &[
LangChoice::System,
LangChoice::Fixed(Lang::ZhCn),
LangChoice::Fixed(Lang::En),
];
/// 配置里 `ui.language` 的原文 → 档位。
///
/// `None`(键不存在)与**不认识的值**都算「跟随系统」——与 [`resolve`] 跳过坏值的行为同源:
/// 配置被 AI / CLI 写脏时退回系统语言,而不是猜成某一种。
#[must_use]
pub fn from_setting(value: Option<&str>) -> LangChoice {
value
.and_then(Lang::from_setting)
.map_or(LangChoice::System, LangChoice::Fixed)
}
/// 落盘用:跟随系统 = `None`(**删掉那个键**,不写哨兵值——哨兵会让「跟随系统」永远失效)。
#[must_use]
pub const fn setting_value(self) -> Option<&'static str> {
match self {
LangChoice::System => None,
LangChoice::Fixed(lang) => Some(lang.setting_value()),
}
}
/// 这一档在设置页上的名字(key 在 `catalog/general.rs` 域)。
#[must_use]
pub const fn label_key(self) -> Key {
match self {
LangChoice::System => Key::SettingsLanguageFollowSystem,
LangChoice::Fixed(Lang::ZhCn) => Key::SettingsLanguageZhCn,
LangChoice::Fixed(Lang::En) => Key::SettingsLanguageEn,
}
}
/// 这一档最终生效的语言 + 来源(日志与设置页回显都用它)。
///
/// 跟随系统 → 走 [`resolve`] 的三级链;手选 → 直接赢,`origin = setting`
/// (`raw` 写配置值,日志里能看出「这是用户手选的」)。
#[must_use]
pub fn resolve(self, platform: &dyn LocaleSource, env: &dyn LocaleSource) -> Detection {
match self {
LangChoice::System => resolve(None, platform, env),
LangChoice::Fixed(lang) => Detection {
lang,
raw: Some(lang.setting_value().to_owned()),
origin: ORIGIN_SETTING,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lang::ORIGIN_DEFAULT;
#[test]
fn choice_round_trips_through_the_config_value() {
// 抓得住:①「跟随系统」被写成哨兵值——那会让 `Lang::from_setting` 认不出、下次启动退回
// 系统语言,「手选」这件事静默丢失;②档位顺序被调换(设置页按钮顺序)。
// 抓不住:配置文件里到底有没有这个键(那是 `SettingsBridge` 的读写,见 presenter 侧测试)。
assert_eq!(LangChoice::from_setting(None), LangChoice::System);
assert_eq!(
LangChoice::from_setting(Some("zh_cn")),
LangChoice::Fixed(Lang::ZhCn)
);
assert_eq!(
LangChoice::from_setting(Some("en_us")),
LangChoice::Fixed(Lang::En)
);
for bogus in ["zh-CN", "system", "", "fr_fr"] {
assert_eq!(
LangChoice::from_setting(Some(bogus)),
LangChoice::System,
"{bogus}"
);
}
assert_eq!(LangChoice::System.setting_value(), None, "跟随系统不写键");
for choice in LangChoice::ALL {
assert_eq!(
LangChoice::from_setting(choice.setting_value()),
*choice,
"{} 落盘后读不回来",
choice.setting_value().unwrap_or("<跟随系统>")
);
}
assert_eq!(
LangChoice::ALL,
&[
LangChoice::System,
LangChoice::Fixed(Lang::ZhCn),
LangChoice::Fixed(Lang::En)
][..]
);
}
#[test]
fn choice_resolution_truth_table() {
// D28 的四级优先级,**逐档逐源**列出来:手选 > 系统 > 环境变量 > 英文。
// 抓得住:优先级顺序被改(例如手选的英文被中文系统盖掉)、或「跟随系统」直接落英文。
// 抓不住:这些源在真机上读到什么(`SystemLocale` 的真机行为在 `app-iced` 侧)。
struct Named(&'static str, Option<&'static str>);
impl LocaleSource for Named {
fn locale(&self) -> Option<String> {
self.1.map(str::to_owned)
}
fn label(&self) -> &'static str {
self.0
}
}
let registry_zh = Named("registry", Some("zh-CN"));
let registry_en = Named("registry", Some("en-US"));
let nothing = Named("registry", None);
let env_zh = Named("env", Some("zh_CN.UTF-8"));
let env_en = Named("env", Some("en_US.UTF-8"));
let env_none = Named("env", None);
let follow = LangChoice::System;
let en_choice = LangChoice::Fixed(Lang::En);
let zh = LangChoice::Fixed(Lang::ZhCn);
// (档位, 平台源, 环境变量源) -> (生效语言, 来源)
let table: &[(LangChoice, &dyn LocaleSource, &dyn LocaleSource, Lang, &str)] = &[
// ① 手选压过一切(系统是中文、环境是中文,手选英文就画英文)
(en_choice, &registry_zh, &env_zh, Lang::En, ORIGIN_SETTING),
(zh, &registry_en, &env_en, Lang::ZhCn, ORIGIN_SETTING),
// ② 跟随系统 → 系统源
(follow, &registry_zh, &env_en, Lang::ZhCn, "registry"),
(follow, &registry_en, &env_zh, Lang::En, "registry"),
// ③ 系统源取不到 → 环境变量
(follow, &nothing, &env_zh, Lang::ZhCn, "env"),
(follow, &nothing, &env_en, Lang::En, "env"),
// ④ 都没有 → 英文 + default(D28:纯英文环境不用管)
(follow, &nothing, &env_none, Lang::En, ORIGIN_DEFAULT),
];
for (choice, platform, env, lang, origin) in table {
let detection = choice.resolve(*platform, *env);
assert_eq!(
(detection.lang, detection.origin),
(*lang, *origin),
"{choice:?} platform={:?} env={:?}",
platform.locale(),
env.locale()
);
}
// 手选档的 `raw` 是配置值(日志一眼看出「用户手选的」),跟随系统档的 `raw` 是系统原文。
assert_eq!(
zh.resolve(&registry_en, &env_en).describe(),
"lang=zh-CN locale=zh_cn source=setting"
);
assert_eq!(
follow.resolve(&registry_zh, &env_en).describe(),
"lang=zh-CN locale=zh-CN source=registry"
);
}
#[test]
fn english_never_needs_a_chinese_font_and_chinese_always_resolves_one() {
// D28 字体那一级的真值表(`font::decide` 是纯函数,这里把语言 × 系统字体库全组合扫一遍):
// 抓得住:①英文环境被接上中文字体/下载(「零额外体积/零网络」的直接违约);②中文环境扫不到
// 字体却不给下载信号(会白块);③扫到了却不用系统字体(会白下 20 MB)。
// 抓不住:本机到底装没装这些族(`app-iced` 的 `fonts/cjk.rs` 把真字体库喂进来)。
use crate::font::{self, Strategy, CJK_FAMILIES, NOTO_SANS_SC};
let has_font =
|families: &'static [&'static str]| move |family: &str| families.contains(&family);
for installed in [
has_font(&[]),
has_font(&["Segoe UI"]),
has_font(CJK_FAMILIES),
] {
assert_eq!(
font::decide(Lang::En, &installed),
Strategy::NotNeeded,
"英文环境不许要中文字体"
);
}
assert_eq!(
font::decide(Lang::ZhCn, &has_font(&["Segoe UI"])),
Strategy::NeedsDownload(&NOTO_SANS_SC)
);
let first = CJK_FAMILIES[0];
let installed = has_font(&["Segoe UI", "Microsoft YaHei UI"]);
assert_eq!(first, "Microsoft YaHei UI");
assert_eq!(
font::decide(Lang::ZhCn, &installed),
Strategy::System { family: first }
);
}
#[test]
fn every_choice_has_a_label_in_both_catalogs() {
// 抓得住:设置页语言行接了一个 catalog 里不存在的 key(画出来是 `⟪missing:…⟫`)。
// 抓不住:译文翻得对不对(金表 + `chinese_catalog_is_actually_chinese` 管那一层)。
let mut keys = Vec::new();
for choice in LangChoice::ALL {
let key = choice.label_key();
for lang in Lang::ALL {
assert!(
lang.catalog().get(key).is_some(),
"{key:?} 在 {} 目录里缺词",
lang.tag()
);
}
keys.push(key);
}
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), LangChoice::ALL.len(), "两档用了同一个 key");
}
}
+331
View File
@@ -0,0 +1,331 @@
//! 中文字体策略(D28 后半):**只做判据,不联网**。
//!
//! ## 三句话
//!
//! 1. **英文环境不要字体**:`Lang::En` → [`Strategy::NotNeeded`],与系统里装了什么无关。
//! D28 原话是「纯英文环境不用管(零额外体积/零网络)」,这条是本模块最重要的断言。
//! 2. **中文环境先扫系统**:候选族名单见 [`CJK_FAMILIES`],命中即用([`Strategy::System`])。
//! 3. **扫不到才说要下载**:[`Strategy::NeedsDownload`] 是一个**信号**,不是动作——
//! 真正下载由注入的 [`Fetcher`] 实现,本 mission 不写网络实现(D28 的 CDN 下载是后续 mission)。
//!
//! ## 扫描为什么在 UI 壳层而不是这里
//!
//! 真扫系统字体要 FontConfig / DirectWrite / iced 的 `font_system()`——任何一条都会把 iced 或平台
//! SDK 拉进本 crate,破掉零依赖纪律。所以本模块只收一个 `installed: &dyn Fn(&str) -> bool`
//! (iced 版实现在 `crates/app-iced/src/fonts/cjk.rs`),单测用假闭包,脱机可跑。
//!
//! ## 诚实边界(抓得住什么 / 抓不住什么)
//!
//! - **抓得住**:语言 → 是否需要中文 → 系统里有没有候选族 → 要不要下载 这条链路的判定;
//! - **抓不住**:①**族名存在 ≠ 字形覆盖**——`fontdb` 里挂着一个叫 `SimSun` 的族也可能是个残缺子集,
//! 真判据要用 `ttf-parser` 查 cmap(M60 Q3 的 spike 做过,本 mission 不引依赖);
//! ②下载下来的字节怎么注册进 iced 的字体系统——iced 0.14 有 `iced::font::load(bytes) -> Task`
//! 可在运行时注册(已核对上游源码),但「什么时候调、失败怎么回退、和启动日志怎么对账」
//! 属于下载 mission,本 crate 不碰。
use crate::lang::Lang;
/// 已知的系统 CJK 族(Windows / macOS / Linux,按「观感优先」排序,命中第一个就用)。
///
/// 只收**简体**优先的常见族;`Microsoft JhengHei` 这类繁体族留在名单里垫底——
/// 仓内只有一套中文目录(简体),繁体用户看简体的观感仍好过看英文。
pub const CJK_FAMILIES: &[&str] = &[
// Windows 10/11
"Microsoft YaHei UI",
"Microsoft YaHei",
"DengXian",
"SimHei",
"SimSun",
"Microsoft JhengHei",
// macOS
"PingFang SC",
"Hiragino Sans GB",
"Heiti SC",
"Songti SC",
// Linux
"Noto Sans CJK SC",
"Noto Sans SC",
"Source Han Sans SC",
"Source Han Sans CN",
"Sarasa Gothic SC",
"WenQuanYi Micro Hei",
"Droid Sans Fallback",
];
/// 中文字体必须覆盖的字重(M-决定日志 ③:Android 16 的可变思源黑体缺 100–400,
/// 而 M3 Expressive 的 emphasized 档要 700 ⇒ 「400 / 500 / 700 至少三档」)。
pub const REQUIRED_WEIGHTS: &[u16] = &[400, 500, 700];
/// 可下载字体的来源。本 mission **不 pin URL/哈希**——见 [`Source::PendingPin`]。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
/// 待下载 mission 钉死:确切 URL + `sha256` + 子集范围(简体常用字 + 标点),
/// 并按 [`REQUIRED_WEIGHTS`] 覆盖 400/500/700。**故意不写一个随手想出来的 URL**:
/// 猜错的 CDN 路径会被后面所有人当事实用。
PendingPin,
}
/// 一套可下载的 CJK 字体描述(不含 URL / 哈希,原因见 [`Source`])。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CjkFont {
/// 族名(下载并注册后,`Font` 用它取字)。
pub family: &'static str,
/// 许可证(必须是 OFL / Apache 这类可再分发的;D28 明令不得内置 Segoe)。
pub license: &'static str,
/// 这套字体可用的字重。
pub weights: &'static [u16],
/// 下载源(尚未钉死)。
pub source: Source,
}
/// 缺字体时的默认方案:Noto Sans SC(OFL-1.1)。
pub const NOTO_SANS_SC: CjkFont = CjkFont {
family: "Noto Sans SC",
license: "OFL-1.1",
weights: &[300, 400, 500, 700, 900],
source: Source::PendingPin,
};
/// 字体策略结论。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Strategy {
/// 不需要中文字体(英文环境)。
NotNeeded,
/// 系统里已有可用族,直接用。
System {
/// 命中的族名。
family: &'static str,
},
/// 需要下载(本模块只发信号,不联网)。
NeedsDownload(&'static CjkFont),
/// 探测不出(拿不到系统字体库)——**不等于**「需下载」:不许凭猜测去下载。
Unprobeable,
}
impl Strategy {
/// 一行日志片段(`cjk=system:"Microsoft YaHei"` / `cjk=needs-download(Noto Sans SC)` …)。
#[must_use]
pub fn describe(&self) -> String {
match self {
Strategy::NotNeeded => "cjk=not-needed".to_owned(),
Strategy::System { family } => format!("cjk=system:\"{family}\""),
Strategy::NeedsDownload(font) => format!("cjk=needs-download({})", font.family),
Strategy::Unprobeable => "cjk=unprobeable".to_owned(),
}
}
}
/// 判据(纯函数):语言 + 「某族装没装」→ 策略。
///
/// `installed` 由调用方提供(iced 版查系统字体库,单测给假闭包)。
#[must_use]
pub fn decide(lang: Lang, installed: &dyn Fn(&str) -> bool) -> Strategy {
if lang != Lang::ZhCn {
// D28:纯英文环境不加载任何中文字体,也不下载。
return Strategy::NotNeeded;
}
CJK_FAMILIES
.iter()
.copied()
.find(|family| installed(family))
.map_or(Strategy::NeedsDownload(&NOTO_SANS_SC), |family| {
Strategy::System { family }
})
}
/// 字体下载器接口(**注入式**;本 mission 没有网络实现)。
pub trait Fetcher {
/// 取一套字体(返回原始字节,装进字体系统是调用方的事)。
///
/// # Errors
/// 网络失败 / 校验失败等人话错误。
fn fetch(&self, font: &'static CjkFont) -> Result<Vec<u8>, FetchError>;
}
/// 下载失败的分类(给 UI 决定要不要重试;分类少是故意的)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FetchError {
/// 网络层失败(超时 / 连不上 / 非 2xx)。
Network(String),
/// 校验失败(哈希 / 长度不符,或不是字体文件)。
Integrity(String),
}
/// [`ensure`] 的结局。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Fetched {
/// 本来就不需要(英文环境)。
NothingToDo,
/// 系统已有,直接用。
AlreadySystem {
/// 命中的族名。
family: &'static str,
},
/// 下载成功,拿到字节(交给调用方注册)。
Downloaded(Vec<u8>),
/// 探测不出,什么都没做。
Unknown,
}
/// 按策略执行:需要下载时才碰 [`Fetcher`]。
///
/// # Errors
/// 下载器返回的错误原样透出(不吞、不改写分类)。
pub fn ensure(strategy: Strategy, fetcher: &dyn Fetcher) -> Result<Fetched, FetchError> {
match strategy {
Strategy::NotNeeded => Ok(Fetched::NothingToDo),
Strategy::System { family } => Ok(Fetched::AlreadySystem { family }),
Strategy::Unprobeable => Ok(Fetched::Unknown),
Strategy::NeedsDownload(font) => fetcher.fetch(font).map(Fetched::Downloaded),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
fn only(installed: &'static [&'static str]) -> impl Fn(&str) -> bool {
move |family| installed.contains(&family)
}
/// 假下载器:记录调用次数,避免「悄悄联网」这种事在单测里被放过。
struct FakeFetcher {
calls: Cell<usize>,
result: Result<Vec<u8>, FetchError>,
seen: Cell<Option<&'static str>>,
}
impl FakeFetcher {
fn new(result: Result<Vec<u8>, FetchError>) -> Self {
FakeFetcher {
calls: Cell::new(0),
result,
seen: Cell::new(None),
}
}
}
impl Fetcher for FakeFetcher {
fn fetch(&self, font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
self.calls.set(self.calls.get() + 1);
self.seen.set(Some(font.family));
self.result.clone()
}
}
#[test]
fn english_never_wants_a_cjk_font() {
// 抓得住:D28 的核心承诺——英文环境不加载/不下载中文字体(D28 说「零额外体积/零网络」)。
// 抓不住:程序真的没去读那个字体文件(那是加载器的行为,本 crate 只管判据)。
assert_eq!(decide(Lang::En, &only(&[])), Strategy::NotNeeded);
assert_eq!(
decide(Lang::En, &only(&["Microsoft YaHei"])),
Strategy::NotNeeded,
"装了中文字体也不该进入中文分支"
);
}
#[test]
fn chinese_prefers_the_first_installed_candidate() {
// 抓得住:候选顺序(YaHei UI > YaHei > SimSun…)被调换,或命中判断写成了「装了任意字面就 ok」。
// 抓不住:命中的族是否真有简体字形覆盖(族名存在 ≠ cmap 覆盖,见模块文档)。
assert_eq!(
decide(
Lang::ZhCn,
&only(&["Segoe UI", "Microsoft YaHei", "SimSun"])
),
Strategy::System {
family: "Microsoft YaHei"
}
);
assert_eq!(
decide(Lang::ZhCn, &only(&["PingFang SC"])),
Strategy::System {
family: "PingFang SC"
}
);
assert_eq!(
decide(Lang::ZhCn, &only(&["Noto Sans CJK SC", "Noto Sans SC"])),
Strategy::System {
family: "Noto Sans CJK SC"
}
);
}
#[test]
fn chinese_without_cjk_fonts_emits_the_download_signal() {
// 抓得住:中文环境 + 系统无 CJK 时**给信号**(而不是静默回英文、也不是自己联网)。
// 抓不住:信号之后 UI 有没有把提示画出来(页面侧 / 截图验收)。
let strategy = decide(Lang::ZhCn, &only(&["Segoe UI", "Roboto", "Cascadia Mono"]));
let Strategy::NeedsDownload(font) = strategy else {
panic!("应当要求下载:{strategy:?}");
};
assert_eq!(font.family, "Noto Sans SC");
assert_eq!(font.license, "OFL-1.1");
assert_eq!(font.source, Source::PendingPin, "URL/哈希留给下载 mission");
assert_eq!(strategy.describe(), "cjk=needs-download(Noto Sans SC)");
}
#[test]
fn download_fonts_cover_the_required_weights() {
// 抓得住:将来换了字体却没覆盖 400/500/700(M3 Expressive 的 emphasized 档要 700)。
// 抓不住:字重是可变轴还是静态子集(那要看下载 mission 选的文件,本 crate 只有元数据)。
for weight in REQUIRED_WEIGHTS {
assert!(
NOTO_SANS_SC.weights.contains(weight),
"缺 {weight} 档:{:?}",
NOTO_SANS_SC.weights
);
}
}
#[test]
fn ensure_only_touches_the_fetcher_when_download_is_needed() {
// 抓得住:英文环境/系统已有字体时**仍然调了下载器**(= 偷跑网络请求);
// 也抓得住下载失败被吞掉(错误必须原样透出)。
// 抓不住:真实现的网络行为(本 crate 没有网络实现,这正是不联网的证据)。
let idle = FakeFetcher::new(Ok(vec![1, 2, 3]));
assert_eq!(ensure(Strategy::NotNeeded, &idle), Ok(Fetched::NothingToDo));
assert_eq!(
ensure(Strategy::System { family: "SimSun" }, &idle),
Ok(Fetched::AlreadySystem { family: "SimSun" })
);
assert_eq!(ensure(Strategy::Unprobeable, &idle), Ok(Fetched::Unknown));
assert_eq!(idle.calls.get(), 0, "这三种情况都不许联网");
let ok = FakeFetcher::new(Ok(vec![9; 4]));
assert_eq!(
ensure(Strategy::NeedsDownload(&NOTO_SANS_SC), &ok),
Ok(Fetched::Downloaded(vec![9; 4]))
);
assert_eq!(ok.calls.get(), 1);
assert_eq!(ok.seen.get(), Some("Noto Sans SC"));
let broken = FakeFetcher::new(Err(FetchError::Network("超时".to_owned())));
assert_eq!(
ensure(Strategy::NeedsDownload(&NOTO_SANS_SC), &broken),
Err(FetchError::Network("超时".to_owned()))
);
}
#[test]
fn strategy_describe_is_one_line_for_every_case() {
// 抓得住:日志片段里混进换行(启动日志是按行解析的)。
// 抓不住:这些片段是否真的被打印(app-iced 的启动路径在那边测)。
let cases = [
Strategy::NotNeeded,
Strategy::System { family: "SimSun" },
Strategy::NeedsDownload(&NOTO_SANS_SC),
Strategy::Unprobeable,
];
for case in cases {
let line = case.describe();
assert_eq!(line.lines().count(), 1, "{line}");
assert!(line.starts_with("cjk="), "{line}");
}
assert_eq!(Strategy::NotNeeded.describe(), "cjk=not-needed");
assert_eq!(Strategy::Unprobeable.describe(), "cjk=unprobeable");
}
}
+387
View File
@@ -0,0 +1,387 @@
//! 语言与系统 locale 探测(D28 前半)。
//!
//! ## 判据只有两条
//!
//! 1. **中文环境**(`zh*` 任意变体)→ [`Lang::ZhCn`];
//! 2. **其它一切**(`en*`、`ja*`、`fr*`、`C`、`POSIX`、变量缺失、解析不了)→ [`Lang::En`]。
//!
//! 「其它一切 → 英文」不是偷懒:D28 的原话是「纯英文环境不用管」,而把 ja/fr/de 猜成中文才是真错
//! (会连带触发中文字体下载)。宁可给一个英文界面,也不给一个语种错误的中文界面。
//!
//! ## 为什么 locale 源要能注入
//!
//! [`detect`] 的形状是 `detect(&dyn LocaleSource)`,[`EnvLocale`] 只是其中一个实现。这样
//! **单测不依赖跑测试那台机器的 locale**(真值表测试在 [`lang`] 自己的 `tests` 里跑固定输入),
//! 后续 mission 加「Win32 `GetUserDefaultLocaleName`」或「读配置里的 `ui.language`」也只是再实现一遍
//! 这个 trait。
//!
//! ## 优先级([`resolve`])
//!
//! **`ui.language` 显式值 > 平台源 > POSIX 环境变量 > 英文**。本 crate 只有 [`EnvLocale`]
//! 一个具体实现——平台真值源(Windows 注册表那一路)在 UI 壳层,因为读注册表要开子进程,
//! 而本 crate 的纪律是不碰外部世界。
//!
//! ## 诚实边界(抓得住什么 / 抓不住什么)
//!
//! - **抓得住**:环境变量字符串 → 语言的映射(含 `zh_CN.UTF-8` / `zh-Hans-CN` / 大小写 / 修饰符);
//! 变量优先级(`LC_ALL` > `LC_MESSAGES` > `LANG`);[`resolve`] 的四级优先级;
//! - **抓不住**:**Windows 上这三个变量通常都不存在**——那条路的真值源是
//! `crates/app-iced/src/platform/locale.rs`(`reg.exe` 读注册表,零 FFI),
//! 它不在本 crate 里(本 crate 零依赖、不 spawn 进程)。所以「Windows 上拿不到语言」
//! 这件事只在**没有接线平台源**时成立,接线后由 `resolve` 的二级兜住。
//! 配置里的 `ui.language` 读取属于设置层接线(本 crate 只提供 [`Lang::from_setting`] 与
//! [`resolve`] 的形状)。
use std::env;
use crate::catalog::Catalog;
/// 界面语言。目前只有中英两套(D28 的「至少 zh-CN / en-US」)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Lang {
/// 英文(默认;未知 locale 也落在这里)。
En,
/// 简体中文。
ZhCn,
}
impl Lang {
/// 全部语言(顺序即设置页下拉框的顺序)。
pub const ALL: &'static [Lang] = &[Lang::En, Lang::ZhCn];
/// BCP-47 标签(日志 / 字体请求里用;`zh-CN` 是本 crate 唯一的中文标识)。
#[must_use]
pub const fn tag(self) -> &'static str {
match self {
Lang::En => "en",
Lang::ZhCn => "zh-CN",
}
}
/// 配置值(`core::setting::keys::LANGUAGES` 的同一份词表)。
#[must_use]
pub const fn setting_value(self) -> &'static str {
match self {
Lang::En => "en_us",
Lang::ZhCn => "zh_cn",
}
}
/// 解析**配置值**(严格):只认 [`Lang::setting_value`] 那两个字符串。
///
/// 与宽松的 [`Lang::from_locale`] 分工明确:配置是我们自己写进去的,写错就该暴露(`None` 让设置层
/// 报错),而不是悄悄换成别的语言;系统 locale 是别人给的,只能尽量理解。
#[must_use]
pub fn from_setting(value: &str) -> Option<Lang> {
Lang::ALL
.iter()
.copied()
.find(|lang| lang.setting_value() == value)
}
/// 解析 **locale / 语言标签**(宽松):`zh`、`zh_CN.UTF-8`、`zh-Hans-CN`、`ZH_cn` 都是中文。
///
/// 只看**主语言子标签**:`zh-TW` / `zh-Hant` 也归 [`Lang::ZhCn`]——仓内只有一套中文目录,
/// 繁体用户看简体比看英文更有用;真要繁体得加第三套目录(见 `bridge` 的上游约定)。
#[must_use]
pub fn from_locale(tag: &str) -> Lang {
let trimmed = tag.trim();
// `zh_CN.UTF-8@euro`:codeset 与修饰符与语种无关,先切掉。
let without_codeset = trimmed.split('.').next().unwrap_or("");
let without_modifier = without_codeset.split('@').next().unwrap_or("");
let primary = without_modifier
.split(['-', '_'])
.next()
.unwrap_or("")
.to_ascii_lowercase();
if primary == "zh" {
Lang::ZhCn
} else {
Lang::En
}
}
/// 本语言的消息目录。
#[must_use]
pub const fn catalog(self) -> Catalog {
Catalog::of(self)
}
}
/// 没有任何 locale 信息时的落点(D28:纯英文环境不用管)。
impl Default for Lang {
fn default() -> Self {
Lang::En
}
}
/// locale 的来源。实现方负责回答「这台机器说哪种语言」。
pub trait LocaleSource {
/// 原始 locale 标签(`None` = 取不到,不是「英文」的断言)。
fn locale(&self) -> Option<String>;
/// 这个源在启动日志里的名字(`registry` / `env` / …)。默认 `injected`(测试用)。
fn label(&self) -> &'static str {
"injected"
}
}
/// [`Detection::origin`]:结论来自 `ui.language` 的显式值。
pub const ORIGIN_SETTING: &str = "setting";
/// [`Detection::origin`]:结论来自环境变量([`EnvLocale`])。
pub const ORIGIN_ENV: &str = "env";
/// [`Detection::origin`]:一个源都没取到,落到默认英文。
pub const ORIGIN_DEFAULT: &str = "default";
/// 进程环境变量式 locale 源(POSIX 语义;Windows 上基本取不到,见模块文档)。
#[derive(Debug, Clone, Copy, Default)]
pub struct EnvLocale;
impl LocaleSource for EnvLocale {
fn locale(&self) -> Option<String> {
// POSIX 优先级:LC_ALL > LC_MESSAGES > LANG。空串按「没设置」处理。
["LC_ALL", "LC_MESSAGES", "LANG"]
.iter()
.find_map(|name| env::var(name).ok().filter(|value| !value.trim().is_empty()))
}
fn label(&self) -> &'static str {
ORIGIN_ENV
}
}
/// 探测结论(`raw` 与 `origin` 一起带出来,是为了日志能解释「为什么是英文」)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Detection {
/// 结论。
pub lang: Lang,
/// 原始 locale 标签(`None` = 一个源都没取到)。
pub raw: Option<String>,
/// 结论的来源(`setting` / 平台源自己的 [`LocaleSource::label`] / `env` / `default`)。
pub origin: &'static str,
}
impl Detection {
/// 一行日志(`lang=zh-CN locale=zh-CN source=registry` / `lang=en locale=<未设置> source=default`)。
#[must_use]
pub fn describe(&self) -> String {
format!(
"lang={} locale={} source={}",
self.lang.tag(),
self.raw.as_deref().unwrap_or("<未设置>"),
self.origin
)
}
}
/// 用给定来源探测语言。
#[must_use]
pub fn detect(source: &dyn LocaleSource) -> Detection {
match source.locale() {
Some(raw) => Detection {
lang: Lang::from_locale(&raw),
raw: Some(raw),
origin: source.label(),
},
None => Detection {
lang: Lang::En,
raw: None,
origin: ORIGIN_DEFAULT,
},
}
}
/// 用进程环境变量探测语言。
#[must_use]
pub fn detect_env() -> Detection {
detect(&EnvLocale)
}
/// 全量优先级:**`ui.language` 显式值 > 平台源 > POSIX 环境变量 > 英文**。
///
/// - `setting`:配置里 `ui.language` 的原文(`zh_cn` / `en_us`)。给了但不认识 → **跳过**
/// (当作「没配」继续往下走,而不是把它猜成某种语言);
/// - `platform`:平台真值源(Windows 上是 `app-iced::platform::locale` 读注册表;其它平台没有);
/// - `env`:`LC_ALL > LC_MESSAGES > LANG`([`EnvLocale`]);
/// - 都不给 → [`Lang::En`],`origin = default`。
///
/// 这正是 D28 要的顺序:**用户手选的**压过系统,系统压过环境变量,都没有就英文。
#[must_use]
pub fn resolve(
setting: Option<&str>,
platform: &dyn LocaleSource,
env: &dyn LocaleSource,
) -> Detection {
if let Some(value) = setting {
if let Some(lang) = Lang::from_setting(value) {
return Detection {
lang,
raw: Some(value.to_owned()),
origin: ORIGIN_SETTING,
};
}
}
let from_platform = detect(platform);
if from_platform.raw.is_some() {
return from_platform;
}
detect(env)
}
#[cfg(test)]
mod tests {
use super::*;
struct Fixed(Option<&'static str>);
impl LocaleSource for Fixed {
fn locale(&self) -> Option<String> {
self.0.map(str::to_owned)
}
}
#[test]
fn locale_truth_table() {
// 抓得住:主语言子标签的解析(分隔符 / 大小写 / codeset / 修饰符 / 变体)。
// 抓不住:这些字符串是否真的是这台机器的 locale——真值来自 LocaleSource 实现。
let zh = [
"zh",
"zh_CN",
"zh_CN.UTF-8",
"zh-CN",
"zh-Hans-CN",
"zh_hans_cn",
"ZH_cn",
" zh_TW ",
"zh_CN.GB18030@euro",
];
for tag in zh {
assert_eq!(Lang::from_locale(tag), Lang::ZhCn, "{tag}");
}
let en = [
"",
"C",
"POSIX",
"en",
"en_US.UTF-8",
"en-GB",
"ja_JP.UTF-8",
"fr_FR@euro",
"de",
// 不是中文但含 zh 之外的字:必须落英文,不许猜成中文
"zhuang",
];
for tag in en {
assert_eq!(Lang::from_locale(tag), Lang::En, "{tag}");
}
}
#[test]
fn detect_falls_back_to_english_without_a_locale() {
// 抓得住:取不到 locale 时**不 panic、不猜中文**(D28 的「纯英文环境不用管」)。
// 抓不住:Windows 上「环境变量取不到」是否等于用户真的说英文——那是平台源的活
// (`app-iced::platform::locale`),见下面 resolve 的优先级测试。
let detection = detect(&Fixed(None));
assert_eq!(detection.lang, Lang::En);
assert_eq!(detection.raw, None);
assert_eq!(detection.origin, ORIGIN_DEFAULT);
assert_eq!(
detection.describe(),
"lang=en locale=<未设置> source=default"
);
assert_eq!(Lang::default(), Lang::En);
}
#[test]
fn detect_uses_the_injected_source_verbatim() {
// 抓得住:探测结论确实来自注入源(不会被本机环境变量污染),且 origin 取自源自报的名字。
// 抓不住:EnvLocale 到底读了哪几个变量(那条路径只能靠人工/集成测试看日志)。
let detection = detect(&Fixed(Some("zh_CN.UTF-8")));
assert_eq!(detection.lang, Lang::ZhCn);
assert_eq!(detection.raw.as_deref(), Some("zh_CN.UTF-8"));
assert_eq!(detection.origin, "injected");
assert_eq!(
detection.describe(),
"lang=zh-CN locale=zh_CN.UTF-8 source=injected"
);
assert_eq!(EnvLocale.label(), "env");
}
#[test]
fn resolve_priority_is_setting_then_platform_then_env_then_english() {
// 抓得住:四级优先级的顺序本身(用户手选压过系统,系统压过环境变量,都没有才英文);
// 以及「配置值不认识时不当成任何一种语言,继续往下走」。
// 抓不住:这些源在真机上到底读到什么(平台源的真机行为在 app-iced 侧测)。
struct Named(&'static str, Option<&'static str>);
impl LocaleSource for Named {
fn locale(&self) -> Option<String> {
self.1.map(str::to_owned)
}
fn label(&self) -> &'static str {
self.0
}
}
let registry_zh = Named("registry", Some("zh-CN"));
let env_en = Named("env", Some("en_US.UTF-8"));
let nothing = Named("registry", None);
// ① 配置显式值压过一切
let detection = resolve(Some("en_us"), &registry_zh, &env_en);
assert_eq!(
(detection.lang, detection.origin),
(Lang::En, ORIGIN_SETTING)
);
// ② 没配置(或配置不认识)→ 平台源
for setting in [None, Some("zh-CN"), Some("")] {
let detection = resolve(setting, &registry_zh, &env_en);
assert_eq!(
(detection.lang, detection.raw.as_deref(), detection.origin),
(Lang::ZhCn, Some("zh-CN"), "registry"),
"setting={setting:?}"
);
}
// ③ 平台源没有 → 环境变量
let detection = resolve(None, &nothing, &env_en);
assert_eq!(
(detection.lang, detection.origin),
(Lang::En, "env"),
"环境变量说是英文,但来源必须是 env"
);
// ④ 全没有 → 英文 + default
let detection = resolve(None, &nothing, &Named("env", None));
assert_eq!(
(detection.lang, detection.origin),
(Lang::En, ORIGIN_DEFAULT)
);
}
#[test]
fn setting_values_match_the_config_vocabulary() {
// 抓得住:本 crate 的配置词表被改动(改名会让已落盘的 ui.language 读不出来)。
// 抓不住:`core::setting::keys::LANGUAGES` 那边先改了——本 crate 不依赖 core(零依赖纪律),
// 所以这条只能靠接线 mission 写一条跨 crate 的 pin 测试,或让 core 反向引用这份词表。
let values = Lang::ALL
.iter()
.map(|lang| lang.setting_value())
.collect::<Vec<_>>();
assert_eq!(values, vec!["en_us", "zh_cn"]);
assert_eq!(Lang::from_setting("zh_cn"), Some(Lang::ZhCn));
assert_eq!(Lang::from_setting("en_us"), Some(Lang::En));
}
#[test]
fn setting_parsing_is_strict_where_locale_parsing_is_lenient() {
// 抓得住:宽松解析被误用到配置值上(`zh-CN` 是合法 locale 但不是合法配置值)。
// 抓不住:有人把非法值直接塞进配置(那是设置层的 schema 校验与该键的 enumeration 约束)。
for bogus in ["zh-CN", "ZH_CN", "zh", "", "en"] {
assert_eq!(Lang::from_setting(bogus), None, "{bogus}");
}
assert_eq!(Lang::from_locale("zh-CN"), Lang::ZhCn);
}
}
+45
View File
@@ -0,0 +1,45 @@
//! # unlucky-i18n · 国际化地基(D28)
//!
//! 本 crate 只做**地基**,不接任何页面:语言探测、两套消息目录、缺译诊断、中文字体策略、
//! 以及「core 的中文状态串怎么进 UI」的契约。页面侧接线的 mission 用它,而不是各自硬编码英文
//! (先硬编码再全量重写就是本 crate 要避免的那条路)。
//!
//! | 模块 | 职责 | 不负责 |
//! |---|---|---|
//! | [`lang`] | `Lang`(en / zh-CN)+ locale 探测(源可注入)+ **四级优先级** [`lang::resolve`] | 读注册表 / 读配置 / spawn 进程 |
//! | [`choice`] | `LangChoice`(跟随系统 / 手选):设置页那一档 + 它到生效语言的映射 | 落盘(那是 `presenter::settings_bridge`) |
//! | [`catalog`] | `Key` 枚举 + 中英两套常量表 + 取词 / 占位符渲染 + 缺译诊断 | 排版、字号、断行 |
//! | [`font`] | 中文环境扫系统 CJK 字体的**判据** + 缺字体时的「需下载」信号 + 注入式下载器接口 | 联网、写文件、装字体 |
//! | [`bridge`] | core / presenter 的状态串 → `Key` 的**示例契约**(文案边界规则全文在这里) | 改 core 的产出 |
//!
//! 语言从哪来的优先级写在 [`lang::resolve`]:**`ui.language` 显式值 > 平台源 > POSIX 环境变量 > 英文**。
//! 平台源(Windows 上 `reg.exe` 读注册表,零 FFI)在 UI 壳层实现并注入——本 crate 不认平台。
//!
//! ## 三条硬规矩(细则见 [`bridge`])
//!
//! 1. **一句话 = 一个 key**。UI 侧不得把片段拼成一句话,尤其不得拼中文:
//! `"Launch failed · exit0 静默失败"` 这种混排就是拼接的后果。带变量的句子用 `{name}` 占位符,
//! 由 [`catalog::Catalog::render`] 统一替换。
//! 2. **core 的中文串不是文案**。core / presenter 里的中文状态串(`原版` / `静默失败(…)`)只用于
//! 日志与 CLI;UI 要么走结构化出口(枚举)映射到 key,要么走 [`bridge::key_of_legacy_status`] 的
//! 过渡表。**任何情况下都不许把未知中文串直接画到界面上。**
//! 3. **英文环境零中文负担**(D28 原话):`Lang::En` 下不要求任何中文字体、不触发任何下载;
//! 判据在 [`font::decide`],是纯函数,可脱机单测。
//!
//! ## 与别处的关系
//!
//! - **不依赖 iced / slint / presenter / core**:`cargo tree -p unlucky-i18n --edges normal` 只应打印它自己。
//! 系统字体扫描由 UI 壳层做(iced 版在 `crates/app-iced/src/fonts/cjk.rs`),扫出来的族名喂给
//! [`font::decide`];
//! - 配置里的 `ui.language` 允许值 `zh_cn` / `en_us`(见 `core::setting::keys::LANGUAGES`)与
//! [`lang::Lang::setting_value`] 必须是同一份词表,由 [`lang`] 的 pin 测试守门。
pub mod bridge;
pub mod catalog;
pub mod choice;
pub mod font;
pub mod lang;
pub use catalog::{Catalog, Key};
pub use choice::LangChoice;
pub use lang::Lang;