Merge branch 'feat/m106-i18nfollow-ui-follows-system-langua'
This commit is contained in:
commit
72b0c9d465
32 files changed
+1678
-135
No files matched your search
@@ -64,7 +64,7 @@ pub(super) fn report_boot(options: &Options, probe: &Probe, decision: &Decision)
|
||||
}
|
||||
acceptance::report_backend_plan(decision, options.backend);
|
||||
if acceptance::enabled() {
|
||||
acceptance::report_startup(probe, options.scale_factor);
|
||||
acceptance::report_startup(probe, options.scale_factor, options.screenshot.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,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;
|
||||
|
||||
@@ -86,6 +87,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 +194,27 @@ 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_SETTINGS_GROUP=<下标>` 直达设置页的某一组(出图要看见语言行)。
|
||||
if let Some(index) =
|
||||
crate::ui::lang::screenshot_settings_group(options.screenshot.is_some())
|
||||
{
|
||||
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 +226,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 +249,10 @@ impl App {
|
||||
};
|
||||
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
|
||||
app.rebuild_ui();
|
||||
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
|
||||
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
|
||||
app.lang.log();
|
||||
app.lang.log_cjk();
|
||||
app
|
||||
}
|
||||
|
||||
@@ -295,7 +316,9 @@ 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()` 占位已删——它恒英文,
|
||||
// 中文界面会弹出英文提示,那正是 M105 r1 复审 P2-2 要堵的同屏两语)。
|
||||
let text = pages::cancelled_toast(self.lang());
|
||||
self.state.toasts.push(ToastKind::Info, text);
|
||||
}
|
||||
|
||||
@@ -307,12 +330,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;
|
||||
|
||||
@@ -180,7 +180,8 @@ fn cancelling_gives_the_panel_back_to_the_install_options() {
|
||||
///
|
||||
/// 抓得住:谁把中文写回 presenter(类型上已经做不到——它没有 i18n 依赖)、
|
||||
/// 或者 app 层每次 `refresh_view` 都重复弹(下面的 `items().len() == 1`)。
|
||||
/// 抓不住:`Lang` 本身接得对不对(`view::ui_lang()` 现恒为英文,那是 M106 的范围)。
|
||||
/// 抓不住:`Lang` 本身接得对不对(M106 起语言来自 `App::lang()`,本测试**显式钉**成英文,
|
||||
/// 否则这条断言会跟着跑测试那台机器的 locale 变——那是假绿)。
|
||||
#[test]
|
||||
fn cancelling_announces_once_in_the_page_language() {
|
||||
let log: Rc<RefCell<Vec<AppCommand>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
@@ -198,6 +199,11 @@ fn cancelling_announces_once_in_the_page_language() {
|
||||
);
|
||||
assert!(app.state.toasts.is_empty(), "出厂态不该有 Toast");
|
||||
|
||||
// M106:语言显式钉成英文。不钉的话「页面语言」就是跑测试那台机器的 locale(英文 CI 绿、
|
||||
// 中文机器红)——那种测试证明不了任何事。钉住之后期望值仍从目录取,守的是同一条原意:
|
||||
// **提示语来自 i18n 目录,不是 presenter 写死的中文**。
|
||||
app.set_language(unlucky_i18n::LangChoice::Fixed(unlucky_i18n::Lang::En));
|
||||
|
||||
app.state.apply(AppEvent::Core(
|
||||
unlucky_presenter::dto::CoreEvent::InstallCancelled,
|
||||
));
|
||||
@@ -207,7 +213,7 @@ fn cancelling_announces_once_in_the_page_language() {
|
||||
let text = items[0].text.clone();
|
||||
assert_eq!(
|
||||
text,
|
||||
pages::cancelled_toast(super::super::view::ui_lang()),
|
||||
pages::cancelled_toast(unlucky_i18n::Lang::En),
|
||||
"提示语必须来自 i18n 目录(不是 presenter 写死的中文)"
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -19,8 +19,20 @@
|
||||
//! - 抓不住:加一个 `pages::Action` 变体 —— 它落进 `Action(action)` 的占位分支(打印一行
|
||||
//! `[page] 占位动作 …`),编译期不会报。这是占位期的既有设计,不是本文件能守的。
|
||||
|
||||
use unlucky_i18n::{Lang, LangChoice};
|
||||
|
||||
use crate::pages;
|
||||
use crate::ui::lang::LangState;
|
||||
|
||||
use super::*;
|
||||
|
||||
// --------------------------------------------------------------- 界面语言(M106)
|
||||
//
|
||||
// 这三个方法住在本文件而不是 `app/state.rs`:`state.rs` 贴着 400 行硬线(M104 的 `agent`、
|
||||
// M105 的 `download_draft` 都往那儿长),而 `set_language` 的唯一调用点就是本文件的
|
||||
// `pages::Message::Language` 分支——落点与调用点放一起,读的人不用来回跳。
|
||||
// 与 M93 把 appearance 那批 `App` 方法放进 `app/appearance.rs` 是同一手法。
|
||||
|
||||
impl App {
|
||||
/// 页面消息:导航走路由栈,真业务命令派发,纯视图态改本地呈现。
|
||||
pub(super) fn on_page(&mut self, message: pages::Message) -> Task<Message> {
|
||||
@@ -41,6 +53,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` 提前消化;显式列出而不是 `_ => {}`,见模块文档
|
||||
@@ -68,8 +84,111 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// 当前生效的界面语言(= 目录选择;页面文案一律由它派生)。
|
||||
pub(in crate::app) fn lang(&self) -> Lang {
|
||||
self.lang.lang()
|
||||
}
|
||||
|
||||
/// 设置页语言行要的两件:档位(画选中态)+ 生效语言(画回显)。
|
||||
pub(in crate::app) fn language(&self) -> pages::Language {
|
||||
self.lang.for_settings()
|
||||
}
|
||||
|
||||
/// 换界面语言(设置页语言行 → `pages::Message::Language`)。
|
||||
///
|
||||
/// 三件事缺一不可:①档位与结论换掉 ②视图模型重建(`view_dirty` + `refresh_view`,目录是
|
||||
/// 视图模型的一部分)③落盘(重启后保持所选语言)。字体不在这里重扫:中文档缺字体时的
|
||||
/// 判据/信号归 `i18n::font::decide` + `fonts/cjk.rs`(见报告「没验的部分」)。
|
||||
pub(in crate::app) fn set_language(&mut self, choice: 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();
|
||||
}
|
||||
}
|
||||
|
||||
#[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,13 +97,6 @@ impl App {
|
||||
|
||||
// --------------------------------------------------------------- 快照 → 视图模型
|
||||
|
||||
/// **产品路径此刻的界面语言**(唯一入口):语言还没接到设置(M106 的范围),所以恒为英文。
|
||||
/// 收成一处是为了别让第二个地方(如取消提示)各硬编码一次;M106 只改这里。
|
||||
/// **M106 合并前中文译文在产品路径上不可达**(页面与提示语都是英文)。
|
||||
pub(super) fn ui_lang() -> Lang {
|
||||
Lang::En
|
||||
}
|
||||
|
||||
/// 把 presenter 的快照转成页面视图模型(**唯一**的展示转换点)。
|
||||
///
|
||||
/// `forced` 是 `--force-state` 的落点(P3-1):它只影响**画出来的**状态层,不改任何状态机。
|
||||
@@ -113,6 +110,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 +135,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 {
|
||||
@@ -288,8 +279,8 @@ mod tests {
|
||||
/// 也抓不住不消费 `model.forced` 的裸 `iced::button`(设置页「外观」区就是)。
|
||||
/// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿。
|
||||
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)
|
||||
let (draft, lang) = (pages::InstallDraft::default(), Lang::En);
|
||||
model(state, 1180.0, forced, pages::Category::All, &draft, lang)
|
||||
}
|
||||
|
||||
#[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};
|
||||
@@ -61,18 +67,13 @@ pub fn report(detection: Detection, faces: Option<&[Face]>) -> CjkReport {
|
||||
|
||||
/// 探测本机:优先级见 [`unlucky_i18n::lang::resolve`]。
|
||||
///
|
||||
/// `setting` 是配置里 `ui.language` 的原文(`zh_cn` / `en_us`);`None` = **还没读配置**
|
||||
/// (设置层的读取点尚未接线,见模块文档的诚实边界)。注意 `None` 与 `Some("不认识的值")`
|
||||
/// 效果相同:都不当显式选择,继续往下走。
|
||||
/// `setting` 是配置里 `ui.language` 的原文(`zh_cn` / `en_us`);`None` = **配置里没写过这个键**
|
||||
/// (= 跟随系统,不是「没读」)。注意 `None` 与 `Some("不认识的值")` 效果相同:都不当显式选择,
|
||||
/// 继续往下走。
|
||||
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;
|
||||
|
||||
@@ -95,8 +95,8 @@ use super::{Action, Message};
|
||||
|
||||
/// 整页(**唯一**的真数据入口:`model` 由 `app/view.rs` 从 presenter 快照建)。
|
||||
///
|
||||
/// `lang` 是 i18n 的入参:`theme::Ui` 还没有 language 字段(那条在 M93 手里),产品路径现传
|
||||
/// [`Lang::En`](D28 的英文基线零中文负担);接上探测结果时只改调用点,页面不用动。
|
||||
/// `lang` 是 i18n 的入参,调用点传的是 `pages::Model.lang`(M106 起 = `App` 的生效语言,
|
||||
/// 见 `ui::lang::LangState`);页面自己不读全局、不猜语言。
|
||||
pub fn view<'a>(
|
||||
ui: &'a Ui,
|
||||
metrics: FrameMetrics,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -89,10 +89,11 @@ pub struct Model {
|
||||
pub download: Download,
|
||||
/// 日志页的数据(M95:presenter 快照 → 日志行 + 诊断结论,见 [`super::logs`])。
|
||||
pub logs: Logs,
|
||||
/// 界面语言(M95):页面文案走 i18n key,语言由 app 层决定后灌进来。
|
||||
/// 界面语言(M106):页面文案走 i18n key,语言由 app 层决定后灌进来。
|
||||
///
|
||||
/// 这里只是**留口**:`theme.rs` 还没有「当前语言」的入口(那条线归 M93),
|
||||
/// 产品路径先给 `Lang::En`;将来 `Ui` 拿到语言后,这个字段改由它填。
|
||||
/// 谁填:`app/state.rs` 的 `App::refresh_view`(`LangState::lang()` → `view::model(..)`),
|
||||
/// 即「用户档位 / 系统 locale → 生效语言」这条链的结论。**不是**基线值:M95 以前这里是
|
||||
/// `Lang::En` 占位,M106 之后中文系统上灌进来的是 `Lang::ZhCn`。
|
||||
pub lang: Lang,
|
||||
/// 取证用的强制交互态(`--force-state`):`Some` 时页面里的控件按该态画。
|
||||
///
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -101,9 +101,12 @@
|
||||
//! `app/view.rs` 不用动 —— **推荐这个形状**。
|
||||
//!
|
||||
//! 顺带订正 r3 之前那句「在 `app/state.rs` 用真值初始化 `Groups.language_appearance`(1 个文件)」:
|
||||
//! 今天 `App` / `app/state.rs` 里**没有**任何语言真值源(`presenter::prefs::AppearancePrefs` 无
|
||||
//! language 字段;唯一的值是 `app/view.rs:128` 里硬编码的 `Lang::En`),要先有真值源才谈得上
|
||||
//! 初始化位置。两种形状都是「谁先撞谁付一次钱」的一次性代价,不是永久性四路撞车。
|
||||
//! **M106 起语言已经有真值源**——`App::lang: LangState`(`crates/app-iced/src/ui/lang.rs`,用户档位
|
||||
//! `LangChoice` + 探测结论 `Detection`),由 `App::refresh_view` 经 `view::model(…, lang)` 灌进
|
||||
//! `pages::Model.lang`,设置页再从 `settings::view` 的 `language` 形参拿到它。所以下一条线要给某一组
|
||||
//! 读「当前语言」时,**不需要**再造一层状态:要么走 `Model.lang`(给 `settings::view` 加形参,
|
||||
//! 与 M106 同形状),要么读 `Groups` 里本线的字段。两种形状都是「谁先撞谁付一次钱」的一次性代价,
|
||||
//! 不是永久性四路撞车。
|
||||
//! 5. **首帧取数 / 异步发起**(同上,r2 复审 P1 补)—— [`Body`] 是
|
||||
//! `fn(&Ui, States) -> Option<Element>`,返回不了 `Task`;而且 `settings::view` → `pages::render`
|
||||
//! → `App::view` **一路都返回 `Element`**(iced 0.14 的 `view` 也只返回 `Element`,`view(&self)`
|
||||
@@ -146,12 +149,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 +217,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 +255,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 +272,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 +288,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 +307,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 +320,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");
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! [`AppleLanguages`] 先落在这里。**它该搬去 `platform/locale.rs`**——两处并存会漂(见交接报告)。
|
||||
|
||||
use unlucky_i18n::lang::{self, Detection, LocaleSource};
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use crate::Probe;
|
||||
|
||||
@@ -83,21 +84,43 @@ pub fn tz() -> Option<String> {
|
||||
/// 到底是「用户就是英文」还是「一个源都没取到」根本分不出来。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Chain {
|
||||
/// `ui.language` 的显式值(当前没有读取点,恒 `None`)。
|
||||
/// `ULL_LANG` 的显式覆盖(截图矩阵的注入值);`None` = 没设。
|
||||
///
|
||||
/// 它与 [`Chain::setting`] 是**两个不同的源**:一个是我们为了出图钉的值,一个是用户配置里存的。
|
||||
/// 判定顺序里前者压后者(见 [`Chain::read`]),日志里分开打,读的人不用猜。
|
||||
pub forced: Option<String>,
|
||||
/// `ui.language` 的显式值(`None` = 文件里**没写过** = 跟随系统)。
|
||||
///
|
||||
/// 与 `App` 启动判定同一个读法(`SettingsBridge::load_ui_language`:只认显式值,不走 schema
|
||||
/// 默认值),所以「没写过」在这里与 `ui::lang` 里是同一个意思。
|
||||
pub setting: Option<String>,
|
||||
/// (源自称的名字, 原始 locale)。
|
||||
pub platform: (&'static str, Option<String>),
|
||||
/// (源自称的名字, 原始 locale)。
|
||||
pub env: (&'static str, Option<String>),
|
||||
/// 结论(由 [`lang::resolve`] 算,不在本模块重写一遍优先级)。
|
||||
/// 只按**平台源 + 环境变量**算出来的「这台机器说什么语言」(**不看** `ui.language`)。
|
||||
///
|
||||
/// `[env] language=` 用的是它:那一行回答的是「本机是什么」,用户手选英文不该改写机器的事实。
|
||||
pub machine: Detection,
|
||||
/// 最终结论:**与 `App` 启动日志的 `[i18n] choice=…` 用同一个判据函数**
|
||||
/// (`ui::lang::choose_choice` + `LangChoice::resolve`),所以两行不可能说的是两种语言。
|
||||
pub detection: Detection,
|
||||
/// 这一次是不是**截图通道**(D30):`true` 时配置那一级按设计被忽略,日志里打出来,
|
||||
/// 否则「配置写了英文、结论是中文」看起来像 bug。
|
||||
pub screenshot: bool,
|
||||
}
|
||||
|
||||
impl Chain {
|
||||
/// 用三个源探测一次。**每个源只读一次**(`defaults` / `reg.exe` 都是子进程)。
|
||||
/// 用全部四个源探测一次。**每个源只读一次**(`defaults` / `reg.exe` 都是子进程)。
|
||||
///
|
||||
/// 优先级**不在这里重写**:档位由 [`crate::ui::lang::choose_choice`] 出(与 `App` 启动判定
|
||||
/// 同一个函数),结论由 `LangChoice::resolve` 出(与 `App` 同一个 `resolve`)。
|
||||
/// 顺序即 **`ULL_LANG` > 配置(`screenshot == false` 才算) > 平台源 > 环境变量 > 英文**。
|
||||
#[must_use]
|
||||
pub fn read(
|
||||
forced: Option<&str>,
|
||||
setting: Option<&str>,
|
||||
screenshot: bool,
|
||||
platform: &dyn LocaleSource,
|
||||
env: &dyn LocaleSource,
|
||||
) -> Self {
|
||||
@@ -109,45 +132,70 @@ impl Chain {
|
||||
label: env.label(),
|
||||
raw: env.locale(),
|
||||
};
|
||||
// 优先级仍由 `resolve` 单点决定:这里把**已读到的值**包成同标签的源喂回去,
|
||||
// 不复制一遍 if/else(复制就会漂)。
|
||||
let detection = lang::resolve(setting, &platform, &env);
|
||||
// 「机器说什么语言」:与 `ui.language` 无关,`[env] language=` 用它。
|
||||
let machine = lang::resolve(None, &platform, &env);
|
||||
// 「这一次界面用什么语言」:**调产品路径那个函数**,不在这里复制一遍优先级表。
|
||||
let detection =
|
||||
crate::ui::lang::choose_choice(forced, setting, screenshot).resolve(&platform, &env);
|
||||
Self {
|
||||
forced: forced.map(str::to_owned),
|
||||
setting: setting.map(str::to_owned),
|
||||
platform: (platform.label, platform.raw),
|
||||
env: (env.label, env.raw),
|
||||
machine,
|
||||
detection,
|
||||
screenshot,
|
||||
}
|
||||
}
|
||||
|
||||
/// 本机(平台源 + 环境变量;配置未接线)。
|
||||
/// 本机链路(`ULL_LANG` + 配置 + 平台源 + 环境变量)。
|
||||
///
|
||||
/// `screenshot` 由调用方从 `Options` 递进来(`report_boot` 手上有):**截图通道按 D30 忽略配置**,
|
||||
/// 这一级判断必须与产品路径同源,否则又会出现「一行说 zh-CN、下一行说 effective=en」。
|
||||
///
|
||||
/// 诚实边界:这里 `SettingsBridge::open()` 走**默认数据根**;同一次运行若带了 `--data-dir`,
|
||||
/// 配置可能来自另一份文件(此时以 `App` 那行 `[i18n] choice=…` 为准,它用的是 App 自己的桥)。
|
||||
#[must_use]
|
||||
pub fn host() -> Self {
|
||||
Self::read(None, platform_locale_source(), &lang::EnvLocale)
|
||||
pub fn host(screenshot: bool) -> Self {
|
||||
let forced = std::env::var(crate::ui::lang::LANG_ENV)
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty());
|
||||
let setting = SettingsBridge::open().load_ui_language();
|
||||
Self::read(
|
||||
forced.as_deref(),
|
||||
setting.as_deref(),
|
||||
screenshot,
|
||||
platform_locale_source(),
|
||||
&lang::EnvLocale,
|
||||
)
|
||||
}
|
||||
|
||||
/// 结论语言标签(`zh-CN` / `en`)。
|
||||
/// **机器的**语言标签(`zh-CN` / `en`)——不含 `ui.language` 的手选。
|
||||
#[must_use]
|
||||
pub fn language(&self) -> &'static str {
|
||||
self.detection.lang.tag()
|
||||
self.machine.lang.tag()
|
||||
}
|
||||
|
||||
/// `[i18n]` 一行:四级链 + 结论。
|
||||
/// `[i18n]` 一行:四个源 + 结论。
|
||||
///
|
||||
/// `ui_text=pinned-en` 是**今天的事实**:`pages/mod.rs::render` 里下载页固定传 `Lang::En`、
|
||||
/// 日志页传 `model.lang`(默认也是 `En`),也就是**文案还没接这条结论**。不写这一格的话,
|
||||
/// 日志会让人以为「语言已经是中文了」——那是新的假绿。
|
||||
/// 顺序即 `ULL_LANG` > 配置(`shot=false` 才参与) > 平台源 > 环境变量 > 英文;`->` 后面那一段
|
||||
/// 就是**最终生效语言 + 它来自哪一级**,与 `App` 的 `[i18n] choice=…` **用同一个判据函数**算出来。
|
||||
///
|
||||
/// 为什么把 `forced=` 与 `shot=` 也打出来:`shot=true` 时配置按 D30 被忽略,不写这一格的话
|
||||
/// 「配置里明明写了 en_us,结论却是 zh-CN」看起来像 bug(r4 复审 p2-1 的两组复现就是这两种)。
|
||||
///
|
||||
/// 曾经这里钉着 `ui_text=pinned-en`(那会儿 `pages/mod.rs::render` 真的把下载页写死成英文)。
|
||||
#[must_use]
|
||||
pub fn line(&self) -> String {
|
||||
format!(
|
||||
"[i18n] setting={} platform={}:{} env={}:{} -> {} ui_text=pinned-en",
|
||||
// `<未接线>` 而不是 `<未设置>`:配置侧的读取点**还不存在**(M98 r1 p3-3)。
|
||||
// 写「未设置」会让读的人以为用户没配过;真实情况是我们没读。
|
||||
self.setting.as_deref().unwrap_or("<未接线>"),
|
||||
"[i18n] forced={} setting={} platform={}:{} env={}:{} shot={} -> {}",
|
||||
self.forced.as_deref().unwrap_or("<未设置>"),
|
||||
self.setting.as_deref().unwrap_or("<未设置=跟随系统>"),
|
||||
self.platform.0,
|
||||
show(self.platform.1.as_deref()),
|
||||
self.env.0,
|
||||
show(self.env.1.as_deref()),
|
||||
self.screenshot,
|
||||
self.detection.describe()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -71,6 +71,8 @@ fn i18n_line_shows_every_rung_and_the_resolved_language() {
|
||||
|
||||
let chain = Chain::read(
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
&Fixed(Some("zh-Hans-CN"), Some("applelanguages")),
|
||||
&Fixed(None, Some("env")),
|
||||
);
|
||||
@@ -81,11 +83,115 @@ fn i18n_line_shows_every_rung_and_the_resolved_language() {
|
||||
"平台源原始值必须打出来:{line}"
|
||||
);
|
||||
assert!(line.contains("env=env:<未设置>"), "{line}");
|
||||
assert!(line.contains("forced=<未设置>"), "{line}");
|
||||
assert!(line.contains("setting=<未设置=跟随系统>"), "{line}");
|
||||
assert!(line.contains("shot=false"), "{line}");
|
||||
assert!(
|
||||
line.contains("-> lang=zh-CN locale=zh-Hans-CN source=applelanguages"),
|
||||
"{line}"
|
||||
);
|
||||
assert!(line.contains("ui_text=pinned-en"), "{line}");
|
||||
// **回归绊线(M106 r1 p2-1)**:这行曾经钉着 `ui_text=pinned-en`,在文案真的接了
|
||||
// `model.lang` 之后变成一句假话(「界面钉在英文」)。它不许回来。
|
||||
assert!(
|
||||
!line.contains("pinned-en"),
|
||||
"`ui_text=pinned-en` 是 M106 之前的旧事实,现在会与 App 的 [i18n] 行自相矛盾:{line}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 配置的手选必须**同时**体现在结论与 `[env]` 的分工上(M106 r1 p2-1 的修法证据)。
|
||||
///
|
||||
/// 抓得住:①`ui.language` 这一级被丢掉(那行 `->` 的结论会退回系统语言,与 `App` 的
|
||||
/// `[i18n] choice=…` 矛盾);②把「机器语言」也改成手选值(`[env] language=` 答的是「本机是什么」,
|
||||
/// 用户手选英文不该改写机器的事实)。
|
||||
#[test]
|
||||
fn i18n_line_honours_the_explicit_setting_without_rewriting_the_machine_language() {
|
||||
struct Fixed(Option<&'static str>);
|
||||
impl LocaleSource for Fixed {
|
||||
fn locale(&self) -> Option<String> {
|
||||
self.0.map(str::to_owned)
|
||||
}
|
||||
fn label(&self) -> &'static str {
|
||||
"registry"
|
||||
}
|
||||
}
|
||||
|
||||
let chain = Chain::read(
|
||||
None,
|
||||
Some("en_us"),
|
||||
false,
|
||||
&Fixed(Some("zh-CN")),
|
||||
&Fixed(None),
|
||||
);
|
||||
assert_eq!(chain.language(), "zh-CN", "[env] 报的是机器语言");
|
||||
assert_eq!(chain.detection.lang, unlucky_i18n::Lang::En, "手选压过系统");
|
||||
let line = chain.line();
|
||||
assert!(line.contains("setting=en_us"), "{line}");
|
||||
assert!(line.contains("source=setting"), "{line}");
|
||||
assert!(line.contains("lang=en"), "{line}");
|
||||
}
|
||||
|
||||
/// **两个 `[i18n]` 行不许打架**(r4 复审 p2-1 的回归绊线)。
|
||||
///
|
||||
/// 复审实测的两组复现,现在必须是同一个结论:
|
||||
/// - ① 只设 `ULL_LANG=en_us`、配置干净 ⇒ 结论 = en(`forced` 压过系统中文);
|
||||
/// - ② 配置 `ui.language=en_us` + **截图通道** ⇒ 结论 = zh-CN(D30:截图不读用户配置);
|
||||
/// - ③ 配置 `ui.language=en_us` + 交互通道 ⇒ 结论 = en(配置这一级只在非截图时参与)。
|
||||
///
|
||||
/// 抓得住:①`ULL_LANG` 这一级被丢掉;②截图通道又去读配置(「一行说 zh-CN、下一行说 effective=en」
|
||||
/// 就是这么来的);③acceptance 侧自己复制了一份优先级表 —— 每条都用
|
||||
/// `crate::ui::lang::choose_choice`(产品路径那张表)算出**期望结论**再与 `Chain` 比,谁复制谁红。
|
||||
/// 抓不住:真机上 `reg.exe` / `defaults` 读到什么(那是真机验收),以及 `App` 那行日志本身
|
||||
/// (它由 `ui/lang.rs` 的单测守)。
|
||||
#[test]
|
||||
fn i18n_line_and_the_app_agree_on_every_channel_and_override() {
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
struct Fixed(Option<&'static str>);
|
||||
impl LocaleSource for Fixed {
|
||||
fn locale(&self) -> Option<String> {
|
||||
self.0.map(str::to_owned)
|
||||
}
|
||||
fn label(&self) -> &'static str {
|
||||
"registry"
|
||||
}
|
||||
}
|
||||
|
||||
let registry_zh = Fixed(Some("zh-CN"));
|
||||
let no_env = Fixed(None);
|
||||
|
||||
/// 一行真值表:`(ULL_LANG, 配置, 截图通道) -> (期望语言, 期望来源)`。
|
||||
type Case<'a> = (Option<&'a str>, Option<&'a str>, bool, Lang, &'a str);
|
||||
let table: &[Case<'_>] = &[
|
||||
(Some("en_us"), None, false, Lang::En, "setting"),
|
||||
// 复审组合 ①:`ULL_LANG` 在截图通道里同样压过系统中文
|
||||
(Some("en_us"), None, true, Lang::En, "setting"),
|
||||
// 复审组合 ②:截图通道**忽略配置**,于是退回系统语言
|
||||
(None, Some("en_us"), true, Lang::ZhCn, "registry"),
|
||||
// 组合 ③:非截图通道,配置这一级生效
|
||||
(None, Some("en_us"), false, Lang::En, "setting"),
|
||||
(None, None, false, Lang::ZhCn, "registry"),
|
||||
];
|
||||
|
||||
for (forced, setting, screenshot, lang, origin) in table {
|
||||
let chain = Chain::read(*forced, *setting, *screenshot, ®istry_zh, &no_env);
|
||||
let line = chain.line();
|
||||
assert_eq!(
|
||||
(chain.detection.lang, chain.detection.origin),
|
||||
(*lang, *origin),
|
||||
"forced={forced:?} setting={setting:?} shot={screenshot} 结论不对:{line}"
|
||||
);
|
||||
// `[env] language=` 只报机器语言,任何手选/覆盖都不该改写它。
|
||||
assert_eq!(chain.language(), "zh-CN", "{line}");
|
||||
|
||||
// **同一张表**的结构性钉子:期望结论取自产品路径那个函数,谁在 acceptance 侧另写一份就红。
|
||||
let expected = crate::ui::lang::choose_choice(*forced, *setting, *screenshot)
|
||||
.resolve(®istry_zh, &no_env);
|
||||
assert_eq!(chain.detection, expected, "两侧的判据表漂了:{line}");
|
||||
assert!(
|
||||
line.contains(&format!("shot={screenshot}")),
|
||||
"截图通道必须在行里写出来:{line}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 抓得住 macOS 最典型的失败形态:**一个源都没有**(Finder 启动的 GUI 进程既没有 `LANG`
|
||||
@@ -102,7 +208,7 @@ fn i18n_line_distinguishes_no_source_from_english_source() {
|
||||
}
|
||||
}
|
||||
|
||||
let empty = Chain::read(None, &Nothing, &Nothing);
|
||||
let empty = Chain::read(None, None, false, &Nothing, &Nothing);
|
||||
assert_eq!(empty.language(), "en");
|
||||
assert!(empty.line().contains("source=default"), "{}", empty.line());
|
||||
|
||||
@@ -116,7 +222,7 @@ fn i18n_line_distinguishes_no_source_from_english_source() {
|
||||
}
|
||||
}
|
||||
|
||||
let env = Chain::read(None, &Nothing, &EnvEnglish);
|
||||
let env = Chain::read(None, None, false, &Nothing, &EnvEnglish);
|
||||
assert!(env.line().contains("source=env"), "{}", env.line());
|
||||
assert!(
|
||||
env.line().contains("locale=en_US.UTF-8"),
|
||||
|
||||
@@ -69,8 +69,10 @@ pub fn announce() {
|
||||
///
|
||||
/// 两行都吃**同一次**适配器枚举与**同一次**语言链(`defaults` / `reg.exe` 是子进程,读两次就是
|
||||
/// 两次开销,而且两次之间值可能变)。
|
||||
pub fn report_startup(probe: &Probe, scale: Option<f32>) {
|
||||
let chain = Chain::host();
|
||||
pub fn report_startup(probe: &Probe, scale: Option<f32>, screenshot: bool) {
|
||||
// `screenshot` 一路递到 `Chain`:截图通道按 D30 忽略配置,判定必须与产品路径同源
|
||||
// (两行日志用的都是 `ui::lang::choose_choice` 那一张表,见 `locale::Chain::read`)。
|
||||
let chain = Chain::host(screenshot);
|
||||
eprintln!(
|
||||
"{}",
|
||||
env_line(probe, chain.language(), scale, tz().as_deref())
|
||||
|
||||
@@ -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;
|
||||
@@ -49,6 +51,10 @@ use crate::theme::Ui;
|
||||
///
|
||||
/// `background` 由 `pages::appearance::layer` 产出(已与 `Message::Page` 映射过);垫在 `stack!`
|
||||
/// 最底层,于是「面板 α < 1」时能真的看见它——这就是 D34 在像素上的落点。
|
||||
// 9 个形参:M104 的 `right`/`overlay` 之后 M106 又加了 `lang`(导航轨标签要按语言取词)。
|
||||
// 拆成结构体要动 `app/view.rs` 的两处调用点,而那正是 M104/M105 反复设锚点的地方——
|
||||
// 等 M105 那轮 rebase(`draft` 与 `lang` 两个形参都要留)时一并结构体化,先就地 `allow`。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn view_with_background<'a>(
|
||||
ui: &'a Ui,
|
||||
selected: NavItem,
|
||||
@@ -58,10 +64,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,283 @@
|
||||
//! 界面语言的**入口层**(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";
|
||||
|
||||
/// 设置页左侧分组的取证开关:`ULL_SETTINGS_GROUP=0` 直达 General(语言行在那里)。
|
||||
///
|
||||
/// 只影响 `--screenshot` 那一次进程(设置页出厂停在「外观」,而用户抱怨的语言行在 General 组;
|
||||
/// 出图必须能直达)。交互模式一律按用户点的那一组,所以这里拿 `screenshot` 当闸门。
|
||||
/// 为什么住在 `ui/lang.rs`:它与 [`LANG_ENV`] 是同一族「出图取证开关」,一起放在语言入口层,
|
||||
/// `App` 那边只留一行调用(`app/state.rs` 贴着 400 行硬线)。
|
||||
pub(crate) fn screenshot_settings_group(screenshot: bool) -> Option<usize> {
|
||||
if !screenshot {
|
||||
return None;
|
||||
}
|
||||
std::env::var("ULL_SETTINGS_GROUP")
|
||||
.ok()
|
||||
.and_then(|raw| raw.trim().parse::<usize>().ok())
|
||||
}
|
||||
|
||||
/// 这一次进程的界面语言:用户选的档 + 探测出来的结论。
|
||||
///
|
||||
/// 不是 `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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 档位选择的**纯判据**(单测直接打这张表)。
|
||||
///
|
||||
/// **产品路径与验收路径共用它**:`LangState::boot` 用它决定目录,`renderer::acceptance::locale`
|
||||
/// 的 `[i18n]` 行也用它算结论——两行日志因此不可能再说出不同的语言(r4 复审 p2-1 就是
|
||||
/// 「两处各算一遍」造成的)。
|
||||
///
|
||||
/// | 输入 | 结果 |
|
||||
/// |---|---|
|
||||
/// | `ULL_LANG` 给了合法值 | 那一档(压过配置) |
|
||||
/// | 没给 + 配置写了 `zh_cn` / `en_us` | 那一档 |
|
||||
/// | 没给 + 配置没写(或写了坏值) | 跟随系统 |
|
||||
/// | **截图通道** + 配置写了值 | 跟随系统(除非 `ULL_LANG` 另给) |
|
||||
pub(crate) 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