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