feat(app-iced,i18n): M106 —— 界面跟随系统语言 + 设置页真实语言行
用户原话:「我系统语言是中文 为什么打开软件是英文 并且设置页里面显示的是 English(system)」。
两处根源:①M92 的探测链一直是对的,但 `pages::Model.lang` 在 `app/view.rs` 里写死成
`Lang::En`(`pages::render` 还把下载页写死成英文),探测结果没人用;②设置页语言行是硬编码
字面量 `("Language", "English (System)")`。
新增/改动(按层):
- i18n:新域 `catalog/general.rs`(24 条中英双份 key,装配链尾追加)+ `catalog/golden.rs`
只追加不重排;`choice.rs`(`LangChoice`:跟随系统/手选 + D28 四级优先级真值表 + 字体优先级真值表)。
- app-iced 语言入口 `src/ui.rs` + `src/ui/lang.rs`:`LangState` = 用户档位 + 探测结论;
启动判定 `ULL_LANG` > 配置 `ui.language` > 系统 locale > 英文;两行日志(探测/生效 + 中文策略)。
- `app/state.rs`:`App` 持有 `LangState`,切语言 = 换目录 + 落盘 + 重建视图模型;
`app/view.rs` 的 `model()` 收 `lang`,不再写死英文。
- 设置页:语言行换真控件(跟随系统 / 简体中文 / 英文,回显「界面当前使用:简体中文」);
页头、左列分组名、逐实例空态、导航轨标签全部走 catalog。
- presenter:`settings_bridge_lang.rs`(`load_ui_language` / `persist_ui_language`)。
「跟随系统」= **删键**(schema 允许值只有 zh_cn/en_us),读的是 `doc().get()` 而不是
`effective_str`(后者会把默认值 `zh_cn` 当成用户手选,英文机器一启动就被切成中文)。
验证:408 条 app-iced lib 测试 + 28 条 i18n 测试 + 全仓 `cargo test --workspace --exclude
unlucky-app` 全绿;fmt / clippy `-D warnings` 干净;出图 6 张(en/zh × 浅/深 + 下载页对照)
在 `DevDocs/artifacts/m106/`(该目录被 .gitignore 忽略,按仓内惯例作为本地验收产物)。
This commit is contained in:
1 parent
777d02e7cb
commit
89dcb6bdf3
25 files changed
+1456
-92
No files matched your search
@@ -20,6 +20,7 @@ use std::time::Instant;
|
||||
use iced::window;
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::backend::{build_backend, config_path, AppOptions};
|
||||
use unlucky_presenter::dto::Backend;
|
||||
use unlucky_presenter::prefs::ThemeMode;
|
||||
@@ -30,6 +31,7 @@ use unlucky_presenter::toast::ToastKind;
|
||||
use crate::cli::Options;
|
||||
use crate::pages;
|
||||
use crate::theme::{Families, Ui};
|
||||
use crate::ui::lang::LangState;
|
||||
|
||||
use super::WINDOW_SIZE;
|
||||
|
||||
@@ -58,6 +60,11 @@ pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs
|
||||
/// 3 秒够真 host 把「命令已受理」的第一条事件交回来;之后由「有 Busy/Running 实例」这个条件续订。
|
||||
pub(super) const WATCH_WINDOW: std::time::Duration = std::time::Duration::from_secs(3);
|
||||
|
||||
/// 设置页左侧分组的取证开关:`ULL_SETTINGS_GROUP=0` 直达 General(语言行在那里)。
|
||||
///
|
||||
/// 只影响 `--screenshot` 那一次进程(见 [`App::with_presenter`]);交互模式按用户点的那一组。
|
||||
pub(super) const SETTINGS_GROUP_ENV: &str = "ULL_SETTINGS_GROUP";
|
||||
|
||||
/// 进程内「run() 开始」的时刻(首帧耗时基线)。
|
||||
pub(super) static STARTED: OnceLock<Instant> = OnceLock::new();
|
||||
/// 首帧日志只打一次。
|
||||
@@ -86,6 +93,11 @@ pub struct App {
|
||||
/// 用来定优先级:`--theme` > 配置文件 > 系统主题。没有它,`Message::SystemTheme` 会无条件
|
||||
/// 盖掉配置文件的显式主题(M90 r3 修)。
|
||||
pub(super) theme_mode: ThemeMode,
|
||||
/// 界面语言(M106):用户档位 + 探测结论 + 目录选择。见 [`crate::ui::lang`]。
|
||||
///
|
||||
/// 为什么不在 `Ui` 里:`theme.rs` 是令牌求值器(颜色/字号),而语言是**运行期状态**——
|
||||
/// 随设置变、要落盘、要重建视图模型(切主题只重建 Style)。
|
||||
pub(super) lang: LangState,
|
||||
/// 上一次推进节拍的时刻(节拍由帧事件驱动,按真实流逝时间累加)。
|
||||
pub(super) last_tick: Instant,
|
||||
/// 窗口宽度(布局档位的判据;交互模式跟着 resize 事件走)。
|
||||
@@ -188,17 +200,33 @@ impl App {
|
||||
// 面板 α / 玻璃的降级判据要的是**实际**后端,不是 `--renderer` 的请求值(复审 r1 P2-2):
|
||||
// 默认 `Auto` + 无独显时请求是 Auto、实际是 tiny-skia,只看请求值就会漏掉 D36 的强制项。
|
||||
let backend_tier = super::backend::effective_backend(options.backend);
|
||||
let appearance = pages::appearance::State::new(
|
||||
let mut appearance = pages::appearance::State::new(
|
||||
&appearance_prefs,
|
||||
&appearance_base,
|
||||
backend_tier,
|
||||
flavor,
|
||||
);
|
||||
let view = pages::Model::new(
|
||||
// 取证开关(D30,与 `ULL_START_ROUTE` 同一条思路):`ULL_SETTINGS_GROUP=<下标>` 选设置页的
|
||||
// 左侧分组。设置页出厂停在「外观」(`APPEARANCE_GROUP`),而「语言」那一行在 General 组——
|
||||
// 出图必须能直达,否则验收图里根本没有用户抱怨的那一行。**只在截图通道生效**:交互模式
|
||||
// 一律按用户点的那一组。
|
||||
if options.screenshot.is_some() {
|
||||
if let Some(index) = std::env::var(SETTINGS_GROUP_ENV)
|
||||
.ok()
|
||||
.and_then(|raw| raw.trim().parse::<usize>().ok())
|
||||
{
|
||||
appearance.selected_group = index;
|
||||
}
|
||||
}
|
||||
// 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在
|
||||
// `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。
|
||||
let lang = LangState::boot(&bridge, options.screenshot.is_some());
|
||||
let mut view = pages::Model::new(
|
||||
pages::Screen::Instances,
|
||||
width,
|
||||
Some(pages::Screen::Instances.nav()),
|
||||
);
|
||||
view.lang = lang.lang();
|
||||
|
||||
let mut app = Self {
|
||||
ui: Ui::new(flavor, families),
|
||||
@@ -210,6 +238,7 @@ impl App {
|
||||
ticks: 0,
|
||||
config_due_at: unlucky_presenter::theme::CONFIG_POLL_TICKS,
|
||||
theme_mode,
|
||||
lang,
|
||||
last_tick: Instant::now(),
|
||||
width,
|
||||
view,
|
||||
@@ -232,6 +261,10 @@ impl App {
|
||||
};
|
||||
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
|
||||
app.rebuild_ui();
|
||||
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
|
||||
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
|
||||
app.lang.log();
|
||||
app.lang.log_cjk();
|
||||
app
|
||||
}
|
||||
|
||||
@@ -295,7 +328,8 @@ impl App {
|
||||
// 否则会出现「选项区英文、取消弹窗中文」的同屏两语(复审 P2-2)。
|
||||
if self.state.install_cancelled != self.install_cancelled_seen {
|
||||
self.install_cancelled_seen = self.state.install_cancelled;
|
||||
let text = pages::cancelled_toast(super::view::ui_lang());
|
||||
// M106:提示语跟着**生效语言**走(M105 留的 `view::ui_lang()` 占位到此收口)。
|
||||
let text = pages::cancelled_toast(self.lang());
|
||||
self.state.toasts.push(ToastKind::Info, text);
|
||||
}
|
||||
|
||||
@@ -307,12 +341,14 @@ impl App {
|
||||
let draft = self.download_draft.clone();
|
||||
// 右栗占掉的宽度先扣掉:面板展开时页面按更窄的宽度排版(「收缩而不是溢出」,M104)
|
||||
let width = self.agent.content_width(self.width);
|
||||
// M106:语言形参跟着 `LangState` 走(`draft` 那一位是 M105 的安装草稿)。
|
||||
self.view = super::view::model(
|
||||
&self.state,
|
||||
width,
|
||||
self.options.force_state,
|
||||
category,
|
||||
&draft,
|
||||
self.lang(),
|
||||
);
|
||||
self.view_dirty = false;
|
||||
self.refreshes += 1;
|
||||
@@ -342,6 +378,37 @@ impl App {
|
||||
pub(super) fn ui(&self) -> &Ui {
|
||||
&self.ui
|
||||
}
|
||||
|
||||
/// 当前生效的界面语言(= 目录选择;页面文案一律由它派生)。
|
||||
pub(super) fn lang(&self) -> Lang {
|
||||
self.lang.lang()
|
||||
}
|
||||
|
||||
/// 设置页语言行要的两件:档位(画选中态)+ 生效语言(画回显)。
|
||||
pub(super) fn language(&self) -> pages::Language {
|
||||
self.lang.for_settings()
|
||||
}
|
||||
|
||||
/// 换界面语言(设置页语言行 → `pages::Message::Language`)。
|
||||
///
|
||||
/// 三件事缺一不可:①档位与结论换掉 ②视图模型重建(`view_dirty` + `refresh_view`,目录是
|
||||
/// 视图模型的一部分)③落盘(重启后保持所选语言)。字体不在这里重扫:中文档缺字体时的
|
||||
/// 判据/信号归 `i18n::font::decide` + `fonts/cjk.rs`(见报告「没验的部分」)。
|
||||
pub(super) fn set_language(&mut self, choice: unlucky_i18n::LangChoice) {
|
||||
if choice == self.lang.choice() {
|
||||
return;
|
||||
}
|
||||
self.lang = LangState::of(choice);
|
||||
self.lang.log();
|
||||
if !self.bridge.persist_ui_language(choice.setting_value()) {
|
||||
eprintln!(
|
||||
"[i18n] 语言偏好没落盘(配置只读或数据根解析不出来):本次生效,重启会回到旧值"
|
||||
);
|
||||
}
|
||||
self.lang.log_cjk();
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动参数 → presenter 的后端选项。
|
||||
|
||||
@@ -41,6 +41,10 @@ impl App {
|
||||
// M101:设置页的四条填页线共用一个投递口;组内变体在 `update/settings.rs` 里穷尽分发
|
||||
// (那一层没有 `_ =>`,见本文件末尾与 `update/settings/tests.rs` 的两条守门)
|
||||
pages::Message::Settings(group) => return self.on_settings(group),
|
||||
// M106:换界面语言(落盘 + 重建视图模型;档位不是业务命令,不派 `AppCommand`)。
|
||||
// 这是**唯一**一处绕开 M101 投递口的页面消息:语言档位不是「某一组的状态」,
|
||||
// 它要落盘到 `ui.language` 并重建整棵视图模型(设置页的语言行只是它的第一个出口)。
|
||||
pages::Message::Language(choice) => self.set_language(choice),
|
||||
// M95:摘要落剪贴板(文本由页面造,这里只搬)
|
||||
pages::Message::CopySummary(text) => return iced::clipboard::write(text),
|
||||
// 下面四支已由 `on_download` 提前消化;显式列出而不是 `_ => {}`,见模块文档
|
||||
@@ -70,6 +74,76 @@ impl App {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_i18n::LangChoice;
|
||||
use unlucky_presenter::mock::MockBackend;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use crate::app::state::App;
|
||||
use crate::cli::Options;
|
||||
use crate::pages;
|
||||
use crate::theme::Families;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
/// 不碰磁盘的 App(mock 后端 + 内存设置桥),与 `app/tests.rs` 的 `headless` 同型。
|
||||
fn headless() -> App {
|
||||
App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(MockBackend::new()),
|
||||
SettingsBridge::memory_only(),
|
||||
)
|
||||
}
|
||||
|
||||
/// M106:设置页语言行点一下 → 生效语言与视图模型都跟着换。
|
||||
///
|
||||
/// 抓得住:①`pages::Message::Language` 没接进 `on_page`(点了没反应,编译器不会报——它接得住,
|
||||
/// 但接错了分支就静默);②`set_language` 只改字段不重建视图(界面要等下一次刷新才变);
|
||||
/// ③档位与生效语言各说各话(设置页会画错选中态)。
|
||||
/// 抓不住:界面真的画成中文了没有(那要截图,见 `DevDocs/artifacts/m106/`)。
|
||||
#[test]
|
||||
fn the_language_message_switches_the_catalog_and_rebuilds_the_view() {
|
||||
let mut app = headless();
|
||||
assert_eq!(
|
||||
app.view.lang,
|
||||
app.lang(),
|
||||
"首帧的视图模型要已经按生效语言建好"
|
||||
);
|
||||
|
||||
let _ = app.update(crate::app::message::Message::Page(
|
||||
pages::Message::Language(LangChoice::Fixed(unlucky_i18n::Lang::ZhCn)),
|
||||
));
|
||||
assert_eq!(app.lang(), unlucky_i18n::Lang::ZhCn);
|
||||
assert_eq!(app.view.lang, unlucky_i18n::Lang::ZhCn, "目录要跟着换");
|
||||
assert_eq!(
|
||||
app.language(),
|
||||
pages::Language {
|
||||
choice: LangChoice::Fixed(unlucky_i18n::Lang::ZhCn),
|
||||
effective: unlucky_i18n::Lang::ZhCn,
|
||||
}
|
||||
);
|
||||
|
||||
let refreshes = app.refreshes;
|
||||
let _ = app.update(crate::app::message::Message::Page(
|
||||
pages::Message::Language(LangChoice::Fixed(unlucky_i18n::Lang::En)),
|
||||
));
|
||||
assert_eq!(app.view.lang, unlucky_i18n::Lang::En);
|
||||
assert_eq!(app.refreshes, refreshes + 1, "换语言要重建一次视图模型");
|
||||
|
||||
// 同一档再点一次:什么都不该发生(不白重建)
|
||||
let refreshes = app.refreshes;
|
||||
let _ = app.update(crate::app::message::Message::Page(
|
||||
pages::Message::Language(LangChoice::Fixed(unlucky_i18n::Lang::En)),
|
||||
));
|
||||
assert_eq!(app.refreshes, refreshes, "同一档不该再重建");
|
||||
}
|
||||
|
||||
/// 抓得住:有人给 `on_page` 的 match 重新加 `_ => {}` 兜底 —— 那正是 M94 复审的 p3:
|
||||
/// 兜底让「加了变体忘了接线」从**编译错误**退化成「点了没反应」,这条测试把它拽回来。
|
||||
/// 抓不住:别的兜底写法(把 match 拆成 if-else 链、给子枚举加通配之后再漏变体),
|
||||
|
||||
@@ -69,12 +69,15 @@ impl App {
|
||||
appearance: Some(&self.appearance),
|
||||
groups: &self.settings,
|
||||
},
|
||||
// M106:设置页 General 组要画「当前档位 + 生效语言」。
|
||||
self.language(),
|
||||
)
|
||||
.map(Message::Page),
|
||||
agent::docked(&self.ui, &self.agent, self.width)
|
||||
.map(|element| element.map(Message::Agent)),
|
||||
overlay,
|
||||
self.options.force_state,
|
||||
self.lang(),
|
||||
),
|
||||
// `Route::Agent`:独立页与右栗共用同一个 `conversation::view`(`agent::page` 内部调的它)。
|
||||
None => shell::view_with_background(
|
||||
@@ -86,6 +89,7 @@ impl App {
|
||||
None,
|
||||
overlay,
|
||||
self.options.force_state,
|
||||
self.lang(),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -93,9 +97,12 @@ impl App {
|
||||
|
||||
// --------------------------------------------------------------- 快照 → 视图模型
|
||||
|
||||
/// **产品路径此刻的界面语言**(唯一入口):语言还没接到设置(M106 的范围),所以恒为英文。
|
||||
/// 收成一处是为了别让第二个地方(如取消提示)各硬编码一次;M106 只改这里。
|
||||
/// **M106 合并前中文译文在产品路径上不可达**(页面与提示语都是英文)。
|
||||
/// **测试用的英文基线**(M105 留的占位在这里收口)。
|
||||
///
|
||||
/// 产品路径的界面语言由 `App::lang()` 经 `model(…, lang)` 与 `pages::cancelled_toast(lang)`
|
||||
/// 递进去(M106),这个函数只剩测试还在用(`app/update/download_wiring.rs` 的取消提示断言要一个
|
||||
/// 与产品无关的语言常量)。`#[cfg(test)]`:非测试构建里它没有调用点,留着就是死代码。
|
||||
#[cfg(test)]
|
||||
pub(super) fn ui_lang() -> Lang {
|
||||
Lang::En
|
||||
}
|
||||
@@ -113,6 +120,7 @@ pub(super) fn model(
|
||||
forced: Option<crate::cli::ForcedState>,
|
||||
category: pages::Category,
|
||||
draft: &pages::InstallDraft,
|
||||
lang: Lang,
|
||||
) -> pages::Model {
|
||||
let route = state.route().clone();
|
||||
let screen = pages::Screen::of(&route).unwrap_or(pages::Screen::Instances);
|
||||
@@ -137,19 +145,12 @@ pub(super) fn model(
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let lang = ui_lang();
|
||||
|
||||
pages::Model {
|
||||
screen,
|
||||
window_width: width,
|
||||
active: route.nav(),
|
||||
instances: pages::Instances { rows, detail },
|
||||
download: pages::Download::from_snapshot(
|
||||
state,
|
||||
category,
|
||||
draft,
|
||||
unlucky_i18n::Lang::En.catalog(),
|
||||
),
|
||||
download: pages::Download::from_snapshot(state, category, draft, lang.catalog()),
|
||||
logs: logs_model(state).with_status(lang),
|
||||
lang,
|
||||
forced: forced.map(|state| match state {
|
||||
@@ -289,7 +290,14 @@ mod tests {
|
||||
/// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿。
|
||||
fn at(state: &AppState, forced: Option<crate::cli::ForcedState>) -> pages::Model {
|
||||
let draft = pages::InstallDraft::default();
|
||||
model(state, 1180.0, forced, pages::Category::All, &draft)
|
||||
model(
|
||||
state,
|
||||
1180.0,
|
||||
forced,
|
||||
pages::Category::All,
|
||||
&draft,
|
||||
Lang::En,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -131,7 +131,16 @@ impl Resolved {
|
||||
}
|
||||
|
||||
/// 求值本机字体(会触发 iced 全局字体系统的首次初始化,约 0.1~0.8s)。
|
||||
///
|
||||
/// 中文那一级按**系统 locale** 判:启动编排(`app/boot.rs`)这一拍还没有配置读取点。真正的
|
||||
/// `ui.language` 在 [`crate::app::state`] 构造 `App` 时读到,随后用 [`cjk_for`] 重算一次并打
|
||||
/// 日志——所以「英文系统上手动切中文」不会停在 `NotNeeded`(见 [`cjk_for`])。
|
||||
pub fn resolve() -> Resolved {
|
||||
resolve_with(None)
|
||||
}
|
||||
|
||||
/// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。
|
||||
pub fn resolve_with(setting: Option<&str>) -> Resolved {
|
||||
let platform = Platform::current();
|
||||
let faces = system_faces();
|
||||
|
||||
@@ -166,7 +175,7 @@ pub fn resolve() -> Resolved {
|
||||
|
||||
// D28 后半:语言 + 中文环境的中文字体策略。`faces` 的 `None` 在这里是「字体库读不出来」,
|
||||
// 适配层会把它翻成 `Unprobeable` 而不是「缺字体」(否则会误触发一次 CDN 下载)。
|
||||
let cjk_report = cjk::probe(faces.as_deref());
|
||||
let cjk_report = cjk::probe_with(setting, faces.as_deref());
|
||||
|
||||
Resolved {
|
||||
platform,
|
||||
@@ -176,6 +185,17 @@ pub fn resolve() -> Resolved {
|
||||
}
|
||||
}
|
||||
|
||||
/// 语言切换后的中文策略重算(M106):`setting` 是配置里 `ui.language` 的**显式值**
|
||||
/// (`None` = 跟随系统,由 [`crate::platform::locale::SystemLocale`] 决定)。
|
||||
///
|
||||
/// 为什么需要它:启动那一轮([`resolve`])拿不到配置,只按系统 locale 判。用户在英文系统上
|
||||
/// 手选「简体中文」时,若不复算,策略会停在 `NotNeeded`——界面文字是中文、字体却没有中文字形,
|
||||
/// 那就是白块。复算出的 `NeedsDownload` 是 D28 的**信号**(下载器仍是 `Source::PendingPin`,
|
||||
/// 见 `unlucky_i18n::font`),本 crate 只负责把它打进日志、不让它静默。
|
||||
pub fn cjk_for(setting: Option<&str>) -> CjkReport {
|
||||
cjk::probe_with(setting, system_faces().as_deref())
|
||||
}
|
||||
|
||||
/// 一条系统字面(族名 + 字重)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Face {
|
||||
|
||||
@@ -20,7 +20,13 @@
|
||||
//! - **抓得住**:语言 + 字体库族名 → 策略(含 `Unprobeable` 这一支);优先级顺序(注入的假源可测);
|
||||
//! - **抓不住**:①族名存在 ≠ 字形覆盖(`SimSun` 也可能是个残缺子集)。真判据要开 `ttf-parser`
|
||||
//! 查 cmap,本 mission 不引依赖(M60 Q3 spike 做过,结论见 `DevDocs`);
|
||||
//! ②`ui.language` 这一级目前**没有接线**(设置层还没有读取点),调用点传 `None`。
|
||||
//! ②下载与注册(`Strategy::NeedsDownload` 只是信号,`Source::PendingPin` 还没钉 URL/哈希)。
|
||||
//!
|
||||
//! ## `ui.language` 的接线(M106)
|
||||
//!
|
||||
//! 启动编排(`app/boot.rs`)那一拍只按系统 locale 判(`fonts::resolve` → `probe_with(None, ..)`);真正的
|
||||
//! `ui.language` 在 `App` 构造/切语言时读到,随后走 `fonts::cjk_for(setting)` 复算一次并打日志。
|
||||
//! 所以「英文系统上手动切中文」也会得到 `needs-download` 而不是 `not-needed`——白块那条路被堵住了。
|
||||
|
||||
use unlucky_i18n::font::{self, Strategy};
|
||||
use unlucky_i18n::lang::{self, Detection, EnvLocale};
|
||||
@@ -68,11 +74,6 @@ pub fn probe_with(setting: Option<&str>, faces: Option<&[Face]>) -> CjkReport {
|
||||
report(lang::resolve(setting, &SystemLocale, &EnvLocale), faces)
|
||||
}
|
||||
|
||||
/// 不读配置时的入口(启动路径用)。
|
||||
pub fn probe(faces: Option<&[Face]>) -> CjkReport {
|
||||
probe_with(None, faces)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -164,7 +165,7 @@ mod tests {
|
||||
fn explicit_setting_overrides_the_platform_source() {
|
||||
// 抓得住:`ui.language` 显式值被平台源盖掉(用户手选中文/英文必须赢过系统语言);
|
||||
// 也让「平台源真的接在链路上」有个不依赖本机语言的证据。
|
||||
// 抓不住:`ui.language` 在真机上读到什么(设置层还没接线,调用点传 None)。
|
||||
// 抓不住:`ui.language` 在真机上读到什么(那一级由 `App` 读配置后经 `fonts::cjk_for` 喂进来)。
|
||||
let installed = faces(&["Microsoft YaHei"]);
|
||||
|
||||
let forced_english = probe_with(Some("en_us"), Some(&installed));
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
//! | `app` / `shell` | Elm 三件套 + 自绘标题栏 / 导航轨 / 内容骨架 | 业务逻辑 |
|
||||
//! | `shot` | `window::screenshot` → PNG(验收通道) | 图像比对 |
|
||||
//! | `icons` | 令牌里的 SVG path → iced `svg` 控件 | 画新图标 |
|
||||
//! | [`ui`] | 界面语言的入口层(M106):`LangState` = 用户档位 + 探测结论,目录由它选 | 翻译(那是 `unlucky-i18n`) |
|
||||
//! | `platform` | 平台窗口能力(D36):整窗 α 的纯判据 + win32 FFI(补分层样式 + 下发 α)—— **全仓唯一** `unsafe` 落点 | 窗口创建 / 业务开关 |
|
||||
//!
|
||||
//! ## 消费纪律
|
||||
@@ -38,6 +39,7 @@ mod fonts;
|
||||
mod icons;
|
||||
mod probe;
|
||||
mod shell;
|
||||
mod ui;
|
||||
|
||||
pub use cli::{Backend, Options};
|
||||
pub use probe::Probe;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! `pages::Message` 是页面层的自有消息类型(**不** import `crate::app::Message`,组件契约 §2 的
|
||||
//! 同一条纪律);app 层只做一次 `.map`。
|
||||
|
||||
use unlucky_i18n::LangChoice;
|
||||
use unlucky_presenter::router::Route;
|
||||
|
||||
use super::appearance;
|
||||
@@ -59,6 +60,16 @@ pub enum Message {
|
||||
/// 也不碰 `app/update/page_messages.rs` 那条不许 `_ =>` 的 match。加变体与落点见
|
||||
/// `pages/settings/message.rs`(`app/update/settings.rs` 对它做穷尽分发,漏接线报 E0004)。
|
||||
Settings(settings::GroupMessage),
|
||||
/// 换界面语言(**M106**:设置页 General 分组的语言行 → `App::set_language`)。
|
||||
///
|
||||
/// 载荷是**用户意图**(跟随系统 / 手选某一门语言),不是「生效语言」:生效语言由
|
||||
/// `LangChoice::resolve` 按 D28 的顺序算出来,页面不参与计算。
|
||||
///
|
||||
/// 为什么不折进 [`Message::Settings`] 那个投递口:语言档位不是「某一组的状态」——它要**落盘**
|
||||
/// 到 `ui.language` 并**重建整棵视图模型**(目录是 `pages::Model` 的一部分),而那条落点
|
||||
/// (`app/update/settings/language_appearance.rs`)是 language+appearance 线的文件。
|
||||
/// 代价是本文件多一个变体;收口方向写在 `settings/general.rs` 的文档里。
|
||||
Language(LangChoice),
|
||||
}
|
||||
|
||||
/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。
|
||||
|
||||
@@ -129,6 +129,7 @@ pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome};
|
||||
pub use message::{Action, Message};
|
||||
pub use model::{Detail, Instances, Meta, Model, Row};
|
||||
pub use screen::Screen;
|
||||
pub use settings::Language;
|
||||
|
||||
use iced::widget::{column, container, row, space};
|
||||
use iced::{Element, Length};
|
||||
@@ -155,6 +156,7 @@ pub fn page<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> {
|
||||
appearance: None,
|
||||
groups: settings::demo_groups(),
|
||||
},
|
||||
Language::default(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -178,6 +180,7 @@ pub fn page_with_appearance<'a>(
|
||||
appearance: Some(appearance),
|
||||
groups: settings::demo_groups(),
|
||||
},
|
||||
Language::default(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -186,21 +189,31 @@ pub fn page_with_appearance<'a>(
|
||||
/// 为什么要单独一个入口:组状态的所有者是 `App`(`app/state.rs` 的 `settings` 字段),页面层
|
||||
/// 只借用一帧。加/改某一组的字段不改变这条签名(`States` 里只有 `&Groups`),所以四条填页线
|
||||
/// 都不必来这里动一行。
|
||||
/// `language` 是 M106 加的:「当前档位 + 生效语言」只有 `App` 知道(`App::language()`),而设置页
|
||||
/// 的 General 组要画它。唯一调用点是 `app/view.rs`;harness 那三条入口([`page`] /
|
||||
/// [`page_with_appearance`] / [`app_with_appearance`])仍用 [`Language::default`]。
|
||||
pub fn page_with_settings<'a>(
|
||||
ui: &'a Ui,
|
||||
model: &'a Model,
|
||||
states: settings::States<'a>,
|
||||
language: Language,
|
||||
) -> Element<'a, Message> {
|
||||
render(ui, model, states)
|
||||
render(ui, model, states, language)
|
||||
}
|
||||
|
||||
fn render<'a>(ui: &'a Ui, model: &'a Model, states: settings::States<'a>) -> Element<'a, Message> {
|
||||
fn render<'a>(
|
||||
ui: &'a Ui,
|
||||
model: &'a Model,
|
||||
states: settings::States<'a>,
|
||||
language: Language,
|
||||
) -> Element<'a, Message> {
|
||||
let metrics = layout::frame_metrics(model.screen.kit(), model.window_width);
|
||||
|
||||
match model.screen {
|
||||
Screen::Instances => instances::view(ui, metrics, model),
|
||||
Screen::Download => download::view(ui, metrics, &model.download, unlucky_i18n::Lang::En),
|
||||
Screen::Settings => settings::view(ui, metrics, states),
|
||||
// M106:下载页此前写死成 `Lang::En`,现在跟 `model.lang`(页面文案跟着界面语言走)。
|
||||
Screen::Download => download::view(ui, metrics, &model.download, model.lang),
|
||||
Screen::Settings => settings::view(ui, metrics, states, language),
|
||||
Screen::Logs => logs::view(ui, metrics, &model.logs, model.lang),
|
||||
}
|
||||
}
|
||||
@@ -233,6 +246,7 @@ pub fn app_with_appearance<'a>(
|
||||
appearance,
|
||||
groups: settings::demo_groups(),
|
||||
},
|
||||
Language::default(),
|
||||
);
|
||||
|
||||
let shell = row![nav::rail(ui, model.active), body]
|
||||
|
||||
@@ -22,6 +22,7 @@ use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::{Key, Lang};
|
||||
|
||||
use crate::pages::appearance::Change;
|
||||
use crate::pages::{Action, Message};
|
||||
@@ -33,22 +34,40 @@ use super::Group;
|
||||
const EMPTY_SLOT_HEIGHT: f32 = 320.0;
|
||||
|
||||
/// 页头:标题 + 副标题 + 两个占位动作。
|
||||
pub(super) fn header(ui: &Ui) -> Header<'_> {
|
||||
Header::new("Settings")
|
||||
.subtitle("Launcher behaviour, Java runtimes and download sources")
|
||||
.action(text_action(ui, "Reset section", Action::ResetSection))
|
||||
.action(text_action(ui, "Save", Action::SaveSettings))
|
||||
///
|
||||
/// M106:三条文案走 catalog(`lang` 由 `settings::view` 传下来)。页标题复用 `NavSettings`——
|
||||
/// 左列与页头说的是同一件事,不该有第二条译文。
|
||||
pub(super) fn header<'a>(ui: &'a Ui, lang: Lang) -> Header<'a> {
|
||||
let catalog = lang.catalog();
|
||||
Header::new(catalog.text(Key::NavSettings))
|
||||
.subtitle(catalog.text(Key::SettingsSubtitle))
|
||||
.action(text_action(
|
||||
ui,
|
||||
catalog.text(Key::SettingsResetSection),
|
||||
Action::ResetSection,
|
||||
))
|
||||
.action(text_action(
|
||||
ui,
|
||||
catalog.text(Key::SettingsSave),
|
||||
Action::SaveSettings,
|
||||
))
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 侧列
|
||||
|
||||
/// 左列分组导航(supporting-pane 的辅助列,宽 = 令牌 `nav_drawer_width` = 240)。
|
||||
pub(super) fn group_nav<'a>(ui: &'a Ui, selected: usize) -> Element<'a, Message> {
|
||||
pub(super) fn group_nav<'a>(ui: &'a Ui, selected: usize, lang: Lang) -> Element<'a, Message> {
|
||||
let catalog = lang.catalog();
|
||||
let items = column(
|
||||
super::GROUPS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, group)| group_item(ui, group, index, index == selected))
|
||||
.map(|(index, group)| {
|
||||
let label = super::GROUP_LABELS
|
||||
.get(index)
|
||||
.map_or(group.name, |key| catalog.text(*key));
|
||||
group_item(ui, group, label, index, index == selected)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.spacing(ui.space(Space::X1))
|
||||
@@ -72,6 +91,7 @@ pub(super) fn group_nav<'a>(ui: &'a Ui, selected: usize) -> Element<'a, Message>
|
||||
fn group_item<'a>(
|
||||
ui: &'a Ui,
|
||||
group: &'a Group,
|
||||
label: &'a str,
|
||||
index: usize,
|
||||
selected: bool,
|
||||
) -> Element<'a, Message> {
|
||||
@@ -86,7 +106,7 @@ fn group_item<'a>(
|
||||
|
||||
let inner = row![
|
||||
glyph_box(group.icon, content_color, ICON_PX),
|
||||
text_role(ui, spec.text, false, group.name, content_color),
|
||||
text_role(ui, spec.text, false, label, content_color),
|
||||
]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.width(Length::Fill)
|
||||
@@ -180,14 +200,18 @@ fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Me
|
||||
}
|
||||
|
||||
/// 空态位:没有逐实例覆盖时的样子(12b §15 的口径)。
|
||||
pub(super) fn overrides_empty<'a>(ui: &'a Ui) -> Element<'a, Message> {
|
||||
pub(super) fn overrides_empty<'a>(ui: &'a Ui, lang: Lang) -> Element<'a, Message> {
|
||||
let catalog = lang.catalog();
|
||||
container(empty_state(
|
||||
ui,
|
||||
IconKind::Instances,
|
||||
"No per-instance overrides",
|
||||
"Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest \
|
||||
of the launcher keeps the defaults above.",
|
||||
vec![text_action(ui, "Add override", Action::AddOverride)],
|
||||
catalog.text(Key::SettingsOverridesEmptyTitle),
|
||||
catalog.text(Key::SettingsOverridesEmptyBody),
|
||||
vec![text_action(
|
||||
ui,
|
||||
catalog.text(Key::SettingsOverridesAdd),
|
||||
Action::AddOverride,
|
||||
)],
|
||||
))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(EMPTY_SLOT_HEIGHT))
|
||||
|
||||
@@ -1,29 +1,225 @@
|
||||
//! 设置页「General」分组:语言 / 主题 / 启动页 / 关闭按钮(演示行)。
|
||||
//! 设置页「General」分组:语言选择器 + 主题 / 启动页 / 关闭按钮(M106 接真控件)。
|
||||
//!
|
||||
//! **这一组归 language+appearance 线**:语言那一行将来换真下拉时,消息加在本线的消息口
|
||||
//! `settings/language_appearance.rs` 的 `Message` 里,落点是
|
||||
//! `app/update/settings/language_appearance.rs`——**不要**去改 `pages/mod.rs` / `pages/message.rs` /
|
||||
//! `app/update/page_messages.rs`(那三个正是 M101 收掉的共享锚点,改了就把本 mission 的目的掀掉);
|
||||
//! `settings/mod.rs` 的注册表也不用动。
|
||||
//! **这一组归 language+appearance 线**(M101 的分线口径)。
|
||||
//!
|
||||
//! 要显示**页外真值**(例如「当前语言」的唯一来源是 `pages::Model.lang`)或首帧就要取数时,
|
||||
//! 那两条仍有代价——见 `settings/mod.rs` 文档「仍然共享」小节里的第 **4、5** 条。
|
||||
//! ## 这一组为什么特殊
|
||||
//!
|
||||
//! 它**不走** `Group::body`:那个渲染器的签名是 `fn(&Ui, States) -> Option<Element>`,`States` 只有
|
||||
//! `appearance` + `groups` 两格,装不下「当前语言档位 + 生效语言」。语言行要的两件在
|
||||
//! `pages::Language` 里,由 `pages::settings::view` 的 `language` 形参一路递进来、`form` 按下标
|
||||
//! 转交 [`body`];所以 `GROUP.body` 仍是 `None`,`tests/mod.rs` 那条「只有外观组带真控件」照样成立。
|
||||
//!
|
||||
//! ## 语言切换**没有**走 M101 的 `GroupMessage` 投递口(偏离,已登记)
|
||||
//!
|
||||
//! `settings/mod.rs` 的文档说本线要加消息就加到 `settings/language_appearance.rs` 的 `Message` 上。
|
||||
//! M106 走的是 `pages::Message::Language(choice)` 这条独立增量,理由:语言档位不是「某一组的状态」,
|
||||
//! 它要**落盘**(`ui.language`)+ **重建整棵视图模型**(目录是 `pages::Model` 的一部分),而落点
|
||||
//! `app/update/settings/language_appearance.rs` 与消息口 `language_appearance.rs` 都不在本 mission 的
|
||||
//! scope 内。代价:`page_messages.rs` 多一支(那是 M101 想收掉的共享锚点)。**收口方向**:等本线
|
||||
//! 再动设置页时,把 `Message::Language` 折进 `GroupMessage::LanguageAppearance(SetChoice)`,并把
|
||||
//! `pages/mod.rs` 的这支删掉——finding 已登记。
|
||||
//!
|
||||
//! ## 语言行的三个事实
|
||||
//!
|
||||
//! 1. **选中态显示的是档位**(跟随系统 / 简体中文 / 英文),不是「猜出来的语言」——用户点过什么
|
||||
//! 就得画回什么,否则「我明明选了跟随系统」这件事在界面上无从确认;
|
||||
//! 2. **旁边回显生效语言**(`界面当前使用:简体中文`):中文系统 + 跟随系统时,这一行是「界面
|
||||
//! 真的用了中文」的唯一可见证据(用户报的 bug 正是「识别到了但没用上」);
|
||||
//! 3. **文案全部走 catalog**([`Key`]),三档选项名跟着界面语言走(见 `catalog/general.rs`
|
||||
//! 的头注释:为什么不写语言自称)。
|
||||
//!
|
||||
//! ## 其余三行仍只读
|
||||
//!
|
||||
//! 主题 / 启动页 / 关闭按钮还没有后端(`pages::Action` 里是占位动作),所以它们是**只读行**;
|
||||
//! 但标签与取值已经进 catalog,中文界面下不会再冒出英文。
|
||||
//!
|
||||
//! 分组元数据只有一份(这里的 [`GROUP`]):`settings/mod.rs` 的 `GROUPS` 直接引用它。
|
||||
|
||||
use unlucky_design::icons::IconKind;
|
||||
use iced::widget::{button, column, container, row};
|
||||
use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow, Theme as IcedTheme};
|
||||
|
||||
use unlucky_design::color::{Role, StateLayer};
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::catalog::Key;
|
||||
use unlucky_i18n::LangChoice;
|
||||
|
||||
use crate::pages::{frame, Language, Message};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::frame::{card, divider};
|
||||
use super::Group;
|
||||
|
||||
/// General 分组(演示行仍是 M84 的文本行;真控件接进来时把 `body` 换成 `Some(...)`)。
|
||||
/// General 分组(左列名固定英文:左列是**导航**,与导航轨同源,不进 catalog)。
|
||||
pub(super) const GROUP: Group = Group {
|
||||
name: "General",
|
||||
icon: IconKind::Settings,
|
||||
// 只给 harness(`pages_demo` 走 `appearance: None` 时)用的演示行:产品路径走 [`body`]。
|
||||
// 语言那一行**不在**这里——它的值是要跟着语言变的,写死在静态表里就是用户看到的那条假值。
|
||||
rows: &[
|
||||
("Language", "English (System)"),
|
||||
("Theme", "Follow system"),
|
||||
("Startup page", "Instances"),
|
||||
("Close button", "Exit to tray"),
|
||||
],
|
||||
body: None,
|
||||
};
|
||||
|
||||
/// 这一组的正文(**产品路径**)。
|
||||
pub(super) fn body<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
|
||||
let catalog = language.effective.catalog();
|
||||
let mut stacked = column![].spacing(0.0).width(Length::Fill);
|
||||
stacked = stacked.push(language_row(ui, language));
|
||||
|
||||
// 主题 / 启动页 / 关闭按钮:还没有后端,只读;文案已经进 catalog。
|
||||
for (label, value) in [
|
||||
(Key::SettingsTheme, Key::SettingsThemeFollowSystem),
|
||||
(Key::SettingsStartupPage, Key::SettingsStartupInstances),
|
||||
(Key::SettingsCloseButton, Key::SettingsCloseToTray),
|
||||
] {
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(setting_row(ui, catalog.text(label), catalog.text(value)));
|
||||
}
|
||||
|
||||
card(ui, stacked)
|
||||
}
|
||||
|
||||
/// 语言行:标题 + 生效语言回显 + 三档互斥按钮 + 一句说明。
|
||||
fn language_row<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
|
||||
let catalog = language.effective.catalog();
|
||||
let effective = LangChoice::Fixed(language.effective).label_key();
|
||||
|
||||
let head = row![
|
||||
container(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
false,
|
||||
catalog.text(Key::SettingsLanguage),
|
||||
ui.color(Role::OnSurface),
|
||||
))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center),
|
||||
frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::LabelLarge,
|
||||
true,
|
||||
catalog.render(
|
||||
Key::SettingsLanguageEffective,
|
||||
&[("language", catalog.text(effective))],
|
||||
),
|
||||
ui.color(Role::OnPrimaryFixedVariant),
|
||||
),
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
let chips = row(LangChoice::ALL
|
||||
.iter()
|
||||
.map(|choice| {
|
||||
chip(
|
||||
ui,
|
||||
catalog.text(choice.label_key()),
|
||||
*choice == language.choice,
|
||||
*choice,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>())
|
||||
.spacing(ui.space(Space::X1))
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
column![
|
||||
head,
|
||||
chips,
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(Key::SettingsLanguageHint),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.padding(Padding::from([ui.space(Space::X2), 0.0]))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一个只读的「标签 + 取值」行(与 `frame::demo_card` 的一行同高同排版)。
|
||||
fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Message> {
|
||||
let height = ui.metrics().control_height + Space::X2.dp();
|
||||
|
||||
row![
|
||||
container(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
false,
|
||||
label,
|
||||
ui.color(Role::OnSurface),
|
||||
))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
value,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.height(height)
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一个互斥按钮(选中态 `SecondaryContainer`,未选中透明 + `OnSurfaceVariant`)。
|
||||
///
|
||||
/// 与 `pages/appearance/section.rs` 的 `choice` 同款,但文案来自 catalog(那份是英文基线)。
|
||||
fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, choice: LangChoice) -> Element<'a, Message> {
|
||||
let height = ui.metrics().control_height;
|
||||
let content_color = if selected {
|
||||
ui.color(Role::OnSecondaryContainer)
|
||||
} else {
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
};
|
||||
|
||||
button(
|
||||
container(frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelLarge,
|
||||
selected,
|
||||
text,
|
||||
content_color,
|
||||
))
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.height(height)
|
||||
.padding(Padding::from([0.0, ui.space(Space::X3)]))
|
||||
.style(move |_theme: &IcedTheme, status| {
|
||||
let background = if selected {
|
||||
Some(Background::Color(ui.color(Role::SecondaryContainer)))
|
||||
} else if matches!(status, button::Status::Hovered | button::Status::Pressed) {
|
||||
Some(Background::Color(ui.state_over(
|
||||
StateLayer::Hover,
|
||||
Role::OnSurface,
|
||||
Role::Surface,
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
button::Style {
|
||||
background,
|
||||
text_color: content_color,
|
||||
border: Border {
|
||||
radius: ui.radius(ShapeScale::Full, height),
|
||||
..Border::default()
|
||||
},
|
||||
shadow: Shadow::default(),
|
||||
snap: true,
|
||||
}
|
||||
})
|
||||
.on_press(Message::Language(choice))
|
||||
.into()
|
||||
}
|
||||
@@ -146,12 +146,38 @@ use unlucky_design::color::Role;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::{Lang, LangChoice};
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::layout::{FrameMetrics, PageLayout};
|
||||
use super::Message;
|
||||
|
||||
/// 设置页 General 分组要的两件(M106):用户选的档位 + 这一次生效的语言。
|
||||
///
|
||||
/// 为什么不塞进 `pages::Model`:那是页面与 presenter 之间的**展示契约**(由快照派生),而档位是
|
||||
/// **用户意图**(`App` 的字段);塞进去会让 `pages::demo_model` 也得伪造一个档位。
|
||||
/// 为什么不是单个 `Lang`:语言行要画**选中态**——「我明明选了跟随系统」得在界面上看得出来。
|
||||
/// 为什么不塞进 [`States`]:那条路要改 `settings/state.rs` 并补齐 4 处 struct literal
|
||||
/// (`settings/mod.rs` 文档「仍然共享」第 4 条已算过账),而 `view` 多一个形参只动 2 个文件。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Language {
|
||||
/// 用户选的那一档(跟随系统 / 手选)。
|
||||
pub choice: LangChoice,
|
||||
/// 这一次真正生效的语言(目录选的就是它)。
|
||||
pub effective: Lang,
|
||||
}
|
||||
|
||||
impl Default for Language {
|
||||
/// harness / 演示路径的默认值:跟随系统 + 英文基线(与 M84 起的截图基线一致)。
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
choice: LangChoice::System,
|
||||
effective: Lang::En,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个分组正文的**真控件渲染器**:入参是「画一帧时那份只读状态」(组状态 + 外观状态),
|
||||
/// 返回 `None` = 这一组现在没东西可画(例如外观分组在 harness 路径下没有外观状态),
|
||||
/// 由 [`form`] 退回演示行。
|
||||
@@ -188,11 +214,36 @@ const GROUPS: [Group; 4] = [
|
||||
/// 演示选中的分组(没有真实外观状态时——`pages_demo` harness——用它)。
|
||||
const SELECTED: usize = 0;
|
||||
|
||||
/// General 分组在 [`GROUPS`] 里的下标(0)。它与 [`SELECTED`] 同值**是巧合,不是依赖**。
|
||||
///
|
||||
/// 为什么需要它:语言行要的是「当前档位 + 生效语言」([`Language`]),而 `Group.body` 的签名只带
|
||||
/// [`States`](外观 + 组状态)——给 `States` 加字段要动 `settings/state.rs` 与 4 处 struct literal
|
||||
/// (见本文件文档「仍然共享」第 4 条),给 `Body` 加形参会令另外三组各自的 `body` 函数编译不过。
|
||||
/// 所以这里做一次**按下标的分发**:代价是一行 `if`,换来 `general::GROUP.body` 仍是 `None`
|
||||
/// (`tests/mod.rs` 的「只有外观组带真控件 body」照样成立)。
|
||||
const GENERAL_GROUP: usize = 0;
|
||||
|
||||
/// 左列分组的**显示名** key(下标与 [`GROUPS`] 一一对应,M106)。
|
||||
///
|
||||
/// 为什么不放进 [`Group`]:那会让另外三组的 `GROUP` 字面量(各自文件、各自填页线)编译不过——
|
||||
/// 与 [`GENERAL_GROUP`] 同一条理由。`Group.name` 因此退化为**id**(测试按它认组;显示走这张表)。
|
||||
pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 4] = [
|
||||
unlucky_i18n::Key::SettingsGroupGeneral,
|
||||
unlucky_i18n::Key::SettingsGroupAppearance,
|
||||
unlucky_i18n::Key::SettingsGroupJava,
|
||||
unlucky_i18n::Key::SettingsGroupDownloads,
|
||||
];
|
||||
|
||||
/// 整页。
|
||||
///
|
||||
/// `states` 是「画一帧时那份只读状态」:[`States::appearance`] 是 M93 接进来的外观运行期状态
|
||||
/// (`None` = harness / 演示路径,设置页仍画占位行),[`States::groups`] 是各组自己的状态。
|
||||
pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, states: States<'a>) -> Element<'a, Message> {
|
||||
pub fn view<'a>(
|
||||
ui: &'a Ui,
|
||||
metrics: FrameMetrics,
|
||||
states: States<'a>,
|
||||
language: Language,
|
||||
) -> Element<'a, Message> {
|
||||
// 选中分组:有真实外观状态时看状态(点左列可切),否则退回演示值 0。
|
||||
let selected = states
|
||||
.appearance
|
||||
@@ -201,13 +252,13 @@ pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, states: States<'a>) -> Elemen
|
||||
let body = match metrics.layout {
|
||||
PageLayout::SupportingPane => frame::panes(
|
||||
metrics,
|
||||
frame::group_nav(ui, selected),
|
||||
frame::left_aligned(metrics.column, form(ui, Some(selected), states)),
|
||||
frame::group_nav(ui, selected, language.effective),
|
||||
frame::left_aligned(metrics.column, form(ui, Some(selected), states, language)),
|
||||
),
|
||||
_ => frame::single_column(metrics, form(ui, None, states)),
|
||||
_ => frame::single_column(metrics, form(ui, None, states, language)),
|
||||
};
|
||||
|
||||
frame::page(ui, metrics, frame::header(ui), body)
|
||||
frame::page(ui, metrics, frame::header(ui, language.effective), body)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 内容分发
|
||||
@@ -218,7 +269,13 @@ pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, states: States<'a>) -> Elemen
|
||||
/// **分发点**:一组的正文由它自己文件里的 `GROUP.body` 决定——`Some(f)` 让 `f` 自己去
|
||||
/// [`States`] 里取它要的数据(`None` 返回值 ⇒ 退回演示行),`None` 直接画演示行
|
||||
/// ([`frame::demo_card`])。这里不出现任何一个分组名,也不比较下标,更不认识任何一组的字段。
|
||||
fn form<'a>(ui: &'a Ui, only: Option<usize>, states: States<'a>) -> Element<'a, Message> {
|
||||
fn form<'a>(
|
||||
ui: &'a Ui,
|
||||
only: Option<usize>,
|
||||
states: States<'a>,
|
||||
language: Language,
|
||||
) -> Element<'a, Message> {
|
||||
let catalog = language.effective.catalog();
|
||||
let mut stack = column![].spacing(ui.space(Space::X6)).width(Length::Fill);
|
||||
|
||||
for (index, group) in GROUPS.iter().enumerate() {
|
||||
@@ -228,10 +285,18 @@ fn form<'a>(ui: &'a Ui, only: Option<usize>, states: States<'a>) -> Element<'a,
|
||||
|
||||
// 「外观」分组:有真实外观状态时画真控件(M93),否则退回演示行(harness)。
|
||||
// 这两条的差别由 `GROUP.body` 的返回值表达,而不是「下标 == APPEARANCE_GROUP」的硬比较。
|
||||
let card = group
|
||||
.body
|
||||
.and_then(|body| body(ui, states))
|
||||
.unwrap_or_else(|| frame::demo_card(ui, group.rows));
|
||||
//
|
||||
// M106:General 是**唯一一处按下标分发**的例外(理由见 [`GENERAL_GROUP`])——语言行的
|
||||
// 档位与生效语言不在 [`States`] 里,只能从 `pages::Language` 进来;它与 `appearance` 在不在场
|
||||
// 无关(harness 那条路径也画得出真控件)。
|
||||
let card = if index == GENERAL_GROUP {
|
||||
general::body(ui, language)
|
||||
} else {
|
||||
group
|
||||
.body
|
||||
.and_then(|body| body(ui, states))
|
||||
.unwrap_or_else(|| frame::demo_card(ui, group.rows))
|
||||
};
|
||||
|
||||
stack = stack.push(
|
||||
column![
|
||||
@@ -239,7 +304,10 @@ fn form<'a>(ui: &'a Ui, only: Option<usize>, states: States<'a>) -> Element<'a,
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
group.name,
|
||||
// 卡片标题与左列同一个词源(`GROUP_LABELS`):`Group.name` 只是 id。
|
||||
GROUP_LABELS
|
||||
.get(index)
|
||||
.map_or(group.name, |key| catalog.text(*key)),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
card,
|
||||
@@ -249,7 +317,7 @@ fn form<'a>(ui: &'a Ui, only: Option<usize>, states: States<'a>) -> Element<'a,
|
||||
);
|
||||
}
|
||||
|
||||
stack = stack.push(frame::overrides_empty(ui));
|
||||
stack = stack.push(frame::overrides_empty(ui, language.effective));
|
||||
|
||||
frame::scroll(ui, stack)
|
||||
}
|
||||
@@ -1,24 +1,33 @@
|
||||
//! 「General」分组自己的断言(M100 **新增**:`settings/tests.rs` 那 7 条全是跨 section 的,
|
||||
//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。
|
||||
//! 「General」分组自己的断言(M106 `I18NFOLLOW` 重写:语言行换成真控件)。
|
||||
//!
|
||||
//! **归 language+appearance 线**。接真控件那天要改**两个**文件(M101 r2 删掉了原先的第 3 处跨
|
||||
//! section 绊线):`settings/general.rs` + 本文件 `the_general_group_is_still_demo_rows`。
|
||||
//!
|
||||
//! M106(`I18NFOLLOW`)已经接了这一组的语言行。用户报的 bug 有两半:**界面全英文**(探测对了
|
||||
//! 但没人用)与**设置页写着 `English (System)`**(一行硬编码假值)。本文件的断言守后半:那行
|
||||
//! 假值不许回来,语言行的三档必须真的能在两套目录里取到词。
|
||||
//!
|
||||
//! 接线形状:语言行的正文由 [`general::body`] 画(入口是 `pages::settings` 的 `GENERAL_GROUP`
|
||||
//! 按下标分发),`GROUP.body` 仍是 `None`——那个槽位的签名是 `fn(&Ui, States) -> Option<Element>`,
|
||||
//! `States` 只有 `appearance` + `groups` 两格,装不下「当前语言档位」。所以 M101 r2 的
|
||||
//! 「本组接真控件只需改两个文件」这条口径,在语言行上**仍然是两个文件**。
|
||||
|
||||
use super::super::general;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_i18n::LangChoice;
|
||||
use unlucky_i18n::{Key, Lang};
|
||||
|
||||
/// 这一组的行集合(标签 + 值)。改文案就是改观感,所以逐条钉住,而不是「非空就行」。
|
||||
/// 这一组的**静态演示行**:语言那一行已经换真控件,所以只剩三行。
|
||||
///
|
||||
/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(四份 `GROUP` 长得几乎一样,最容易整块贴错);
|
||||
/// ② 漏掉/多出一条行;③ 标签与值错位(`("Theme", "Follow system")` 被写成 `("Theme", "Instances")`)。
|
||||
/// 抓不住:值对不对(`"Follow system"` 是不是当前系统主题的真相)——那是接线后真数据源的事。
|
||||
/// 抓得住:①四条线并行合并时把别人的行粘到本文件;②语言行被「顺手」加回静态表——
|
||||
/// 那等于把用户看到的假值又写回去(它的值必须跟着语言变,静态表做不到)。
|
||||
/// 抓不住:真控件画出来长什么样(那是截图验收的事,见 `DevDocs/artifacts/m106/`)。
|
||||
#[test]
|
||||
fn the_general_group_shows_language_theme_startup_and_close() {
|
||||
fn the_general_group_keeps_theme_startup_and_close_as_static_rows() {
|
||||
let rows: Vec<(&str, &str)> = general::GROUP.rows.to_vec();
|
||||
assert_eq!(
|
||||
rows,
|
||||
vec![
|
||||
("Language", "English (System)"),
|
||||
("Theme", "Follow system"),
|
||||
("Startup page", "Instances"),
|
||||
("Close button", "Exit to tray"),
|
||||
@@ -32,17 +41,82 @@ fn the_general_group_keeps_its_icon() {
|
||||
assert_eq!(general::GROUP.icon, IconKind::Settings);
|
||||
}
|
||||
|
||||
/// **绊线**:这一组还没接真控件,画的是演示行。
|
||||
/// **回归绊线(用户原话)**:`"English (System)"` 那条硬编码字面量不许回来。
|
||||
///
|
||||
/// 抓得住:language+appearance 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。
|
||||
/// 抓不住:界面上真控件长什么样。
|
||||
/// 抓得住:①原来那条「这一组还没接真控件」在接上之后没更新(静默变假绿);
|
||||
/// ②语言行**退回静态演示行**(哪怕换个说法,只要还写着「English」就是同一个 bug 的复盘)。
|
||||
/// 抓不住:界面上真控件长什么样、三档选项名对不对(下一条与截图各管一段)。
|
||||
///
|
||||
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**(M101 r2 起不必再去
|
||||
/// `tests/mod.rs` 报到:那条跨 section 聚合绊线已删)。
|
||||
/// 现状(M106):`body` 仍是 `None`(语言行走 `GENERAL_GROUP` 的按下标分发),但这一组**已经**
|
||||
/// 有真控件了——所以这里断言的**不再是「还没接」**,而是「不许回到静态行」。
|
||||
#[test]
|
||||
fn the_general_group_is_still_demo_rows() {
|
||||
fn the_language_row_is_not_a_static_string() {
|
||||
for (label, value) in general::GROUP.rows {
|
||||
assert_ne!(
|
||||
*label, "Language",
|
||||
"语言行必须走真控件(pages::Language),不许回到静态演示行"
|
||||
);
|
||||
assert!(
|
||||
!value.contains("English"),
|
||||
"静态行里不许再出现写死的语言名:{value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 语言行**三档**,且在**两套目录**里都有词(中文界面不该出现 `⟪missing⟫`)。
|
||||
///
|
||||
/// 抓得住:①少一档(用户就没法切回去了);②标签 key 只加进了中文表(英文界面上一档变成
|
||||
/// `⟪missing⟫`);③回显那句的占位符名字与调用点对不上(会原样印出 `{language}`)。
|
||||
/// 抓不住:译文翻得对不对(金表 + `chinese_catalog_is_actually_chinese` 管那一层)。
|
||||
#[test]
|
||||
fn the_language_row_offers_three_choices_in_both_languages() {
|
||||
assert_eq!(
|
||||
LangChoice::ALL.len(),
|
||||
3,
|
||||
"跟随系统 / 简体中文 / 英文,少一档就切不回来"
|
||||
);
|
||||
|
||||
for lang in Lang::ALL {
|
||||
let catalog = lang.catalog();
|
||||
for choice in LangChoice::ALL {
|
||||
assert!(
|
||||
catalog.get(choice.label_key()).is_some(),
|
||||
"{} 在 {} 目录里没词",
|
||||
choice.label_key().name(),
|
||||
lang.tag()
|
||||
);
|
||||
}
|
||||
|
||||
// 「界面当前使用:⟨语言名⟩」——中文系统下这句必须真的出现「简体中文」。
|
||||
let effective = catalog.text(LangChoice::Fixed(*lang).label_key());
|
||||
let line = catalog.render(Key::SettingsLanguageEffective, &[("language", effective)]);
|
||||
assert!(line.contains(effective), "回显没带上语言名:{line}");
|
||||
assert!(!line.contains('{'), "占位符没被替换:{line}");
|
||||
}
|
||||
|
||||
// 中文系统 + 跟随系统 ⇒ 回显是「简体中文」(用户报的 bug 的直接判据)。
|
||||
let zh = Lang::ZhCn.catalog();
|
||||
assert_eq!(
|
||||
zh.render(
|
||||
Key::SettingsLanguageEffective,
|
||||
&[(
|
||||
"language",
|
||||
zh.text(LangChoice::Fixed(Lang::ZhCn).label_key())
|
||||
)]
|
||||
),
|
||||
"界面当前使用:简体中文"
|
||||
);
|
||||
}
|
||||
|
||||
/// **绊线**:这一组仍不走 `Group.body`。
|
||||
///
|
||||
/// 为什么不是「接了真控件」:语言行的入口是 `pages::Language`(`GENERAL_GROUP` 那条分发),
|
||||
/// 而 `Group.body` 那个槽位的签名是 `fn(&Ui, &pages::appearance::State)`——装不下「当前语言档位」。
|
||||
/// 谁把语言行塞进 `body`,就得先给它换签名(那会改到另外三组的文件),这条断言逼他先想一遍。
|
||||
#[test]
|
||||
fn the_general_group_does_not_use_the_appearance_body_slot() {
|
||||
assert!(
|
||||
general::GROUP.body.is_none(),
|
||||
"General 还没接真控件:接了就改本断言"
|
||||
"General 的真控件走 pages::Language 入口(见 settings/mod.rs 的 GENERAL_GROUP),不是 body"
|
||||
);
|
||||
}
|
||||
@@ -136,6 +136,12 @@ fn the_split_kept_the_left_column_order() {
|
||||
/// 抓得住:谁把状态漏斗拆回去——让 `view` / `form` 直接收 `appearance::State` 或某一组的 `State`
|
||||
/// (那正是四条线「加一个字段就要改本文件」的形状,本次复审要求修掉的就是它)。
|
||||
/// 抓不住:各组的**字段**(那些活在各组自己的文件里,本文件本来就不该知道,也就没有可断言的东西)。
|
||||
///
|
||||
/// **M106 的例外要读清楚**:General 已经接了真控件(语言行),但它**不走 `Group.body`**——
|
||||
/// 那个槽位的签名是 `fn(&Ui, States) -> Option<Element>`,`States` 只有 `appearance` + `groups`
|
||||
/// 两格,装不下「当前语言档位 + 生效语言」。它走 `settings::view` 的 `language` 形参 +
|
||||
/// `GENERAL_GROUP` 那条按下标分发,`body` 仍是 `None`,所以本断言不动。
|
||||
/// 判据没变:**`body: Some(..)` 仍然只有外观组**。
|
||||
#[test]
|
||||
fn the_settings_module_does_not_name_any_group_state() {
|
||||
let source = include_str!("../mod.rs");
|
||||
|
||||
@@ -40,6 +40,8 @@ use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::cli::ForcedState;
|
||||
use crate::icons;
|
||||
@@ -58,10 +60,12 @@ pub fn view_with_background<'a>(
|
||||
right: Option<Element<'a, Message>>,
|
||||
overlay: Option<Element<'a, Message>>,
|
||||
forced: Option<ForcedState>,
|
||||
lang: Lang,
|
||||
) -> Element<'a, Message> {
|
||||
// 右栗(M104)是 `row` 的**第三个兄弟**而不是叠层:它是布局宽度的一部分,压在上面会让
|
||||
// 中栗以为自己还有那么宽(那就是任务里禁止的「溢出」)。它的宽度由 `agent::docked` 自己声明。
|
||||
let mut work = row![nav::rail(ui, selected, forced), content]
|
||||
// M106:导航轨标签按当前界面语言取词(`lang` 一路从 `App::lang()` 递进来)。
|
||||
let mut work = row![nav::rail(ui, selected, forced, lang), content]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
if let Some(right) = right {
|
||||
|
||||
@@ -21,6 +21,7 @@ use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::{LayoutMetrics, Space};
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::{Key, Lang};
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::cli::ForcedState;
|
||||
@@ -53,17 +54,25 @@ impl NavItem {
|
||||
NavItem::Ai,
|
||||
];
|
||||
|
||||
/// 显示名(i18n 是后续 mission;本 mission 用英文基线文案,避免干净 CI 上缺中文变豆腐块)。
|
||||
pub const fn label(self) -> &'static str {
|
||||
/// 显示名的 key(M106 起走 catalog;两套目录都有词由 `catalog` 的完整性测试守)。
|
||||
pub const fn label_key(self) -> Key {
|
||||
match self {
|
||||
NavItem::Instances => "Instances",
|
||||
NavItem::Download => "Download",
|
||||
NavItem::Logs => "Logs",
|
||||
NavItem::Settings => "Settings",
|
||||
NavItem::Ai => "Assistant",
|
||||
NavItem::Instances => Key::NavInstances,
|
||||
NavItem::Download => Key::NavDownload,
|
||||
NavItem::Logs => Key::NavLogs,
|
||||
NavItem::Settings => Key::NavSettings,
|
||||
NavItem::Ai => Key::NavAi,
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示名(按当前界面语言)。
|
||||
///
|
||||
/// 为什么带 `lang` 而不是读全局:这个函数要被单测与 harness 调,全局状态会让「同一份代码在
|
||||
/// 不同语言下画什么」不可断言。
|
||||
pub fn label(self, lang: Lang) -> &'static str {
|
||||
lang.catalog().text(self.label_key())
|
||||
}
|
||||
|
||||
/// 令牌图标。
|
||||
pub const fn icon(self) -> IconKind {
|
||||
match self {
|
||||
@@ -81,13 +90,14 @@ pub(super) fn rail<'a>(
|
||||
ui: &'a Ui,
|
||||
selected: NavItem,
|
||||
forced: Option<ForcedState>,
|
||||
lang: Lang,
|
||||
) -> Element<'a, Message> {
|
||||
let metrics = ui.metrics();
|
||||
let indicator = indicator_size(metrics);
|
||||
|
||||
let items = NavItem::ALL
|
||||
.iter()
|
||||
.map(|item| rail_item(ui, *item, *item == selected, indicator, forced))
|
||||
.map(|item| rail_item(ui, *item, *item == selected, indicator, forced, lang))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// 轨顶留白走 container padding,而不是「先塞一个 spacer 元素」:后者会被 column 的
|
||||
@@ -108,6 +118,7 @@ fn rail_item<'a>(
|
||||
selected: bool,
|
||||
indicator: (f32, f32),
|
||||
forced: Option<ForcedState>,
|
||||
lang: Lang,
|
||||
) -> Element<'a, Message> {
|
||||
let (indicator_width, indicator_height) = indicator;
|
||||
|
||||
@@ -145,7 +156,13 @@ fn rail_item<'a>(
|
||||
// 药丸会贴左缘(x=0..55 而不是 16..71)——r1 实测踩到的第二个坑。
|
||||
let stack = column![
|
||||
pill,
|
||||
label(ui, TypeRole::LabelMedium, false, item.label(), label_color),
|
||||
label(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
item.label(lang),
|
||||
label_color
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(nav_rail_width(ui.metrics()))
|
||||
@@ -253,7 +270,7 @@ mod tests {
|
||||
fn every_nav_item_has_a_label_and_an_icon() {
|
||||
assert!((4..=6).contains(&NavItem::ALL.len()));
|
||||
for item in NavItem::ALL {
|
||||
assert!(!item.label().is_empty(), "{item:?} 没有文案");
|
||||
assert!(!item.label(Lang::En).is_empty(), "{item:?} 没有文案");
|
||||
assert!(!item.icon().icon().path.is_empty(), "{item:?} 没有图标");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
//! # 界面语言入口(M106 `I18NFOLLOW`)
|
||||
//!
|
||||
//! 用户报的原话:「我系统语言是中文 为什么打开软件是英文 并且设置页里面显示的是 English (System)」。
|
||||
//! 前半是**探测对了但没人用**(`app/view.rs` 里 `Lang::En` 写死),后半是一行硬编码假值。
|
||||
//!
|
||||
//! 这个模块就是那条缺失的入口:把「用户选的档位 + M92 的探测结论」变成一个值,交给
|
||||
//! `pages::Model.lang`(目录)与设置页语言行(档位回显)。
|
||||
//!
|
||||
//! | 文件 | 是什么 |
|
||||
//! |---|---|
|
||||
//! | [`lang`] | [`lang::LangState`]:启动判定(`ULL_LANG` > 配置 > 系统 locale > 英文)、切档、两行日志 |
|
||||
//!
|
||||
//! 为什么不叫 `theme::Ui` 的字段:`theme.rs` 是令牌求值器(颜色/字号/间距),语言不是令牌——
|
||||
//! 它是**运行期状态**,随设置变化,还要落盘。混进 `Ui` 会让「切主题」与「切语言」共享一条重建路径,
|
||||
//! 而它们该重建的东西根本不是同一批(一个重建 Style,一个重建视图模型)。
|
||||
|
||||
pub mod lang;
|
||||
@@ -0,0 +1,260 @@
|
||||
//! 界面语言的**入口层**(M106 `I18NFOLLOW`):`Ui` 缺的那条语言入口就是这里。
|
||||
//!
|
||||
//! ## 它解决的是什么
|
||||
//!
|
||||
//! M92 把探测链做对了(启动日志里 `lang=zh-CN locale=zh-CN source=registry` 一直是对的),但
|
||||
//! **没人消费它**:`app/view.rs` 的 `model()` 里 `Lang::En` 是写死的,于是中文系统上开出来的是
|
||||
//! 全英文界面,设置页还写着 `English (System)`。本模块把「用户档位 + 探测结论」变成一个**值**
|
||||
//! ([`LangState`]),`App` 持有它、`view::model` 只认它,页面侧 `pages::Model.lang` 决定目录。
|
||||
//!
|
||||
//! ## 判定的四级优先级([`LangState::boot`])
|
||||
//!
|
||||
//! | 级 | 来源 | 什么时候用 |
|
||||
//! |---|---|---|
|
||||
//! | 1 | `ULL_LANG`(`zh_cn` / `en_us`) | 截图矩阵要**可复现地**换语言(D30) |
|
||||
//! | 2 | 配置里的 `ui.language` | 用户手选(设置页写入) |
|
||||
//! | 3 | 系统 locale(注册表 / 环境变量) | 跟随系统 |
|
||||
//! | 4 | 英文 | 一个源都没有 |
|
||||
//!
|
||||
//! **截图通道不读用户配置**(与 `appearance_prefs` 同一条 D30 纪律):同一份代码在两台机器上出
|
||||
//! 不了同一张图,除非语言也由 `ULL_LANG` 显式给。
|
||||
//!
|
||||
//! ## 日志(两个下一个人要问的问题)
|
||||
//!
|
||||
//! 「探测错了吗」与「探测对了但没用上吗」在日志里必须分得开:
|
||||
//!
|
||||
//! ```text
|
||||
//! [i18n] choice=system lang=zh-CN locale=zh-CN source=registry effective=zh-CN
|
||||
//! [i18n] lang=zh-CN locale=zh-CN source=registry cjk=system:"Microsoft YaHei UI"
|
||||
//! ```
|
||||
//!
|
||||
//! 第一行是**档位 + 探测结论 + 最终生效语言**([`LangState::log`]),第二行是中文策略(D28:
|
||||
//! 缺字体是信号,不许静默;[`LangState::log_cjk`])。
|
||||
//!
|
||||
//! ## 诚实边界
|
||||
//!
|
||||
//! - **抓得住**:档位的选择顺序、坏值不猜、截图通道忽略配置、切档后生效语言跟着换;
|
||||
//! - **抓不住**:系统 locale 的真机行为(那在 [`crate::platform::locale::SystemLocale`],Windows 上
|
||||
//! 真的去跑 `reg.exe`);中文字体到底装没装(`fonts::cjk_for` 查 iced 的字体库)。
|
||||
|
||||
use unlucky_i18n::lang::{Detection, EnvLocale};
|
||||
use unlucky_i18n::{Lang, LangChoice};
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use crate::pages;
|
||||
use crate::platform::locale::SystemLocale;
|
||||
|
||||
/// 截图通道的语言覆盖(D30 可复现):`ULL_LANG=zh_cn` / `ULL_LANG=en_us`。
|
||||
///
|
||||
/// 为什么走环境变量而不是 `--lang`:`cli.rs` 不在本 mission 的 scope 里,而截图矩阵要能**只**改
|
||||
/// 一个环境变量就换语言(与 `ULL_APPEARANCE_*` 那批同一条做法)。
|
||||
pub(crate) const LANG_ENV: &str = "ULL_LANG";
|
||||
|
||||
/// 这一次进程的界面语言:用户选的档 + 探测出来的结论。
|
||||
///
|
||||
/// 不是 `Copy`:结论里带着探测到的原始 locale 字符串(`Detection.raw`),而它只在切档/启动时
|
||||
/// 构造一次——每帧问的是 [`LangState::lang`](一个 `Copy` 的枚举),不是整份状态。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct LangState {
|
||||
/// 用户意图(决定设置页画哪个按钮、落盘写不写 `ui.language`)。
|
||||
choice: LangChoice,
|
||||
/// 生效结论(决定用哪套目录;`describe()` 就是日志那一段)。
|
||||
detection: Detection,
|
||||
}
|
||||
|
||||
impl LangState {
|
||||
/// 启动判定:`ULL_LANG` > 配置 `ui.language` > 系统 locale > 英文。
|
||||
pub(crate) fn boot(bridge: &SettingsBridge, screenshot: bool) -> Self {
|
||||
let forced = std::env::var(LANG_ENV)
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty());
|
||||
let setting = bridge.load_ui_language();
|
||||
Self::of(choose_choice(
|
||||
forced.as_deref(),
|
||||
setting.as_deref(),
|
||||
screenshot,
|
||||
))
|
||||
}
|
||||
|
||||
/// 换了档位之后的状态(重新走一遍 `resolve`,不保留旧结论)。
|
||||
pub(crate) fn of(choice: LangChoice) -> Self {
|
||||
Self {
|
||||
choice,
|
||||
detection: choice.resolve(&SystemLocale, &EnvLocale),
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前档位。
|
||||
pub(crate) fn choice(&self) -> LangChoice {
|
||||
self.choice
|
||||
}
|
||||
|
||||
/// 当前生效的语言(目录选择)。
|
||||
pub(crate) fn lang(&self) -> Lang {
|
||||
self.detection.lang
|
||||
}
|
||||
|
||||
/// 设置页语言行要的两件(档位 + 生效语言)。
|
||||
pub(crate) fn for_settings(&self) -> pages::Language {
|
||||
pages::Language {
|
||||
choice: self.choice,
|
||||
effective: self.lang(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 主日志行:档位 + 探测结论 + 最终生效语言。
|
||||
pub(crate) fn log(&self) {
|
||||
eprintln!(
|
||||
"[i18n] choice={} {} effective={}",
|
||||
self.choice.setting_value().unwrap_or("system"),
|
||||
self.detection.describe(),
|
||||
self.lang().tag()
|
||||
);
|
||||
}
|
||||
|
||||
/// 中文策略日志(D28):缺字体是**信号**,不许静默。
|
||||
///
|
||||
/// 为什么要单独一次(启动编排里已经打过一行 `[font]`):那一拍按**系统 locale** 判,不知道用户
|
||||
/// 手选了什么。英文系统上手动切中文时,只有这一行会变成 `cjk=needs-download(...)`——白块那条路
|
||||
/// 就是靠它被看见的。
|
||||
pub(crate) fn log_cjk(&self) {
|
||||
let report = crate::fonts::cjk_for(self.choice.setting_value());
|
||||
eprintln!("[i18n] {}", report.describe());
|
||||
if matches!(
|
||||
report.strategy,
|
||||
unlucky_i18n::font::Strategy::NeedsDownload(_)
|
||||
) {
|
||||
eprintln!(
|
||||
"[i18n] 缺中文字体:界面会是中文但字形可能缺失(D28 的下载通道还是 PendingPin)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 档位选择的**纯判据**(单测直接打这张表)。
|
||||
///
|
||||
/// | 输入 | 结果 |
|
||||
/// |---|---|
|
||||
/// | `ULL_LANG` 给了合法值 | 那一档(压过配置) |
|
||||
/// | 没给 + 配置写了 `zh_cn` / `en_us` | 那一档 |
|
||||
/// | 没给 + 配置没写(或写了坏值) | 跟随系统 |
|
||||
/// | **截图通道** + 配置写了值 | 跟随系统(除非 `ULL_LANG` 另给) |
|
||||
fn choose_choice(forced: Option<&str>, setting: Option<&str>, screenshot: bool) -> LangChoice {
|
||||
let configured = if screenshot { None } else { setting };
|
||||
LangChoice::from_setting(forced.or(configured))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 档位判据的真值表(D28 第一级 + D30 的截图可复现)。
|
||||
///
|
||||
/// 抓得住:①`ULL_LANG` 没压过配置(截图矩阵就换不了语言);②截图通道读了用户配置(同一份代码
|
||||
/// 在两台机器上出不同的图);③坏值被猜成某种语言。
|
||||
/// 抓不住:系统 locale 真探测成什么(那是 `SystemLocale` + `i18n::lang::resolve` 的事)。
|
||||
#[test]
|
||||
fn the_language_choice_is_ull_lang_then_config_then_system() {
|
||||
let en = Some("en_us");
|
||||
let zh = Some("zh_cn");
|
||||
|
||||
assert_eq!(choose_choice(None, None, false), LangChoice::System);
|
||||
assert_eq!(
|
||||
choose_choice(None, zh, false),
|
||||
LangChoice::Fixed(Lang::ZhCn),
|
||||
"配置里的手选要生效"
|
||||
);
|
||||
assert_eq!(
|
||||
choose_choice(en, zh, false),
|
||||
LangChoice::Fixed(Lang::En),
|
||||
"ULL_LANG 压过配置(截图矩阵靠它换语言)"
|
||||
);
|
||||
assert_eq!(
|
||||
choose_choice(zh, None, false),
|
||||
LangChoice::Fixed(Lang::ZhCn)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
choose_choice(None, zh, true),
|
||||
LangChoice::System,
|
||||
"截图通道必须忽略配置文件里的语言,否则同一份代码出不了同一张图"
|
||||
);
|
||||
assert_eq!(choose_choice(en, zh, true), LangChoice::Fixed(Lang::En));
|
||||
|
||||
for bogus in ["zh-CN", "system", "", "fr_fr"] {
|
||||
assert_eq!(
|
||||
choose_choice(Some(bogus), None, false),
|
||||
LangChoice::System,
|
||||
"{bogus}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 「重启后保持所选语言」的**闭环证据**:真的落一个临时 `launcher.json`,再用**新开的** bridge
|
||||
/// 读回来(等价于重启)。
|
||||
///
|
||||
/// 抓得住:①「跟随系统」被读成 schema 默认值 `zh_cn`(英文机器一启动就是中文);
|
||||
/// ②「跟随系统」写了哨兵值(下次启动撒谎);③删键时把别的键一起删了。
|
||||
/// 抓不住:数据根解析(`SettingsBridge::open` 那条路)——这里用的是显式路径。
|
||||
#[test]
|
||||
fn the_choice_round_trips_through_the_config_file() {
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("ull-m106-lang-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
|
||||
let path = dir.join("config/launcher.json");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{"appearance":{"theme_mode":"dark"},"custom":{"keep":true}}"#,
|
||||
)
|
||||
.expect("写配置");
|
||||
|
||||
// ① 没写过 = 跟随系统(不是默认值 zh_cn)
|
||||
let bridge = SettingsBridge::with_path(&path);
|
||||
assert_eq!(
|
||||
bridge.load_ui_language(),
|
||||
None,
|
||||
"没写过这个键 = 跟随系统,不许回落成 schema 默认值"
|
||||
);
|
||||
|
||||
// ② 手选落盘 → 新开的 bridge(= 重启)读回来
|
||||
let mut bridge = SettingsBridge::with_path(&path);
|
||||
assert!(bridge.persist_ui_language(Some("zh_cn")));
|
||||
assert_eq!(
|
||||
SettingsBridge::with_path(&path)
|
||||
.load_ui_language()
|
||||
.as_deref(),
|
||||
Some("zh_cn"),
|
||||
"重启后要保持所选语言"
|
||||
);
|
||||
|
||||
// ③ 切回跟随系统 = 删键,且不许顺手删别的键
|
||||
let mut bridge = SettingsBridge::with_path(&path);
|
||||
assert!(bridge.persist_ui_language(None));
|
||||
assert_eq!(SettingsBridge::with_path(&path).load_ui_language(), None);
|
||||
let text = std::fs::read_to_string(&path).expect("读配置");
|
||||
assert!(text.contains("theme_mode"), "外观键被删了:{text}");
|
||||
assert!(text.contains("custom"), "用户手写的未知键被删了:{text}");
|
||||
|
||||
// ④ 已经是跟随系统时再点一次是幂等的
|
||||
let mut bridge = SettingsBridge::with_path(&path);
|
||||
assert!(!bridge.persist_ui_language(None), "没有键可删 = 没有落盘");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// 手选档会赢过系统源,且 `raw`/`origin` 看得出来是手选的(日志可分诊)。
|
||||
///
|
||||
/// 抓得住:`of()` 忘了重算(切档后结论还停在旧语言)、手选被系统源盖掉。
|
||||
/// 抓不住:真机上系统源读到什么(`SystemLocale` 跑 `reg.exe`)。
|
||||
#[test]
|
||||
fn a_fixed_choice_wins_over_the_system_locale() {
|
||||
let zh = LangState::of(LangChoice::Fixed(Lang::ZhCn));
|
||||
assert_eq!(zh.lang(), Lang::ZhCn);
|
||||
assert_eq!(zh.for_settings().effective, Lang::ZhCn);
|
||||
assert_eq!(zh.for_settings().choice, LangChoice::Fixed(Lang::ZhCn));
|
||||
|
||||
let en = LangState::of(LangChoice::Fixed(Lang::En));
|
||||
assert_eq!(en.lang(), Lang::En, "手选英文压过任何中文系统源");
|
||||
assert_eq!(en.detection.origin, "setting");
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@
|
||||
//! | [`nav`] / [`instances`] / [`loader`] / [`launch`] / [`font`] / [`core_text`] | 页面与运行时文案 | 各自页面 |
|
||||
//! | [`download`] / [`logs`] | 下载页 / 日志页(M94 / M95) | 那两页 |
|
||||
//! | `settings/`(`general` / `appearance` / `java` / `download` / `ai`) | 设置页,**一个分组一个文件** | 设置页四条线 |
|
||||
//! | [`general`] | 设置页 General 分组的**真控件**文案(语言选择器三档 + 主题/启动页/关闭三行) | M106 `I18NFOLLOW`(挂在链尾,既有顺序不动) |
|
||||
//!
|
||||
//! ### 域文件的形状(加 key 时照抄)
|
||||
//!
|
||||
@@ -115,6 +116,7 @@ mod agent;
|
||||
mod core_text;
|
||||
mod download;
|
||||
mod font;
|
||||
mod general;
|
||||
mod instances;
|
||||
mod launch;
|
||||
mod loader;
|
||||
@@ -132,6 +134,7 @@ use agent::agent_entries;
|
||||
use core_text::core_text_entries;
|
||||
use download::download_entries;
|
||||
use font::font_entries;
|
||||
use general::general_entries;
|
||||
use instances::instances_entries;
|
||||
use launch::launch_entries;
|
||||
use loader::loader_entries;
|
||||
@@ -158,6 +161,7 @@ nav_entries! {
|
||||
download_entries,
|
||||
logs_entries,
|
||||
agent_entries,
|
||||
general_entries,
|
||||
finish,
|
||||
{}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -124,4 +124,28 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
||||
("AgentRoleYou", "You", "你"),
|
||||
("AgentRoleAssistant", "Assistant", "助手"),
|
||||
("AgentRoleNotice", "Notice", "提示"),
|
||||
// ── 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 的骨架回退。"),
|
||||
];
|
||||
@@ -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, ®istry_zh, &env_zh, Lang::En, ORIGIN_SETTING),
|
||||
(zh, ®istry_en, &env_en, Lang::ZhCn, ORIGIN_SETTING),
|
||||
// ② 跟随系统 → 系统源
|
||||
(follow, ®istry_zh, &env_en, Lang::ZhCn, "registry"),
|
||||
(follow, ®istry_en, &env_zh, Lang::En, "registry"),
|
||||
// ③ 系统源取不到 → 环境变量
|
||||
(follow, ¬hing, &env_zh, Lang::ZhCn, "env"),
|
||||
(follow, ¬hing, &env_en, Lang::En, "env"),
|
||||
// ④ 都没有 → 英文 + default(D28:纯英文环境不用管)
|
||||
(follow, ¬hing, &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(®istry_en, &env_en).describe(),
|
||||
"lang=zh-CN locale=zh_cn source=setting"
|
||||
);
|
||||
assert_eq!(
|
||||
follow.resolve(®istry_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");
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
//! | 模块 | 职责 | 不负责 |
|
||||
//! |---|---|---|
|
||||
//! | [`lang`] | `Lang`(en / zh-CN)+ locale 探测(源可注入)+ **四级优先级** [`lang::resolve`] | 读注册表 / 读配置 / spawn 进程 |
|
||||
//! | [`choice`] | `LangChoice`(跟随系统 / 手选):设置页那一档 + 它到生效语言的映射 | 落盘(那是 `presenter::settings_bridge`) |
|
||||
//! | [`catalog`] | `Key` 枚举 + 中英两套常量表 + 取词 / 占位符渲染 + 缺译诊断 | 排版、字号、断行 |
|
||||
//! | [`font`] | 中文环境扫系统 CJK 字体的**判据** + 缺字体时的「需下载」信号 + 注入式下载器接口 | 联网、写文件、装字体 |
|
||||
//! | [`bridge`] | core / presenter 的状态串 → `Key` 的**示例契约**(文案边界规则全文在这里) | 改 core 的产出 |
|
||||
@@ -35,8 +36,10 @@
|
||||
|
||||
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;
|
||||
@@ -113,6 +113,7 @@ pub mod mock_data;
|
||||
pub mod prefs;
|
||||
pub mod router;
|
||||
pub mod settings_bridge;
|
||||
mod settings_bridge_lang;
|
||||
pub mod state;
|
||||
pub mod theme;
|
||||
pub mod toast;
|
||||
|
||||
@@ -33,10 +33,11 @@ const DATA_ROOT_KEYS: [&str; 4] = [
|
||||
#[derive(Debug)]
|
||||
pub struct SettingsBridge {
|
||||
/// `config/launcher.json`;`None` = 数据根解析不出来(内存态运行,不落盘)
|
||||
path: Option<PathBuf>,
|
||||
store: Option<SettingStore>,
|
||||
pub(super) path: Option<PathBuf>,
|
||||
/// 设置文档(schema 校验 + 原子写的唯一入口)
|
||||
pub(super) store: Option<SettingStore>,
|
||||
/// 上次读/写之后的文件 `mtime`(热切探测用)
|
||||
last_mtime: Option<SystemTime>,
|
||||
pub(super) last_mtime: Option<SystemTime>,
|
||||
}
|
||||
|
||||
impl SettingsBridge {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! 界面语言这一个键的读写(M106):`ui.language` 的**显式值**。
|
||||
//!
|
||||
//! ## 为什么单独一个文件
|
||||
//!
|
||||
//! `settings_bridge.rs` 是外观 / 主题 / 数据根那一摊的落点,已经贴着 400 行硬线;语言的读写只有
|
||||
//! 两个方法 + 一个「删键」辅助,独立出来之后两边都还能长。`impl SettingsBridge` 写在别的模块是
|
||||
//! Rust 允许的(同一 crate 的固有实现),只需要主文件把三个字段放成 `pub(super)`。
|
||||
//!
|
||||
//! ## 两条判据(都跟 schema 的默认值有关)
|
||||
//!
|
||||
//! 1. **读的是显式值**,不是 `effective_str`:`ui.language` 的 schema 默认值是 `zh_cn`,用它会把
|
||||
//! 「没配过」读成「用户手选中文」——英文机器一启动就被强行切成中文(D28 违约)。
|
||||
//! 2. **「跟随系统」= 删掉这个键**,不是写哨兵值:允许值只有 `zh_cn` / `en_us`(枚举校验会拒别的),
|
||||
//! 而「跟随系统」的语义本来就是「没有显式选择」。
|
||||
//!
|
||||
//! 这两条都在 `.tower/comms/findings/` 里登了记(建议把 schema 默认值改成 `null` 或加
|
||||
//! `follow_system` 取值,那时本文件的删键路径可以简化成一次 `store.set`)。
|
||||
|
||||
use unlucky_core::setting::document::SettingDocument;
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
|
||||
use crate::settings_bridge::SettingsBridge;
|
||||
|
||||
/// 界面语言键(M106):`zh_cn` / `en_us`(允许值见 `core::setting::keys::LANGUAGES`)。
|
||||
///
|
||||
/// **不写这个键 = 跟随系统**,所以它没有「system」这种取值,见 [`SettingsBridge::persist_ui_language`]。
|
||||
const UI_LANGUAGE: &str = "ui.language";
|
||||
|
||||
impl SettingsBridge {
|
||||
/// 配置里 `ui.language` 的**显式值**(M106):`None` = 没写过 = **跟随系统**。
|
||||
///
|
||||
/// 不用 `effective_str`:那会把 schema 默认值(`zh_cn`)当成「用户手选中文」,于是**没配过**
|
||||
/// 的英文机器会一启动就被强行切成中文(D28 的「纯英文环境不用管」直接违约)。
|
||||
#[must_use]
|
||||
pub fn load_ui_language(&self) -> Option<String> {
|
||||
let store = self.store.as_ref()?;
|
||||
store
|
||||
.doc()
|
||||
.get(UI_LANGUAGE)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
/// 写 `ui.language`:`Some("zh_cn")` / `Some("en_us")` 手选,`None` = **跟随系统 = 删键**。
|
||||
///
|
||||
/// 返回是否真的落盘(只读 / 内存态时是 `false`,配置写不进去不该把 UI 卡住)。
|
||||
pub fn persist_ui_language(&mut self, value: Option<&str>) -> bool {
|
||||
let Some(store) = self.store.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let changed = match value {
|
||||
Some(language) => store
|
||||
.set(UI_LANGUAGE, serde_json::Value::String(language.to_owned()))
|
||||
.is_ok(),
|
||||
None => store.doc().get(UI_LANGUAGE).is_some() && remove_ui_language(store),
|
||||
};
|
||||
if !changed {
|
||||
return false;
|
||||
}
|
||||
let saved = store.save().is_ok();
|
||||
if let Some(path) = self.path() {
|
||||
self.last_mtime = std::fs::metadata(path)
|
||||
.ok()
|
||||
.and_then(|meta| meta.modified().ok());
|
||||
}
|
||||
saved
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 `ui.language` 从文档里摘掉:`SettingDocument` 没有 remove API,而落盘写的是**全量视图**,
|
||||
/// 所以把全量视图里的那个键去掉再 load 回一份文档就等价于删键(用户手写的未知键也原样带回)。
|
||||
fn remove_ui_language(store: &mut SettingStore) -> bool {
|
||||
let mut value = store.doc().to_value();
|
||||
let removed = value
|
||||
.get_mut("ui")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
.and_then(|ui| ui.remove("language"))
|
||||
.is_some();
|
||||
if !removed {
|
||||
return false;
|
||||
}
|
||||
|
||||
let (mut doc, _problems) = SettingDocument::load(&launcher_schema(), &value);
|
||||
// 落盘的闸门是 `is_dirty()`(`write_document` 写全量视图,不是脏字段补丁)。
|
||||
doc.mark_all_dirty();
|
||||
*store.doc_mut() = doc;
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn temp_config(label: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ull-bridge-lang-{label}-{}-{}",
|
||||
std::process::id(),
|
||||
line!()
|
||||
));
|
||||
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
|
||||
let path = dir.join("config/launcher.json");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
path
|
||||
}
|
||||
|
||||
/// 抓得住:①把 `effective_str` 当读法(英文机器没配过 → 被默认值 `zh_cn` 强行切成中文);
|
||||
/// ②「跟随系统」写成哨兵值(枚举校验会拒、下次启动会撒谎);③删键时连别的键一起删。
|
||||
/// 抓不住:UI 是否真的按这个值选目录(那是 `app-iced/src/ui/lang.rs` 的往返测试)。
|
||||
#[test]
|
||||
fn ui_language_round_trips_and_follow_system_removes_only_that_key() {
|
||||
let path = temp_config("roundtrip");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{"appearance":{"theme_mode":"dark"},"custom":{"keep":true}}"#,
|
||||
)
|
||||
.expect("写配置");
|
||||
|
||||
// 没写过 = 跟随系统(不是默认值 zh_cn)
|
||||
let bridge = SettingsBridge::with_path(&path);
|
||||
assert_eq!(bridge.load_ui_language(), None);
|
||||
|
||||
// 手选落盘 → 新开的 bridge(= 重启)读得回
|
||||
let mut bridge = SettingsBridge::with_path(&path);
|
||||
assert!(bridge.persist_ui_language(Some("zh_cn")));
|
||||
assert_eq!(
|
||||
SettingsBridge::with_path(&path)
|
||||
.load_ui_language()
|
||||
.as_deref(),
|
||||
Some("zh_cn")
|
||||
);
|
||||
|
||||
// 切回跟随系统 = 删键,且别的键一个不动
|
||||
let mut bridge = SettingsBridge::with_path(&path);
|
||||
assert!(bridge.persist_ui_language(None));
|
||||
assert_eq!(SettingsBridge::with_path(&path).load_ui_language(), None);
|
||||
let text = std::fs::read_to_string(&path).expect("读配置");
|
||||
assert!(text.contains("theme_mode"), "外观键被删了:{text}");
|
||||
assert!(text.contains("custom"), "用户手写的未知键被删了:{text}");
|
||||
|
||||
// 幂等:没有键可删 = 没有落盘
|
||||
let mut bridge = SettingsBridge::with_path(&path);
|
||||
assert!(!bridge.persist_ui_language(None));
|
||||
|
||||
// 内存态桥不许 panic
|
||||
assert_eq!(SettingsBridge::memory_only().load_ui_language(), None);
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user