diff --git a/crates/app-iced/src/pages/appearance/background.rs b/crates/app-iced/src/pages/appearance/background.rs index d706029..aa7d6e8 100644 --- a/crates/app-iced/src/pages/appearance/background.rs +++ b/crates/app-iced/src/pages/appearance/background.rs @@ -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 { +pub fn small_tile_note(image: &Image, fill: BgFill) -> Option { if !matches!(fill, BgFill::Tile) { return None; } @@ -201,9 +262,13 @@ pub fn small_tile_note(image: &Image, fill: BgFill) -> Option { 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()), + ], )) } diff --git a/crates/app-iced/src/pages/appearance/background/tests.rs b/crates/app-iced/src/pages/appearance/background/tests.rs index 4e69b88..b455372 100644 --- a/crates/app-iced/src/pages/appearance/background/tests.rs +++ b/crates/app-iced/src/pages/appearance/background/tests.rs @@ -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()); diff --git a/crates/app-iced/src/pages/appearance/mod.rs b/crates/app-iced/src/pages/appearance/mod.rs index ccb2505..0574c8f 100644 --- a/crates/app-iced/src/pages/appearance/mod.rs +++ b/crates/app-iced/src/pages/appearance/mod.rs @@ -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")); - } -} diff --git a/crates/app-iced/src/pages/appearance/palette.rs b/crates/app-iced/src/pages/appearance/palette.rs index b611027..0543de4 100644 --- a/crates/app-iced/src/pages/appearance/palette.rs +++ b/crates/app-iced/src/pages/appearance/palette.rs @@ -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 { 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 { 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] diff --git a/crates/app-iced/src/pages/appearance/probe.rs b/crates/app-iced/src/pages/appearance/probe.rs index 2c340c2..b2f4774 100644 --- a/crates/app-iced/src/pages/appearance/probe.rs +++ b/crates/app-iced/src/pages/appearance/probe.rs @@ -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> = 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> = 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())) +} diff --git a/crates/app-iced/src/pages/appearance/section/bg_image.rs b/crates/app-iced/src/pages/appearance/section/bg_image.rs index 0c954e5..4ea9e68 100644 --- a/crates/app-iced/src/pages/appearance/section/bg_image.rs +++ b/crates/app-iced/src/pages/appearance/section/bg_image.rs @@ -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>) { +pub(super) fn push<'a>( + ui: &'a Ui, + state: &'a State, + lang: Lang, + rows: &mut Vec>, +) { // ④ 背景图开关 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 = 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 0.0, slider( diff --git a/crates/app-iced/src/pages/appearance/section/colour_source.rs b/crates/app-iced/src/pages/appearance/section/colour_source.rs index e79a2b9..8b9c85f 100644 --- a/crates/app-iced/src/pages/appearance/section/colour_source.rs +++ b/crates/app-iced/src/pages/appearance/section/colour_source.rs @@ -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>) { +pub(super) fn push<'a>( + ui: &'a Ui, + state: &'a State, + lang: Lang, + rows: &mut Vec>, +) { // ⑨ 配色来源 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), ), diff --git a/crates/app-iced/src/pages/appearance/section/mod.rs b/crates/app-iced/src/pages/appearance/section/mod.rs index e6aa711..e6b7367 100644 --- a/crates/app-iced/src/pages/appearance/section/mod.rs +++ b/crates/app-iced/src/pages/appearance/section/mod.rs @@ -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> = 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) diff --git a/crates/app-iced/src/pages/appearance/section/panel.rs b/crates/app-iced/src/pages/appearance/section/panel.rs index 58661ba..665d711 100644 --- a/crates/app-iced/src/pages/appearance/section/panel.rs +++ b/crates/app-iced/src/pages/appearance/section/panel.rs @@ -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>) { +pub(super) fn push<'a>( + ui: &'a Ui, + state: &'a State, + lang: Lang, + rows: &mut Vec>, +) { // ③ 面板透明度(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( @@ -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( diff --git a/crates/app-iced/src/pages/appearance/state.rs b/crates/app-iced/src/pages/appearance/state.rs index e1868bf..354b3b9 100644 --- a/crates/app-iced/src/pages/appearance/state.rs +++ b/crates/app-iced/src/pages/appearance/state.rs @@ -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, - /// 读盘/解码失败的**可读诊断**(按发生顺序追加)。 - diagnostics: Vec, + /// 读盘/解码失败的**可读诊断**(按发生顺序追加;`Note` = key + 参数,句子在词表里)。 + diagnostics: Vec, /// 上一次的配色解析结果。 resolved: Option, /// 当前深浅(配色解析的输入)。 @@ -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 { + 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 { 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()), } } diff --git a/crates/app-iced/src/pages/appearance/tests.rs b/crates/app-iced/src/pages/appearance/tests.rs new file mode 100644 index 0000000..b57dceb --- /dev/null +++ b/crates/app-iced/src/pages/appearance/tests.rs @@ -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 = 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) +} diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index 8972de0..78f95db 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -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)之前,所以这几行必须落在 diff --git a/crates/i18n/src/catalog/settings/appearance.rs b/crates/i18n/src/catalog/settings/appearance.rs index 907e9a2..52cdddd 100644 --- a/crates/i18n/src/catalog/settings/appearance.rs +++ b/crates/i18n/src/catalog/settings/appearance.rs @@ -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::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() + ); + } + } +}