复审 r1(p1-1/fix-then-merge)五项全改:
1. **p1-1 CI 会红的 4 条 integration test**(`crates/core/tests/setting/`,我上一轮只跑了 `--lib`,
scope 漏掉了覆盖 schema 版本号的目录式 target):
- `java_install_keys` / `isolation_keys` / `appearance_keys`:版本断言 1.6.0 → 1.7.0(M168 的
`ui.font_cdn`),并写明「每加一批键推一次 minor 是纪律」。
- `update_keys::a_read_only_launcher_config_never_retires_the_legacy_file`:夹具从**写死
1.7.0** 改成 `launcher_version().minor + 1` 经 `SchemaRef::url()` 生成。写死数字的版本一推
这条守卫就变成「同版本」⇒ 文件可写 ⇒ 迁移真的执行 ⇒ 旧文件被退役,而断言还绿着(用户数据
守卫静默失效)。相对推导不会过期。
2. **p2-1 备用镜像不备用**:`cdn` 与 `fastly` 同属 Fastly(都 CNAME 到 jsdelivr.map.fastly.net),
换掉。现在首选 `gcore.jsdelivr.net`(Cloudflare,独立失败域),二选 `cdn.jsdelivr.net`
(Fastly)。注释里放了实测表(本机 + 复审机两份数据、字节数、sha256、IPv6 慢路径),
并写清排序依据(Fastly 侧 IPv6 实测 67 KB/s ⇒ 丢掉 4 分钟;Cloudflare 侧两次都完整下完)。
3. **p2-2 整体时限**:`core::download::font` 新增 `ATTEMPT_DEADLINE = 90 s` /
`TOTAL_DEADLINE = 180 s`,落在本模块自己的候选循环里(不动引擎全体语义):每次尝试
`min(单次上限, 剩余总预算)`,超时归 `Network` 档。新增守卫
`a_trickling_connection_is_cut_by_the_deadline`(回环服务端每 100 ms 挤 1 字节、预算 300 ms)。
顺带修掉一个真 bug:`tokio::time::timeout` 在运行时外构造会 panic(5 条既有用例当场抓到),
现在整段包进 `block_on(async { … })`。UI 文案改成「网络不通或太慢(超时)」,不校验失败混。
4. **p2-3 说 `Source::PendingPin` 的两处**:`fonts/cjk.rs` 的模块文档已改;`ui/lang.rs::log_cjk`
那句「D28 的下载通道还是 PendingPin」(在下载已发起之后打印,等于当面否定本功能)改成
「已按 D28 发起下载,见 [font] cjk-download … 与设置页那一行」。全仓再扫,只剩注释里的历史说明。
5. **p2-4 假守卫**:判据抽成纯函数 `install::plan(strategy, current) -> Step`(`Skip` / `Apply` /
`Start`),`after_probe` 只执行。旧的空转用例换成打真判据的打表用例;变异证明:删掉
`Skip` 那一臂 ⇒ `a_started_or_finished_install_is_not_restarted` 红(已实跑)。
次要项也一并处理:`from_cache` 改成**本次是否真的命中缓存**(`CdnFetcher::was_cached()`,
`has_cached()` 降级为诊断字段 `cache_present=`);`FontSpec` 的 URL 口径收紧为「非回环必须
https」(`is_acceptable_url` + 守卫);`ui.font_cdn` 写进状态行 hint;文档里的 17.0 MB → 16.9 MB。
`download/font.rs` 拆出 `font/spec.rs` 守 400 行。
验证(这台机器上单个 800 MB 测试 exe 链接要 10 分钟,链接期间文件被 Defender 扫描,故耗时很长):
- `cargo test -p unlucky-core --all-targets` → **1188 passed / 0 failed**(16 个 target,含
`setting` 那个 115 条、上一轮红 4 条的 integration target)
- `cargo test -p unlucky-i18n` → 31 passed / 0 failed
- `cargo test -p unlucky-app-iced --lib` → 685 passed / 0 failed
- `cargo clippy -p unlucky-i18n -p unlucky-core -p unlucky-app-iced --all-targets -- -D warnings` → 干净
- `cargo fmt --all -- --check` → 干净;全仓受检文件行数最大 388(<400)
335 lines
13 KiB
Rust
335 lines
13 KiB
Rust
//! 设置页「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::fonts::install::{CjkInstall, FetchClass};
|
||
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));
|
||
// M168(D28 后半):中文字体的下载状态行。紧贴语言行——用户在这里手选中文,反馈就该在这行下面,
|
||
// 而不是等他切到别的页面才发现「刚才那几秒在干嘛」。
|
||
stacked = stacked.push(divider(ui));
|
||
stacked = stacked.push(font_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)
|
||
}
|
||
|
||
/// 中文字体状态行的**文案**(纯函数:状态 × 目录 → 一句话 + 可选的一句解释)。
|
||
///
|
||
/// 为什么单独拎出来:截图只能证明「画出来了」,而这里能被断言证明「四种状态的词都在、占位符都被
|
||
/// 替换、两套目录都没有 `⟪missing⟫`」(见 `tests/general_section.rs`)。
|
||
///
|
||
/// 失败态**不写具体错误串**:那是给我们看的(`[font] cjk-download failed` 那行日志),用户只需要
|
||
/// 知道「网络 / 内容 / 磁盘」里哪一类,以及「界面照常可用」。
|
||
pub(super) fn font_status(
|
||
catalog: &unlucky_i18n::catalog::Catalog,
|
||
status: &CjkInstall,
|
||
) -> (String, Option<String>) {
|
||
match status {
|
||
CjkInstall::Unknown => (catalog.text(Key::FontCjkStatusUnknown).to_owned(), None),
|
||
CjkInstall::Unprobeable => (catalog.text(Key::FontCjkStatusUnprobeable).to_owned(), None),
|
||
CjkInstall::NotNeeded => (catalog.text(Key::FontCjkStatusNotNeeded).to_owned(), None),
|
||
CjkInstall::System { family } => (
|
||
catalog.render(Key::FontCjkStatusSystem, &[("family", family)]),
|
||
None,
|
||
),
|
||
CjkInstall::Downloading { bytes } => (
|
||
catalog.render(
|
||
Key::FontCjkStatusDownloading,
|
||
&[("size", &format_size(*bytes))],
|
||
),
|
||
Some(catalog.text(Key::FontCjkStatusHint).to_owned()),
|
||
),
|
||
CjkInstall::Ready { family, .. } => (
|
||
catalog.render(Key::FontCjkReady, &[("family", family)]),
|
||
Some(catalog.text(Key::FontCjkStatusHint).to_owned()),
|
||
),
|
||
CjkInstall::Failed { class, .. } => {
|
||
let reason = catalog.text(match class {
|
||
FetchClass::Network => Key::FontCjkReasonNetwork,
|
||
FetchClass::Integrity => Key::FontCjkReasonIntegrity,
|
||
FetchClass::Disk => Key::FontCjkReasonDisk,
|
||
});
|
||
(
|
||
catalog.render(Key::FontCjkStatusFailed, &[("reason", reason)]),
|
||
None,
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 字节数 → 人话体积(17 772 300 B → `16.9 MB`,按 1 MiB = 1048576 B)。只用于「≈」级别的量。
|
||
fn format_size(bytes: u64) -> String {
|
||
const MIB: f64 = 1024.0 * 1024.0;
|
||
let mib = bytes as f64 / MIB;
|
||
if mib >= 1.0 {
|
||
format!("{mib:.1} MB")
|
||
} else {
|
||
format!("{:.0} KB", bytes as f64 / 1024.0)
|
||
}
|
||
}
|
||
|
||
/// 中文字体状态行:标签一行、状态一行(失败红、就绪主色)、需要时再补一句解释。
|
||
///
|
||
/// 为什么**竖排**而不是像主题那几行「标签左、取值右」:失败那句话是整句(「获取失败(网络不通)。
|
||
/// 界面照常可用…」),塞进右列会把它压成一行挤掉标签(实测截图就是一整行红字、标签消失)。
|
||
/// 语言行也是竖排(标题 + 选项 + 说明),这一行贴着它,观感一致。
|
||
fn font_row<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
|
||
let catalog = language.effective.catalog();
|
||
let status = crate::fonts::install::status();
|
||
let (value, hint) = font_status(&catalog, &status);
|
||
let color = match status {
|
||
CjkInstall::Failed { .. } => ui.color(Role::Error),
|
||
CjkInstall::Ready { .. } | CjkInstall::System { .. } => ui.color(Role::Primary),
|
||
_ => ui.color(Role::OnSurfaceVariant),
|
||
};
|
||
|
||
let mut stacked = column![
|
||
frame::text_role(
|
||
ui,
|
||
TypeRole::BodyMedium,
|
||
false,
|
||
catalog.text(Key::FontCjkStatusLabel),
|
||
ui.color(Role::OnSurface),
|
||
),
|
||
container(frame::text_role_owned(
|
||
ui,
|
||
TypeRole::BodySmall,
|
||
false,
|
||
value,
|
||
color
|
||
))
|
||
.width(Length::Fill),
|
||
]
|
||
.spacing(ui.space(Space::X1))
|
||
.width(Length::Fill)
|
||
.padding(Padding::from([ui.space(Space::X2), 0.0]));
|
||
|
||
if let Some(hint) = hint {
|
||
stacked = stacked.push(container(frame::text_role_owned(
|
||
ui,
|
||
TypeRole::BodySmall,
|
||
false,
|
||
hint,
|
||
ui.color(Role::OnSurfaceVariant),
|
||
)));
|
||
}
|
||
|
||
stacked.into()
|
||
}
|
||
|
||
/// 语言行:标题 + 生效语言回显 + 三档互斥按钮 + 一句说明。
|
||
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()
|
||
}
|