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:
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());
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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, ¬e, ui.color(Role::OnSurfaceVariant)));
|
||||
let text = tr_note(lang, ¬e);
|
||||
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(), ¬e.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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)之前,所以这几行必须落在
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user