Files
Unlucky-Launcher/crates/app-iced/src/pages/settings/general.rs
T
AskaEth 89dcb6bdf3 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 忽略,按仓内惯例作为本地验收产物)。
2026-11-04 22:50:09 +08:00

226 lines
8.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 设置页「General」分组:语言选择器 + 主题 / 启动页 / 关闭按钮(M106 接真控件)。
//!
//! **这一组归 language+appearance 线**(M101 的分线口径)。
//!
//! ## 这一组为什么特殊
//!
//! 它**不走** `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 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 分组(左列名固定英文:左列是**导航**,与导航轨同源,不进 catalog)。
pub(super) const GROUP: Group = Group {
name: "General",
icon: IconKind::Settings,
// 只给 harness(`pages_demo` 走 `appearance: None` 时)用的演示行:产品路径走 [`body`]。
// 语言那一行**不在**这里——它的值是要跟着语言变的,写死在静态表里就是用户看到的那条假值。
rows: &[
("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()
}