feat(i18n): 外观页 40 条文案进词表(M180 I18NAPPEAR)

- 卡片内 22 处硬编码英文(整窗/面板透明度、背景图开关+路径、填充、暗化、配色来源)全部改走 Key
- 运行时诊断改成 key + 参数:`background::Note`(读盘/解码/过大/小图平铺)、`state::backend_note`、
  `palette::note` 全部返回 Key;`state.rs` 只留 key、不放文案表(390 → 391 行)
- 上限/下限常量走占位符 `{floor}` / `{max}` / `{limit}` / `{requested}`;`{:.2}` 在 Rust 侧格式化好
  再套模板(`Catalog::render` 只认 `{name}`)
- 新增 40 条 key(中英各一条),按装配链锚点插在 `SettingsAppearanceCornerSuspended` 之后,
  金表 `golden/tail.rs` 同位置插入 40 行(`rows() == len() == Key::ALL.len()`)
- 守卫三条:中文渲染全非 ASCII、卡片 key 全覆盖(改回写死英文即红)、外观 key 逐条真翻过
- probe 加第二条取证路(`record_rendered`),`take()` 签名不动(M179 的守卫在 scope 外)
This commit is contained in:
AskaEth committed 2026-11-08 21:42:11 +08:00
1 parent 65e7ccc3bf
commit c4c3c15285
14 files changed
+858 -184

No files matched your search

@@ -26,6 +26,8 @@
//! 为什么向底色退而不是「叠黑」:浅色主题下叠黑会把亮图压成暗块,深色正文反而更读不清
//! (偏离登记在 [`super`] 的偏离表 #7)。
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use unlucky_presenter::prefs::BgFill;
/// 暗化上限(core 的 `BG_DIM_MAX` = 0.95,经 `unlucky_presenter::prefs` 重导出;这里只给 f32 形态)。
@@ -63,7 +65,7 @@ pub const TILE_SCALE_STEP: f32 = 1.15;
/// 会退化成「留缝」,正是 r1 的 P2-1。真实图片不会走到这个上限。
pub const MAX_TILE_SCALE: f32 = 1024.0;
/// 解码失败的原因(人话在 [`LoadError::describe`])。
/// 解码失败的原因(人话在 [`LoadError::note`])。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoadError {
/// 读文件失败:不存在 / 没权限 / 被占用。
@@ -79,17 +81,76 @@ pub enum LoadError {
},
}
impl LoadError {
/// 可读诊断(设置页提示行与日志共用同一句)。
/// 一条**可翻译**诊断:`key` + 已经格式化好的参数(模板里只有 `{name}`,见 [`Key`])。
///
/// 为什么不是 `String`:文案表在金表里(M180 `I18NAPPEAR`),本模块只给「哪条 key + 哪些值」。
/// 数字在这一层就格式化好(`{:.2}` / `{:?}` 这些格式说明词表不支持)。
///
/// 为什么 `types` 是 `String` 而不是 `&'static str`:`Io(reason)` / `Undecodable(reason)` 的原因来自
/// 磁盘与解码库(运行时字符串),只能持有。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Note {
key: Key,
args: Vec<(&'static str, String)>,
}
impl Note {
/// 组装一条(参数名必须与译文里的 `{name}` 逐字相同)。
fn new(key: Key, args: Vec<(&'static str, String)>) -> Self {
Self { key, args }
}
/// 这条诊断是哪条文案。
#[must_use]
pub fn describe(&self) -> String {
pub fn key(&self) -> Key {
self.key
}
/// 参数(名 → 已成形的值)。
#[must_use]
pub fn args(&self) -> Vec<(&str, &str)> {
self.args
.iter()
.map(|(name, value)| (*name, value.as_str()))
.collect()
}
/// 按语言取整句(`{name}` 全替换;缺参时占位符原样留着,见 [`unlucky_i18n::catalog::Catalog::render`])。
#[must_use]
pub fn render(&self, lang: Lang) -> String {
lang.catalog().render(self.key, &self.args())
}
/// 英文基线里有没有这个子串。
///
/// 给**跨 mission 的既有断言**用(`app/tests/appearance.rs` 查「诊断里有没有说出原因」),
/// 那条断言不在本 mission 的 scope 里、不能改成按 key 断言。UI 一律走 [`Self::render`]。
#[must_use]
pub fn contains(&self, needle: &str) -> bool {
self.render(Lang::En).contains(needle)
}
}
impl LoadError {
/// 可翻译诊断(设置页提示行与日志共用同一条 key)。
#[must_use]
pub fn note(&self) -> Note {
match self {
Self::Io(reason) => format!("Background image could not be read: {reason}"),
Self::Undecodable(reason) => format!(
"Background image could not be decoded ({reason}; this build decodes PNG, JPEG and BMP)"
Self::Io(reason) => Note::new(
Key::SettingsAppearanceBgReadFailed,
vec![("reason", reason.clone())],
),
Self::TooLarge { width, height } => format!(
"Background image is too large to decode: {width}×{height} (limit {MAX_SOURCE_EDGE} px per edge)"
Self::Undecodable(reason) => Note::new(
Key::SettingsAppearanceBgDecodeFailed,
vec![("reason", reason.clone())],
),
Self::TooLarge { width, height } => Note::new(
Key::SettingsAppearanceBgTooLarge,
vec![
("width", width.to_string()),
("height", height.to_string()),
("limit", MAX_SOURCE_EDGE.to_string()),
],
),
}
}
@@ -193,7 +254,7 @@ fn header_size(bytes: &[u8]) -> Result<(u32, u32), LoadError> {
/// 判据只看图片本身(短边 < [`SMALL_TILE_EDGE`])——视口尺寸在渲染层才知道,而这里要的是
/// 「这张图小到要放大」这个与视口无关的事实。`scale` 条数上限由 [`tiles`] 保证覆盖 ≥ 视口。
#[must_use]
pub fn small_tile_note(image: &Image, fill: BgFill) -> Option<String> {
pub fn small_tile_note(image: &Image, fill: BgFill) -> Option<Note> {
if !matches!(fill, BgFill::Tile) {
return None;
}
@@ -201,9 +262,13 @@ pub fn small_tile_note(image: &Image, fill: BgFill) -> Option<String> {
if width.min(height) >= SMALL_TILE_EDGE {
return None;
}
Some(format!(
"Small image ({}×{}): tiles are scaled up so the window stays covered (max {MAX_TILES} tiles, no gaps).",
image.source.0, image.source.1
Some(Note::new(
Key::SettingsAppearanceBgSmallTile,
vec![
("width", image.source.0.to_string()),
("height", image.source.1.to_string()),
("max", MAX_TILES.to_string()),
],
))
}
@@ -4,6 +4,8 @@
use super::{dim_alpha, load, small_tile_note, tiles, LoadError, BG_DIM_MAX, MAX_TILES};
use std::path::{Path, PathBuf};
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use unlucky_presenter::prefs::BgFill;
fn temp_file(label: &str, name: &str) -> PathBuf {
@@ -66,19 +68,28 @@ fn a_real_jpeg_decodes_too() {
#[test]
fn a_missing_or_broken_file_reports_a_readable_reason() {
// 抓得住:①路径不存在 → Io;②文件存在但不是图片 → Undecodable;③空路径 → Io。
// 都必须是 Err(不许静默回默认色),且 describe() 是人话。
// 都必须是 Err(不许静默回默认色),且 note() 给出**哪条 key + 哪些参数**(句子在词表里)。
// 抓不住:webp/tiff 这类没开编码器的格式具体报什么字符串(只钉分类,不钉解码库原文)。
let missing = temp_file("missing", "bg.png");
let error = load(missing.to_str().unwrap()).expect_err("不存在的路径必须报错");
assert!(matches!(error, LoadError::Io(_)), "实际 {error:?}");
assert!(error
.describe()
.starts_with("Background image could not be read"));
let note = error.note();
assert_eq!(note.key(), Key::SettingsAppearanceBgReadFailed);
assert!(
note.contains("could not be read"),
"英文基线要说出「读不出来」:{}",
note.render(Lang::En)
);
let broken = temp_file("broken", "bg.png");
std::fs::write(&broken, b"this is not an image").expect("写坏文件");
let error = load(broken.to_str().unwrap()).expect_err("非图片必须报错");
assert!(matches!(error, LoadError::Undecodable(_)), "实际 {error:?}");
assert_eq!(
error.note().key(),
Key::SettingsAppearanceBgDecodeFailed,
"解不开与读不出来必须是两条 key(措辞与修法都不同)"
);
cleanup(&broken);
let empty = load(" ").expect_err("空路径必须报错");
@@ -168,10 +179,21 @@ fn an_over_sized_image_reports_too_large_instead_of_trying_to_decode() {
),
"实际 {error:?}"
);
let note = error.note();
assert_eq!(note.key(), Key::SettingsAppearanceBgTooLarge);
assert!(
error.describe().contains("too large"),
"提示要人话:{}",
error.describe()
note.contains("too large"),
"英文基线要说出「太大」:{}",
note.render(Lang::En)
);
let args = note.args();
assert!(
args.contains(&("width", "9000")) && args.contains(&("height", "4")),
"宽高要作为参数传进去(不是写进译文):{args:?}"
);
assert!(
args.contains(&("limit", "8192")),
"上限走占位符而不是写进译文:{args:?}"
);
// 上限不许再放宽(r1 的 16384 等于允许 1 GB 的中间缓冲):常量级约束放在测试里钉。
const _: () = assert!(super::MAX_SOURCE_EDGE <= 8_192);
@@ -187,8 +209,21 @@ fn an_over_sized_image_reports_too_large_instead_of_trying_to_decode() {
fn small_images_get_a_tile_diagnostic_instead_of_silently_degrading() {
let small = super::Image::from_rgba((32, 32), 4, 4, vec![0; 64]);
let note = small_tile_note(&small, BgFill::Tile).expect("小图 tile 必须有提示");
assert!(note.contains("32×32"), "{note}");
assert!(note.contains("scaled up"), "{note}");
assert_eq!(note.key(), Key::SettingsAppearanceBgSmallTile);
let args = note.args();
assert!(
args.contains(&("width", "32")) && args.contains(&("height", "32")),
"尺寸作为参数:{args:?}"
);
assert!(
args.contains(&("max", "64")),
"块数上限走占位符(`MAX_TILES` 改了不该动译文):{args:?}"
);
assert!(
note.render(Lang::En).contains("scaled up"),
"英文基线要说出「会被放大」:{}",
note.render(Lang::En)
);
// 非 tile 档不提示(cover/contain 会等比铺满,不存在「小图被放大铺」这件事)。
assert!(small_tile_note(&small, BgFill::Cover).is_none());
+2 -53
View File
@@ -69,6 +69,8 @@ mod picture;
pub(crate) mod probe;
mod section;
mod state;
#[cfg(test)]
mod tests;
/// 状态提示的 **tonal notice**(行数硬顶把 `section/` 撑开之后它仍放在模块根)。
///
@@ -166,56 +168,3 @@ impl Change {
})
}
}
#[cfg(test)]
mod tests {
use super::{Change, APPEARANCE_GROUP};
use unlucky_presenter::prefs::{keys, BgFill, PaletteSource};
/// 抓得住:每条改动都点名到自己那个 schema 键(写错键 = 静默写到别的配置项上)。
/// 抓不住:这些键是否真的在 core 的 schema 白名单里(那由 presenter/core 的测试守)。
#[test]
fn every_change_names_its_own_schema_key() {
assert_eq!(APPEARANCE_GROUP, 1, "左列第 1 项是 Appearance");
let cases = [
(Change::WindowOpacity(0.8), keys::WINDOW_OPACITY),
(Change::PanelAlpha(0.6), keys::PANEL_ALPHA),
(Change::BgEnabled(true), keys::BG_IMAGE_ENABLED),
(Change::BgImage("D:/x.png".to_owned()), keys::BG_IMAGE),
(Change::BgFill(BgFill::Tile), keys::BG_FILL),
(Change::BgDim(0.5), keys::BG_DIM),
(
Change::PaletteSource(PaletteSource::Wallpaper),
keys::PALETTE_SOURCE,
),
(
Change::Corner(unlucky_presenter::prefs::WindowCorner::Square),
keys::WINDOW_CORNER,
),
];
for (change, key) in cases {
assert_eq!(change.key(), Some(key), "{change:?}");
assert!(change.value().is_some());
}
assert_eq!(Change::SelectGroup(2).key(), None, "切分组不落盘");
assert_eq!(Change::SelectGroup(2).value(), None);
}
/// 抓得住:落盘字符串必须是 schema 认识的词表(枚举档写错就整条配置读回默认值)。
/// 抓不住:数值是否在范围内(钳位在 core 侧,越界写入会被 schema 拒绝)。
#[test]
fn persisted_values_use_the_schema_vocabulary() {
assert_eq!(
Change::BgFill(BgFill::Contain).value().as_deref(),
Some("contain")
);
assert_eq!(
Change::PaletteSource(PaletteSource::BackgroundImage)
.value()
.as_deref(),
Some("background-image")
);
assert_eq!(Change::BgEnabled(false).value().as_deref(), Some("false"));
assert_eq!(Change::WindowOpacity(0.8).value().as_deref(), Some("0.8"));
}
}
+29 -32
View File
@@ -9,7 +9,8 @@
//!
//! 1. 把「没设」与「设了但坏了」**分开传**([`Source::Absent`] vs [`Source::Undecodable`])——
//! 混成一个 `None` 会让设置页说错话(「没设背景图」 vs 「你那张图读不出来」);
//! 2. 把 `Provenance::hint_key()` 翻成一句可读提示(键 → 文案是 UI 层的活,i18n 之后再统一)。
//! 2. 把 `Provenance::hint_key()` 翻成一条词表 key(M180:句子在 `catalog/settings/appearance.rs`,
//! 这里只给 key——中文界面下这句提示也是中文)。
//!
//! ## 为什么不在这里做对比度
//!
@@ -19,6 +20,7 @@
use unlucky_design::color::{Scheme, SchemeFlavor};
use unlucky_design::palette::{resolve_source, ImageInput, Level, Provenance};
use unlucky_i18n::catalog::Key;
use unlucky_presenter::prefs::PaletteSource;
use super::background::Image;
@@ -62,9 +64,9 @@ impl Resolved {
self.provenance.level
}
/// 可读提示(`None` = 一级成功、没什么可解释的)。
/// 提示 key(`None` = 一级成功、没什么可解释的)。
#[must_use]
pub fn note(&self) -> Option<&'static str> {
pub fn note(&self) -> Option<Key> {
note(&self.provenance)
}
}
@@ -97,43 +99,33 @@ pub fn resolve(
Resolved { scheme, provenance }
}
/// `Provenance::hint_key()` → 一句可读提示(英文基线;i18n mission 之后走词表)。
/// `Provenance::hint_key()` → 一条词表 key(M180:句子在 `catalog/settings/appearance.rs`)。
///
/// 键的完整表在 `unlucky_design::palette::resolve::Provenance::hint_key` 的文档里;
/// 这里只翻「本 mission 真会产生的那几个」,未识别键返回 `None` 而不是编一句话。
#[must_use]
pub fn note(provenance: &Provenance) -> Option<&'static str> {
pub fn note(provenance: &Provenance) -> Option<Key> {
Some(match provenance.hint_key()? {
"palette.hint.background.undecodable" => {
"Background image could not be read; colours fall back."
}
"palette.hint.background.too-small" => {
"Background image is too small to sample; colours fall back."
}
"palette.hint.background.bad-dimensions" => {
"Background image data looks inconsistent; colours fall back."
}
"palette.hint.background.undecodable" => Key::SettingsAppearancePaletteBgUndecodable,
"palette.hint.background.too-small" => Key::SettingsAppearancePaletteBgTooSmall,
"palette.hint.background.bad-dimensions" => Key::SettingsAppearancePaletteBgBadDimensions,
"palette.hint.background.no-opaque-pixels" => {
"Background image is almost fully transparent; colours fall back."
Key::SettingsAppearancePaletteBgNoOpaquePixels
}
"palette.hint.background.low-chroma" => {
"Background image is too monochrome to tint from; colours fall back."
"palette.hint.background.low-chroma" => Key::SettingsAppearancePaletteBgLowChroma,
"palette.hint.background.contrast" => Key::SettingsAppearancePaletteBgContrast,
"palette.hint.wallpaper.undecodable" => Key::SettingsAppearancePaletteWallpaperUndecodable,
"palette.hint.wallpaper.low-chroma" => Key::SettingsAppearancePaletteWallpaperLowChroma,
"palette.hint.wallpaper.too-small" => Key::SettingsAppearancePaletteWallpaperTooSmall,
"palette.hint.wallpaper.bad-dimensions" => {
Key::SettingsAppearancePaletteWallpaperBadDimensions
}
"palette.hint.background.contrast" => {
"Background image colours would be unreadable; colours fall back."
}
"palette.hint.wallpaper.undecodable" => "Desktop wallpaper could not be read.",
"palette.hint.wallpaper.low-chroma" => "Desktop wallpaper is too monochrome to tint from.",
"palette.hint.wallpaper.too-small" => "Desktop wallpaper is too small to sample.",
"palette.hint.wallpaper.bad-dimensions" => "Desktop wallpaper data looks inconsistent.",
"palette.hint.wallpaper.no-opaque-pixels" => {
"Desktop wallpaper is almost fully transparent."
Key::SettingsAppearancePaletteWallpaperNoOpaquePixels
}
"palette.hint.wallpaper.contrast" => {
"Desktop wallpaper colours would be unreadable; colours fall back."
}
"palette.hint.preset.contrast" => "The built-in card needed a small contrast correction.",
"palette.hint.adjusted" => "A few colours were nudged for readability.",
"palette.hint.wallpaper.contrast" => Key::SettingsAppearancePaletteWallpaperContrast,
"palette.hint.preset.contrast" => Key::SettingsAppearancePalettePresetContrast,
"palette.hint.adjusted" => Key::SettingsAppearancePaletteAdjusted,
_ => return None,
})
}
@@ -145,6 +137,7 @@ mod tests {
use unlucky_design::color::SchemeFlavor;
use unlucky_design::palette::wcag::check;
use unlucky_design::palette::{Level, Outcome, Skip};
use unlucky_i18n::catalog::Key;
use unlucky_presenter::prefs::PaletteSource;
/// 高彩度的图(纯红):取色一定成功。
@@ -193,7 +186,7 @@ mod tests {
assert!(resolved.provenance.did_fall_back());
assert_eq!(
resolved.note(),
Some("Background image could not be read; colours fall back.")
Some(Key::SettingsAppearancePaletteBgUndecodable)
);
assert!(check(&resolved.scheme).is_clean());
}
@@ -215,7 +208,11 @@ mod tests {
}
other => panic!("灰图必须被判不合格,实际 {other:?}"),
}
assert!(resolved.note().unwrap().contains("monochrome"));
assert_eq!(
resolved.note(),
Some(Key::SettingsAppearancePaletteBgLowChroma),
"灰图的回落原因要指名「彩度不足」"
);
}
#[test]
+25 -3
View File
@@ -10,8 +10,9 @@
//! - 想走 `Widget::operate` 得自己造 `Tree` + 跑布局 + 传 `Renderer`,而单测里**没有渲染器**
//! (`pages/download/tests/options_screen.rs` 记的正是这条边界)。
//!
//! 所以取证点退到「字进树之前」那一层:`section::t` 是本分组**唯一**的取词口,它把每条译文
//! 顺手记一份,测试再断言这批字里有没有汉字。这不是「看源码字符串」那种假守卫——它要求真的把
//! 所以取证点退到「字进树之前」那一层:`section::t` / `section::tr` 是本分组**唯一**两个取词口,
//! 它们把每条译文顺手记一份(M180 起分成两份:`t()` 的 `&'static str` 与 `tr()` 替换完占位符的
//! 整句),测试再断言这批字里有没有汉字。这不是「看源码字符串」那种假守卫——它要求真的把
//! [`crate::pages::appearance::section`] 跑一遍(并且走 `settings::view` 的语言分发),只是不画像素。
//!
//! ## 为什么是 thread-local
@@ -25,7 +26,7 @@ use std::cell::RefCell;
use unlucky_i18n::catalog::Key;
// 自上次 `take` 以来记录下的 `(key, 译文)`,顺序 = 取词顺序。
// 自上次 `take` 以来记录下的 `(key, 译文)`,顺序 = 取词顺序(`t()` 取的 `&'static str`)。
thread_local! {
// clippy 1.99.1(= 本仓 CI 的 stable)对这条 lint **误报**:初始化器已经写成 `const { … }`
// 它还是报「can be made const」。最小复现(clippy-driver 原地可验,无本仓任何符号):
@@ -35,12 +36,33 @@ thread_local! {
static TEXTS: RefCell<Vec<(Key, &'static str)>> = const { RefCell::new(Vec::new()) };
}
// 自上次 `take_rendered` 以来记录下的 `(key, 整句)`:`tr()` 那条带占位符的路径(M180 加)。
//
// 为什么单独一份而不是把上面那份改成 `String`:M179 的守卫
// (`pages/settings/tests/appearance_section.rs::the_appearance_group_speaks_chinese_when_the_interface_does`)
// 断言的是 `&'static str`,那个文件不在 M180 的 scope 里、不能跟着改签名。两份记录各自的
// `take*` 也各自清空,互不干扰。
thread_local! {
#[allow(clippy::missing_const_for_thread_local)]
static RENDERED: RefCell<Vec<(Key, String)>> = const { RefCell::new(Vec::new()) };
}
/// 记一条(由 [`super::section::t`] 调用;不对外)。
pub(crate) fn record(key: Key, text: &'static str) {
TEXTS.with(|texts| texts.borrow_mut().push((key, text)));
}
/// 记一条**替换完占位符**的整句(由 [`super::section::tr`] 调用;不对外)。
pub(crate) fn record_rendered(key: Key, text: String) {
RENDERED.with(|texts| texts.borrow_mut().push((key, text)));
}
/// 取走并清空本线程记录下的取词(测试用)。
pub(crate) fn take() -> Vec<(Key, &'static str)> {
TEXTS.with(|texts| std::mem::take(&mut *texts.borrow_mut()))
}
/// 取走并清空本线程记录下的整句(测试用)。
pub(crate) fn take_rendered() -> Vec<(Key, String)> {
RENDERED.with(|texts| std::mem::take(&mut *texts.borrow_mut()))
}
@@ -8,6 +8,8 @@ use iced::{Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use unlucky_presenter::prefs::BgFill;
use crate::app::components::inputs::{slider, text_field, Field, Slider};
@@ -17,17 +19,32 @@ use crate::theme::Ui;
use super::super::background::{self, BG_DIM_MAX};
use super::super::state::State;
use super::super::Change;
use super::{choice, choice_row, label, note_line, slider_row};
use super::{choice, choice_row, fixed2, label, note_line, slider_row, t, tr, tr_note};
/// 把 ④ 开关、⑤ 路径、⑥ 诊断、⑦ 填充、⑧ 暗化按视觉顺序推进 `rows`。
pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, rows: &mut Vec<Element<'a, Message>>) {
pub(super) fn push<'a>(
ui: &'a Ui,
state: &'a State,
lang: Lang,
rows: &mut Vec<Element<'a, Message>>,
) {
// ④ 背景图开关
rows.push(choice_row(
ui,
"Background image",
t(Key::SettingsAppearanceBackgroundImage, lang),
vec![
choice(ui, "Off", !state.bg_enabled, Change::BgEnabled(false)),
choice(ui, "On", state.bg_enabled, Change::BgEnabled(true)),
choice(
ui,
t(Key::SettingsAppearanceOff, lang),
!state.bg_enabled,
Change::BgEnabled(false),
),
choice(
ui,
t(Key::SettingsAppearanceOn, lang),
state.bg_enabled,
Change::BgEnabled(true),
),
],
));
@@ -35,57 +52,74 @@ pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, rows: &mut Vec<Element<'a,
let path_field: Element<'a, Message> = text_field(
ui,
Field::new(&state.bg_path)
.label("Image path")
.label(t(Key::SettingsAppearanceImagePath, lang))
.placeholder("D:/Pictures/background.png"),
|value| Message::Appearance(Change::BgImage(value)),
);
rows.push(
column![label(ui, "Image path"), path_field]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into(),
column![
label(ui, t(Key::SettingsAppearanceImagePath, lang)),
path_field
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into(),
);
// ⑥ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默**,且必须在**首屏可见处**
// (复审 r1 P2-3:文案落在折叠线以下等于没提示)。位置就在背景图那两行的紧下方。
for note in state.diagnostics() {
rows.push(note_line(ui, note, ui.color(Role::Error)));
let text = tr_note(lang, note);
rows.push(note_line(ui, &text, ui.color(Role::Error)));
}
if let Some(note) = state.backend_note() {
rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant)));
if let Some(key) = state.backend_note() {
rows.push(note_line(
ui,
t(key, lang),
ui.color(Role::OnSurfaceVariant),
));
}
if let Some(note) = state
.image()
.and_then(|image| background::small_tile_note(image, state.bg_fill))
{
rows.push(note_line(ui, &note, ui.color(Role::OnSurfaceVariant)));
let text = tr_note(lang, &note);
rows.push(note_line(ui, &text, ui.color(Role::OnSurfaceVariant)));
}
if let Some(note) = state.palette_note() {
rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant)));
if let Some(key) = state.palette_note() {
rows.push(note_line(
ui,
t(key, lang),
ui.color(Role::OnSurfaceVariant),
));
}
let resolved = format!("Colours: {}", state.palette_level());
rows.push(note_line(ui, &resolved, ui.color(Role::OnSurfaceVariant)));
let level = tr(
lang,
Key::SettingsAppearancePaletteLevel,
&[("level", state.palette_level())],
);
rows.push(note_line(ui, &level, ui.color(Role::OnSurfaceVariant)));
// ⑦ 填充
rows.push(choice_row(
ui,
"Fill",
t(Key::SettingsAppearanceBgFill, lang),
vec![
choice(
ui,
"Cover",
t(Key::SettingsAppearanceBgFillCover, lang),
state.bg_fill == BgFill::Cover,
Change::BgFill(BgFill::Cover),
),
choice(
ui,
"Contain",
t(Key::SettingsAppearanceBgFillContain, lang),
state.bg_fill == BgFill::Contain,
Change::BgFill(BgFill::Contain),
),
choice(
ui,
"Tile",
t(Key::SettingsAppearanceBgFillTile, lang),
state.bg_fill == BgFill::Tile,
Change::BgFill(BgFill::Tile),
),
@@ -95,10 +129,12 @@ pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, rows: &mut Vec<Element<'a,
// ⑧ 暗化
rows.push(slider_row(
ui,
"Dim",
t(Key::SettingsAppearanceBgDim, lang),
format!("{:.0}%", state.bg_dim * 100.0),
format!(
"Blends the image toward the panel colour so text stays readable. Max {BG_DIM_MAX:.2}."
tr(
lang,
Key::SettingsAppearanceBgDimHint,
&[("max", &fixed2(BG_DIM_MAX))],
),
state.bg_dim > 0.0,
slider(
@@ -1,7 +1,8 @@
//! 外观分组第 ⑨ 段:**配色来源**(背景图 / 壁纸 / 色卡三档;它选的正是 D38 三级优先序的起点)。
use iced::Element;
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use unlucky_presenter::prefs::PaletteSource;
use crate::pages::Message;
@@ -9,30 +10,35 @@ use crate::theme::Ui;
use super::super::state::State;
use super::super::Change;
use super::{choice, choice_row};
use super::{choice, choice_row, t};
/// 把 ⑨ 配色来源推进行 `rows`。
pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, rows: &mut Vec<Element<'a, Message>>) {
pub(super) fn push<'a>(
ui: &'a Ui,
state: &'a State,
lang: Lang,
rows: &mut Vec<Element<'a, Message>>,
) {
// ⑨ 配色来源
rows.push(choice_row(
ui,
"Colour source",
t(Key::SettingsAppearancePaletteSource, lang),
vec![
choice(
ui,
"Background",
t(Key::SettingsAppearancePaletteBackground, lang),
state.palette_source == PaletteSource::BackgroundImage,
Change::PaletteSource(PaletteSource::BackgroundImage),
),
choice(
ui,
"Wallpaper",
t(Key::SettingsAppearancePaletteWallpaper, lang),
state.palette_source == PaletteSource::Wallpaper,
Change::PaletteSource(PaletteSource::Wallpaper),
),
choice(
ui,
"Preset",
t(Key::SettingsAppearancePalettePreset, lang),
state.palette_source == PaletteSource::Preset,
Change::PaletteSource(PaletteSource::Preset),
),
@@ -35,12 +35,14 @@
//! 4. 唯一一处非 `Element` 实参:`rows.push(column![…])` → 末尾加 `.into()`(`Vec::push` 不做 `Into`);
//! 5. rustfmt 因为多出来的 `, lang` 把两处调用换成了多行。
//!
//! ## 语言(M179 只接管子,不补文案)
//! ## 语言(M179 接过管子,M180 补齐文案)
//!
//! [`t`] 是**唯一**取词口,现在收 `lang`(真值从 `settings::view` 的 `Language.effective`
//! → `Group.body` → [`section`] 一路递进来),所以中文界面上走词表的那 5 处(窗口圆角一行、
//! 两个选项、说明段、notice)真的是中文。**其余文案仍是英文基线**(字面量,没有 key)——
//! 补文案是下一条 mission,M179 只负责让「管子」通到这一层。
//! [`t`] / [`tr`] 是**唯一**两个取词口(一个拿 `&'static str`、一个拿带占位符的 owned 句子),
//! `lang` 从 `settings::view` 的 `Language.effective` → `Group.body` → [`section`] 一路递进来。
//! M179 只把管子接通(当时本组 27 条文案里真正本地化的只有圆角那 5 条);M180 `I18NAPPEAR` 把
//! 剩下 22 条字面量与 `state.rs` / `background.rs` / `palette.rs` 的运行时诊断全部搬进
//! `catalog/settings/appearance.rs`,于是中文界面上这一页**没有一句是英文**。
//!
//! ## 为什么「值」与「生效值」分开显示
//!
@@ -80,9 +82,9 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Mess
let mut rows: Vec<Element<'a, Message>> = Vec::new();
window::push(ui, state, lang, &mut rows);
panel::push(ui, state, &mut rows);
bg_image::push(ui, state, &mut rows);
colour_source::push(ui, state, &mut rows);
panel::push(ui, state, lang, &mut rows);
bg_image::push(ui, state, lang, &mut rows);
colour_source::push(ui, state, lang, &mut rows);
card(
ui,
@@ -126,8 +128,8 @@ pub(super) fn label<'a>(ui: &'a Ui, content: &'a str) -> Element<'a, Message> {
///
/// `lang` 是**这一次生效的语言**,由 [`section`] 一路递下来(M179 `I18NSEAM` 之前这里按英文目录
/// 取词,于是中文界面上本分组的 27 条文案里真正本地化 0 条)。现在这个函数是全模块
/// **唯一**的取词点:谁硬编码英文,就不会经过这里——M179 报告里逐行列了那些字面量,补文案是
/// 下一条 mission。
/// **唯一**的取词点:谁硬编码英文,就不会经过这里——M180 `I18NAPPEAR` 已把这一页剩余的 22 处
/// 字面量与全部运行时诊断搬进词表,见 `catalog/settings/appearance.rs` 的模块文档。
pub(super) fn t(key: Key, lang: Lang) -> &'static str {
let text = lang.catalog().text(key);
// 测试专用:把「这一帧真的取了哪条译文」记一份(iced 0.14 的 `Element` 没有可遍历控件树的
@@ -137,6 +139,28 @@ pub(super) fn t(key: Key, lang: Lang) -> &'static str {
text
}
/// 带占位符的句子(`{name}` 替换后的 owned 版本)。
///
/// 为什么不直接 `lang.catalog().render(...)`:这条路径也要进 [`t`] 那份取证记录
/// (`probe::record_rendered`),否则「中文界面下这一页每一句都不是纯 ASCII」的守卫会漏掉所有
/// 带数字的说明句。数值参数由调用方先格式化好(词表只认 `{name}`,不认 `{:.2}`)。
pub(super) fn tr(lang: Lang, key: Key, args: &[(&str, &str)]) -> String {
let text = lang.catalog().render(key, args);
#[cfg(test)]
super::probe::record_rendered(key, text.clone());
text
}
/// 两位小数(`{:.2}` 这种格式说明由 Rust 侧做,词表里只留 `{name}`)。
pub(super) fn fixed2(value: f32) -> String {
format!("{value:.2}")
}
/// 一条 [`Note`](key + 已格式化的参数)按语言取整句;与 [`tr`] 共用取证口。
pub(super) fn tr_note(lang: Lang, note: &super::background::Note) -> String {
tr(lang, note.key(), &note.args())
}
pub(super) fn note_line<'a>(ui: &'a Ui, content: &str, color: Color) -> Element<'a, Message> {
// 诊断文案是**运行时拼出来的**(路径、错误原因),只能走 owned 版本。
frame::text_role_owned(ui, TypeRole::BodySmall, false, content.to_owned(), color)
@@ -2,28 +2,45 @@
use iced::Element;
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use crate::app::components::inputs::{slider, Slider};
use crate::pages::Message;
use crate::theme::Ui;
use super::super::alpha::{panel_alpha_from_fraction, panel_fraction};
use super::super::alpha::{panel_alpha_from_fraction, panel_fraction, PANEL_ALPHA_FLOOR};
use super::super::state::State;
use super::super::Change;
use super::slider_row;
use super::{fixed2, slider_row, t, tr};
/// 把 ③ 面板透明度推进 `rows`。
pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, rows: &mut Vec<Element<'a, Message>>) {
pub(super) fn push<'a>(
ui: &'a Ui,
state: &'a State,
lang: Lang,
rows: &mut Vec<Element<'a, Message>>,
) {
// ③ 面板透明度(CPU 档强制 1.0)
let forced = state.panel_is_forced();
let panel_percent = if forced {
format!("100% (forced from {:.2})", state.panel_alpha)
// 数值占位:`{requested}` 是请求值(词表不认 `{:.2}`,所以在这里先格式化成两位小数)。
tr(
lang,
Key::SettingsAppearancePanelOpacityForcedValue,
&[("requested", &fixed2(state.panel_alpha))],
)
} else {
format!("{:.0}%", state.effective_panel_alpha() * 100.0)
};
let panel_hint = if forced {
"Software renderer: panels stay fully opaque (window transparency still works)."
t(Key::SettingsAppearancePanelOpacityForcedHint, lang).to_owned()
} else {
"Only the panel/card layer. Floor 0.40; independent of window transparency."
tr(
lang,
Key::SettingsAppearancePanelOpacityHint,
&[("floor", &fixed2(PANEL_ALPHA_FLOOR))],
)
};
let panel_control = if forced {
slider(Slider::new(ui, panel_fraction(1.0)))
@@ -36,9 +53,9 @@ pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, rows: &mut Vec<Element<'a,
};
rows.push(slider_row(
ui,
"Panel transparency",
t(Key::SettingsAppearancePanelOpacity, lang),
panel_percent,
panel_hint.to_owned(),
panel_hint,
!forced,
panel_control,
));
@@ -20,7 +20,7 @@ use crate::theme::Ui;
use super::super::alpha::{window_alpha_from_fraction, window_fraction, WINDOW_ALPHA_FLOOR};
use super::super::state::State;
use super::super::Change;
use super::{choice, choice_row, note_line, slider_row, t};
use super::{choice, choice_row, fixed2, note_line, slider_row, t, tr};
/// 把 ① 整窗透明度与 ② 窗口圆角按视觉顺序推进 `rows`。
pub(super) fn push<'a>(
@@ -33,11 +33,12 @@ pub(super) fn push<'a>(
let window_percent = format!("{:.0}%", state.window_opacity * 100.0);
rows.push(slider_row(
ui,
"Window transparency",
t(Key::SettingsAppearanceWindowOpacity, lang),
window_percent,
format!(
"Applies to the whole window. Floor {:.2}; 1.00 = fully opaque.",
WINDOW_ALPHA_FLOOR
tr(
lang,
Key::SettingsAppearanceWindowOpacityHint,
&[("floor", &fixed2(WINDOW_ALPHA_FLOOR))],
),
state.window_opacity < 1.0,
slider(
+15 -14
View File
@@ -17,6 +17,7 @@
//! 变成一条**可读诊断**而不是一个 `unwrap`。
use unlucky_design::color::{Scheme, SchemeFlavor};
use unlucky_i18n::catalog::Key;
use unlucky_presenter::prefs::{
AppearanceBase, AppearancePrefs, BgFill, PaletteSource, WindowCorner,
};
@@ -24,7 +25,7 @@ use unlucky_presenter::prefs::{
use crate::Backend;
use super::alpha::{self, effective_glass, effective_panel_alpha, panel_is_forced_opaque};
use super::background::{self, Image};
use super::background::{self, Image, Note};
use super::palette::{self, Resolved, Source};
/// 取证开关的环境变量名(截图矩阵与真机日志用;生产默认全部不设)。
@@ -67,8 +68,8 @@ pub struct State {
pub selected_group: usize,
/// 已解码的背景图(`None` = 没设 / 读失败)。
image: Option<Image>,
/// 读盘/解码失败的**可读诊断**(按发生顺序追加)。
diagnostics: Vec<String>,
/// 读盘/解码失败的**可读诊断**(按发生顺序追加;`Note` = key + 参数,句子在词表里)。
diagnostics: Vec<Note>,
/// 上一次的配色解析结果。
resolved: Option<Resolved>,
/// 当前深浅(配色解析的输入)。
@@ -196,24 +197,24 @@ impl State {
Some(&resolved.scheme)
}
/// 读盘/解码失败的诊断(设置页逐条显示)。
/// 读盘/解码失败的诊断(设置页逐条显示;一条 = 一条 key + 它的参数)。
#[must_use]
pub fn diagnostics(&self) -> &[String] {
pub fn diagnostics(&self) -> &[Note] {
&self.diagnostics
}
/// CPU 档降级的说明(`None` = 没有降级)。
/// CPU 档降级的说明 key(`None` = 没有降级)。
///
/// M180:返回 key 而不是英文句子——本文件已 390 行、贴着 400 行硬线,**不放文案表**。
#[must_use]
pub fn backend_note(&self) -> Option<&'static str> {
self.panel_is_forced().then_some(
"Software renderer: panel transparency and glass stay fully opaque (window \
transparency still works).",
)
pub fn backend_note(&self) -> Option<Key> {
self.panel_is_forced()
.then_some(Key::SettingsAppearanceSoftwareRendererNote)
}
/// 配色来源的提示(`None` = 一级成功)。
/// 配色来源的提示 key(`None` = 一级成功)。
#[must_use]
pub fn palette_note(&self) -> Option<&'static str> {
pub fn palette_note(&self) -> Option<Key> {
self.resolved.as_ref()?.note()
}
@@ -362,7 +363,7 @@ impl State {
}
match background::load(trimmed) {
Ok(image) => self.image = Some(image),
Err(error) => self.diagnostics.push(error.describe()),
Err(error) => self.diagnostics.push(error.note()),
}
}
@@ -0,0 +1,253 @@
//! `pages::appearance` 的单测:`Change` 的落盘映射(M93)+ **中文界面文案的取证守卫**(M180)。
//!
//! 为什么是独立文件:`mod.rs` 已经 220 行上下,而 M180 要在模块里加三条取证用例。搬移口径与
//! `background/tests.rs` 一致——既有两条用例**一字未动**,只是从 `mod.rs` 的行内 `mod tests` 挪到这里。
//!
//! ## M180 的两条取证守卫,它们「抓得住什么」
//!
//! iced 0.14 没有可遍历控件树的公开接口(见 [`super::probe`] 的模块文档),所以取证点只能是
//! 取词口 `section::t` / `section::tr`。于是这一对守卫分工如下:
//!
//! | 守卫 | 抓得住 | 抓不住 |
//! |---|---|---|
//! | [`the_appearance_card_speaks_chinese`] | 中文界面下取到的每一条文案都是纯 ASCII(= 有人把某条 key 的英文写回中文表 / 中文表里放英文原句) | 硬编码字面量(它压根不过取词口) |
//! | [`every_appearance_card_key_is_drawn`] | 某一处控件被改回**写死英文**:那条 key 不再被取,集合里就少一个元素 | 新加一条字面量(新 key 还没有,集合不看它) |
//!
//! 两条合起来才等于「中文界面下外观卡片里不再出现英文字面量」——单看任一条都有一个真会发生的
//! 漏网口,所以这里刻意写两条。
use super::{Change, APPEARANCE_GROUP};
use unlucky_i18n::catalog::Key;
use unlucky_i18n::Lang;
use unlucky_presenter::prefs::{keys, BgFill, PaletteSource};
/// 抓得住:每条改动都点名到自己那个 schema 键(写错键 = 静默写到别的配置项上)。
/// 抓不住:这些键是否真的在 core 的 schema 白名单里(那由 presenter/core 的测试守)。
#[test]
fn every_change_names_its_own_schema_key() {
assert_eq!(APPEARANCE_GROUP, 1, "左列第 1 项是 Appearance");
let cases = [
(Change::WindowOpacity(0.8), keys::WINDOW_OPACITY),
(Change::PanelAlpha(0.6), keys::PANEL_ALPHA),
(Change::BgEnabled(true), keys::BG_IMAGE_ENABLED),
(Change::BgImage("D:/x.png".to_owned()), keys::BG_IMAGE),
(Change::BgFill(BgFill::Tile), keys::BG_FILL),
(Change::BgDim(0.5), keys::BG_DIM),
(
Change::PaletteSource(PaletteSource::Wallpaper),
keys::PALETTE_SOURCE,
),
(
Change::Corner(unlucky_presenter::prefs::WindowCorner::Square),
keys::WINDOW_CORNER,
),
];
for (change, key) in cases {
assert_eq!(change.key(), Some(key), "{change:?}");
assert!(change.value().is_some());
}
assert_eq!(Change::SelectGroup(2).key(), None, "切分组不落盘");
assert_eq!(Change::SelectGroup(2).value(), None);
}
/// 抓得住:落盘字符串必须是 schema 认识的词表(枚举档写错就整条配置读回默认值)。
/// 抓不住:数值是否在范围内(钳位在 core 侧,越界写入会被 schema 拒绝)。
#[test]
fn persisted_values_use_the_schema_vocabulary() {
assert_eq!(
Change::BgFill(BgFill::Contain).value().as_deref(),
Some("contain")
);
assert_eq!(
Change::PaletteSource(PaletteSource::BackgroundImage)
.value()
.as_deref(),
Some("background-image")
);
assert_eq!(Change::BgEnabled(false).value().as_deref(), Some("false"));
assert_eq!(Change::WindowOpacity(0.8).value().as_deref(), Some("0.8"));
}
// --------------------------------------------------------------- 文案(M180 I18NAPPEAR)
/// 画一帧外观卡片,取走这一帧从词表取到的**全部**字:`(key, 文案)`。
///
/// 三档状态各画一帧:
/// - [`Case::Factory`]:auto 档、没设背景图——覆盖常驻的标签 / 选项 / 说明;
/// - [`Case::Cpu`]:CPU 档——覆盖「强制 100%」那一行与软件渲染说明(这两条只在降级态出现);
/// - [`Case::Dim`]:整窗 α 调低——覆盖「圆角暂时不生效」那条 notice。
///
/// 两条取词路径都要收:`t()` 的 `&'static str` 与 `tr()` 替换完占位符的整句(后者不进前者的记录)。
fn appearance_texts(lang: Lang, case: Case) -> Vec<(Key, String)> {
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::prefs::{AppearanceBase, AppearancePrefs};
use super::probe;
use crate::cli::Backend;
use crate::theme::{Families, Ui};
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
let (backend, alpha) = match case {
Case::Factory => (Backend::Auto, 1.0),
Case::Cpu => (Backend::Cpu, 1.0),
Case::Dim => (Backend::Gpu, 0.5),
};
let mut state = super::State::new(
&AppearancePrefs::factory(),
&AppearanceBase::factory(),
backend,
SchemeFlavor::Light,
);
if alpha < 1.0 {
state.apply(Change::WindowOpacity(alpha));
}
let ui = Ui::new(SchemeFlavor::Light, FAMILIES);
// 前面几条用例可能留了记录:本线程的取证每次都从清空开始。
probe::take();
probe::take_rendered();
let _tree = super::section(&ui, &state, lang);
let mut texts: Vec<(Key, String)> = probe::take()
.into_iter()
.map(|(key, text)| (key, text.to_owned()))
.collect();
texts.extend(probe::take_rendered());
texts
}
/// 画哪一档状态。
#[derive(Debug, Clone, Copy)]
enum Case {
/// 工厂态(GPU / auto)。
Factory,
/// CPU 降级态(面板 α 与玻璃被强制)。
Cpu,
/// 整窗 α 调低(圆角暂时不生效)。
Dim,
}
/// 这一页**常驻**的 key(三档状态合起来必须一个不缺地取到)。
///
/// 为什么是手写清单而不是「`Key::ALL` 里所有 `SettingsAppearance*`」:诊断一族(读盘失败 / 配色回落 /
/// 小图平铺)取决于运行时状态,单测里要让它们出现得造磁盘上的坏图;那些由
/// `background/tests.rs` 与 `palette.rs` 的单测按 key 逐条钉(见各自的用例注释)。
const CARD_KEYS: &[Key] = &[
Key::SettingsAppearanceCorner,
Key::SettingsAppearanceCornerSystem,
Key::SettingsAppearanceCornerSquare,
Key::SettingsAppearanceCornerHint,
Key::SettingsAppearanceCornerSuspended,
Key::SettingsAppearanceWindowOpacity,
Key::SettingsAppearanceWindowOpacityHint,
Key::SettingsAppearancePanelOpacity,
Key::SettingsAppearancePanelOpacityHint,
Key::SettingsAppearancePanelOpacityForcedValue,
Key::SettingsAppearancePanelOpacityForcedHint,
Key::SettingsAppearanceSoftwareRendererNote,
Key::SettingsAppearanceBackgroundImage,
Key::SettingsAppearanceOff,
Key::SettingsAppearanceOn,
Key::SettingsAppearanceImagePath,
Key::SettingsAppearancePaletteLevel,
Key::SettingsAppearanceBgFill,
Key::SettingsAppearanceBgFillCover,
Key::SettingsAppearanceBgFillContain,
Key::SettingsAppearanceBgFillTile,
Key::SettingsAppearanceBgDim,
Key::SettingsAppearanceBgDimHint,
Key::SettingsAppearancePaletteSource,
Key::SettingsAppearancePaletteBackground,
Key::SettingsAppearancePaletteWallpaper,
Key::SettingsAppearancePalettePreset,
];
/// **M180 守门 ①**:中文界面下,外观卡片里取到的每一条文案都不是纯 ASCII。
///
/// 抓得住:某条 key 的中文译文被换成英文原句(中文表里出现纯 ASCII 的一句);中文译文写成
/// `PNG` / `Preset` 这种专名;`tr()` 那条路径把数字拼进英文模板(比如 `Colours: preset`)。
/// 抓不住:硬编码字面量——它不过取词口,由 [`every_appearance_card_key_is_drawn`] 与
/// `catalog/settings/appearance.rs` 的译文守卫合力守。
///
/// 变异证明:把 `SettingsAppearanceOff` 的中文改成 `"Off"` ⇒ 本条立刻红。
#[test]
fn the_appearance_card_speaks_chinese() {
// 白名单:技术专名允许是纯 ASCII(现在一条都没有——`%` / 数字不在文案里,它们是参数)。
const ASCII_OK: &[&str] = &[];
for case in [Case::Factory, Case::Cpu, Case::Dim] {
let pairs = appearance_texts(Lang::ZhCn, case);
assert!(
pairs.len() >= 20,
"{case:?}:取词口只记到 {} 条文案,渲染路径没走到底(语言管子断了?)",
pairs.len()
);
for (key, text) in &pairs {
if ASCII_OK.contains(&text.as_str()) {
continue;
}
assert!(
!text.is_ascii(),
"{case:?}:中文界面下外观卡片里出现了纯 ASCII 的文案「{text}」({})\
——写死英文,或中文表里放了英文原句",
key.name()
);
}
}
}
/// **M180 守门 ②(漏项)**:这一页每一处控件都必须从词表取词。
///
/// 抓得住:把某处 `t(Key::X, lang)` / `tr(lang, Key::X, …)` 改回写死的英文字面量——那条 key 不再
/// 出现在取词记录里,集合比较当场红(这正是 M172 口径「22 处硬编码」的复发形态)。
/// 抓不住:新加一条写死英文(它没有 key,也就没有「少了哪条」)。
///
/// 变异证明:把 `bg_image.rs` 里 `t(Key::SettingsAppearanceOff, lang)` 换回 `"Off"` ⇒ 本条红。
#[test]
fn every_appearance_card_key_is_drawn() {
let mut seen: Vec<Key> = Vec::new();
for case in [Case::Factory, Case::Cpu, Case::Dim] {
seen.extend(
appearance_texts(Lang::ZhCn, case)
.into_iter()
.map(|(key, _)| key),
);
}
let missing: Vec<&str> = CARD_KEYS
.iter()
.filter(|key| !seen.contains(key))
.map(|key| key.name())
.collect();
assert!(
missing.is_empty(),
"这些 key 一帧都没取到(控件被改回写死英文,或整段没画):{missing:?}"
);
}
/// 英文界面下不许出现汉字(否则「含汉字」可能来自写死的中文,而不是词表)。
#[test]
fn the_appearance_card_keeps_its_english_baseline() {
for case in [Case::Factory, Case::Cpu, Case::Dim] {
let texts = appearance_texts(Lang::En, case);
let with_han: Vec<&(Key, String)> = texts
.iter()
.filter(|(_, text)| text.chars().any(is_han))
.collect();
assert!(
with_han.is_empty(),
"{case:?}:英文界面下外观卡片出现了汉字:{with_han:?}"
);
}
}
/// 有没有汉字(与 `pages/settings/tests/appearance_section.rs` 的 `is_han` 同一段码位)。
fn is_han(ch: char) -> bool {
('\u{4e00}'..='\u{9fff}').contains(&ch)
}
+44
View File
@@ -29,6 +29,50 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
("SettingsAppearanceCornerSquare", "Square", "直角"),
("SettingsAppearanceCornerHint", "On Windows 11 and Server 2025 we ask the system for rounded corners; Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply. We never draw corners ourselves.", "Windows 11 与 Windows Server 2025 上我们请求系统处理外框圆角;Windows 10 保持直角;macOS 与 Linux 交给桌面合成器自己的规则;我们不自己画圆角。"),
("SettingsAppearanceCornerSuspended", "Not in effect right now: window transparency is below 100%. Setting it back to 100% restores the rounded corners.", "当前因整窗透明度不生效:把整窗透明度调回 100% 圆角即恢复。"),
// M180:外观分组补齐(`catalog/settings/appearance.rs` 的第二批,M116 圆角那 5 条之后)。
// 位置就是装配链里那个域的位置——`settings_appearance_entries` 在 `settings_java_entries` 之前,
// 所以这 40 行必须落在 `SettingsAppearanceCornerSuspended` 与 `SettingsJavaScan` 之间
// (顺序 = `Key::ALL`;为排到文件尾而重排别人的族会让 `split_keeps_every_translation` 整片红)。
("SettingsAppearanceWindowOpacity", "Window transparency", "整窗透明度"),
("SettingsAppearanceWindowOpacityHint", "Applies to the whole window. Floor {floor}; 1.00 = fully opaque.", "作用于整个窗口。下限 {floor};1.00 = 完全不透明。"),
("SettingsAppearancePanelOpacity", "Panel transparency", "面板透明度"),
("SettingsAppearancePanelOpacityHint", "Only the panel/card layer. Floor {floor}; independent of window transparency.", "只作用于面板 / 卡片这一层。下限 {floor};与整窗透明度互不影响。"),
("SettingsAppearancePanelOpacityForcedValue", "100% (forced from {requested})", "100%(强制,原请求值 {requested})"),
("SettingsAppearancePanelOpacityForcedHint", "Software renderer: panels stay fully opaque (window transparency still works).", "软件渲染:面板恒为不透明(整窗透明度仍然有效)。"),
("SettingsAppearanceBackgroundImage", "Background image", "背景图"),
("SettingsAppearanceOff", "Off", "关"),
("SettingsAppearanceOn", "On", "开"),
("SettingsAppearanceImagePath", "Image path", "图片路径"),
("SettingsAppearancePaletteLevel", "Colours: {level}", "配色:{level}"),
("SettingsAppearanceBgFill", "Fill", "填充方式"),
("SettingsAppearanceBgFillCover", "Cover", "铺满"),
("SettingsAppearanceBgFillContain", "Contain", "完整显示"),
("SettingsAppearanceBgFillTile", "Tile", "平铺"),
("SettingsAppearanceBgDim", "Dim", "暗化"),
("SettingsAppearanceBgDimHint", "Blends the image toward the panel colour so text stays readable. Max {max}.", "把背景图往面板底色上退,让文字读得清。上限 {max}。"),
("SettingsAppearancePaletteSource", "Colour source", "配色来源"),
("SettingsAppearancePaletteBackground", "Background", "取自背景图"),
("SettingsAppearancePaletteWallpaper", "Wallpaper", "取自壁纸"),
("SettingsAppearancePalettePreset", "Preset", "内置色卡"),
("SettingsAppearanceSoftwareRendererNote", "Software renderer: panel transparency and glass stay fully opaque (window transparency still works).", "软件渲染:面板透明度与玻璃效果恒为不透明(整窗透明度仍然有效)。"),
("SettingsAppearanceBgReadFailed", "Background image could not be read: {reason}", "背景图读不出来:{reason}"),
("SettingsAppearanceBgDecodeFailed", "Background image could not be decoded ({reason}; this build decodes PNG, JPEG and BMP)", "背景图解不开({reason};这个构建只支持 PNG、JPEG 与 BMP)"),
("SettingsAppearanceBgTooLarge", "Background image is too large to decode: {width}×{height} (limit {limit} px per edge)", "背景图太大,解不开:{width}×{height}(每边上限 {limit} 像素)"),
("SettingsAppearanceBgSmallTile", "Small image ({width}×{height}): tiles are scaled up so the window stays covered (max {max} tiles, no gaps).", "图片很小({width}×{height}):图块会被放大,保证窗口铺满(最多 {max} 块,不留缝)。"),
("SettingsAppearancePaletteBgUndecodable", "Background image could not be read; colours fall back.", "背景图读不出来,配色回落到下一级。"),
("SettingsAppearancePaletteBgTooSmall", "Background image is too small to sample; colours fall back.", "背景图太小,取不了色,配色回落到下一级。"),
("SettingsAppearancePaletteBgBadDimensions", "Background image data looks inconsistent; colours fall back.", "背景图数据前后不一致,配色回落到下一级。"),
("SettingsAppearancePaletteBgNoOpaquePixels", "Background image is almost fully transparent; colours fall back.", "背景图几乎全透明,配色回落到下一级。"),
("SettingsAppearancePaletteBgLowChroma", "Background image is too monochrome to tint from; colours fall back.", "背景图太单调(彩度不足),取不出配色,回落到下一级。"),
("SettingsAppearancePaletteBgContrast", "Background image colours would be unreadable; colours fall back.", "按背景图取的颜色读不清,配色回落到下一级。"),
("SettingsAppearancePaletteWallpaperUndecodable", "Desktop wallpaper could not be read.", "桌面壁纸读不出来。"),
("SettingsAppearancePaletteWallpaperTooSmall", "Desktop wallpaper is too small to sample.", "桌面壁纸太小,取不了色。"),
("SettingsAppearancePaletteWallpaperBadDimensions", "Desktop wallpaper data looks inconsistent.", "桌面壁纸数据前后不一致。"),
("SettingsAppearancePaletteWallpaperNoOpaquePixels", "Desktop wallpaper is almost fully transparent.", "桌面壁纸几乎全透明。"),
("SettingsAppearancePaletteWallpaperLowChroma", "Desktop wallpaper is too monochrome to tint from.", "桌面壁纸太单调(彩度不足),取不出配色。"),
("SettingsAppearancePaletteWallpaperContrast", "Desktop wallpaper colours would be unreadable; colours fall back.", "按桌面壁纸取的颜色读不清,配色回落到下一级。"),
("SettingsAppearancePalettePresetContrast", "The built-in card needed a small contrast correction.", "内置色卡做了一点对比度修正。"),
("SettingsAppearancePaletteAdjusted", "A few colours were nudged for readability.", "有几个颜色为了可读性做了微调。"),
// M175:设置页「Java」分组(`catalog/settings/java.rs` 的 `settings_java_entries`)。
// 位置就是装配链里那个域的位置:`settings_java_entries` 在 `settings_appearance_entries` 之后、
// `settings_download_entries`(空)与 `settings_ai_entries`(M174)之前,所以这几行必须落在
+229 -5
View File
@@ -1,12 +1,38 @@
//! 设置页「Appearance」分组文案(M116 首次落地:窗口圆角一档)。
//! 设置页「Appearance」分组文案(M116 落了圆角一族;M180 `I18NAPPEAR` 补齐全组)。
//!
//! 加 key 时把条目写进 `$($acc)*` 后面即可(形状见 [`crate::catalog`] 的「域装配」一节)。
//!
//! ## 为什么只有圆角这一组的 key
//! ## 两批 key:圆角(M116)在前,其余(M180)在后
//!
//! 本分组的既有控件(整窗/面板透明度、背景图、配色来源……)是 M93 落的,文案是英文基线字面量
//! (`pages/appearance/section.rs`)。M116 新增的圆角档位**从一开始就走词表**(tower 口径:
//! 设置项文案不许硬编码),所以这里先只有它这一组;老控件接词表归 i18n 接线 mission。
//! M116 只落了圆角那 5 条;本分组其余控件(整窗/面板透明度、背景图、填充、暗化、配色来源)
//! 是 M93 落的,文案一直是英文基线字面量。M172 审计量出这一页 27 处文案里 22 处硬编码、中文界面
//! 本地化 0/27。M180 把**控件文案 + 运行时诊断 + 配色回落提示**全部搬进本文件:
//!
//! | 批次 | 条目 | 来源 |
//! |---|---|---|
//! | M116 | `Corner` … `CornerSuspended` | `pages/appearance/section/window.rs` 的圆角一段 |
//! | M180 | `WindowOpacity` … `PaletteAdjusted` | 卡片各段标签/说明 + `state.rs` / `background.rs` / `palette.rs` 的诊断 prose |
//!
//! ## 为什么诊断也在这里(而不是留在 `state.rs`)
//!
//! `pages/appearance/state.rs` 已经 390 行、贴着 400 行硬线,放不下任何一张文案表。所以那一层只
//! 给 **key + 已格式化好的参数**(`background::Note`),句子一律在本文件里。
//!
//! ## 数值参数:模板里只有 `{name}`,格式在 Rust 侧做
//!
//! [`crate::catalog::Catalog::render`] 只做 `{name}` → 值的字面替换,**不认** `{:.2}` 这类格式说明。
//! 所以「位置/尺寸/上限」这些数字由调用方先 `format!("{:.2}")` 好再当参数传进来:
//!
//! | key | 原字面量 | 现在 |
//! |---|---|---|
//! | `SettingsAppearancePanelOpacityForcedValue` | `format!("100% (forced from {:.2})")` | `{requested}` = `format!("{:.2}")` |
//! | `SettingsAppearanceWindowOpacityHint` | `Floor {:.2}` = `WINDOW_ALPHA_FLOOR` | `{floor}` |
//! | `SettingsAppearancePanelOpacityHint` | `Floor 0.40` = `PANEL_ALPHA_FLOOR` | `{floor}` |
//! | `SettingsAppearanceBgDimHint` | `Max 0.95` = `BG_DIM_MAX` | `{max}` |
//! | `SettingsAppearanceBgTooLarge` | `(limit 8192 px per edge)` = `MAX_SOURCE_EDGE` | `{limit}` |
//! | `SettingsAppearanceBgSmallTile` | `(max 64 tiles …)` = `MAX_TILES` | `{max}` |
//!
//! 常量走占位符而不是写进译文,是为了「改常量不用改两套译文」(M180 任务口径)。
//!
//! ## 文案口径(不许暗示所有平台一致)
//!
@@ -28,8 +54,206 @@ macro_rules! settings_appearance_entries {
"Not in effect right now: window transparency is below 100%. Setting it back to 100% restores the rounded corners.",
"当前因整窗透明度不生效:把整窗透明度调回 100% 圆角即恢复。"
),
// ---- M180 `I18NAPPEAR`:卡片内文案(① 整窗 / ③ 面板 / ④~⑧ 背景图 / ⑨ 配色来源)
SettingsAppearanceWindowOpacity => ("Window transparency", "整窗透明度"),
SettingsAppearanceWindowOpacityHint => (
"Applies to the whole window. Floor {floor}; 1.00 = fully opaque.",
"作用于整个窗口。下限 {floor};1.00 = 完全不透明。"
),
SettingsAppearancePanelOpacity => ("Panel transparency", "面板透明度"),
SettingsAppearancePanelOpacityHint => (
"Only the panel/card layer. Floor {floor}; independent of window transparency.",
"只作用于面板 / 卡片这一层。下限 {floor};与整窗透明度互不影响。"
),
SettingsAppearancePanelOpacityForcedValue => (
"100% (forced from {requested})",
"100%(强制,原请求值 {requested})"
),
SettingsAppearancePanelOpacityForcedHint => (
"Software renderer: panels stay fully opaque (window transparency still works).",
"软件渲染:面板恒为不透明(整窗透明度仍然有效)。"
),
SettingsAppearanceBackgroundImage => ("Background image", "背景图"),
SettingsAppearanceOff => ("Off", "关"),
SettingsAppearanceOn => ("On", "开"),
SettingsAppearanceImagePath => ("Image path", "图片路径"),
SettingsAppearancePaletteLevel => ("Colours: {level}", "配色:{level}"),
SettingsAppearanceBgFill => ("Fill", "填充方式"),
SettingsAppearanceBgFillCover => ("Cover", "铺满"),
SettingsAppearanceBgFillContain => ("Contain", "完整显示"),
SettingsAppearanceBgFillTile => ("Tile", "平铺"),
SettingsAppearanceBgDim => ("Dim", "暗化"),
SettingsAppearanceBgDimHint => (
"Blends the image toward the panel colour so text stays readable. Max {max}.",
"把背景图往面板底色上退,让文字读得清。上限 {max}。"
),
SettingsAppearancePaletteSource => ("Colour source", "配色来源"),
SettingsAppearancePaletteBackground => ("Background", "取自背景图"),
SettingsAppearancePaletteWallpaper => ("Wallpaper", "取自壁纸"),
SettingsAppearancePalettePreset => ("Preset", "内置色卡"),
// ---- M180:运行时诊断(`state.rs` 的 backend_note + `background.rs` 的三种读盘失败 + 小图平铺)
SettingsAppearanceSoftwareRendererNote => (
"Software renderer: panel transparency and glass stay fully opaque (window transparency still works).",
"软件渲染:面板透明度与玻璃效果恒为不透明(整窗透明度仍然有效)。"
),
SettingsAppearanceBgReadFailed => (
"Background image could not be read: {reason}",
"背景图读不出来:{reason}"
),
SettingsAppearanceBgDecodeFailed => (
"Background image could not be decoded ({reason}; this build decodes PNG, JPEG and BMP)",
"背景图解不开({reason};这个构建只支持 PNG、JPEG 与 BMP)"
),
SettingsAppearanceBgTooLarge => (
"Background image is too large to decode: {width}×{height} (limit {limit} px per edge)",
"背景图太大,解不开:{width}×{height}(每边上限 {limit} 像素)"
),
SettingsAppearanceBgSmallTile => (
"Small image ({width}×{height}): tiles are scaled up so the window stays covered (max {max} tiles, no gaps).",
"图片很小({width}×{height}):图块会被放大,保证窗口铺满(最多 {max} 块,不留缝)。"
),
// ---- M180:配色回落提示(`palette.rs` 把 design 的 `hint_key()` 翻成下面这些)
SettingsAppearancePaletteBgUndecodable => (
"Background image could not be read; colours fall back.",
"背景图读不出来,配色回落到下一级。"
),
SettingsAppearancePaletteBgTooSmall => (
"Background image is too small to sample; colours fall back.",
"背景图太小,取不了色,配色回落到下一级。"
),
SettingsAppearancePaletteBgBadDimensions => (
"Background image data looks inconsistent; colours fall back.",
"背景图数据前后不一致,配色回落到下一级。"
),
SettingsAppearancePaletteBgNoOpaquePixels => (
"Background image is almost fully transparent; colours fall back.",
"背景图几乎全透明,配色回落到下一级。"
),
SettingsAppearancePaletteBgLowChroma => (
"Background image is too monochrome to tint from; colours fall back.",
"背景图太单调(彩度不足),取不出配色,回落到下一级。"
),
SettingsAppearancePaletteBgContrast => (
"Background image colours would be unreadable; colours fall back.",
"按背景图取的颜色读不清,配色回落到下一级。"
),
SettingsAppearancePaletteWallpaperUndecodable => (
"Desktop wallpaper could not be read.",
"桌面壁纸读不出来。"
),
SettingsAppearancePaletteWallpaperTooSmall => (
"Desktop wallpaper is too small to sample.",
"桌面壁纸太小,取不了色。"
),
SettingsAppearancePaletteWallpaperBadDimensions => (
"Desktop wallpaper data looks inconsistent.",
"桌面壁纸数据前后不一致。"
),
SettingsAppearancePaletteWallpaperNoOpaquePixels => (
"Desktop wallpaper is almost fully transparent.",
"桌面壁纸几乎全透明。"
),
SettingsAppearancePaletteWallpaperLowChroma => (
"Desktop wallpaper is too monochrome to tint from.",
"桌面壁纸太单调(彩度不足),取不出配色。"
),
SettingsAppearancePaletteWallpaperContrast => (
"Desktop wallpaper colours would be unreadable; colours fall back.",
"按桌面壁纸取的颜色读不清,配色回落到下一级。"
),
SettingsAppearancePalettePresetContrast => (
"The built-in card needed a small contrast correction.",
"内置色卡做了一点对比度修正。"
),
SettingsAppearancePaletteAdjusted => (
"A few colours were nudged for readability.",
"有几个颜色为了可读性做了微调。"
),
} }
};
}
pub(crate) use settings_appearance_entries;
#[cfg(test)]
mod tests {
use crate::catalog::{placeholders, Catalog, Key};
use crate::Lang;
/// 本域的 key(`Key::ALL` 里按名字前缀筛;不手工列表,漏一条就少一条守卫)。
fn appearance_keys() -> Vec<Key> {
Key::ALL
.iter()
.copied()
.filter(|key| key.name().starts_with("SettingsAppearance"))
.collect()
}
/// 有没有汉字(与 `pages/settings/tests/appearance_section.rs` 的 `is_han` 同一段码位)。
fn is_han(ch: char) -> bool {
('\u{4e00}'..='\u{9fff}').contains(&ch)
}
/// **M180 守门 ②**:外观分组的每条 key 都必须真翻过——英文照抄进中文表就地判红。
///
/// 抓得住:① 新加 key 时中文栏直接抄英文(`chinese_catalog_is_actually_chinese` 也抓这条,
/// 但那条在 `catalog/tests.rs`、不属于本 mission 的 scope,而且不查「中文字里有没有汉字」);
/// ② 中文译文变成纯 ASCII(比如只写了 `PNG` / `Preset` 这种专名);③ 占位符集合两边不一致
/// (`{floor}` 只在一边有 ⇒ `render()` 吐出半截句子)。
/// 抓不住:译文翻得对不对(语义只能人审)。
///
/// 变异证明:把任一条的第二个字符串换成第一个(`("Cover", "Cover")`)⇒ 本条立刻红(`assert_ne!`
/// 与 `is_han` 命中同一行)。
#[test]
fn every_appearance_key_is_translated_into_chinese() {
let keys = appearance_keys();
assert!(
keys.len() >= 40,
"外观分组至少 40 条 key(M180 落地时是 45 条),实际 {}:\
key 被搬走或前缀改名了?",
keys.len()
);
let en = Catalog::of(Lang::En);
let zh = Catalog::of(Lang::ZhCn);
for key in keys {
let (english, chinese) = (en.text(key), zh.text(key));
assert_ne!(english, chinese, "{} 的中文是英文照抄", key.name());
assert!(
chinese.chars().any(is_han),
"{} 的中文里一个汉字都没有:{chinese}",
key.name()
);
let mut left = placeholders(english);
let mut right = placeholders(chinese);
left.sort_unstable();
right.sort_unstable();
assert_eq!(left, right, "{} 的占位符两套译文不一致", key.name());
}
}
/// 数值参数必须走占位符,不许把常量写进译文(改常量时不该还要改两套译文)。
#[test]
fn numeric_limits_are_placeholders_not_text() {
let en = Catalog::of(Lang::En);
for (key, needle, placeholder) in [
(Key::SettingsAppearanceWindowOpacityHint, "0.08", "floor"),
(Key::SettingsAppearancePanelOpacityHint, "0.40", "floor"),
(Key::SettingsAppearanceBgDimHint, "0.95", "max"),
(Key::SettingsAppearanceBgTooLarge, "8192", "limit"),
(Key::SettingsAppearanceBgSmallTile, "64", "max"),
] {
let text = en.text(key);
assert!(
!text.contains(needle),
"{} 把常量 {needle} 写死在译文里了(应当走 {{{placeholder}}})",
key.name()
);
assert!(
placeholders(text).contains(&placeholder),
"{} 缺 {{{placeholder}}} 占位符:{text}",
key.name()
);
}
}
}