Files
mqttx/gpui-app/src/ui/i18n.rs
T
JoeAllen 35e2b6fb60 refactor(i18n): 迁移到 rust-i18n 语义键体系(ns.key),废弃中文原文作 key
旧实现的问题(按用户反馈):
- 中文原文作 key:文案措辞与代码耦合,一词多义撞 key("主题" Topic/Theme)
- 巨型 match 文案表:重复键靠编译警告暴露;漏翻静默回退,无任何校验
- tf 参数到处 &n.to_string() 噪音

新体系(gpui-kit 官方方案):
- locales/ui.yml 语言包(rust-i18n v2,en/zh-CN 双语并排),key 为
  语义标识符(common.save / conn.connect / status.connected…),
  与自然语言彻底解耦;改措辞不动代码
- crate 根 i18n! 编译期内嵌;init_i18n() 经 extend! 并入 gpui-kit
  组件内置文案(按钮/空态等随语言切换)
- t/tf 薄门面查 _rust_i18n_translate(运行时 key),模型 label()
  改返回语义 key;tf 参数只需 Display
- 四个强制单测:语言包 en/zh-CN 完整、占位符 {x} 两侧一致、
  全源码 t()/tf() 字面量必须存在词条、模型 label 必须有真实译文
- 语言中性文案("QoS"、"/mqtt" 等)去掉 t() 包裹
- 顺带补翻 30 条漏网文案(资源监控统计行/表单校验/阿里云说明段等)

42 测试全绿;中英双语实机截图验证零裸 key。
2026-10-04 12:27:55 +08:00

317 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! i18n 门面:语义键(`ns.key`)+ rust-i18n(gpui-kit 官方方案)。
//!
//! # 架构
//! - 语言包在 `locales/ui.yml`(en / zh-CN 双语并排,`_version: 2`),
//! 由 crate 根的 `rust_i18n::i18n!("locales", fallback = "en")` 编译期内嵌;
//! gpui-kit 组件内置文案经 main.rs 的 `rust_i18n::extend!` 随语言切换。
//! - 调用点写 `t("common.save")` / `tf("msg.count", &[("n", &count)])`,
//! key 是**稳定标识符**,与任何自然语言解耦——改文案措辞不动代码,
//! 也不存在「一词多义撞 key」。
//! - [`t`]/[`tf`] 直接查 [`crate::_rust_i18n_translate`](rust-i18n 生成、
//! 接受运行时 key 的公开函数),不经 `t!` 宏——`label()` 这类运行时
//! 值同样可查。
//!
//! # 规则(单元测试强制,违反即测试失败)
//! 1. 新增文案:调用点 `t("ns.key")`,`locales/ui.yml` 加 key 块(en/zh-CN 必填)。
//! 2. 源码每个 `t("…")` / `tf("…")` 字面量必须在语言包中存在。
//! 3. 模型 `label()` 返回的 key 必须有**非自映射**的 en 词条。
//! 4. 同一 key 的 zh-CN 与 en 占位符 `{name}` 集合必须一致
//! (`tf` 按名替换,两侧不一致即静默漏参)。
//!
//! # 已知限制
//! 语言切换对**已打开页签里的下拉框**(QoS / Retain Handling 等)不即时生效——
//! 这些 SelectState 在构造时固化了标签列表;重开页签即刷新。其余界面即时生效
//! (save_settings 内 `window.refresh()` 兜底整窗重绘)。
use std::borrow::Cow;
use std::sync::atomic::{AtomicU8, Ordering};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Lang {
Zh,
En,
}
const LOCALE_ZH: &str = "zh-CN";
const LOCALE_EN: &str = "en";
/// 0 = 中文,1 = English。默认中文。
static LANG: AtomicU8 = AtomicU8::new(0);
/// 切换语言:同时更新 rust-i18n 全局 locale(gpui-kit 组件内置文案随之切换)。
/// 已渲染界面的刷新由调用方负责(settings 保存路径带整窗 refresh)。
pub fn set_lang(lang: Lang) {
LANG.store(lang as u8, Ordering::Relaxed);
gpui_kit::component::set_locale(match lang {
Lang::Zh => LOCALE_ZH,
Lang::En => LOCALE_EN,
});
}
pub fn lang() -> Lang {
if LANG.load(Ordering::Relaxed) == 1 {
Lang::En
} else {
Lang::Zh
}
}
/// 按系统语言解析初始语言(AppSettings::System 时使用)。
pub fn from_system() -> Lang {
match sys_locale::get_locale().unwrap_or_default().to_lowercase() {
l if l.starts_with("zh") => Lang::Zh,
_ => Lang::En,
}
}
/// 查指定 locale 的词条。
fn translate<'k>(locale: &str, key: &'k str) -> Cow<'k, str> {
crate::_rust_i18n_translate(locale, key)
}
/// 取当前语言的界面文案。miss 时 rust-i18n 原样返回 key(测试会拦截,
/// 运行期裸 key 只是最后的安全网)。
pub fn t(key: &'static str) -> &'static str {
let locale = match lang() {
Lang::Zh => LOCALE_ZH,
Lang::En => LOCALE_EN,
};
match translate(locale, key) {
// 无参查表只返回 Borrowed(静态表值或原 key);Owned 视为异常回退 key
Cow::Borrowed(s) => s,
Cow::Owned(_) => key,
}
}
/// 带参数的界面文案:语言包内占位符写作 `{name}`(非 rust-i18n 的 `%{name}`,
/// 由本函数按名替换),参数值只需 `Display`。
pub fn tf(key: &'static str, args: &[(&str, &dyn std::fmt::Display)]) -> String {
let mut out = t(key).to_string();
for (name, value) in args {
out = out.replace(&format!("{{{name}}}"), &value.to_string());
}
out
}
// ─── 单元测试:语言包完整性由测试强制,而不是靠人工纪律 ──────────────────────
/// 提取 `{name}` 形式的占位符(ASCII 标识符;`{{x}}` 双括号、`{$builtin}`
/// 与中文花括号说明文不算),排序后比较集合。
#[cfg(test)]
fn placeholders(s: &str) -> Vec<String> {
let b = s.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < b.len() {
if b[i] == b'{' {
let before_ok = i == 0 || b[i - 1] != b'{';
let mut j = i + 1;
while j < b.len() && (b[j].is_ascii_alphanumeric() || b[j] == b'_') {
j += 1;
}
let after_ok = j < b.len() && b[j] == b'}' && (j + 1 == b.len() || b[j + 1] != b'}');
let name = &s[i + 1..j];
let name_ok = name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
if before_ok && after_ok && name_ok {
out.push(name.to_string());
i = j + 1;
continue;
}
}
i += 1;
}
out.sort();
out
}
/// 解析生成器产出的规整 YAML(key 行 + 缩进的 en/zh-CN 值行)。
/// 格式意外时 panic——语言包是构建产物,格式受控。
#[cfg(test)]
fn load_ui_yml() -> std::collections::BTreeMap<String, (String, String)> {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales/ui.yml"),
)
.expect("locales/ui.yml 应存在");
let unquote = |v: &str| -> String {
let v = v.trim();
let inner = v
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(v);
inner.replace("\\\"", "\"").replace("\\\\", "\\")
};
let mut map = std::collections::BTreeMap::new();
let mut key: Option<String> = None;
let mut en: Option<String> = None;
let mut zh: Option<String> = None;
for line in text.lines() {
if line.starts_with('#') || line.starts_with("_version") || line.trim().is_empty() {
continue;
}
if !line.starts_with(' ') {
if let (Some(k), Some(e), Some(z)) = (key.take(), en.take(), zh.take()) {
map.insert(k, (e, z));
}
key = Some(unquote(line.trim_end_matches(':')));
} else if let Some(v) = line.trim().strip_prefix("en:") {
en = Some(unquote(v));
} else if let Some(v) = line.trim().strip_prefix("zh-CN:") {
zh = Some(unquote(v));
}
}
if let (Some(k), Some(e), Some(z)) = (key, en, zh) {
map.insert(k, (e, z));
}
map
}
#[cfg(test)]
mod tests {
use super::*;
/// 每个 key 都必须有非空 en 与 zh-CN;占位符两侧集合一致。
#[test]
fn yml_complete_and_placeholder_parity() {
let map = load_ui_yml();
assert!(!map.is_empty(), "语言包不能为空");
for (k, (en, zh)) in &map {
assert!(!en.trim().is_empty(), "en 为空: {k:?}");
assert!(!zh.trim().is_empty(), "zh-CN 为空: {k:?}");
assert_eq!(
placeholders(zh),
placeholders(en),
"占位符两侧不一致: {k:?}"
);
}
}
/// 扫描 src/ 全部源码里的 `t("…")` / `tf("…")` 字面量,
/// 逐一断言语言包有词条——漏翻是测试失败,不再是静默回退。
#[test]
fn call_sites_covered_by_yml() {
let map = load_ui_yml();
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
collect_rs(&root, &mut files);
assert!(!files.is_empty(), "应扫描到源码文件");
let mut missing = Vec::new();
for f in files {
if f.ends_with("i18n.rs") {
continue;
}
let src = std::fs::read_to_string(&f).unwrap();
for key in extract_t_literals(&src) {
if !map.contains_key(&key) {
missing.push(format!("{}: {key:?}", f.display()));
}
}
}
assert!(
missing.is_empty(),
"以下 t()/tf() 字面量没有语言包词条:\n{}",
missing.join("\n")
);
}
/// 模型 `label()` 返回的 key 必须有真实(非自映射)的 en 词条。
#[test]
fn model_labels_have_real_translations() {
let labels = [
crate::model::ConnectionStatus::Disconnected.label(),
crate::model::ConnectionStatus::Connecting.label(),
crate::model::ConnectionStatus::Connected.label(),
crate::model::ConnectionStatus::Error.label(),
crate::model::Direction::Received.label(),
crate::model::Direction::Published.label(),
crate::model::ThemeModePref::System.label(),
crate::model::ThemeModePref::Light.label(),
crate::model::ThemeModePref::Dark.label(),
];
let missing: Vec<&str> = labels
.iter()
.filter(|l| translate(LOCALE_EN, l) == Cow::Borrowed(**l))
.copied()
.collect();
assert!(missing.is_empty(), "模型 label 缺 en 词条: {missing:?}");
}
fn collect_rs(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
collect_rs(&p, out);
} else if p.extension().is_some_and(|x| x == "rs") {
out.push(p);
}
}
}
/// 手写扫描(避免引入 regex 依赖):找 `::t("` / `::tf("` 后的字面量。
/// 两个针互不误伤:`::t("` 要求引号紧跟 t,不会命中 `::tf("`。
fn extract_t_literals(src: &str) -> Vec<String> {
let b = src.as_bytes();
let mut out = Vec::new();
for needle in [b"::tf(\"".as_slice(), b"::t(\"".as_slice()] {
let mut i = 0;
while let Some(rel) = find(b, i, needle) {
let start = rel + needle.len();
let (lit, end) = read_string(b, start);
if let Some(lit) = lit {
out.push(lit);
}
i = end.max(i + 1);
}
}
out
}
fn find(hay: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
hay[from..]
.windows(needle.len())
.position(|w| w == needle)
.map(|p| p + from)
}
/// 从 `start`(首字节应为 `"`)读一个 Rust 字符串字面量,
/// 处理转义,返回 (解码内容, 结束位置)。
fn read_string(b: &[u8], start: usize) -> (Option<String>, usize) {
if start >= b.len() || b[start] != b'"' {
return (None, start + 1);
}
let mut out = String::new();
let mut i = start + 1;
while i < b.len() {
match b[i] {
b'\\' => {
i += 1;
if i < b.len() {
match b[i] {
b'"' => out.push('"'),
b'\\' => out.push('\\'),
other => {
out.push('\\');
out.push(other as char);
}
}
i += 1;
}
}
b'"' => return (Some(out), i + 1),
_ => {
let mut end = i + 1;
while end < b.len() && (b[end] & 0xC0) == 0x80 {
end += 1;
}
out.push_str(&String::from_utf8_lossy(&b[i..end]));
i = end;
}
}
}
(None, i)
}
}