feat(i18n): 建国际化地基——语言探测 / 中英消息目录 / 中文字体策略(D28)
M92 只建地基、不接页面(页面接线是后续 mission,避免「先硬编码再全量重写」)。
新建的 crates/i18n 是**零依赖** crate:`cargo tree -p unlucky-i18n --edges normal`
只打印它自己,所以 iced 版、将来的别的壳、presenter 都能依赖它而不锁死渲染框架。
- lang:`Lang::{En, ZhCn}` + 可注入的 `LocaleSource`(`EnvLocale` 按 POSIX 优先级读
LC_ALL > LC_MESSAGES > LANG)。未知语种一律英文——把 ja/fr 猜成中文会连带触发中文字体
下载,比英文界面更糟。配置值(`ui.language` 的 zh_cn/en_us)走严格解析,locale 走宽松
解析,两条路刻意分开。**已知缺口**:Windows 上这三个变量通常都不存在,
`GetUserDefaultLocaleName` 属于 platform 的 FFI(后续 mission)。
- catalog:`Key` 枚举(拼错的 key = 编译错误,附 compile_fail 文档测试守着)
+ `catalog!` 宏「一行 = 一个 key 的全部语言」⇒ 内置两套目录缺译在结构上不可能发生。
缺译诊断(`Catalog::diagnose`)留给注入目录与回归守门:`get` 严格返回 None、
`text` 返回 ⟪missing⟫、`render` 返回 ⟪missing:KeyName⟫——**不回落**到另一种语言。
带变量的句子走 `{name}` 占位符,缺参时占位符原样留在文案里(可见,不静默变空)。
- font:`decide(lang, installed)` 是纯函数。英文环境恒 `NotNeeded`(D28 的零字体/零网络);
中文环境按候选族名单命中系统字体,扫不到就发 `NeedsDownload(Noto Sans SC)` 信号,
**本 crate 不联网**;`Fetcher` 是注入式接口,网络实现留给后续 mission。
URL/哈希显式留白(`Source::PendingPin`)——不编造 CDN 路径,猜错的地址会被后面所有人当事实用。
- bridge:文案边界规则全文(R1 不许拼句 / R2 core 中文串不进 UI / R3 未知串不许画原文)
+ `classify()` 示例契约:过渡表把已知漏到界面的中文串映射到 key,品牌专名显式放行,
其余一律 `Untranslated`(UI 只许显示兜底 key)。
- app-iced/fonts:新增 `fonts/cjk.rs` 适配层,把系统字面表喂给 `decide`;读不到字体库时给
`Unprobeable` 而不是「需下载」(否则会误触发一次 CDN 下载)。结论并入同一行启动日志:
`[font] … | lang=zh-CN locale=zh_CN.UTF-8 cjk=system:"Microsoft YaHei"`。
测试:26 条(i18n 22 + app-iced fonts/cjk 4),每条断言都写了「抓得住什么 / 抓不住什么」。
This commit is contained in:
1 parent
1331d90ff4
commit
e77a5df6e8
12 files changed
+1418
-3
No files matched your search
@@ -0,0 +1,258 @@
|
||||
//! 语言与系统 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。
|
||||
//!
|
||||
//! ## 诚实边界(抓得住什么 / 抓不住什么)
|
||||
//!
|
||||
//! - **抓得住**:环境变量字符串 → 语言的映射(含 `zh_CN.UTF-8` / `zh-Hans-CN` / 大小写 / 修饰符);
|
||||
//! 变量优先级(`LC_ALL` > `LC_MESSAGES` > `LANG`);
|
||||
//! - **抓不住**:**Windows 上这三个变量通常都不存在**,`GetUserDefaultLocaleName` 才是真值源。
|
||||
//! 本 mission 不碰 FFI(`app-iced::platform` 才是全仓唯一 `unsafe` 落点),所以 Windows 目前
|
||||
//! 一律落到 [`Lang::En`];配置里的 `ui.language` 覆盖也是后续接线 mission 的事。两者都是**已知缺口**,
|
||||
//! 不是「已完成」。
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
/// 进程环境变量式 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()))
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测结论(`raw` 一起带出来,是为了日志能解释「为什么是英文」)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Detection {
|
||||
/// 结论。
|
||||
pub lang: Lang,
|
||||
/// 原始 locale 标签(`None` = 一个变量都没取到)。
|
||||
pub raw: Option<String>,
|
||||
}
|
||||
|
||||
impl Detection {
|
||||
/// 一行日志(`lang=zh-CN locale=zh_CN.UTF-8` / `lang=en locale=<未设置>`)。
|
||||
#[must_use]
|
||||
pub fn describe(&self) -> String {
|
||||
format!(
|
||||
"lang={} locale={}",
|
||||
self.lang.tag(),
|
||||
self.raw.as_deref().unwrap_or("<未设置>")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 用给定来源探测语言。
|
||||
#[must_use]
|
||||
pub fn detect(source: &dyn LocaleSource) -> Detection {
|
||||
let raw = source.locale();
|
||||
let lang = raw.as_deref().map_or(Lang::En, Lang::from_locale);
|
||||
Detection { lang, raw }
|
||||
}
|
||||
|
||||
/// 用进程环境变量探测语言(启动日志用)。
|
||||
#[must_use]
|
||||
pub fn detect_env() -> Detection {
|
||||
detect(&EnvLocale)
|
||||
}
|
||||
|
||||
#[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 上「取不到」是否等于用户真的说英文——这正是本 crate 的已知缺口。
|
||||
let detection = detect(&Fixed(None));
|
||||
assert_eq!(detection.lang, Lang::En);
|
||||
assert_eq!(detection.raw, None);
|
||||
assert_eq!(detection.describe(), "lang=en locale=<未设置>");
|
||||
assert_eq!(Lang::default(), Lang::En);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_uses_the_injected_source_verbatim() {
|
||||
// 抓得住:探测结论确实来自注入源(不会被本机环境变量污染)。
|
||||
// 抓不住: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.describe(), "lang=zh-CN locale=zh_CN.UTF-8");
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user